diff --git a/api/accessrequest/access_request.go b/api/accessrequest/access_request.go index 2823aefbdc7d5..1af10ac4400d3 100644 --- a/api/accessrequest/access_request.go +++ b/api/accessrequest/access_request.go @@ -60,7 +60,7 @@ func GetResourceDetails(ctx context.Context, clusterName string, lister client.L // We're interested in hostname or friendly name details. These apply to // nodes, app servers, and user groups. switch resourceID.Kind { - case types.KindNode, types.KindApp, types.KindUserGroup: + case types.KindNode, types.KindApp, types.KindUserGroup, types.KindIdentityCenterAccount: resourceIDs = append(resourceIDs, resourceID) } } diff --git a/api/client/client.go b/api/client/client.go index 003a69637d728..fae9ebf561546 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -3808,6 +3808,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..e2862a086ac05 100644 --- a/api/types/resource_153.go +++ b/api/types/resource_153.go @@ -21,6 +21,7 @@ import ( "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" ) // ResourceMetadata is the smallest interface that defines a Teleport resource. @@ -116,7 +117,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 +132,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 +216,111 @@ func (r *resource153ToLegacyAdapter) SetRevision(rev string) { func (r *resource153ToLegacyAdapter) SetSubKind(subKind string) { panic("interface Resource153 does not implement SetSubKind") } + +// ClonableResource153 adds a restriction on [Resource153] such that implementors +// must have a CloneResource() method. +type ClonableResource153 interface { + Resource153 + CloneResource() ClonableResource153 +} + +// UnifiedResource represents the combined set of interfaces that a resource +// must implement to be used with the Teleport Unified Resource Cache +type UnifiedResource interface { + ResourceWithLabels + CloneResource() ResourceWithLabels +} + +// Resource153ToUnifiedResource wraps an RFD153-style resource in a type that +// implements the legacy [ResourceWithLabels] interface and is suitable for use +// with the Teleport Unified Resources Cache. +// +// The same caveats that apply to [Resource153ToLegacy] apply. +func Resource153ToUnifiedResource(r ClonableResource153) UnifiedResource { + return &resource153ToUnifiedResourceAdapter{ + resource153ToLegacyAdapter: resource153ToLegacyAdapter{ + inner: r, + }, + } +} + +// resource153ToUnifiedResourceAdapter wraps a [resource153ToLegacyAdapter] to +// provide an implementation of [UnifiedResource] +type resource153ToUnifiedResourceAdapter struct { + resource153ToLegacyAdapter +} + +// Origin implements ResourceWithLabels for the adapter. +func (r *resource153ToUnifiedResourceAdapter) Origin() string { + m := r.inner.GetMetadata() + if m == nil { + return "" + } + return m.Labels[OriginLabel] +} + +// SetOrigin implements ResourceWithLabels for the adapter. +func (r *resource153ToUnifiedResourceAdapter) SetOrigin(origin string) { + m := r.inner.GetMetadata() + if m == nil { + return + } + m.Labels[OriginLabel] = origin +} + +// GetLabel implements ResourceWithLabels for the adapter. +func (r *resource153ToUnifiedResourceAdapter) GetLabel(key string) (value string, ok bool) { + m := r.inner.GetMetadata() + if m == nil { + return "", false + } + value, ok = m.Labels[key] + return +} + +// GetAllLabels implements ResourceWithLabels for the adapter. +func (r *resource153ToUnifiedResourceAdapter) GetAllLabels() map[string]string { + m := r.inner.GetMetadata() + if m == nil { + return nil + } + return m.Labels +} + +// GetStaticLabels implements ResourceWithLabels for the adapter. +func (r *resource153ToUnifiedResourceAdapter) GetStaticLabels() map[string]string { + return r.GetAllLabels() +} + +// SetStaticLabels implements ResourceWithLabels for the adapter. +func (r *resource153ToUnifiedResourceAdapter) SetStaticLabels(labels map[string]string) { + m := r.inner.GetMetadata() + if m == nil { + return + } + m.Labels = labels +} + +// MatchSearch implements ResourceWithLabels for the adapter. If the underlying +// type exposes a MatchSearch method, this method will defer to that, otherwise +// it will match against the resource label values and name. +func (r *resource153ToUnifiedResourceAdapter) MatchSearch(searchValues []string) bool { + if matcher, ok := r.inner.(interface{ MatchSearch([]string) bool }); ok { + return matcher.MatchSearch(searchValues) + } + fieldVals := append(utils.MapToStrings(r.GetAllLabels()), r.GetName()) + return MatchSearch(fieldVals, searchValues, nil) +} + +// CloneResource clones the underlying resource and wraps it in +func (r *resource153ToUnifiedResourceAdapter) CloneResource() ResourceWithLabels { + // We assume that this type assertion will work because we force `inner` + // to implement ClonableResource153 in [Resource153ToUnifiedResource], which + // is the only externally-visible constructor function + clone := r.inner.(ClonableResource153).CloneResource() + return &resource153ToUnifiedResourceAdapter{ + resource153ToLegacyAdapter: resource153ToLegacyAdapter{ + inner: clone, + }, + } +} diff --git a/api/types/resource_153_test.go b/api/types/resource_153_test.go index 301ab9fa81c10..f6cbb14c9a64b 100644 --- a/api/types/resource_153_test.go +++ b/api/types/resource_153_test.go @@ -80,10 +80,10 @@ func TestResource153ToLegacy(t *testing.T) { } legacyResource := types.Resource153ToLegacy(bot) - // Unwrap gives the underlying resource back. t.Run("unwrap", func(t *testing.T) { - unwrapped := legacyResource.(interface{ Unwrap() types.Resource153 }).Unwrap() + unwrapper := legacyResource.(types.Resource153Unwrapper) + 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..3878f38044d98 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,8 @@ 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 } + +// GetAccount fetches the Account ID from a Role Condition Account Assignment +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..1ff0dbb4594d2 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -1666,7 +1666,8 @@ 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) diff --git a/lib/auth/auth_with_roles_test.go b/lib/auth/auth_with_roles_test.go index 5ccf7c5330b18..f7b12fcae29b8 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" @@ -5698,7 +5701,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 +5777,57 @@ 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) + + t.Run("access denied", func(t *testing.T) { + // Asserts that, with no RBAC or matchers in place, acces to IC Accounts + // is denied by default + + userNoAccess, _, err := CreateUserAndRole(srv.Auth(), "test", nil, nil) + require.NoError(t, err) + + identity := TestUser(userNoAccess.GetName()) + clt, err := srv.NewClient(identity) + require.NoError(t, err) + defer clt.Close() + + _, err = clt.ListResources(ctx, proto.ListResourcesRequest{ + ResourceType: types.KindIdentityCenterAccount, + Labels: map[string]string{ + types.OriginLabel: apicommon.OriginAWSIdentityCenter, + }, + }) + require.True(t, trace.IsAccessDenied(err)) + }) + + // TODO(tcsc): Add other tests one RBAC implemented +} + func BenchmarkListUnifiedResourcesFilter(b *testing.B) { const nodeCount = 150_000 const roleCount = 32 diff --git a/lib/services/identitycenter.go b/lib/services/identitycenter.go index d3053b544e005..538f8a84f91ea 100644 --- a/lib/services/identitycenter.go +++ b/lib/services/identitycenter.go @@ -19,10 +19,11 @@ package services import ( "context" - "google.golang.org/protobuf/proto" "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/pagination" ) @@ -49,12 +50,17 @@ type IdentityCenterAccount struct { } // CloneResource creates a deep copy of the underlying account resource -func (a IdentityCenterAccount) CloneResource() IdentityCenterAccount { +func (a IdentityCenterAccount) CloneResource() types.ClonableResource153 { 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 +185,9 @@ type IdentityCenterAccountAssignment struct { } // CloneResource creates a deep copy of the underlying account resource -func (a IdentityCenterAccountAssignment) CloneResource() IdentityCenterAccountAssignment { +func (a IdentityCenterAccountAssignment) CloneResource() types.ClonableResource153 { return IdentityCenterAccountAssignment{ - AccountAssignment: proto.Clone(a.AccountAssignment).(*identitycenterv1.AccountAssignment), + AccountAssignment: apiutils.CloneProtoMsg(a.AccountAssignment), } } diff --git a/lib/services/identitycenter_test.go b/lib/services/identitycenter_test.go index 5cbc87493feed..98de511413325 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) diff --git a/lib/services/local/presence.go b/lib/services/local/presence.go index aacc78c40f78c..e7424e500f050 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.Resource153ToUnifiedResource( + 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/unified_resource.go b/lib/services/unified_resource.go index 7c06471b9d3a6..521bf293716d1 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,26 @@ 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 { + accounts = append(accounts, types.Resource153ToUnifiedResource(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 +700,30 @@ func (c *UnifiedResourceCache) processEventsAndUpdateCurrent(ctx context.Context case types.OpDelete: c.deleteLocked(event.Resource) case types.OpPut: - c.putLocked(event.Resource.(resource)) + switch r := event.Resource.(type) { + case resource: + c.putLocked(r) + + case types.Resource153Unwrapper: + // Raw RFD-153 style resources generally have very few methods + // defined on them by design. One way to add complex behavior to + // these resources is to wrap them inside another type that implements + // any methods or interfaces they need. Resources arriving here + // via the cache protocol will have those wrappers stripped away, + // so we unfortunately need to unwrap and re-wrap these values + // to restore them to a useful state. + switch unwrapped := r.Unwrap().(type) { + case IdentityCenterAccount: + c.putLocked(types.Resource153ToUnifiedResource(unwrapped)) + + default: + c.log.Warnf("unsupported Resource153 type %T.", unwrapped) + } + + default: + c.log.Warnf("unsupported Resource type %T.", r) + } + default: c.log.Warnf("unsupported event type %s.", event.Type) continue @@ -879,6 +932,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 +946,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, + AssignmentID: ps.AssignmentId, + } + } + + 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..c40aa9f43b890 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.Resource153ToUnifiedResource(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.Resource153ToUnifiedResource(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,39 @@ 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, + }, + }, + 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..313cb4f103603 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:"permissionSets,omitempty"` } // UserGroupAndDescription is a user group name and its description. @@ -76,6 +82,19 @@ type UserGroupAndDescription struct { Description string `json:"description"` } +// IdentityCenterPermissionSet holds information about Identity Center +// Permission Sets for transmission to the UI +type IdentityCenterPermissionSet struct { + // Name is the human-readable name of the permission set + Name string `json:"name"` + // ARN is the AWS-assigned ARN of the permission set + ARN string `json:"arn"` + // AssignmentID is the assignment resource that will provision an Account + // Assignment for this Permission Set on the enclosing account + AssignmentID 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 +148,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 +166,7 @@ func MakeApp(app types.Application, c MakeAppsConfig) App { SAMLApp: false, RequiresRequest: c.RequiresRequest, Integration: app.GetIntegration(), + PermissionSets: permissionSets, } if app.IsAWSConsole() { @@ -155,6 +178,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, + AssignmentID: srcPS.AssignmentID, + } + } + 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