mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
var rolesSupportingScopes = types.SystemRoles{
|
||||
types.RoleNode,
|
||||
types.RoleKube,
|
||||
types.RoleApp,
|
||||
types.RoleBot,
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user