From 82f58ee671c438344ec59113a00625e5e73036cc Mon Sep 17 00:00:00 2001 From: Trent Clarke Date: Wed, 4 Dec 2024 22:41:14 +1100 Subject: [PATCH] Exposes Identity Center accounts as Apps in Unified Resource Cache For the purposes of the UI, Identity Center accounts and account assignments are treated like special Apps. This patch exposes Account Assignments to the UI via the Unified Resource Cache. Includes: - Generating an App resource from an Identity Center Account resource - Adds a RoleMatcher for matching Identity Center resources against the Account Assignments in Role Condition Statements - Adds a special actionChecker that checks actions against Identity Center resource kinds, falling back to the generic `KindIdentityCenter` unless explicitly denied. - Disables label matching for Identity Center Account resources - General plumbing from backend through to cache and UI --- api/client/client.go | 2 + api/types/app.go | 28 +++++ api/types/resource.go | 9 +- api/types/resource_153.go | 74 ++++++++++++- api/types/resource_153_test.go | 3 +- api/types/role.go | 17 +++ lib/auth/auth_with_roles.go | 47 +++++++- lib/auth/auth_with_roles_test.go | 133 +++++++++++++++++++++- lib/services/access_checker.go | 12 ++ lib/services/access_request.go | 25 ++++- lib/services/identitycenter.go | 101 ++++++++++++++++- lib/services/identitycenter_test.go | 153 +++++++++++++++++++++++++- lib/services/local/presence.go | 19 ++++ lib/services/matchers.go | 3 +- lib/services/role.go | 49 +++++---- lib/services/unified_resource.go | 99 +++++++++++++++++ lib/services/unified_resource_test.go | 112 +++++++++++++++++-- lib/web/ui/app.go | 36 ++++++ 18 files changed, 868 insertions(+), 54 deletions(-) diff --git a/api/client/client.go b/api/client/client.go index c9a89cfdf820e..68a28c9f06c58 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -3807,6 +3807,8 @@ func (c *Client) ListResources(ctx context.Context, req proto.ListResourcesReque resources[i] = respResource.GetAppServerOrSAMLIdPServiceProvider() case types.KindSAMLIdPServiceProvider: resources[i] = respResource.GetSAMLIdPServiceProvider() + case types.KindIdentityCenterAccount: + resources[i] = respResource.GetAppServer() default: return nil, trace.NotImplemented("resource type %s does not support pagination", req.ResourceType) } diff --git a/api/types/app.go b/api/types/app.go index 7452d35879815..75b6283d2e514 100644 --- a/api/types/app.go +++ b/api/types/app.go @@ -91,6 +91,8 @@ type Application interface { GetTCPPorts() []*PortRange // SetTCPPorts sets port ranges to which connections can be forwarded to. SetTCPPorts([]*PortRange) + // GetIdentityCenter fetches identity center info for the app, if any. + GetIdentityCenter() *AppIdentityCenter } // NewAppV3 creates a new app resource. @@ -456,6 +458,23 @@ func (a *AppV3) checkTCPPorts() error { return nil } +// GetIdentityCenter returns the Identity Center information for the app, if any. +// May be nil. +func (a *AppV3) GetIdentityCenter() *AppIdentityCenter { + return a.Spec.IdentityCenter +} + +// GetDisplayName fetches a human-readable display name for the App. +func (a *AppV3) GetDisplayName() string { + // Only Identity Center apps have a display name at this point. Returning + // the empty string signals to the caller they should fall back to whatever + // they have been using in the past. + if a.Spec.IdentityCenter == nil { + return "" + } + return a.GetName() +} + // IsEqual determines if two application resources are equivalent to one another. func (a *AppV3) IsEqual(i Application) bool { if other, ok := i.(*AppV3); ok { @@ -509,3 +528,12 @@ func (a Apps) Less(i, j int) bool { return a[i].GetName() < a[j].GetName() } // Swap swaps two apps. func (a Apps) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +// GetPermissionSets fetches the list of permission sets from the Identity Center +// app information. Handles nil identity center values. +func (a *AppIdentityCenter) GetPermissionSets() []*IdentityCenterPermissionSet { + if a == nil { + return nil + } + return a.PermissionSets +} diff --git a/api/types/resource.go b/api/types/resource.go index ec87a72c97a8c..a2703e54cc619 100644 --- a/api/types/resource.go +++ b/api/types/resource.go @@ -509,7 +509,7 @@ func MatchKinds(resource ResourceWithLabels, kinds []string) bool { } resourceKind := resource.GetKind() switch resourceKind { - case KindApp, KindSAMLIdPServiceProvider: + case KindApp, KindSAMLIdPServiceProvider, KindIdentityCenterAccount: return slices.Contains(kinds, KindApp) default: return slices.Contains(kinds, resourceKind) @@ -686,8 +686,11 @@ func FriendlyName(resource ResourceWithLabels) string { return resource.GetMetadata().Description } - if hn, ok := resource.(interface{ GetHostname() string }); ok { - return hn.GetHostname() + switch rr := resource.(type) { + case interface{ GetHostname() string }: + return rr.GetHostname() + case interface{ GetDisplayName() string }: + return rr.GetDisplayName() } return "" diff --git a/api/types/resource_153.go b/api/types/resource_153.go index 969509d7e910e..bb7ba24b49bd4 100644 --- a/api/types/resource_153.go +++ b/api/types/resource_153.go @@ -18,9 +18,12 @@ import ( "encoding/json" "time" + "google.golang.org/protobuf/protoadapt" "google.golang.org/protobuf/types/known/timestamppb" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + "github.com/gravitational/teleport/api/utils" + apiutils "github.com/gravitational/teleport/api/utils" ) // ResourceMetadata is the smallest interface that defines a Teleport resource. @@ -116,7 +119,8 @@ func (r *legacyToResource153Adapter) GetVersion() string { } // Resource153ToLegacy transforms an RFD 153 style resource into a legacy -// [Resource] type. +// [Resource] type. Implements [ResourceWithLabels] and CloneResource (where the) +// wrapped resource supports cloning). // // Note that CheckAndSetDefaults is a noop for the returned resource and // SetSubKind is not implemented and panics on use. @@ -130,6 +134,8 @@ type Resource153Unwrapper interface { Unwrap() Resource153 } +// resource153ToLegacyAdapter wraps a new-style resource in a type implementing +// the legacy resource interfaces type resource153ToLegacyAdapter struct { inner Resource153 } @@ -212,3 +218,69 @@ func (r *resource153ToLegacyAdapter) SetRevision(rev string) { func (r *resource153ToLegacyAdapter) SetSubKind(subKind string) { panic("interface Resource153 does not implement SetSubKind") } + +func (r *resource153ToLegacyAdapter) Origin() string { + m := r.inner.GetMetadata() + if m == nil { + return "" + } + return m.Labels[OriginLabel] +} + +func (r *resource153ToLegacyAdapter) SetOrigin(origin string) { + m := r.inner.GetMetadata() + if m == nil { + return + } + m.Labels[OriginLabel] = origin +} + +func (r *resource153ToLegacyAdapter) GetLabel(key string) (value string, ok bool) { + m := r.inner.GetMetadata() + if m == nil { + return "", false + } + value, ok = m.Labels[key] + return +} + +func (r *resource153ToLegacyAdapter) GetAllLabels() map[string]string { + m := r.inner.GetMetadata() + if m == nil { + return nil + } + return m.Labels +} + +func (r *resource153ToLegacyAdapter) GetStaticLabels() map[string]string { + return r.GetAllLabels() +} + +func (r *resource153ToLegacyAdapter) SetStaticLabels(labels map[string]string) { + m := r.inner.GetMetadata() + if m == nil { + return + } + m.Labels = labels +} + +func (r *resource153ToLegacyAdapter) MatchSearch(searchValues []string) bool { + fieldVals := append(utils.MapToStrings(r.GetAllLabels()), r.GetName()) + return MatchSearch(fieldVals, searchValues, nil) +} + +func (r *resource153ToLegacyAdapter) CloneResource() ResourceWithLabels { + switch clonable := r.inner.(type) { + case interface{ CloneResource() Resource153 }: + return &resource153ToLegacyAdapter{ + inner: clonable.CloneResource(), + } + + case protoadapt.MessageV1: + return &resource153ToLegacyAdapter{ + inner: apiutils.CloneProtoMsg(clonable).(Resource153), + } + } + + panic("interface Resource153 does not implement CloneResource for the wrapped type") +} diff --git a/api/types/resource_153_test.go b/api/types/resource_153_test.go index 301ab9fa81c10..218530a657105 100644 --- a/api/types/resource_153_test.go +++ b/api/types/resource_153_test.go @@ -83,7 +83,8 @@ func TestResource153ToLegacy(t *testing.T) { // Unwrap gives the underlying resource back. t.Run("unwrap", func(t *testing.T) { - unwrapped := legacyResource.(interface{ Unwrap() types.Resource153 }).Unwrap() + unwrapper := types.Resource153Unwrapper(legacyResource) + unwrapped := unwrapper.Unwrap() if diff := cmp.Diff(bot, unwrapped, protocmp.Transform()); diff != "" { t.Errorf("Unwrap mismatch (-want +got)\n%s", diff) } diff --git a/api/types/role.go b/api/types/role.go index 6983047e51d47..fbb3ed06adcc0 100644 --- a/api/types/role.go +++ b/api/types/role.go @@ -284,6 +284,10 @@ type Role interface { GetGitHubPermissions(RoleConditionType) []GitHubPermission // SetGitHubPermissions sets the allow or deny GitHub-related permissions. SetGitHubPermissions(RoleConditionType, []GitHubPermission) + + // GetIdentityCenterAccountAssignments fetches the allow or deny Account + // Assignments for the role + GetIdentityCenterAccountAssignments(RoleConditionType) []IdentityCenterAccountAssignment } // NewRole constructs new standard V7 role. @@ -2061,6 +2065,15 @@ func (r *RoleV6) makeGitServerLabelMatchers(cond *RoleConditions) LabelMatchers } } +// GetIdentityCenterAccountAssignments fetches the allow or deny Identity Center +// Account Assignments for the role +func (r *RoleV6) GetIdentityCenterAccountAssignments(rct RoleConditionType) []IdentityCenterAccountAssignment { + if rct == Allow { + return r.Spec.Allow.AccountAssignments + } + return r.Spec.Deny.AccountAssignments +} + // LabelMatcherKinds is the complete list of resource kinds that support label // matchers. var LabelMatcherKinds = []string{ @@ -2286,3 +2299,7 @@ func (h *CreateDatabaseUserMode) UnmarshalJSON(data []byte) error { func (m CreateDatabaseUserMode) IsEnabled() bool { return m != CreateDatabaseUserMode_DB_USER_MODE_UNSPECIFIED && m != CreateDatabaseUserMode_DB_USER_MODE_OFF } + +func (a IdentityCenterAccountAssignment) GetAccount() string { + return a.Account +} diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index 5f24f86247aae..d67ed3ae6b6c9 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -145,6 +145,26 @@ func (a *ServerWithRoles) authConnectorAction(resource string, verb string) erro return nil } +// identityCenterAction wraps [ServerWithRoles.identityCenterActionNamespace], +// supplying the default namespace for convenience. +func (a *ServerWithRoles) identityCenterAction(resource string, verbs ...string) error { + return a.identityCenterActionNamespace(apidefaults.Namespace, resource, verbs...) +} + +// identityCenterActionNamespace is a special checker that grants access to +// Identity Center resources. In order to simplify the writing of role condition +// statements, the various Identity Center resources are bundled up under an +// umbrella `KindIdentityCenter` resource kind. This means that if access to the +// target resource is not explicitly denied, then the user has a second chance +// to get access via the generic resource kind. +func (a *ServerWithRoles) identityCenterActionNamespace(namespace string, resource string, verbs ...string) error { + err := a.actionNamespace(namespace, resource, verbs...) + if err == nil || services.IsAccessExplicitlyDenied(err) { + return trace.Wrap(err) + } + return trace.Wrap(a.actionNamespace(namespace, types.KindIdentityCenter, verbs...)) +} + // actionForListWithCondition extracts a restrictive filter condition to be // added to a list query after a simple resource check fails. func (a *ServerWithRoles) actionForListWithCondition(resource, identifier string) (*types.WhereExpr, error) { @@ -1329,7 +1349,12 @@ func (a *ServerWithRoles) ListUnifiedResources(ctx context.Context, req *proto.L actionVerbs = []string{types.VerbList} } - resourceAccess.kindAccessMap[kind] = a.action(kind, actionVerbs...) + actionChecker := a.action + if kind == types.KindIdentityCenterAccount { + actionChecker = a.identityCenterAction + } + + resourceAccess.kindAccessMap[kind] = actionChecker(kind, actionVerbs...) } // Before doing any listing, verify that the user is allowed to list @@ -1666,13 +1691,19 @@ func (a *ServerWithRoles) ListResources(ctx context.Context, req proto.ListResou types.KindWindowsDesktop, types.KindWindowsDesktopService, types.KindUserGroup, - types.KindSAMLIdPServiceProvider: + types.KindSAMLIdPServiceProvider, + types.KindIdentityCenterAccount: default: return nil, trace.NotImplemented("resource type %s does not support pagination", req.ResourceType) } - if err := a.actionNamespace(req.Namespace, req.ResourceType, actionVerbs...); err != nil { + actionChecker := a.actionNamespace + if req.ResourceType == types.KindIdentityCenterAccount { + actionChecker = a.identityCenterActionNamespace + } + + if err := actionChecker(req.Namespace, req.ResourceType, actionVerbs...); err != nil { return nil, trace.Wrap(err) } @@ -1799,9 +1830,14 @@ func (r resourceChecker) CanAccess(resource types.Resource) error { } case types.SAMLIdPServiceProvider: return r.CheckAccess(rr, state) + + case types.Resource153Unwrapper: + if checkable, ok := rr.(services.AccessCheckable); ok { + return r.CheckAccess(checkable, state) + } } - return trace.BadParameter("could not check access to resource type %T", r) + return trace.BadParameter("could not check access to resource type %T", resource) } // newResourceAccessChecker creates a resourceAccessChecker for the provided resource type @@ -1816,7 +1852,8 @@ func (a *ServerWithRoles) newResourceAccessChecker(resource string) (resourceAcc types.KindKubeServer, types.KindUserGroup, types.KindUnifiedResource, - types.KindSAMLIdPServiceProvider: + types.KindSAMLIdPServiceProvider, + types.KindIdentityCenterAccount: return &resourceChecker{AccessChecker: a.context.Checker}, nil default: return nil, trace.BadParameter("could not check access to resource type %s", resource) diff --git a/lib/auth/auth_with_roles_test.go b/lib/auth/auth_with_roles_test.go index 5ccf7c5330b18..c032dca549158 100644 --- a/lib/auth/auth_with_roles_test.go +++ b/lib/auth/auth_with_roles_test.go @@ -49,12 +49,15 @@ import ( "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1" trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1" userpreferencesv1 "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1" "github.com/gravitational/teleport/api/metadata" "github.com/gravitational/teleport/api/mfa" "github.com/gravitational/teleport/api/types" + apicommon "github.com/gravitational/teleport/api/types/common" apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/types/installers" wanpb "github.com/gravitational/teleport/api/types/webauthn" @@ -1078,7 +1081,6 @@ func TestGenerateUserCertsWithMFAVerification(t *testing.T) { }) } } - func TestGenerateUserCertsWithRoleRequest(t *testing.T) { ctx := context.Background() srv := newTestTLSServer(t) @@ -5698,7 +5700,8 @@ func TestListUnifiedResources_MixedAccess(t *testing.T) { Limit: 20, SortBy: types.SortBy{IsDesc: true, Field: types.ResourceMetadataName}, }) - require.True(t, trace.IsAccessDenied(err)) + + require.True(t, trace.IsAccessDenied(err), "Expected Access Denied, got %v", err) require.Nil(t, resp) // Validate that an error is returned when a subset of kinds are requested. @@ -5773,6 +5776,132 @@ func TestListUnifiedResources_WithPredicate(t *testing.T) { require.Error(t, err) } +func TestUnifiedResources_IdentityCenter(t *testing.T) { + ctx := context.Background() + srv := newTestTLSServer(t, withCacheEnabled(true)) + + require.Eventually(t, func() bool { + return srv.Auth().UnifiedResourceCache.IsInitialized() + }, 5*time.Second, 200*time.Millisecond, "unified resource watcher never initialized") + + _, err := srv.Auth().CreateIdentityCenterAccount(ctx, services.IdentityCenterAccount{ + Account: &identitycenterv1.Account{ + Kind: types.KindIdentityCenterAccount, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "test_acct", + Labels: map[string]string{ + types.OriginLabel: apicommon.OriginAWSIdentityCenter, + }, + }, + Spec: &identitycenterv1.AccountSpec{ + Id: "11111111", + Arn: "some:arn", + Name: "Test Account", + }, + }, + }) + require.NoError(t, err) + + setAccountAssignment := func(role types.Role) { + r := role.(*types.RoleV6) + r.Spec.Allow.AccountAssignments = []types.IdentityCenterAccountAssignment{ + { + Account: "11111111", + PermissionSet: "some:arn", + }, + } + } + + t.Run("no access", func(t *testing.T) { + userNoAccess, _, err := CreateUserAndRole(srv.Auth(), "no-access", nil, nil) + require.NoError(t, err) + + identity := TestUser(userNoAccess.GetName()) + clt, err := srv.NewClient(identity) + require.NoError(t, err) + defer clt.Close() + + resp, err := clt.ListResources(ctx, proto.ListResourcesRequest{ + ResourceType: types.KindIdentityCenterAccount, + Labels: map[string]string{ + types.OriginLabel: apicommon.OriginAWSIdentityCenter, + }, + }) + require.NoError(t, err) + require.Empty(t, resp.Resources) + }) + + t.Run("access via generic kind", func(t *testing.T) { + user, _, err := CreateUserAndRole(srv.Auth(), "read-generic", nil, + []types.Rule{ + types.NewRule(types.KindIdentityCenter, services.RO()), + }, + WithRoleMutator(setAccountAssignment)) + require.NoError(t, err) + + identity := TestUser(user.GetName()) + clt, err := srv.NewClient(identity) + require.NoError(t, err) + defer clt.Close() + + resp, err := clt.ListResources(ctx, proto.ListResourcesRequest{ + ResourceType: types.KindIdentityCenterAccount, + Labels: map[string]string{ + types.OriginLabel: apicommon.OriginAWSIdentityCenter, + }, + }) + require.NoError(t, err) + require.Len(t, resp.Resources, 1) + }) + + t.Run("access via specific kind", func(t *testing.T) { + user, _, err := CreateUserAndRole(srv.Auth(), "read-specific", nil, + []types.Rule{ + types.NewRule(types.KindIdentityCenterAccount, services.RO()), + }, + WithRoleMutator(setAccountAssignment)) + require.NoError(t, err) + + identity := TestUser(user.GetName()) + clt, err := srv.NewClient(identity) + require.NoError(t, err) + defer clt.Close() + + resp, err := clt.ListResources(ctx, proto.ListResourcesRequest{ + ResourceType: types.KindIdentityCenterAccount, + }) + require.NoError(t, err) + require.Len(t, resp.Resources, 1) + }) + + t.Run("denied via specific kind beats allow via generic kind", func(t *testing.T) { + user, _, err := CreateUserAndRole(srv.Auth(), "specific-beats-generic", nil, + []types.Rule{ + types.NewRule(types.KindIdentityCenter, services.RO()), + }, + WithRoleMutator(func(r types.Role) { + setAccountAssignment(r) + r.SetRules(types.Deny, []types.Rule{ + types.NewRule(types.KindIdentityCenterAccount, services.RO()), + }) + })) + require.NoError(t, err) + + identity := TestUser(user.GetName()) + clt, err := srv.NewClient(identity) + require.NoError(t, err) + defer clt.Close() + + _, err = clt.ListResources(ctx, proto.ListResourcesRequest{ + ResourceType: types.KindIdentityCenterAccount, + }) + require.True(t, trace.IsAccessDenied(err), + "Expected Access Denied, got %v", err) + }) + +} + func BenchmarkListUnifiedResourcesFilter(b *testing.B) { const nodeCount = 150_000 const roleCount = 32 diff --git a/lib/services/access_checker.go b/lib/services/access_checker.go index 39da72d5bbf8d..6174f56d7f7ba 100644 --- a/lib/services/access_checker.go +++ b/lib/services/access_checker.go @@ -444,6 +444,18 @@ func (a *accessChecker) CheckAccess(r AccessCheckable, state AccessState, matche if err := a.checkAllowedResources(r); err != nil { return trace.Wrap(err) } + + switch rr := r.(type) { + case types.Resource153Unwrapper: + switch urr := rr.Unwrap().(type) { + case IdentityCenterAccount: + matchers = append(matchers, NewIdentityCenterAccountMatcher(urr)) + + case IdentityCenterAccountAssignment: + matchers = append(matchers, NewIdentityCenterAccountAssignmentMatcher(urr)) + } + } + return trace.Wrap(a.RoleSet.checkAccess(r, a.info.Traits, state, matchers...)) } diff --git a/lib/services/access_request.go b/lib/services/access_request.go index 1e3ef0c8fb9de..fec0280019911 100644 --- a/lib/services/access_request.go +++ b/lib/services/access_request.go @@ -2191,19 +2191,32 @@ func (m *RequestValidator) pruneResourceRequestRoles( necessaryRoles := make(map[string]struct{}) for _, resource := range resources { var ( - rolesForResource []types.Role - resourceMatcher *KubeResourcesMatcher + rolesForResource []types.Role + matchers []RoleMatcher + kubeResourceMatcher *KubeResourcesMatcher ) kubernetesResources, err := getKubeResourcesFromResourceIDs(resourceIDs, resource.GetName()) if err != nil { return nil, trace.Wrap(err) } if len(kubernetesResources) > 0 { - resourceMatcher = NewKubeResourcesMatcher(kubernetesResources) + kubeResourceMatcher = NewKubeResourcesMatcher(kubernetesResources) + matchers = append(matchers, kubeResourceMatcher) + } + + switch rr := resource.(type) { + case types.Resource153Unwrapper: + switch urr := rr.Unwrap().(type) { + case IdentityCenterAccount: + matchers = append(matchers, NewIdentityCenterAccountMatcher(urr)) + + case IdentityCenterAccountAssignment: + matchers = append(matchers, NewIdentityCenterAccountAssignmentMatcher(urr)) + } } for _, role := range allRoles { - roleAllowsAccess, err := m.roleAllowsResource(role, resource, loginHint, resourceMatcherToMatcherSlice(resourceMatcher)...) + roleAllowsAccess, err := m.roleAllowsResource(role, resource, loginHint, matchers...) if err != nil { return nil, trace.Wrap(err) } @@ -2217,7 +2230,7 @@ func (m *RequestValidator) pruneResourceRequestRoles( // If any of the requested resources didn't match with the provided roles, // we deny the request because the user is trying to request more access // than what is allowed by its search_as_roles. - if resourceMatcher != nil && len(resourceMatcher.Unmatched()) > 0 { + if kubeResourceMatcher != nil && len(kubeResourceMatcher.Unmatched()) > 0 { resourcesStr, err := types.ResourceIDsToString(resourceIDs) if err != nil { return nil, trace.Wrap(err) @@ -2226,7 +2239,7 @@ func (m *RequestValidator) pruneResourceRequestRoles( `no roles configured in the "search_as_roles" for this user allow `+ `access to at least one requested resources. `+ `resources: %s roles: %v unmatched resources: %v`, - resourcesStr, roles, resourceMatcher.Unmatched()) + resourcesStr, roles, kubeResourceMatcher.Unmatched()) } if len(loginHint) > 0 { // If we have a login hint, request the single role with the fewest diff --git a/lib/services/identitycenter.go b/lib/services/identitycenter.go index d3053b544e005..fa15b59469860 100644 --- a/lib/services/identitycenter.go +++ b/lib/services/identitycenter.go @@ -18,11 +18,16 @@ package services import ( "context" + "fmt" + "strings" - "google.golang.org/protobuf/proto" + "github.com/gravitational/trace" "google.golang.org/protobuf/types/known/emptypb" identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" + "github.com/gravitational/teleport/api/types" + apiutils "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/pagination" ) @@ -49,12 +54,17 @@ type IdentityCenterAccount struct { } // CloneResource creates a deep copy of the underlying account resource -func (a IdentityCenterAccount) CloneResource() IdentityCenterAccount { +func (a IdentityCenterAccount) CloneResource() types.Resource153 { return IdentityCenterAccount{ - Account: proto.Clone(a.Account).(*identitycenterv1.Account), + Account: apiutils.CloneProtoMsg(a.Account), } } +// GetDisplayName returns a human-readable name for the account for UI display. +func (a IdentityCenterAccount) GetDisplayName() string { + return a.Account.GetSpec().GetName() +} + // IdentityCenterAccountID is a strongly-typed Identity Center account ID. type IdentityCenterAccountID string @@ -179,9 +189,9 @@ type IdentityCenterAccountAssignment struct { } // CloneResource creates a deep copy of the underlying account resource -func (a IdentityCenterAccountAssignment) CloneResource() IdentityCenterAccountAssignment { +func (a IdentityCenterAccountAssignment) CloneResource() types.Resource153 { return IdentityCenterAccountAssignment{ - AccountAssignment: proto.Clone(a.AccountAssignment).(*identitycenterv1.AccountAssignment), + AccountAssignment: apiutils.CloneProtoMsg(a.AccountAssignment), } } @@ -226,3 +236,84 @@ type IdentityCenter interface { IdentityCenterPrincipalAssignments IdentityCenterAccountAssignments } + +// NewIdentityCenterAccountMatcher creates a new [IdentityCenterMatcher] +// configured to match the supplied [IdentityCenterAccount]. +func NewIdentityCenterAccountMatcher(account IdentityCenterAccount) *IdentityCenterMatcher { + return &IdentityCenterMatcher{ + accountID: account.GetSpec().GetId(), + permissionSetARN: nil, + } +} + +// NewIdentityCenterAccountAssignmentMatcher creates a new [IdentityCenterMatcher] +// configured to match the supplied [IdentityCenterAccountAssignment]. +func NewIdentityCenterAccountAssignmentMatcher(account IdentityCenterAccountAssignment) *IdentityCenterMatcher { + psARN := account.GetSpec().GetPermissionSet().GetArn() + return &IdentityCenterMatcher{ + accountID: account.GetSpec().GetAccountId(), + permissionSetARN: &psARN, + } +} + +// IdentityCenterMatcher implements a [RoleMatcher] for comparing Identity Center +// resources against the AccountAssignments specified in a Role condition. +// +// The same type is used for matching both [IdentityCenterAccount]s and +// [IdentityCenterAccountAssignment]s, the permission set is `nil` when matching +// an Account. +type IdentityCenterMatcher struct { + accountID string + permissionSetARN *string +} + +// Match implements RoleMatcher for IdentityCenterMatcher. It attempts to match +// the Account Assignments in a Role Condition against a known Account ID or +// (Account ID, PermissionSetARN) pair +func (m *IdentityCenterMatcher) Match(role types.Role, condition types.RoleConditionType) (bool, error) { + for _, asmt := range role.GetIdentityCenterAccountAssignments(condition) { + accountMatches, err := m.matchExpression(m.accountID, asmt.Account) + if err != nil { + return false, trace.Wrap(err) + } + + if !accountMatches { + continue + } + + if m.permissionSetARN == nil { + return true, nil + } + + psMatches, err := m.matchExpression(*(m.permissionSetARN), asmt.PermissionSet) + if err != nil { + return false, trace.Wrap(err) + } + + if psMatches { + return true, nil + } + } + return false, nil +} + +func (m *IdentityCenterMatcher) matchExpression(target, accountExpression string) (bool, error) { + if accountExpression == types.Wildcard { + return true, nil + } + matches, err := utils.MatchString(target, accountExpression) + if err != nil { + return false, trace.Wrap(err) + } + return matches, nil +} + +func (m *IdentityCenterMatcher) String() string { + var text strings.Builder + fmt.Fprintf(&text, "IdentityCenterMatcher(account==%v", m.accountID) + if m.permissionSetARN != nil { + fmt.Fprintf(&text, ", ps==%v", *(m.permissionSetARN)) + } + text.WriteRune(')') + return text.String() +} diff --git a/lib/services/identitycenter_test.go b/lib/services/identitycenter_test.go index 5cbc87493feed..42831b95058a1 100644 --- a/lib/services/identitycenter_test.go +++ b/lib/services/identitycenter_test.go @@ -48,7 +48,7 @@ func TestIdentityCenterAccountClone(t *testing.T) { } // WHEN I clone the resource - dst := src.CloneResource() + dst := src.CloneResource().(IdentityCenterAccount) // EXPECT that the resulting clone compares equally require.Equal(t, src, dst) @@ -82,7 +82,7 @@ func TestIdentityCenterAccountAssignmentClone(t *testing.T) { } // WHEN I clone the resource - dst := src.CloneResource() + dst := src.CloneResource().(IdentityCenterAccountAssignment) // EXPECT that the resulting clone compares equally require.Equal(t, src, dst) @@ -95,3 +95,152 @@ func TestIdentityCenterAccountAssignmentClone(t *testing.T) { require.NotEqual(t, src, dst) require.Equal(t, "original name", dst.Spec.PermissionSet.Name) } + +func ptr[T any](v T) *T { + return &v +} + +func TestIdentityCenterMatcher(t *testing.T) { + + testCases := []struct { + name string + roleAssignment types.IdentityCenterAccountAssignment + condition types.RoleConditionType + matcher IdentityCenterMatcher + expectMatch require.BoolAssertionFunc + }{ + { + name: "simple account match", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "11111111", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + }, + expectMatch: require.True, + }, + { + name: "simple account nonmatch", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "11111111", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "potato", + }, + expectMatch: require.False, + }, + { + name: "account glob", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "*1", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + }, + expectMatch: require.True, + }, + { + name: "account glob nonmatch", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "*!!!!", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + }, + expectMatch: require.False, + }, + { + name: "simple account assignment", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "11111111", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + permissionSetARN: ptr("some:arn"), + }, + expectMatch: require.True, + }, + { + name: "account assignment globbed", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "*", + PermissionSet: "*", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + permissionSetARN: ptr("some:arn"), + }, + expectMatch: require.True, + }, + { + name: "account assignment globbed nonmatch", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "*", + PermissionSet: ":not:an:arn:*", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + permissionSetARN: ptr("some:arn"), + }, + expectMatch: require.False, + }, + { + name: "simple account assignment with bad account", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "11111111", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "potato", + permissionSetARN: ptr("some:arn"), + }, + expectMatch: require.False, + }, + { + name: "simple account assignment with bad ps arn", + roleAssignment: types.IdentityCenterAccountAssignment{ + Account: "11111111", + PermissionSet: "some:arn", + }, + condition: types.Allow, + matcher: IdentityCenterMatcher{ + accountID: "11111111", + permissionSetARN: ptr("banana"), + }, + expectMatch: require.False, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + roleSpec := types.RoleSpecV6{} + condition := &roleSpec.Deny + if testCase.condition == types.Allow { + condition = &roleSpec.Allow + } + condition.AccountAssignments = append(condition.AccountAssignments, + testCase.roleAssignment) + + r, err := types.NewRole("test", roleSpec) + require.NoError(t, err) + + match, err := testCase.matcher.Match(r, testCase.condition) + require.NoError(t, err) + + testCase.expectMatch(t, match) + }) + } +} diff --git a/lib/services/local/presence.go b/lib/services/local/presence.go index aacc78c40f78c..8b2bf5c662f35 100644 --- a/lib/services/local/presence.go +++ b/lib/services/local/presence.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" "github.com/gravitational/teleport/api/internalutils/stream" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/retryutils" @@ -1373,6 +1374,9 @@ func (s *PresenceService) listResources(ctx context.Context, req proto.ListResou case types.KindUserGroup: keyPrefix = []string{userGroupPrefix} unmarshalItemFunc = backendItemToUserGroup + case types.KindIdentityCenterAccount: + keyPrefix = []string{awsResourcePrefix, awsAccountPrefix} + unmarshalItemFunc = backendItemToIdentityCenterAccount default: return nil, trace.NotImplemented("%s not implemented at ListResources", req.ResourceType) } @@ -1761,6 +1765,21 @@ func backendItemToUserGroup(item backend.Item) (types.ResourceWithLabels, error) ) } +func backendItemToIdentityCenterAccount(item backend.Item) (types.ResourceWithLabels, error) { + assignment, err := services.UnmarshalProtoResource[*identitycenterv1.Account]( + item.Value, + services.WithExpires(item.Expires), + services.WithRevision(item.Revision), + ) + if err != nil { + return nil, trace.Wrap(err) + } + resource := types.Resource153ToLegacy( + services.IdentityCenterAccount{Account: assignment}, + ) + return resource.(types.ResourceWithLabels), nil +} + const ( reverseTunnelsPrefix = "reverseTunnels" tunnelConnectionsPrefix = "tunnelConnections" diff --git a/lib/services/matchers.go b/lib/services/matchers.go index 19d543ef022c4..2efc6869172f3 100644 --- a/lib/services/matchers.go +++ b/lib/services/matchers.go @@ -157,7 +157,8 @@ func MatchResourceByFilters(resource types.ResourceWithLabels, filter MatchResou types.KindDatabaseService, types.KindKubernetesCluster, types.KindWindowsDesktop, types.KindWindowsDesktopService, - types.KindUserGroup: + types.KindUserGroup, + types.KindIdentityCenterAccount: specResource = resource case types.KindKubeServer: if seenMap != nil { diff --git a/lib/services/role.go b/lib/services/role.go index 683dbd2d39616..689cf604dfc74 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -2567,6 +2567,12 @@ func (set RoleSet) checkAccess(r AccessCheckable, traits wrappers.Traits, state return ErrSessionMFARequired } + requiresLabelMatching := true + switch r.GetKind() { + case types.KindIdentityCenterAccount, types.KindIdentityCenterAccountAssignment: + requiresLabelMatching = false + } + namespace := types.ProcessNamespace(r.GetMetadata().Namespace) // Additional message depending on kind of resource @@ -2589,18 +2595,18 @@ func (set RoleSet) checkAccess(r AccessCheckable, traits wrappers.Traits, state if !matchNamespace { continue } - - matchLabels, labelsMessage, err := checkRoleLabelsMatch(types.Deny, role, traits, r, isDebugEnabled) - if err != nil { - return trace.Wrap(err) - } - if matchLabels { - debugf("Access to %v %q denied, deny rule in role %q matched; match(namespace=%v, %s)", - r.GetKind(), r.GetName(), role.GetName(), namespaceMessage, labelsMessage) - return trace.AccessDenied("access to %v denied. User does not have permissions. %v", - r.GetKind(), additionalDeniedMessage) + if requiresLabelMatching { + matchLabels, labelsMessage, err := checkRoleLabelsMatch(types.Deny, role, traits, r, isDebugEnabled) + if err != nil { + return trace.Wrap(err) + } + if matchLabels { + debugf("Access to %v %q denied, deny rule in role %q matched; match(namespace=%v, %s)", + r.GetKind(), r.GetName(), role.GetName(), namespaceMessage, labelsMessage) + return trace.AccessDenied("access to %v denied. User does not have permissions. %v", + r.GetKind(), additionalDeniedMessage) + } } - // Deny rules are greedy on purpose. They will always match if // at least one of the matchers returns true. matchMatchers, matchersMessage, err := RoleMatchers(matchers).MatchAny(role, types.Deny) @@ -2634,19 +2640,20 @@ func (set RoleSet) checkAccess(r AccessCheckable, traits wrappers.Traits, state continue } - matchLabels, labelsMessage, err := checkRoleLabelsMatch(types.Allow, role, traits, r, isDebugEnabled) - if err != nil { - return trace.Wrap(err) - } + if requiresLabelMatching { + matchLabels, labelsMessage, err := checkRoleLabelsMatch(types.Allow, role, traits, r, isDebugEnabled) + if err != nil { + return trace.Wrap(err) + } - if !matchLabels { - if isDebugEnabled { - errs = append(errs, trace.AccessDenied("role=%v, match(%s)", - role.GetName(), labelsMessage)) + if !matchLabels { + if isDebugEnabled { + errs = append(errs, trace.AccessDenied("role=%v, match(%s)", + role.GetName(), labelsMessage)) + } + continue } - continue } - // Allow rules are not greedy. They will match only if all of the // matchers return true. matchMatchers, err := RoleMatchers(matchers).MatchAll(role, types.Allow) diff --git a/lib/services/unified_resource.go b/lib/services/unified_resource.go index 7c06471b9d3a6..773b569cc907c 100644 --- a/lib/services/unified_resource.go +++ b/lib/services/unified_resource.go @@ -20,6 +20,7 @@ package services import ( "context" + "maps" "strings" "sync" "time" @@ -37,6 +38,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/pagination" ) // UnifiedResourceKinds is a list of all kinds that are stored in the unified resource cache. @@ -47,6 +49,7 @@ var UnifiedResourceKinds []string = []string{ types.KindAppServer, types.KindWindowsDesktop, types.KindSAMLIdPServiceProvider, + types.KindIdentityCenterAccount, } // UnifiedResourceCacheConfig is used to configure a UnifiedResourceCache @@ -352,6 +355,7 @@ type ResourceGetter interface { WindowsDesktopGetter KubernetesServerGetter SAMLIdpServiceProviderGetter + IdentityCenterAccountGetter } // newWatcher starts and returns a new resource watcher for unified resources. @@ -455,6 +459,11 @@ func (c *UnifiedResourceCache) getResourcesAndUpdateCurrent(ctx context.Context) return trace.Wrap(err) } + newICAccounts, err := c.getIdentityCenterAccounts(ctx) + if err != nil { + return trace.Wrap(err) + } + c.rw.Lock() defer c.rw.Unlock() // empty the trees @@ -470,6 +479,7 @@ func (c *UnifiedResourceCache) getResourcesAndUpdateCurrent(ctx context.Context) putResources[types.KubeServer](c, newKubes) putResources[types.SAMLIdPServiceProvider](c, newSAMLApps) putResources[types.WindowsDesktop](c, newDesktops) + putResources[resource](c, newICAccounts) c.stale = false c.defineCollectorAsInitialized() return nil @@ -581,6 +591,30 @@ func (c *UnifiedResourceCache) getSAMLApps(ctx context.Context) ([]types.SAMLIdP return newSAMLApps, nil } +func (c *UnifiedResourceCache) getIdentityCenterAccounts(ctx context.Context) ([]resource, error) { + var accounts []resource + var pageRequest pagination.PageRequestToken + for { + resultsPage, nextPage, err := c.ListIdentityCenterAccounts(ctx, apidefaults.DefaultChunkSize, &pageRequest) + if err != nil { + return nil, trace.Wrap(err, "getting AWS Identity Center accounts for resource watcher") + } + for _, a := range resultsPage { + acct, ok := types.Resource153ToLegacy(a).(resource) + if !ok { + return nil, trace.BadParameter("Unexpected type for resource: %T", a) + } + accounts = append(accounts, acct) + } + + if nextPage == pagination.EndOfList { + break + } + pageRequest.Update(nextPage) + } + return accounts, nil +} + // read applies the supplied closure to either the primary tree or the ttl-based fallback tree depending on // wether or not the cache is currently healthy. locking is handled internally and the passed-in tree should // not be accessed after the closure completes. @@ -879,6 +913,13 @@ func MakePaginatedResource(ctx context.Context, requestType string, r types.Reso RequiresRequest: requiresRequest, } } + case types.KindIdentityCenterAccount: + var err error + protoResource, err = makePaginatedIdentityCenterAccount(resourceKind, resource, requiresRequest) + if err != nil { + return nil, trace.Wrap(err) + } + default: return nil, trace.NotImplemented("resource type %s doesn't support pagination", resource.GetKind()) } @@ -886,6 +927,64 @@ func MakePaginatedResource(ctx context.Context, requestType string, r types.Reso return protoResource, nil } +// makePaginatedIdentityCenterAccount returns a representation of the supplied +// Identity Center account as an App. +func makePaginatedIdentityCenterAccount(resourceKind string, resource types.ResourceWithLabels, requiresRequest bool) (*proto.PaginatedResource, error) { + unwrapper, ok := resource.(types.Resource153Unwrapper) + if !ok { + return nil, trace.BadParameter("%s has invalid type %T", resourceKind, resource) + } + acct, ok := unwrapper.Unwrap().(IdentityCenterAccount) + if !ok { + return nil, trace.BadParameter("%s has invalid inner type %T", resourceKind, resource) + } + srcPSs := acct.GetSpec().GetPermissionSetInfo() + pss := make([]*types.IdentityCenterPermissionSet, len(srcPSs)) + for i, ps := range acct.GetSpec().GetPermissionSetInfo() { + pss[i] = &types.IdentityCenterPermissionSet{ + ARN: ps.Arn, + Name: ps.Name, + AssignmentName: ps.AssignmentName, + } + } + + protoResource := &proto.PaginatedResource{ + Resource: &proto.PaginatedResource_AppServer{ + AppServer: &types.AppServerV3{ + Kind: types.KindAppServer, + Version: types.V3, + Metadata: resource.GetMetadata(), + Spec: types.AppServerSpecV3{ + App: &types.AppV3{ + Kind: types.KindApp, + SubKind: types.KindIdentityCenterAccount, + Version: types.V3, + Metadata: types.Metadata{ + Name: acct.Spec.Name, + Description: acct.Spec.Description, + Labels: maps.Clone(acct.Metadata.Labels), + }, + Spec: types.AppSpecV3{ + URI: acct.Spec.StartUrl, + PublicAddr: acct.Spec.StartUrl, + AWS: &types.AppAWS{ + ExternalID: acct.Spec.Id, + }, + IdentityCenter: &types.AppIdentityCenter{ + AccountID: acct.Spec.Id, + PermissionSets: pss, + }, + }, + }, + }, + }, + }, + RequiresRequest: requiresRequest, + } + + return protoResource, nil +} + // MakePaginatedResources converts a list of resources into a list of paginated proto representations. func MakePaginatedResources(ctx context.Context, requestType string, resources []types.ResourceWithLabels, requestableMap map[string]struct{}) ([]*proto.PaginatedResource, error) { paginatedResources := make([]*proto.PaginatedResource, 0, len(resources)) diff --git a/lib/services/unified_resource_test.go b/lib/services/unified_resource_test.go index 6e27cd2f45a71..5915874619d4d 100644 --- a/lib/services/unified_resource_test.go +++ b/lib/services/unified_resource_test.go @@ -35,8 +35,11 @@ import ( "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/defaults" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" apimetadata "github.com/gravitational/teleport/api/metadata" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/common" "github.com/gravitational/teleport/api/types/header" "github.com/gravitational/teleport/lib/backend/memory" "github.com/gravitational/teleport/lib/services" @@ -55,17 +58,24 @@ func TestUnifiedResourceWatcher(t *testing.T) { services.Presence services.WindowsDesktops services.SAMLIdPServiceProviders + services.IdentityCenterAccounts types.Events } samlService, err := local.NewSAMLIdPServiceProviderService(bk) require.NoError(t, err) + icService, err := local.NewIdentityCenterService(local.IdentityCenterServiceConfig{ + Backend: bk, + }) + require.NoError(t, err) + clt := &client{ Presence: local.NewPresenceService(bk), WindowsDesktops: local.NewWindowsDesktopService(bk), SAMLIdPServiceProviders: samlService, Events: local.NewEventsService(bk), + IdentityCenterAccounts: icService, } // Add node to the backend. node := newNodeServer(t, "node1", "hostname1", "127.0.0.1:22", false /*tunnel*/) @@ -142,8 +152,11 @@ func TestUnifiedResourceWatcher(t *testing.T) { err = clt.UpsertWindowsDesktop(ctx, win) require.NoError(t, err) + icAcct := newIdentityCenterAccount(t, ctx, clt) + // we expect each of the resources above to exist - expectedRes := []types.ResourceWithLabels{node, app, samlapp, dbServer, win} + expectedRes := []types.ResourceWithLabels{node, app, samlapp, dbServer, win, + types.Resource153ToLegacy(icAcct)} assert.Eventually(t, func() bool { res, err = w.GetUnifiedResources(ctx) return len(res) == len(expectedRes) @@ -156,6 +169,19 @@ func TestUnifiedResourceWatcher(t *testing.T) { cmpopts.IgnoreFields(header.Metadata{}, "Revision"), // Ignore order. cmpopts.SortSlices(func(a, b types.ResourceWithLabels) bool { return a.GetName() < b.GetName() }), + + // Allow comparison of the wrapped resource inside a resource153ToLegacyAdapter + cmp.Transformer("Unwrap", + func(t types.Resource153Unwrapper) types.Resource153 { + return t.Unwrap() + }), + + // Ignore unexported values in RFD153-style resources + cmpopts.IgnoreUnexported( + headerv1.Metadata{}, + identitycenterv1.Account{}, + identitycenterv1.AccountSpec{}, + identitycenterv1.PermissionSetInfo{}), )) // // Update and remove some resources. @@ -166,7 +192,9 @@ func TestUnifiedResourceWatcher(t *testing.T) { require.NoError(t, err) // this should include the updated node, and shouldn't have any apps included - expectedRes = []types.ResourceWithLabels{nodeUpdated, samlapp, dbServer, win} + expectedRes = []types.ResourceWithLabels{nodeUpdated, samlapp, dbServer, win, + types.Resource153ToLegacy(icAcct)} + assert.Eventually(t, func() bool { res, err = w.GetUnifiedResources(ctx) require.NoError(t, err) @@ -182,6 +210,20 @@ func TestUnifiedResourceWatcher(t *testing.T) { cmpopts.EquateEmpty(), cmpopts.IgnoreFields(types.Metadata{}, "Revision"), cmpopts.IgnoreFields(header.Metadata{}, "Revision"), + + // Allow comparison of the wrapped values inside a Resource153ToLegacyAdapter + cmp.Transformer("Unwrap", + func(t types.Resource153Unwrapper) types.Resource153 { + return t.Unwrap() + }), + + // Ignore unexported values in RFD153-style resources + cmpopts.IgnoreUnexported( + headerv1.Metadata{}, + identitycenterv1.Account{}, + identitycenterv1.AccountSpec{}, + identitycenterv1.PermissionSetInfo{}), + // Ignore order. cmpopts.SortSlices(func(a, b types.ResourceWithLabels) bool { return a.GetName() < b.GetName() }), )) @@ -199,17 +241,24 @@ func TestUnifiedResourceWatcher_PreventDuplicates(t *testing.T) { services.Presence services.WindowsDesktops services.SAMLIdPServiceProviders + services.IdentityCenterAccountGetter types.Events } samlService, err := local.NewSAMLIdPServiceProviderService(bk) require.NoError(t, err) + icService, err := local.NewIdentityCenterService(local.IdentityCenterServiceConfig{ + Backend: bk, + }) + require.NoError(t, err) + clt := &client{ - Presence: local.NewPresenceService(bk), - WindowsDesktops: local.NewWindowsDesktopService(bk), - SAMLIdPServiceProviders: samlService, - Events: local.NewEventsService(bk), + Presence: local.NewPresenceService(bk), + WindowsDesktops: local.NewWindowsDesktopService(bk), + SAMLIdPServiceProviders: samlService, + Events: local.NewEventsService(bk), + IdentityCenterAccountGetter: icService, } w, err := services.NewUnifiedResourceCache(ctx, services.UnifiedResourceCacheConfig{ ResourceWatcherConfig: services.ResourceWatcherConfig{ @@ -255,17 +304,24 @@ func TestUnifiedResourceWatcher_DeleteEvent(t *testing.T) { services.Presence services.WindowsDesktops services.SAMLIdPServiceProviders + services.IdentityCenterAccounts types.Events } samlService, err := local.NewSAMLIdPServiceProviderService(bk) require.NoError(t, err) + icService, err := local.NewIdentityCenterService(local.IdentityCenterServiceConfig{ + Backend: bk, + }) + require.NoError(t, err) + clt := &client{ Presence: local.NewPresenceService(bk), WindowsDesktops: local.NewWindowsDesktopService(bk), SAMLIdPServiceProviders: samlService, Events: local.NewEventsService(bk), + IdentityCenterAccounts: icService, } w, err := services.NewUnifiedResourceCache(ctx, services.UnifiedResourceCacheConfig{ ResourceWatcherConfig: services.ResourceWatcherConfig{ @@ -360,9 +416,12 @@ func TestUnifiedResourceWatcher_DeleteEvent(t *testing.T) { require.NoError(t, err) _, err = clt.UpsertKubernetesServer(ctx, kubeServer) require.NoError(t, err) + + icAcct := newIdentityCenterAccount(t, ctx, clt) + assert.Eventually(t, func() bool { res, _ := w.GetUnifiedResources(ctx) - return len(res) == 6 + return len(res) == 7 }, 5*time.Second, 10*time.Millisecond, "Timed out waiting for unified resources to be added") // delete everything @@ -378,6 +437,8 @@ func TestUnifiedResourceWatcher_DeleteEvent(t *testing.T) { require.NoError(t, err) err = clt.DeleteKubernetesServer(ctx, kubeServer.Spec.HostID, kubeServer.GetName()) require.NoError(t, err) + err = clt.DeleteIdentityCenterAccount(ctx, services.IdentityCenterAccountID(icAcct.GetMetadata().GetName())) + require.NoError(t, err) assert.Eventually(t, func() bool { res, _ := w.GetUnifiedResources(ctx) @@ -440,3 +501,40 @@ const testEntityDescriptor = ` ` + +func newIdentityCenterAccount(t *testing.T, ctx context.Context, svc services.IdentityCenterAccounts) services.IdentityCenterAccount { + t.Helper() + + accountID := t.Name() + + icAcct, err := svc.CreateIdentityCenterAccount(ctx, services.IdentityCenterAccount{ + Account: &identitycenterv1.Account{ + Kind: types.KindIdentityCenterAccount, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: t.Name(), + Labels: map[string]string{ + types.OriginLabel: common.OriginIntegrationAWSOIDC, + types.IdentityCenterAccountLabel: accountID, + }, + }, + Spec: &identitycenterv1.AccountSpec{ + Id: accountID, + Arn: "arn:aws:sso:::account/" + accountID, + Name: "Test AWS Account", + Description: "Used for testing", + PermissionSetInfo: []*identitycenterv1.PermissionSetInfo{ + { + Name: "Alpha", + Arn: "arn:aws:sso:::permissionSet/ssoins-1234567890/ps-alpha", + }, + { + Name: "Beta", + Arn: "arn:aws:sso:::permissionSet/ssoins-1234567890/ps-beta", + }, + }, + }, + }}) + require.NoError(t, err, "creating Identity Center Account") + return icAcct +} diff --git a/lib/web/ui/app.go b/lib/web/ui/app.go index e77958486b729..0ac6394b2810b 100644 --- a/lib/web/ui/app.go +++ b/lib/web/ui/app.go @@ -34,6 +34,9 @@ import ( type App struct { // Kind is the kind of resource. Used to parse which kind in a list of unified resources in the UI Kind string `json:"kind"` + // SubKind is the subkind of the app resource. Used to differentiate different + // flavors of app. + SubKind string `json:"subkind,omitempty"` // Name is the name of the application. Name string `json:"name"` // Description is the app description. @@ -66,6 +69,9 @@ type App struct { // Integration is the integration name that must be used to access this Application. // Only applicable to AWS App Access. Integration string `json:"integration,omitempty"` + // PermissionSets holds the permission sets that this app grants access to. + // Only valid for Identity Center Account apps + PermissionSets []IdentityCenterPermissionSet `json:"permission_sets,omitempty"` } // UserGroupAndDescription is a user group name and its description. @@ -76,6 +82,17 @@ type UserGroupAndDescription struct { Description string `json:"description"` } +// IdentityCenterPermissionSet holds information about Identity Center +// Permission Sets for transmission to the UI +type IdentityCenterPermissionSet struct { + Name string `json:"name"` + ARN string `json:"arn"` + // AssignmentName is the assignment resource that will provision an Account + // Assignment for this Permission Set on the enclosing account + AssignmentName string `json:"accountAssignment,omitempty"` + RequiresRequest bool `json:"requiresRequest,omitempty"` +} + // MakeAppsConfig contains parameters for converting apps to UI representation. type MakeAppsConfig struct { // LocalClusterName is the name of the local cluster. @@ -129,8 +146,11 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { description = oktaDescription } + permissionSets := makePermissionSets(app.GetIdentityCenter().GetPermissionSets()) + resultApp := App{ Kind: types.KindApp, + SubKind: app.GetSubKind(), Name: app.GetName(), Description: description, URI: app.GetURI(), @@ -144,6 +164,7 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { SAMLApp: false, RequiresRequest: c.RequiresRequest, Integration: app.GetIntegration(), + PermissionSets: permissionSets, } if app.IsAWSConsole() { @@ -155,6 +176,21 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { return resultApp } +func makePermissionSets(src []*types.IdentityCenterPermissionSet) []IdentityCenterPermissionSet { + if src == nil { + return nil + } + dst := make([]IdentityCenterPermissionSet, len(src)) + for i, srcPS := range src { + dst[i] = IdentityCenterPermissionSet{ + Name: srcPS.Name, + ARN: srcPS.ARN, + AssignmentName: srcPS.AssignmentName, + } + } + return dst +} + // MakeAppTypeFromSAMLApp creates App type from SAMLIdPServiceProvider type for the WebUI. // Keep in sync with lib/teleterm/apiserver/handler/handler_apps.go. // Note: The SAMLAppPreset field is used in SAML service provider update flow in the