From 6ba62c5e29766744bd61e9f4cdfb756485422945 Mon Sep 17 00:00:00 2001 From: Trent Clarke Date: Wed, 20 Nov 2024 16:32:05 +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 - Automatic label generation for Accounts - General plumbing from backend through to cache --- api/types/app.go | 17 +++ api/types/constants.go | 8 ++ api/types/resource.go | 2 +- api/types/role.go | 11 ++ api/types/role_test.go | 58 +++++++++ api/utils/slices.go | 14 +++ api/utils/slices_test.go | 22 ++++ lib/auth/auth_with_roles.go | 17 ++- lib/services/matchers.go | 2 + lib/services/unified_resource.go | 108 +++++++++++++++- lib/services/unified_resource_adapter.go | 154 +++++++++++++++++++++++ lib/services/unified_resource_test.go | 97 ++++++++++++-- lib/web/ui/app.go | 24 ++++ 13 files changed, 521 insertions(+), 13 deletions(-) create mode 100644 lib/services/unified_resource_adapter.go diff --git a/api/types/app.go b/api/types/app.go index 7806d54c2b899..5a5edfed41b8d 100644 --- a/api/types/app.go +++ b/api/types/app.go @@ -86,6 +86,8 @@ type Application interface { GetRequiredAppNames() []string // GetCORS returns the CORS configuration for the app. GetCORS() *CORSPolicy + // GetIdentityCenter fetches identity center info for the app, if any. + GetIdentityCenter() *AppIdentityCenter } // NewAppV3 creates a new app resource. @@ -405,6 +407,12 @@ func (a *AppV3) CheckAndSetDefaults() 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 +} + // IsEqual determines if two application resources are equivalent to one another. func (a *AppV3) IsEqual(i Application) bool { if other, ok := i.(*AppV3); ok { @@ -458,3 +466,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/constants.go b/api/types/constants.go index 62125faa1bd7a..3fee884f8de64 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -178,6 +178,10 @@ const ( // KindApp is a web app resource. KindApp = "app" + // AppSubKindIdentityCenterAccount indicates that an App actually represents + // an Identity Center account + AppSubKindIdentityCenterAccount = KindIdentityCenterAccount + // KindAppOrSAMLIdPServiceProvider represent an App Server resource or a SAML IdP Service Provider (SAML Application) resource. // This is not a real resource stored in the backend, it is a pseudo resource used only to provide a common interface to // the ListResources RPC in order to be able to list both AppServers and SAMLIdPServiceProviders in the same request. @@ -832,6 +836,10 @@ const ( // ReqAnnotationTeamsLabel is the request annotation key at which teams are stored for access plugins. ReqAnnotationTeamsLabel = "/teams" + // IdentityCenterAccountLabel annotates an Identity Center resource with the + // AWS account it applies to + IdentityCenterAccountLabel = TeleportNamespace + "/aws-ic-account" + // CloudAWS identifies that a resource was discovered in AWS. CloudAWS = "AWS" // CloudAzure identifies that a resource was discovered in Azure. diff --git a/api/types/resource.go b/api/types/resource.go index ec87a72c97a8c..0b0618e00ac73 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) diff --git a/api/types/role.go b/api/types/role.go index af435f822074d..d75be1661aa6d 100644 --- a/api/types/role.go +++ b/api/types/role.go @@ -1913,6 +1913,13 @@ func (r *RoleV6) GetLabelMatchers(rct RoleConditionType, kind string) (LabelMatc return LabelMatchers{cond.WindowsDesktopLabels, cond.WindowsDesktopLabelsExpression}, nil case KindUserGroup: return LabelMatchers{cond.GroupLabels, cond.GroupLabelsExpression}, nil + case KindIdentityCenterAccount: + var matchers LabelMatchers + accounts := utils.Transform(cond.AccountAssignments, IdentityCenterAccountAssignment.GetAccount) + if len(accounts) > 0 { + matchers.Labels = Labels{IdentityCenterAccountLabel: utils.Deduplicate(accounts)} + } + return matchers, nil } return LabelMatchers{}, trace.BadParameter("can't get label matchers for resource kind %q", kind) } @@ -2241,3 +2248,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/api/types/role_test.go b/api/types/role_test.go index 09ac7c2072951..2e67406b89b5e 100644 --- a/api/types/role_test.go +++ b/api/types/role_test.go @@ -811,3 +811,61 @@ func TestRoleFilterMatch(t *testing.T) { }) } } + +func TestIdentityCenterAccountLabels(t *testing.T) { + role, err := NewRole(t.Name(), RoleSpecV6{ + Allow: RoleConditions{ + AccountAssignments: []IdentityCenterAccountAssignment{ + { + Account: "11111111", + PermissionSet: "some-permission-set", + }, + { + Account: "11111111", + PermissionSet: "some-other-permission-set", + }, + { + Account: "22222222", + PermissionSet: "*", + }, + }, + }, + Deny: RoleConditions{ + AccountAssignments: []IdentityCenterAccountAssignment{ + { + Account: "33333333", + PermissionSet: "some-other-permission-set", + }, + }, + }, + }) + require.NoError(t, err) + + testCases := []struct { + name string + condition RoleConditionType + expectedAccounts []string + }{ + { + name: "allow", + condition: Allow, + expectedAccounts: []string{"11111111", "22222222"}, + }, + { + name: "deny", + condition: Deny, + expectedAccounts: []string{"33333333"}, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(y *testing.T) { + labelMatchers, err := role.GetLabelMatchers(test.condition, KindIdentityCenterAccount) + require.NoError(t, err) + require.Empty(t, labelMatchers.Expression) + require.Len(t, labelMatchers.Labels, 1) + require.Contains(t, labelMatchers.Labels, IdentityCenterAccountLabel) + require.ElementsMatch(t, test.expectedAccounts, labelMatchers.Labels[IdentityCenterAccountLabel]) + }) + } +} diff --git a/api/utils/slices.go b/api/utils/slices.go index 0b1cf0f7b802d..df19fcfb05623 100644 --- a/api/utils/slices.go +++ b/api/utils/slices.go @@ -129,3 +129,17 @@ func CountBy[S ~[]E, E any](elements S, mapper func(E) string) map[string]int { } return out } + +// Transform transforms a slice of values into a new slice, with values being converted by the +// supplied mapping function +func Transform[S ~[]T, T any, U any](src S, mapper func(T) U) []U { + // preserve nil in case it matters + if src == nil { + return nil + } + dst := make([]U, len(src)) + for i, t := range src { + dst[i] = mapper(t) + } + return dst +} diff --git a/api/utils/slices_test.go b/api/utils/slices_test.go index 64885624de135..5e57d4c62b895 100644 --- a/api/utils/slices_test.go +++ b/api/utils/slices_test.go @@ -223,3 +223,25 @@ func TestCountBy(t *testing.T) { }) } } + +func TestTransform(t *testing.T) { + t.Run("nil", func(t *testing.T) { + dst := Transform(([]int)(nil), strconv.Itoa) + require.Nil(t, dst) + }) + + t.Run("empty", func(t *testing.T) { + dst := Transform([]int{}, strconv.Itoa) + require.NotNil(t, dst) + require.Empty(t, dst) + }) + + t.Run("populated", func(t *testing.T) { + src := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 0} + dst := Transform(src, strconv.Itoa) + require.Equal(t, + []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"}, + dst, + ) + }) +} diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index 70078ee0ff737..c018fddc175f3 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -1281,7 +1281,7 @@ func (c *resourceAccess) checkAccess(resource types.ResourceWithLabels, filter s return false, nil } - // check access normally if base checker doesnt exist + // check access normally if base checker doesn't exist if c.baseAuthChecker == nil { if err := c.accessChecker.CanAccess(resource); err != nil { if trace.IsAccessDenied(err) { @@ -1352,7 +1352,12 @@ func (a *ServerWithRoles) ListUnifiedResources(ctx context.Context, req *proto.L actionVerbs = []string{types.VerbList} } - resourceAccess.kindAccessMap[kind] = a.action(apidefaults.Namespace, kind, actionVerbs...) + checkKind := kind + if kind == types.KindIdentityCenterAccount { + checkKind = types.KindIdentityCenter + } + + resourceAccess.kindAccessMap[kind] = a.action(apidefaults.Namespace, checkKind, actionVerbs...) } // Before doing any listing, verify that the user is allowed to list @@ -1822,9 +1827,12 @@ func (r resourceChecker) CanAccess(resource types.Resource) error { } case types.SAMLIdPServiceProvider: return r.CheckAccess(rr, state) + + case services.UnifiedResource153Adapter[services.IdentityCenterAccount]: + return r.CheckAccess(rr, 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 @@ -1839,7 +1847,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/services/matchers.go b/lib/services/matchers.go index 19d543ef022c4..2564ac46290b0 100644 --- a/lib/services/matchers.go +++ b/lib/services/matchers.go @@ -185,6 +185,8 @@ func MatchResourceByFilters(resource types.ResourceWithLabels, filter MatchResou default: return false, trace.BadParameter("expected types.SAMLIdPServiceProvider or types.AppServer, got %T", resource) } + case types.KindIdentityCenterAccount: + specResource = resource default: // We check if the resource kind is a Kubernetes resource kind to reduce the amount of // of cases we need to handle. If the resource type didn't match any arm before diff --git a/lib/services/unified_resource.go b/lib/services/unified_resource.go index 7c06471b9d3a6..3d97fd578ac44 100644 --- a/lib/services/unified_resource.go +++ b/lib/services/unified_resource.go @@ -20,6 +20,8 @@ package services import ( "context" + "maps" + "reflect" "strings" "sync" "time" @@ -37,6 +39,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 +50,7 @@ var UnifiedResourceKinds []string = []string{ types.KindAppServer, types.KindWindowsDesktop, types.KindSAMLIdPServiceProvider, + types.KindIdentityCenterAccount, } // UnifiedResourceCacheConfig is used to configure a UnifiedResourceCache @@ -352,6 +356,7 @@ type ResourceGetter interface { WindowsDesktopGetter KubernetesServerGetter SAMLIdpServiceProviderGetter + IdentityCenterAccountGetter } // newWatcher starts and returns a new resource watcher for unified resources. @@ -455,6 +460,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 +480,7 @@ func (c *UnifiedResourceCache) getResourcesAndUpdateCurrent(ctx context.Context) putResources[types.KubeServer](c, newKubes) putResources[types.SAMLIdPServiceProvider](c, newSAMLApps) putResources[types.WindowsDesktop](c, newDesktops) + putResources[UnifiedResource153Adapter[IdentityCenterAccount]](c, newICAccounts) c.stale = false c.defineCollectorAsInitialized() return nil @@ -581,6 +592,26 @@ func (c *UnifiedResourceCache) getSAMLApps(ctx context.Context) ([]types.SAMLIdP return newSAMLApps, nil } +func (c *UnifiedResourceCache) getIdentityCenterAccounts(ctx context.Context) ([]UnifiedResource153Adapter[IdentityCenterAccount], error) { + var accounts []UnifiedResource153Adapter[IdentityCenterAccount] + 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 { + accounts = append(accounts, WrapUnifiedResource153(a)) + } + + 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. @@ -670,7 +701,20 @@ func (c *UnifiedResourceCache) processEventsAndUpdateCurrent(ctx context.Context case types.OpDelete: c.deleteLocked(event.Resource) case types.OpPut: - c.putLocked(event.Resource.(resource)) + r := event.Resource + if u, ok := r.(types.Resource153Unwrapper); ok { + switch unwrapped := u.Unwrap().(type) { + case IdentityCenterAccount: + r = WrapUnifiedResource153(unwrapped) + + default: + c.log. + WithField("type", reflect.TypeOf(unwrapped)). + Warn("Unsupported resource type") + continue + } + } + c.putLocked(r.(resource)) default: c.log.Warnf("unsupported event type %s.", event.Type) continue @@ -879,6 +923,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 +937,61 @@ 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.(UnifiedResource153Adapter[IdentityCenterAccount]) + if !ok { + return nil, trace.BadParameter("%s has invalid type %T", resourceKind, resource) + } + acct := unwrapper.Unwrap().Account + + 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, + } + } + + 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.AppSubKindIdentityCenterAccount, + 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_adapter.go b/lib/services/unified_resource_adapter.go new file mode 100644 index 0000000000000..f64e0fe85a0ca --- /dev/null +++ b/lib/services/unified_resource_adapter.go @@ -0,0 +1,154 @@ +package services + +import ( + "encoding/json" + "time" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// UnifiedResource153 is a type constraint that requires a type to implement +// the Resource153 interface AND provides a Clone method. +type UnifiedResource153[T interface{ CloneResource() T }] interface { + types.Resource153 + CloneResource() T +} + +// UnifiedResource153Adapter is a wrapper around a newer, RFD153-style resource +// that provides the newer style resources with the interfaces required for use +// with the Unified resource cache. +type UnifiedResource153Adapter[T UnifiedResource153[T]] struct { + Inner T +} + +// WrapUnifiedResource153 wraps a RFD153-style resource in a type that implements +// the interfaces required for use with the Unified Resource Cache +func WrapUnifiedResource153[T UnifiedResource153[T]](r T) UnifiedResource153Adapter[T] { + return UnifiedResource153Adapter[T]{Inner: r} +} + +// Unwrap pulls the underlying resource out of the wrapper. +func (r UnifiedResource153Adapter[T]) Unwrap() T { + return r.Inner +} + +// MarshalJSON adds support for marshaling the wrapped resource (instead of +// marshaling the adapter itself). +func (r UnifiedResource153Adapter[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Inner) +} + +// Expiry maps the RFD153 metadata expiry time (which is a protobuf timestamp) +// to the older style resource Expiry (which is a Go time.Time). +func (r UnifiedResource153Adapter[T]) Expiry() time.Time { + expires := r.Inner.GetMetadata().Expires + // return zero time.time{} for zero *timestamppb.Timestamp, instead of 01/01/1970. + if expires == nil { + return time.Time{} + } + + return expires.AsTime() +} + +func (r UnifiedResource153Adapter[T]) GetKind() string { + return r.Inner.GetKind() +} + +func (r UnifiedResource153Adapter[T]) GetMetadata() types.Metadata { + md := r.Inner.GetMetadata() + + // use zero time.time{} for zero *timestamppb.Timestamp, instead of 01/01/1970. + expires := md.Expires.AsTime() + if md.Expires == nil { + expires = time.Time{} + } + + return types.Metadata{ + Name: md.Name, + Namespace: md.Namespace, + Description: md.Description, + Labels: md.Labels, + Expires: &expires, + Revision: md.Revision, + } +} + +func (r UnifiedResource153Adapter[T]) GetName() string { + return r.Inner.GetMetadata().Name +} + +func (r UnifiedResource153Adapter[T]) GetRevision() string { + return r.Inner.GetMetadata().Revision +} + +func (r UnifiedResource153Adapter[T]) GetSubKind() string { + return r.Inner.GetSubKind() +} + +func (r UnifiedResource153Adapter[T]) GetVersion() string { + return r.Inner.GetVersion() +} + +func (r UnifiedResource153Adapter[T]) SetExpiry(t time.Time) { + r.Inner.GetMetadata().Expires = timestamppb.New(t) +} + +func (r UnifiedResource153Adapter[T]) SetName(name string) { + r.Inner.GetMetadata().Name = name +} + +func (r UnifiedResource153Adapter[T]) SetRevision(rev string) { + r.Inner.GetMetadata().Revision = rev +} + +func (r UnifiedResource153Adapter[T]) SetSubKind(subKind string) { + panic("interface Resource153 does not implement SetSubKind") +} + +func (r UnifiedResource153Adapter[T]) Origin() string { + m := r.Inner.GetMetadata() + if m == nil { + return "" + } + return m.Labels[types.OriginLabel] +} + +func (r UnifiedResource153Adapter[T]) SetOrigin(string) { + panic("interface Resource153 does not implement SetOrigin") +} + +func (r UnifiedResource153Adapter[T]) GetLabel(key string) (value string, ok bool) { + m := r.Inner.GetMetadata() + if m == nil { + return "", false + } + value, ok = m.Labels[key] + return +} + +func (r UnifiedResource153Adapter[T]) GetAllLabels() map[string]string { + m := r.Inner.GetMetadata() + if m == nil { + return nil + } + return m.Labels +} + +func (r UnifiedResource153Adapter[T]) GetStaticLabels() map[string]string { + return r.GetAllLabels() +} + +func (r UnifiedResource153Adapter[T]) SetStaticLabels(map[string]string) { + panic("interface Resource153 does not implement SetStaticLabels") +} + +func (r UnifiedResource153Adapter[T]) MatchSearch(searchValues []string) bool { + fieldVals := append(utils.MapToStrings(r.GetAllLabels()), r.GetName()) + return types.MatchSearch(fieldVals, searchValues, nil) +} + +func (r UnifiedResource153Adapter[T]) CloneResource() types.ResourceWithLabels { + return UnifiedResource153Adapter[T]{Inner: r.Inner.CloneResource()} +} diff --git a/lib/services/unified_resource_test.go b/lib/services/unified_resource_test.go index 6e27cd2f45a71..704927c8689a6 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,10 @@ 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, services.WrapUnifiedResource153(icAcct)} assert.Eventually(t, func() bool { res, err = w.GetUnifiedResources(ctx) return len(res) == len(expectedRes) @@ -156,6 +168,13 @@ 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() }), + + // Ignore unexported values in RFD153-style resources + cmpopts.IgnoreUnexported( + headerv1.Metadata{}, + identitycenterv1.Account{}, + identitycenterv1.AccountSpec{}, + identitycenterv1.PermissionSetInfo{}), )) // // Update and remove some resources. @@ -166,7 +185,7 @@ 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, services.WrapUnifiedResource153(icAcct)} assert.Eventually(t, func() bool { res, err = w.GetUnifiedResources(ctx) require.NoError(t, err) @@ -182,6 +201,14 @@ func TestUnifiedResourceWatcher(t *testing.T) { cmpopts.EquateEmpty(), cmpopts.IgnoreFields(types.Metadata{}, "Revision"), cmpopts.IgnoreFields(header.Metadata{}, "Revision"), + + // 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 +226,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 +289,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 +401,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 +422,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 +486,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..1571bb243f8d9 100644 --- a/lib/web/ui/app.go +++ b/lib/web/ui/app.go @@ -25,6 +25,7 @@ import ( "github.com/sirupsen/logrus" "github.com/gravitational/teleport/api/types" + apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/ui" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/aws" @@ -34,6 +35,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 +70,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 +83,14 @@ 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"` + 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. @@ -131,6 +146,7 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { resultApp := App{ Kind: types.KindApp, + Subkind: app.GetSubKind(), Name: app.GetName(), Description: description, URI: app.GetURI(), @@ -144,6 +160,7 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { SAMLApp: false, RequiresRequest: c.RequiresRequest, Integration: app.GetIntegration(), + PermissionSets: apiutils.Transform(app.GetIdentityCenter().GetPermissionSets(), mapPermissionSet), } if app.IsAWSConsole() { @@ -155,6 +172,13 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { return resultApp } +func mapPermissionSet(ps *types.IdentityCenterPermissionSet) IdentityCenterPermissionSet { + return IdentityCenterPermissionSet{ + Name: ps.Name, + ARN: ps.ARN, + } +} + // 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