From fb656bd0a25acfae9a7ac2cea6bed55014f28f7b Mon Sep 17 00:00:00 2001 From: Trent Clarke Date: Thu, 12 Dec 2024 20:14:29 +1100 Subject: [PATCH] Adds FriendlyName support for IC resources The IC Account and AccountAssignment resources do not have very user-friendly ids, making it hard to know what access is actually being granted when reviewing an access request. This patch adds support for - querying Identity Center accounts via `auth.ListResources` - generating a friendly resource name when reviewing an access request with IC resources. --- api/accessrequest/access_request.go | 22 ++++++- api/types/resource.go | 14 ++++ lib/auth/auth.go | 46 +++++++++++++ lib/auth/authclient/api.go | 4 ++ lib/cache/cache.go | 20 ++++++ lib/cache/identitycenter.go | 1 + lib/services/identitycenter.go | 8 +++ lib/services/local/identitycenter.go | 34 ++++++++++ lib/services/local/identitycenter_test.go | 78 +++++++++++++++++++++++ lib/utils/pagination/pagination.go | 5 ++ 10 files changed, 231 insertions(+), 1 deletion(-) diff --git a/api/accessrequest/access_request.go b/api/accessrequest/access_request.go index 7b82cfc8f25a6..75175872e324a 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, user groups and Identity Center resources. switch resourceID.Kind { - case types.KindNode, types.KindApp, types.KindUserGroup, types.KindIdentityCenterAccount: + case types.KindNode, types.KindApp, types.KindUserGroup, types.KindIdentityCenterAccount, types.KindIdentityCenterAccountAssignment: resourceIDs = append(resourceIDs, resourceID) } } @@ -89,6 +89,26 @@ func GetResourceDetails(ctx context.Context, clusterName string, lister client.L Kind: resource.GetKind(), Name: resource.GetName(), } + + // We pretend that AWS accounts are Apps for display, so we have to rewrite + // the `id` of the App resource returned by GetResourcesByResourceIDs() + // to that of the corresponding `IdentityCenterAccount` that the caller + // was asking for. + if resource.GetKind() == types.KindApp && resource.GetSubKind() == types.KindIdentityCenterAccount { + appResource, ok := resource.(*types.AppV3) + if !ok { + return nil, trace.BadParameter("invalid type for kind App: %T", resource) + } + + icInfo := appResource.GetIdentityCenter() + if icInfo == nil { + return nil, trace.BadParameter("malformed Identity Center App: identity center info is missing") + } + + id.Kind = types.KindIdentityCenterAccount + id.Name = icInfo.AccountID + } + result[types.ResourceIDToString(id)] = types.ResourceDetails{ FriendlyName: friendlyName, } diff --git a/api/types/resource.go b/api/types/resource.go index ad5beaceb786b..9f8b795b16ec5 100644 --- a/api/types/resource.go +++ b/api/types/resource.go @@ -26,6 +26,7 @@ import ( "github.com/gravitational/trace" "github.com/gravitational/teleport/api/defaults" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" "github.com/gravitational/teleport/api/types/common" "github.com/gravitational/teleport/api/types/compare" "github.com/gravitational/teleport/api/utils" @@ -705,6 +706,19 @@ func FriendlyName(resource ResourceWithLabels) string { } switch rr := resource.(type) { + case Resource153Unwrapper: + // RFD-153 style resources are generally data-only and do not have any + // methods beyond the minimal [Resource153] interface. Because we can't + // rely on them being able to implement an interface in order to generate + // a friendly name, any 153-style resources that *want* a friendly name + // will have to manually generate it. + switch urr := rr.Unwrap().(type) { + case *identitycenterv1.Account: + return urr.GetSpec().GetName() + + case *identitycenterv1.AccountAssignment: + return urr.GetSpec().GetDisplay() + } case interface{ GetHostname() string }: return rr.GetHostname() case interface{ GetDisplayName() string }: diff --git a/lib/auth/auth.go b/lib/auth/auth.go index c240ad6fc585f..25926fa8fc31d 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -123,6 +123,7 @@ import ( usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/interval" + "github.com/gravitational/teleport/lib/utils/pagination" vc "github.com/gravitational/teleport/lib/versioncontrol" "github.com/gravitational/teleport/lib/versioncontrol/github" uw "github.com/gravitational/teleport/lib/versioncontrol/upgradewindow" @@ -6372,9 +6373,54 @@ func (a *Server) ListResources(ctx context.Context, req proto.ListResourcesReque NextKey: wResp.NextKey, }, nil } + if req.ResourceType == types.KindIdentityCenterAccount { + return a.listIdentityCenterAccounts(ctx, req) + } return a.Cache.ListResources(ctx, req) } +func (a *Server) listIdentityCenterAccounts(ctx context.Context, req proto.ListResourcesRequest) (*types.ListResourcesResponse, error) { + filter := services.MatchResourceFilter{ + ResourceKind: types.KindIdentityCenterAccount, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + filter.PredicateExpression = expression + } + + startKey := pagination.NewRequestToken(req.StartKey) + + accounts, nextPage, err := a.Cache.ListIdentityCenterAccountsWithFilter(ctx, int(req.Limit), &startKey, + func(acct services.IdentityCenterAccount) bool { + match, err := services.MatchResourceByFilters( + types.Resource153ToResourceWithLabels(acct), filter, nil) + if err != nil { + a.logger.ErrorContext(ctx, "failed running matcher", "error", err) + return false + } + return match + }) + if err != nil { + return nil, trace.Wrap(err) + } + + resources := make([]types.ResourceWithLabels, len(accounts)) + for i, acct := range accounts { + resources[i] = types.Resource153ToResourceWithLabels(acct) + } + + return &types.ListResourcesResponse{ + Resources: resources, + NextKey: string(nextPage), + }, nil +} + // CreateKubernetesCluster creates a new kubernetes cluster resource. func (a *Server) CreateKubernetesCluster(ctx context.Context, kubeCluster types.KubeCluster) error { if err := enforceLicense(types.KindKubernetesCluster); err != nil { diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index 409e4850e8a97..1ca977725384a 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -1244,6 +1244,10 @@ type Cache interface { // GetProvisioningState gets a specific provisioning state GetProvisioningState(context.Context, services.DownstreamID, services.ProvisioningStateID) (*provisioningv1.PrincipalState, error) + // ListIdentityCenterAccountsWithFilter returns a paginated list of all + // Identity Center Accounts in the cache that match the supplied predicate + ListIdentityCenterAccountsWithFilter(context.Context, int, *pagination.PageRequestToken, func(services.IdentityCenterAccount) bool) ([]services.IdentityCenterAccount, pagination.NextPageToken, error) + // GetAccountAssignment fetches specific IdentityCenter Account Assignment GetAccountAssignment(context.Context, services.IdentityCenterAccountAssignmentID) (services.IdentityCenterAccountAssignment, error) diff --git a/lib/cache/cache.go b/lib/cache/cache.go index 0c0f05febe720..d1b13889a7d1c 100644 --- a/lib/cache/cache.go +++ b/lib/cache/cache.go @@ -3620,6 +3620,26 @@ func (c *Cache) GetProvisioningState(ctx context.Context, downstream services.Do return rg.reader.GetProvisioningState(ctx, downstream, id) } +// ListIdentityCenterAccountsWithFilter returns a paginated list of all +// Identity Center Accounts in the cache that match the supplied predicate +func (c *Cache) ListIdentityCenterAccountsWithFilter( + ctx context.Context, + pageSize int, + pageToken *pagination.PageRequestToken, + filter func(services.IdentityCenterAccount) bool, +) ([]services.IdentityCenterAccount, pagination.NextPageToken, error) { + ctx, span := c.Tracer.Start(ctx, "cache/ListIdentityCenterAccountsWithFilter") + defer span.End() + + rg, err := readCollectionCache(c, c.collections.identityCenterAccounts) + if err != nil { + return nil, "", trace.Wrap(err) + } + defer rg.Release() + + return rg.reader.ListIdentityCenterAccountsWithFilter(ctx, pageSize, pageToken, filter) +} + func (c *Cache) GetAccountAssignment(ctx context.Context, id services.IdentityCenterAccountAssignmentID) (services.IdentityCenterAccountAssignment, error) { ctx, span := c.Tracer.Start(ctx, "cache/GetAccountAssignment") defer span.End() diff --git a/lib/cache/identitycenter.go b/lib/cache/identitycenter.go index 953da7d4ce913..7c94ee3f6b455 100644 --- a/lib/cache/identitycenter.go +++ b/lib/cache/identitycenter.go @@ -30,6 +30,7 @@ import ( type identityCenterAccountGetter interface { GetIdentityCenterAccount(context.Context, services.IdentityCenterAccountID) (services.IdentityCenterAccount, error) ListIdentityCenterAccounts(context.Context, int, *pagination.PageRequestToken) ([]services.IdentityCenterAccount, pagination.NextPageToken, error) + ListIdentityCenterAccountsWithFilter(context.Context, int, *pagination.PageRequestToken, func(services.IdentityCenterAccount) bool) ([]services.IdentityCenterAccount, pagination.NextPageToken, error) } type identityCenterAccountExecutor struct{} diff --git a/lib/services/identitycenter.go b/lib/services/identitycenter.go index 5bac0349b804b..87770d520220c 100644 --- a/lib/services/identitycenter.go +++ b/lib/services/identitycenter.go @@ -83,6 +83,14 @@ type IdentityCenterAccountGetter interface { type IdentityCenterAccounts interface { IdentityCenterAccountGetter + // ListIdentityCenterAccountsWithFilter lists Identity Center Accounts in the backend store + ListIdentityCenterAccountsWithFilter( + context.Context, + int, + *pagination.PageRequestToken, + func(IdentityCenterAccount) bool, + ) ([]IdentityCenterAccount, pagination.NextPageToken, error) + // CreateIdentityCenterAccount creates a new Identity Center Account record CreateIdentityCenterAccount(context.Context, IdentityCenterAccount) (IdentityCenterAccount, error) diff --git a/lib/services/local/identitycenter.go b/lib/services/local/identitycenter.go index 92904a5b9fa42..c66733c602219 100644 --- a/lib/services/local/identitycenter.go +++ b/lib/services/local/identitycenter.go @@ -169,6 +169,40 @@ func (svc *IdentityCenterService) ListIdentityCenterAccounts(ctx context.Context return result, pagination.NextPageToken(nextPage), nil } +// ListIdentityCenterAccountsWithFilter returns a paginated list of all +// Identity Center Accounts in the service backend that match the supplied +// predicate +func (svc *IdentityCenterService) ListIdentityCenterAccountsWithFilter( + ctx context.Context, + pageSize int, + page *pagination.PageRequestToken, + matcher func(services.IdentityCenterAccount) bool, +) ([]services.IdentityCenterAccount, pagination.NextPageToken, error) { + if pageSize == 0 { + pageSize = identityCenterPageSize + } + + pageToken, err := page.Consume() + if err != nil { + return nil, "", trace.Wrap(err, "listing identity center assignment records") + } + + wrappedMatcher := func(a *identitycenterv1.Account) bool { + return matcher(services.IdentityCenterAccount{Account: a}) + } + + accounts, nextPage, err := svc.accounts.ListResourcesWithFilter(ctx, pageSize, pageToken, wrappedMatcher) + if err != nil { + return nil, "", trace.Wrap(err) + } + + result := make([]services.IdentityCenterAccount, len(accounts)) + for i, acct := range accounts { + result[i] = services.IdentityCenterAccount{Account: acct} + } + return result, pagination.NextPageToken(nextPage), nil +} + // CreateIdentityCenterAccount creates a new Identity Center Account record func (svc *IdentityCenterService) CreateIdentityCenterAccount(ctx context.Context, acct services.IdentityCenterAccount) (services.IdentityCenterAccount, error) { created, err := svc.accounts.CreateResource(ctx, acct.Account) diff --git a/lib/services/local/identitycenter_test.go b/lib/services/local/identitycenter_test.go index 0a2c085fa76ce..5817eddb96df7 100644 --- a/lib/services/local/identitycenter_test.go +++ b/lib/services/local/identitycenter_test.go @@ -19,6 +19,8 @@ package local import ( "context" "fmt" + "maps" + "slices" "testing" "github.com/gravitational/trace" @@ -32,6 +34,7 @@ import ( "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/lite" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils/pagination" ) func newTestBackend(t *testing.T, ctx context.Context, clock clockwork.Clock) backend.Backend { @@ -270,6 +273,81 @@ func TestIdentityCenterResourceCRUD(t *testing.T) { } } +func TestIdentityCenterAccountListing(t *testing.T) { + // GIVEN a test cluster + ctx := newTestContext(t) + clock := clockwork.NewFakeClock() + backend := newTestBackend(t, ctx, clock) + + // GIVEN an Identity Center Service + uut, err := NewIdentityCenterService(IdentityCenterServiceConfig{Backend: backend}) + require.NoError(t, err) + + // GIVEN a collection of Identity Center accounts + accounts := make(map[services.IdentityCenterAccountID]services.IdentityCenterAccount) + for _, id := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf"} { + accounts[services.IdentityCenterAccountID(id)] = + makeTestIdentityCenterAccount(t, ctx, uut, id) + } + + testCases := []struct { + name string + pageSize int + filter func(services.IdentityCenterAccount) bool + expected []services.IdentityCenterAccountID + }{ + { + name: "full", + pageSize: 0, + filter: func(services.IdentityCenterAccount) bool { return true }, + expected: slices.Collect(maps.Keys(accounts)), + }, + { + name: "paged", + pageSize: 2, + filter: func(services.IdentityCenterAccount) bool { return true }, + expected: slices.Collect(maps.Keys(accounts)), + }, + { + name: "filtered", + pageSize: 3, + expected: []services.IdentityCenterAccountID{ + "alpha", "charlie", "echo", "golf", + }, + filter: func(acct services.IdentityCenterAccount) bool { + name := acct.Metadata.Name + return name == "alpha" || name == "charlie" || + name == "echo" || name == "golf" + }, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + var pageToken pagination.PageRequestToken + output := make(map[services.IdentityCenterAccountID]services.IdentityCenterAccount) + for { + page, nextPage, err := uut.ListIdentityCenterAccountsWithFilter(ctx, test.pageSize, &pageToken, test.filter) + require.NoError(t, err) + + if test.pageSize != 0 { + require.LessOrEqual(t, len(page), test.pageSize) + } + + for _, account := range page { + output[services.IdentityCenterAccountID(account.Metadata.Name)] = account + } + + if nextPage == pagination.EndOfList { + break + } + pageToken.Update(nextPage) + } + require.Len(t, output, len(test.expected)) + }) + } +} + func makeTestIdentityCenterAccount(t *testing.T, ctx context.Context, svc services.IdentityCenter, id string) services.IdentityCenterAccount { t.Helper() created, err := svc.CreateIdentityCenterAccount(ctx, services.IdentityCenterAccount{ diff --git a/lib/utils/pagination/pagination.go b/lib/utils/pagination/pagination.go index d013ea321e8e8..e2f7ae84c3404 100644 --- a/lib/utils/pagination/pagination.go +++ b/lib/utils/pagination/pagination.go @@ -95,6 +95,11 @@ type PageRequestToken struct { stale bool } +// NewRequestToken wraps the supplied string in a PageRequestToken +func NewRequestToken(key string) PageRequestToken { + return PageRequestToken{token: key} +} + // Consume moves the token value out of the PageRequestToken and marks the token // as stale. If the token is already stale, this method will fail with // BadParameter.