add scoped joining for app svc (#67101)

* Add support for scoped joins for apps

address linter

Update server_test.go

gen docs

* enforce app and server scope to be equal
This commit is contained in:
williamong-tel
2026-06-24 18:22:29 +00:00
committed by GitHub
parent 4f19256298
commit f944f2e160
24 changed files with 2836 additions and 2345 deletions
@@ -1097,6 +1097,8 @@ message AppV3 {
(gogoproto.nullable) = false,
(gogoproto.jsontag) = "spec"
];
// The App's scope.
string scope = 6;
}
// CORSPolicy defines the CORS policy for AppSpecV3
+24 -1
View File
@@ -120,14 +120,27 @@ type Application interface {
GetTLSMode() AppTLSMode
// IsEqual determines if two application resources are equivalent to one another.
IsEqual(Application) bool
// GetScope gets the scope of the app.
GetScope() string
}
// NewAppV3 creates a new app resource.
func NewAppV3(meta Metadata, spec AppSpecV3) (*AppV3, error) {
// TODO(williamo/scopes): scope is variadic only so existing
// callers compile unchanged during the scope migration.
func NewAppV3(meta Metadata, spec AppSpecV3, scope ...string) (*AppV3, error) {
app := &AppV3{
Metadata: meta,
Spec: spec,
}
switch len(scope) {
case 0: // unscoped
case 1:
app.Scope = scope[0]
default:
return nil, trace.BadParameter("expected at most 1 scope, got %d", len(scope))
}
if err := app.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
@@ -523,6 +536,7 @@ func (a *AppV3) CheckAndSetDefaults() error {
}
a.Metadata.Labels[AppSubKindLabel] = a.SubKind
}
return nil
}
@@ -724,6 +738,15 @@ func (a *AppV3) GetClientCertMode() AppClientCertMode {
return a.Spec.TLS.ClientCertMode
}
// GetScope returns the scope of the app.
func (a *AppV3) GetScope() string {
if a == nil {
return ""
}
return a.Scope
}
// DeduplicateApps deduplicates apps by combination of app name and public address.
// Apps can have the same name but also could have different addresses.
func DeduplicateApps(apps []Application) []Application {
+22 -1
View File
@@ -437,6 +437,7 @@ func TestNewAppV3(t *testing.T) {
name string
meta Metadata
spec AppSpecV3
scope string
want *AppV3
wantErr require.ErrorAssertionFunc
}{
@@ -767,14 +768,34 @@ func TestNewAppV3(t *testing.T) {
},
wantErr: require.Error,
},
{
name: "set with scope",
meta: Metadata{Name: "myapp"},
spec: AppSpecV3{URI: "https://localhost:1337"},
scope: "/staging/test",
want: &AppV3{
Kind: "app",
Version: "v3",
Metadata: Metadata{Name: "myapp", Namespace: "default"},
Spec: AppSpecV3{URI: "https://localhost:1337"},
Scope: "/staging/test",
},
wantErr: require.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual, err := NewAppV3(tt.meta, tt.spec)
actual, err := NewAppV3(tt.meta, tt.spec, tt.scope)
tt.wantErr(t, err)
require.Equal(t, tt.want, actual)
})
}
// TODO (williamo/scopes) temp test - delete this once we delete the variadic params for scopes.
t.Run("set with a multiple scopes", func(t *testing.T) {
_, err := NewAppV3(Metadata{Name: "myapp"}, AppSpecV3{URI: "https://localhost:1337"}, "/staging/test", "/prod/test")
require.ErrorContains(t, err, "expected at most 1 scope, got 2")
})
}
func TestPortRangesContains(t *testing.T) {
+12 -1
View File
@@ -81,11 +81,22 @@ type AppServer interface {
}
// NewAppServerV3 creates a new app server instance.
func NewAppServerV3(meta Metadata, spec AppServerSpecV3) (*AppServerV3, error) {
// TODO(williamo/scopes): scope is variadic only so existing
// callers compile unchanged during the scope migration.
func NewAppServerV3(meta Metadata, spec AppServerSpecV3, scope ...string) (*AppServerV3, error) {
s := &AppServerV3{
Metadata: meta,
Spec: spec,
}
switch len(scope) {
case 0: // unscoped
case 1:
s.Scope = scope[0]
default:
return nil, trace.BadParameter("expected at most 1 scope, got %d", len(scope))
}
if err := s.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
+25
View File
@@ -120,3 +120,28 @@ func TestNewAppServerForAWSOIDCIntegration(t *testing.T) {
})
}
}
func TestAppServerV3(t *testing.T) {
t.Parallel()
app, err := NewAppV3(Metadata{Name: "myapp"}, AppSpecV3{URI: "https://example.com"})
require.NoError(t, err)
t.Run("set with scope", func(t *testing.T) {
server, err := NewAppServerV3(Metadata{Name: "myapp"}, AppServerSpecV3{
HostID: "host-1",
App: app,
}, "/staging/test")
require.NoError(t, err)
require.Equal(t, "/staging/test", server.GetScope())
})
// TODO (williamo/scopes) temp test - delete this once we migrate from the variadic params for scopes.
t.Run("set with multiple scopes", func(t *testing.T) {
_, err := NewAppServerV3(Metadata{Name: "myapp"}, AppServerSpecV3{
HostID: "host-1",
App: app,
}, "/staging/test", "/staging/test2")
require.ErrorContains(t, err, "expected at most 1 scope, got 2")
})
}
+2 -1
View File
@@ -59,7 +59,8 @@ func deriveTeleportEqualAppV3(this, that *AppV3) bool {
this.SubKind == that.SubKind &&
this.Version == that.Version &&
deriveTeleportEqualMetadata(&this.Metadata, &that.Metadata) &&
deriveTeleportEqual_3(&this.Spec, &that.Spec)
deriveTeleportEqual_3(&this.Spec, &that.Spec) &&
this.Scope == that.Scope
}
// deriveTeleportEqualAppServerV3 returns whether this and that are equal.
+2347 -2302
View File
File diff suppressed because it is too large Load Diff
@@ -239,12 +239,14 @@ sub_kind: "string"
version: "string"
metadata: # [...]
spec: # [...]
scope: "string"
```
|Field Name|Description|Type|
|---|---|---|
|kind|The app resource kind. Always "app".|string|
|metadata|The app resource metadata.|[Metadata](#metadata)|
|scope|The App's scope.|string|
|spec|The app resource spec.|[App Spec V3](#app-spec-v3)|
|sub_kind|An optional resource subkind.|string|
|version|The resource version. It must be specified. Supported values are:`v3`.|string|
@@ -37,11 +37,13 @@ sub_kind: "string"
version: "string"
metadata: # [...]
spec: # [...]
scope: "string"
```
|Field Name|Description|Type|
|---|---|---|
|kind|The app resource kind. Always "app".|string|
|metadata|The app resource metadata.|[Metadata](#metadata)|
|scope|The App's scope.|string|
|spec|The app resource spec.|[App Spec V3](#app-spec-v3)|
|sub_kind|An optional resource subkind.|string|
|version|The resource version. It must be specified. Supported values are:`v3`.|string|
@@ -28,6 +28,7 @@ Teleport Terraform provider.
### Optional
- `metadata` (Attributes) Metadata is the app resource metadata. (see [below for nested schema](#nested-schema-for-metadata))
- `scope` (String) The App's scope.
- `spec` (Attributes) Spec is the app resource spec. (see [below for nested schema](#nested-schema-for-spec))
- `sub_kind` (String) SubKind is an optional resource subkind.
@@ -47,6 +47,7 @@ resource "teleport_app" "example" {
### Optional
- `metadata` (Attributes) Metadata is the app resource metadata. (see [below for nested schema](#nested-schema-for-metadata))
- `scope` (String) The App's scope.
- `spec` (Attributes) Spec is the app resource spec. (see [below for nested schema](#nested-schema-for-spec))
- `sub_kind` (String) SubKind is an optional resource subkind.
@@ -908,6 +908,11 @@ func GenSchemaAppV3(ctx context.Context) (github_com_hashicorp_terraform_plugin_
Description: "Metadata is the app resource metadata.",
Optional: true,
},
"scope": {
Description: "The App's scope.",
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
"spec": {
Attributes: github_com_hashicorp_terraform_plugin_framework_tfsdk.SingleNestedAttributes(map[string]github_com_hashicorp_terraform_plugin_framework_tfsdk.Attribute{
"aws": {
@@ -14178,6 +14183,23 @@ func CopyAppV3FromTerraform(_ context.Context, tf github_com_hashicorp_terraform
}
}
}
{
a, ok := tf.Attrs["scope"]
if !ok {
diags.Append(attrReadMissingDiag{"AppV3.scope"})
} else {
v, ok := a.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrReadConversionFailureDiag{"AppV3.scope", "github.com/hashicorp/terraform-plugin-framework/types.String"})
} else {
var t string
if !v.Null && !v.Unknown {
t = string(v.Value)
}
obj.Scope = t
}
}
}
return diags
}
@@ -16277,6 +16299,28 @@ func CopyAppV3ToTerraform(ctx context.Context, obj *github_com_gravitational_tel
}
}
}
{
t, ok := tf.AttrTypes["scope"]
if !ok {
diags.Append(attrWriteMissingDiag{"AppV3.scope"})
} else {
v, ok := tf.Attrs["scope"].(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
i, err := t.ValueFromTerraform(ctx, github_com_hashicorp_terraform_plugin_go_tftypes.NewValue(t.TerraformType(ctx), nil))
if err != nil {
diags.Append(attrWriteGeneralError{"AppV3.scope", err})
}
v, ok = i.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrWriteConversionFailureDiag{"AppV3.scope", "github.com/hashicorp/terraform-plugin-framework/types.String"})
}
v.Null = string(obj.Scope) == ""
}
v.Value = string(obj.Scope)
v.Unknown = false
tf.Attrs["scope"] = v
}
}
return diags
}
+7
View File
@@ -1115,6 +1115,8 @@ func scopedDefinitionForBuiltinRole(clusterName string, recConfig readonly.Sessi
},
},
)
case types.RoleApp:
return unscopedDefinitionForBuiltinRole(clusterName, recConfig, types.RoleApp)
}
return nil, trace.NotFound("builtin role for scoped %q is not recognized", role.String())
@@ -1200,6 +1202,11 @@ func unscopedDefinitionForBuiltinRole(clusterName string, recConfig readonly.Ses
types.NewRule(types.KindApp, services.RO()),
types.NewRule(types.KindJWT, services.RW()),
types.NewRule(types.KindLock, services.RO()),
// TODO(fspmarshall/scopes): we eventually want to remove blanket scoped role
// access in favor of apps only being able to read scoped roles that may affect
// access decisions for the given app specifically. This verb grant will need to
// be revisited as part of that work.
types.NewRule(scopedaccess.KindScopedRole, services.RO()),
},
},
})
+24
View File
@@ -51,6 +51,7 @@ import (
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/modules/modulestest"
"github.com/gravitational/teleport/lib/scopes"
scopedaccess "github.com/gravitational/teleport/lib/scopes/access"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
"github.com/gravitational/teleport/lib/services/readonly"
@@ -1312,6 +1313,29 @@ func TestRoleSetForBuiltinRoles(t *testing.T) {
}
},
},
{
name: "Scoped RoleApp is mapped",
clusterName: clusterName,
roles: []types.SystemRole{types.RoleApp},
isScoped: true,
assertRoleSet: func(t *testing.T, rs services.RoleSet) {
allowedResourceKinds := make(map[string]bool)
for i, r := range rs {
assert.NotEmpty(t, r.GetNamespaces(types.Allow), "RoleSetForBuiltinRoles: rs[%v]: role has no namespaces", i)
assert.NotEmpty(t, r.GetRules(types.Allow), "RoleSetForBuiltinRoles: rs[%v]: role has no rules", i)
if r.GetName() == constants.DefaultImplicitRole {
continue
}
for _, rule := range r.GetRules(types.Allow) {
for _, resource := range rule.Resources {
allowedResourceKinds[resource] = true
}
}
}
assert.True(t, allowedResourceKinds[types.KindApp], "expected scoped RoleApp to allow reading apps")
assert.True(t, allowedResourceKinds[scopedaccess.KindScopedRole], "expected scoped RoleApp to allow reading scoped roles")
},
},
{
name: "Scoped RoleMDM is not mapped",
clusterName: clusterName,
+6 -1
View File
@@ -1056,7 +1056,7 @@ func (c *Controller) handleAppServerHB(handle *upstreamHandle, appServer *types.
return trace.AccessDenied("incorrect app server ID (expected %q, got %q)", handle.Hello().GetServerID(), appServer.GetHostID())
}
// Agent's that don't know about scopes can still have a scoped identity. In that case, we consider an empty
// Agents that don't know about scopes can still have a scoped identity. In that case, we consider an empty
// scope to defer to what was found in the identity during the initial hello.
if appServer.Scope == "" {
appServer.Scope = handle.Hello().GetScope()
@@ -1067,6 +1067,11 @@ func (c *Controller) handleAppServerHB(handle *upstreamHandle, appServer *types.
return trace.AccessDenied("incorrect app server scope (expected %q, got %q)", handle.Hello().GetScope(), appServer.Scope)
}
// Require the embedded app scope to equal the server scope.
if app := appServer.GetApp(); app != nil && !services.AppServerScopesEqual(appServer.Scope, app.GetScope()) {
return trace.AccessDenied("incorrect embedded app scope (server scope %q does not match app scope %q)", appServer.Scope, app.GetScope())
}
// Older agents send mixed-case names and URL-shaped public_addr;
// normalize before deriving the cache key so it matches the
// backend write.
+83 -10
View File
@@ -927,14 +927,38 @@ func TestAppServerHeartbeatNormalization(t *testing.T) {
synctest.Test(t, testAppServerHeartbeatNormalization)
}
func testAppServerHeartbeatNormalization(t *testing.T) {
func TestScopedAppServer(t *testing.T) {
t.Parallel()
// happy path: server and app scopes match the hello scope (static registration).
synctest.Test(t, testAppServerScoped("/test", "/test", "/test", true))
// embedded app scope is different from the server scope.
synctest.Test(t, testAppServerScoped("/test", "/test", "/test/child", false))
}
// appTestController bundles the pieces an app-server heartbeat test needs from a
// controller wired to an in-memory control stream.
type appTestController struct {
ctx context.Context
serverID string
downstream client.DownstreamInventoryControlStream
events chan testEvent
handle *upstreamHandle
}
// newAppTestController starts a controller and registers an app agent control
// stream pinned to the given scope (pass "" for an unscoped agent). It launches
// a goroutine that answers pings and registers cleanup for the stream and
// controller. Use the returned handle/downstream/events to drive heartbeats and
// assert on emitted events.
func newAppTestController(t *testing.T, scope string) appTestController {
t.Helper()
const serverID = "test-server"
ctx := t.Context()
events := make(chan testEvent, 1024)
auth := &fakeAuth{}
controller := NewController(
auth,
&fakeAuth{},
usagereporter.DiscardUsageReporter{},
withServerKeepAlive(time.Millisecond*200),
withTestEventsChannel(events),
@@ -965,19 +989,31 @@ func testAppServerHeartbeatNormalization(t *testing.T) {
ServerID: serverID,
Version: teleport.Version,
Services: types.SystemRoles{types.RoleApp}.StringSlice(),
Scope: scope,
}.Build())
h, ok := controller.GetControlStream(serverID)
require.True(t, ok)
handle := h.(*upstreamHandle)
err := downstream.Send(ctx, proto.InventoryHeartbeat_builder{
return appTestController{
ctx: ctx,
serverID: serverID,
downstream: downstream,
events: events,
handle: h.(*upstreamHandle),
}
}
func testAppServerHeartbeatNormalization(t *testing.T) {
c := newAppTestController(t, "")
err := c.downstream.Send(c.ctx, proto.InventoryHeartbeat_builder{
AppServer: &types.AppServerV3{
Metadata: types.Metadata{
Name: "MixedCaseApp",
},
Spec: types.AppServerSpecV3{
HostID: serverID,
HostID: c.serverID,
App: &types.AppV3{
Kind: types.KindApp,
Version: types.V3,
@@ -994,20 +1030,57 @@ func testAppServerHeartbeatNormalization(t *testing.T) {
}.Build())
require.NoError(t, err)
awaitEvents(t, events,
awaitEvents(t, c.events,
expect(appUpsertOk),
deny(appUpsertErr, handlerClose),
)
synctest.Wait()
expectedKey := resourceKey{hostID: serverID, name: "mixedcaseapp"}
srv, ok := handle.appServers[expectedKey]
require.True(t, ok, "expected handle.appServers key %+v; got %+v", expectedKey, handle.appServers)
expectedKey := resourceKey{hostID: c.serverID, name: "mixedcaseapp"}
srv, ok := c.handle.appServers[expectedKey]
require.True(t, ok, "expected handle.appServers key %+v; got %+v", expectedKey, c.handle.appServers)
require.Equal(t, "mixedcaseapp", srv.resource.GetApp().GetName())
require.Equal(t, "mixedcaseapp", srv.resource.GetName())
require.Equal(t, "mixedcaseapp.example.com", srv.resource.GetApp().GetPublicAddr())
}
func testAppServerScoped(initialScope, serverScope, appScope string, expectOK bool) func(t *testing.T) {
return func(t *testing.T) {
c := newAppTestController(t, initialScope)
err := c.downstream.Send(c.ctx, proto.InventoryHeartbeat_builder{
AppServer: &types.AppServerV3{
Metadata: types.Metadata{Name: c.serverID},
Scope: serverScope,
Spec: types.AppServerSpecV3{
HostID: c.serverID,
App: &types.AppV3{
Kind: types.KindApp,
Version: types.V3,
Scope: appScope,
Metadata: types.Metadata{Name: "app"},
Spec: types.AppSpecV3{URI: "http://localhost:8080"},
},
},
},
}.Build())
require.NoError(t, err)
if !expectOK {
// A scope violation is rejected before upsert and closes the stream.
awaitEvents(t, c.events,
expect(handlerClose),
deny(appUpsertOk, appUpsertErr, appKeepAliveErr),
)
return
}
awaitEvents(t, c.events,
expect(appUpsertOk, appKeepAliveOk),
deny(appUpsertErr, appKeepAliveErr, handlerClose),
)
}
}
// TestAppKeepAliveRetryRoutesThroughUpsert asserts a retry tick
// re-runs Upsert while steady-state uses UnconditionalUpdate.
func TestAppKeepAliveRetryRoutesThroughUpsert(t *testing.T) {
+1
View File
@@ -38,6 +38,7 @@ import (
var rolesSupportingScopes = types.SystemRoles{
types.RoleNode,
types.RoleKube,
types.RoleApp,
types.RoleBot,
}
+16 -1
View File
@@ -490,6 +490,15 @@ func (c *Connector) ScopePin() *scopesv1.Pin {
return c.scopePin
}
// checkScopedAppJoin rejects an app agent that joined with a scoped token while
// agent scope pins are disabled.
func checkScopedAppJoin(conn *Connector) error {
if conn.Scope() != "" && conn.ScopePin() == nil {
return trace.BadParameter("set TELEPORT_UNSTABLE_AGENT_SCOPE_PIN=yes on main auth service to join a scoped app agent")
}
return nil
}
func (c *Connector) Role() types.SystemRole {
return c.role
}
@@ -6888,6 +6897,11 @@ func (process *TeleportProcess) initApps() {
})
}
if err := checkScopedAppJoin(conn); err != nil {
return trace.Wrap(err)
}
scopePin := conn.ScopePin()
// Loop over each application and create a server.
var applications types.Apps
for _, app := range process.Config.Apps.Apps {
@@ -6937,7 +6951,7 @@ func (process *TeleportProcess) initApps() {
MCP: app.MCP,
LLM: app.LLM,
TLS: app.TLS,
})
}, scopePin.GetScope())
if err != nil {
return trace.Wrap(err)
}
@@ -7057,6 +7071,7 @@ func (process *TeleportProcess) initApps() {
ConnectionsHandler: connectionsHandler,
InventoryHandle: process.inventoryHandle,
IgnoreAppsWithCommandLabels: runningOnBeams,
ScopePin: scopePin,
})
if err != nil {
return trace.Wrap(err)
+40
View File
@@ -56,6 +56,7 @@ import (
"github.com/gravitational/teleport/api/breaker"
apidefaults "github.com/gravitational/teleport/api/defaults"
autoupdatepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/autoupdate/v1"
scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1"
"github.com/gravitational/teleport/api/types"
autoupdate "github.com/gravitational/teleport/api/types/autoupdate"
apiutils "github.com/gravitational/teleport/api/utils"
@@ -2653,3 +2654,42 @@ func TestInitAppsFromConfig(t *testing.T) {
require.Empty(t, cmp.Diff(appCfg.LLM, app.GetLLM(), protocmp.Transform()))
}
}
func TestInitAppsScoped(t *testing.T) {
t.Parallel()
agentPin := func(scope string) *scopesv1.Pin {
return scopesv1.Pin_builder{
Kind: scopesv1.PinKind_PIN_KIND_AGENT,
Scope: scope,
}.Build()
}
tests := []struct {
name string
conn *Connector
assertErr require.ErrorAssertionFunc
}{
{
name: "unscoped agent is allowed",
conn: &Connector{},
assertErr: require.NoError,
},
{
name: "scoped agent with a pin is allowed",
conn: &Connector{scopePin: agentPin("/staging/west")},
assertErr: require.NoError,
},
{
name: "scoped agent without a pin is rejected",
conn: &Connector{scope: "/staging/west"},
assertErr: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.assertErr(t, checkScopedAppJoin(tt.conn))
})
}
}
+17
View File
@@ -47,6 +47,7 @@ import (
"github.com/gravitational/teleport/api/utils/clientutils"
"github.com/gravitational/teleport/api/utils/tlsutils"
"github.com/gravitational/teleport/lib/backend"
"github.com/gravitational/teleport/lib/scopes"
"github.com/gravitational/teleport/lib/utils"
)
@@ -297,9 +298,25 @@ func ValidateAppServer(server types.AppServer, proxyGetter ProxyGetter) error {
if errs := validation.IsDNS1123SubdomainWithUnderscore(server.GetName()); len(errs) > 0 {
return trace.BadParameter("app server name %q must be a valid DNS name (lowercase alphanumeric, '-', '_', or '.', must start and end with alphanumeric, max 253 chars): %s", server.GetName(), strings.Join(errs, ", "))
}
if app := server.GetApp(); app != nil && !AppServerScopesEqual(server.GetScope(), app.GetScope()) {
return trace.BadParameter("app server %q scope %q does not match its embedded app scope %q", server.GetName(), server.GetScope(), app.GetScope())
}
return trace.Wrap(ValidateApp(server.GetApp(), proxyGetter))
}
// AppServerScopesEqual reports whether an app server's scope and its embedded
// app's scope are equivalent.
func AppServerScopesEqual(serverScope, appScope string) bool {
// Empty string comparison is treated as orthogonal in scopes.Compare.
// If server scope is empty (unscoped), we should make sure the app's scope is also empty, and vice versa.
if serverScope == "" || appScope == "" {
return serverScope == appScope
}
return scopes.Compare(serverScope, appScope) == scopes.Equivalent
}
// ValidatePublicAddr requires a lowercase DNS-1123 hostname. An
// empty addr is treated as unset.
func ValidatePublicAddr(appName, addr string) error {
+89 -24
View File
@@ -209,36 +209,101 @@ func TestValidateApp(t *testing.T) {
}
}
func makeServer(t *testing.T, outerName, innerName, serverScope, appScope string) types.AppServer {
t.Helper()
app, err := types.NewAppV3(types.Metadata{Name: innerName}, types.AppSpecV3{URI: "http://localhost:8080"}, appScope)
require.NoError(t, err)
srv, err := types.NewAppServerV3FromApp(app, "localhost", "host-id")
require.NoError(t, err)
srv.Metadata.Name = outerName
srv.Scope = serverScope
return srv
}
func TestValidateAppServer(t *testing.T) {
proxyGetter := &mockProxyGetter{addrs: []string{"proxy.example.com:443"}}
makeServer := func(t *testing.T, outerName, innerName string) types.AppServer {
t.Helper()
app, err := types.NewAppV3(types.Metadata{Name: innerName}, types.AppSpecV3{URI: "http://localhost:8080"})
require.NoError(t, err)
srv, err := types.NewAppServerV3FromApp(app, "localhost", "host-id")
require.NoError(t, err)
srv.Metadata.Name = outerName
return srv
tests := []struct {
name string
srvName string
appName string
srvScope string
appScope string
wantErr string
}{
// Name validation.
{
name: "valid outer and inner",
srvName: "myapp",
appName: "myapp",
},
{
name: "mixed-case outer rejected",
srvName: "MyApp",
appName: "myapp",
wantErr: `app server name "MyApp" must be a valid DNS name (lowercase alphanumeric, '-', '_', or '.', must start and end with alphanumeric, max 253 chars): a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '_', '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '_?[a-z0-9]([-_a-z0-9]*[a-z0-9])?(\._?[a-z0-9]([-_a-z0-9]*[a-z0-9])?)*')`,
},
{
name: "underscore in outer accepted",
srvName: "ok_name",
appName: "good-name"},
{
name: "mixed-case inner rejected",
srvName: "myapp",
appName: "MyApp",
wantErr: `application name "MyApp" must be a valid DNS name (lowercase alphanumeric, '-', '_', or '.', must start and end with alphanumeric, max 253 chars): https://goteleport.com/docs/enroll-resources/application-access/guides/connecting-apps/#application-name`,
},
// Scope validation: the embedded app scope must equal the server scope.
{
name: "equal scope accepted",
srvName: "my-srv",
appName: "my-app",
srvScope: "/prod",
appScope: "/prod"},
{
name: "unscoped server and app accepted",
srvName: "my-srv",
appName: "my-app",
},
{
name: "different scopes rejected",
srvName: "my-srv",
appName: "my-app",
srvScope: "/prod/web",
appScope: "/prod",
wantErr: `app server "my-srv" scope "/prod/web" does not match its embedded app scope "/prod"`,
},
{
name: "empty embedded app scope under scoped server rejected",
srvName: "my-srv",
appName: "my-app",
srvScope: "/prod",
appScope: "",
wantErr: `app server "my-srv" scope "/prod" does not match its embedded app scope ""`,
},
{
name: "scoped app under unscoped server rejected",
srvName: "my-srv",
appName: "my-app",
srvScope: "",
appScope: "/prod",
wantErr: `app server "my-srv" scope "" does not match its embedded app scope "/prod"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateAppServer(makeServer(t, tt.srvName, tt.appName, tt.srvScope, tt.appScope), proxyGetter)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
t.Run("nil server rejected", func(t *testing.T) {
require.ErrorContains(t, ValidateAppServer(nil, proxyGetter), "nil app server")
})
t.Run("valid outer and inner", func(t *testing.T) {
require.NoError(t, ValidateAppServer(makeServer(t, "myapp", "myapp"), proxyGetter))
})
t.Run("mixed-case outer rejected", func(t *testing.T) {
err := ValidateAppServer(makeServer(t, "MyApp", "myapp"), proxyGetter)
require.ErrorContains(t, err, "app server name")
require.ErrorContains(t, err, "MyApp")
})
t.Run("underscore in outer accepted", func(t *testing.T) {
require.NoError(t, ValidateAppServer(makeServer(t, "ok_name", "good-name"), proxyGetter))
})
t.Run("mixed-case inner rejected", func(t *testing.T) {
err := ValidateAppServer(makeServer(t, "myapp", "MyApp"), proxyGetter)
require.ErrorContains(t, err, "application name")
require.EqualError(t, ValidateAppServer(nil, proxyGetter), "nil app server")
})
}
+21 -1
View File
@@ -34,12 +34,14 @@ import (
"github.com/gravitational/teleport"
apidefaults "github.com/gravitational/teleport/api/defaults"
scopesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/v1"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/componentfeatures"
"github.com/gravitational/teleport/lib/inventory"
"github.com/gravitational/teleport/lib/labels"
"github.com/gravitational/teleport/lib/reversetunnelclient"
"github.com/gravitational/teleport/lib/scopes/pinning"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/readonly"
"github.com/gravitational/teleport/lib/srv"
@@ -85,6 +87,9 @@ type Config struct {
// ResourceMatchers is a list of app resource matchers.
ResourceMatchers []services.ResourceMatcher
// ScopePin is the scope and scoped role assignments the agent is pinned to.
ScopePin *scopesv1.Pin
// OnReconcile is called after each app resource reconciliation.
OnReconcile func(types.Apps)
@@ -132,9 +137,19 @@ func (c *Config) CheckAndSetDefaults() error {
if c.ConnectedProxyGetter == nil {
return trace.BadParameter("ConnectedProxyGetter missing")
}
if c.ScopePin != nil {
if err := pinning.WeakValidate(c.ScopePin); err != nil {
return trace.Wrap(err)
}
}
return nil
}
// GetScope returns the scope the agent is pinned to.
func (c *Config) GetScope() string {
return c.ScopePin.GetScope()
}
// Server is an application server. It authenticates requests from the web
// proxy and forwards th to internal applications.
type Server struct {
@@ -200,6 +215,11 @@ func New(ctx context.Context, c *Config) (*Server, error) {
return nil, trace.Wrap(err)
}
// TODO(williamo/scopes): remove this validation once dynamic app registration supports scopes.
if c.GetScope() != "" && len(c.ResourceMatchers) > 0 {
return nil, trace.BadParameter("dynamic app registration not supported for scoped app_service, resource matchers must be empty")
}
closeContext, closeFunc := context.WithCancel(ctx)
// in case of errors cancel context to avoid context leak
callClose := true
@@ -363,7 +383,7 @@ func (s *Server) getServerInfo(app types.Application) (*types.AppServerV3, error
Rotation: s.getRotationState(),
App: copy,
ProxyIDs: s.c.ConnectedProxyGetter.GetProxyIDs(),
})
}, s.c.GetScope())
if err != nil {
return nil, trace.Wrap(err)
}
+1
View File
@@ -455,6 +455,7 @@ func dialAppServer(ctx context.Context, clusterClient reversetunnelclient.Cluste
ServerID: server.GetHostID() + "." + clusterClient.GetName(),
ConnType: server.GetTunnelType(),
ProxyIDs: server.GetProxyIDs(),
TargetScope: server.GetScope(),
})
return conn, trace.Wrap(err)
}
+47 -2
View File
@@ -615,10 +615,12 @@ func (p *mockClusterGetter) Cluster(context.Context, string) (reversetunnelclien
type mockCluster struct {
reversetunnelclient.Cluster
dialErr error
dialErr error
dialParamsReceived reversetunnelclient.DialParams
}
func (r *mockCluster) Dial(_ reversetunnelclient.DialParams) (net.Conn, error) {
func (r *mockCluster) Dial(params reversetunnelclient.DialParams) (net.Conn, error) {
r.dialParamsReceived = params
if r.dialErr != nil {
return nil, r.dialErr
}
@@ -637,3 +639,46 @@ type mockDialConn struct {
func (c *mockDialConn) Close() error {
return nil
}
func Test_dialAppServer_setsTargetScope(t *testing.T) {
t.Parallel()
app, err := types.NewAppV3(
types.Metadata{Name: "test-app"},
types.AppSpecV3{URI: "https://app.localhost"},
)
require.NoError(t, err)
tests := []struct {
name string
scope string
wantScope string
}{
{
name: "scope threaded into dial params",
scope: "/staging/test",
wantScope: "/staging/test",
},
{
name: "empty scope by default",
scope: "",
wantScope: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
appServer, err := types.NewAppServerV3(
types.Metadata{Name: "test-app"},
types.AppServerSpecV3{HostID: "host-1", App: app},
tt.scope,
)
require.NoError(t, err)
cluster := &mockCluster{}
_, err = dialAppServer(t.Context(), cluster, appServer)
require.NoError(t, err)
require.Equal(t, tt.wantScope, cluster.dialParamsReceived.TargetScope)
})
}
}