diff --git a/api/types/constants.go b/api/types/constants.go index 85dbe7c140388..36e2571891067 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -564,6 +564,20 @@ const ( // KindStaticHostUser is a host user to be created on matching SSH nodes. KindStaticHostUser = "static_host_user" + // KindIdentityCenterAccount describes an Identity-Center managed AWS Account + KindIdentityCenterAccount = "aws_ic_account" + + // KindIdentityCenterPermissionSet describes an AWS Identity Center Permission Set + KindIdentityCenterPermissionSet = "aws_ic_permission_set" + + // KindIdentityCenterPermissionSet describes an AWS Principal Assignment, representing + // a collection Account Assignments assigned to a Teleport User or AccessList + KindIdentityCenterPrincipalAssignment = "aws_ic_principal_assignment" + + // KindIdentityCenterAccountAssignment describes an AWS Account and Permission Set + // pair that can be requested by a Teleport User. + KindIdentityCenterAccountAssignment = "aws_ic_account_assignment" + // MetaNameAccessGraphSettings is the exact name of the singleton resource holding // access graph settings. MetaNameAccessGraphSettings = "access-graph-settings" diff --git a/lib/auth/accesspoint/accesspoint.go b/lib/auth/accesspoint/accesspoint.go index 158243d126550..3cf7c1d2e86aa 100644 --- a/lib/auth/accesspoint/accesspoint.go +++ b/lib/auth/accesspoint/accesspoint.go @@ -106,6 +106,7 @@ type Config struct { WindowsDesktops services.WindowsDesktops AutoUpdateService services.AutoUpdateServiceGetter ProvisioningStates services.ProvisioningStates + IdentityCenter services.IdentityCenter } func (c *Config) CheckAndSetDefaults() error { diff --git a/lib/auth/auth.go b/lib/auth/auth.go index ffffea18d4e72..232c7587f9c6c 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -347,7 +347,14 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) { if cfg.ProvisioningStates == nil { cfg.ProvisioningStates, err = local.NewProvisioningStateService(cfg.Backend) if err != nil { - return nil, trace.Wrap(err, "Creating provisioning state service") + return nil, trace.Wrap(err) + } + } + if cfg.IdentityCenter == nil { + svcCfg := local.IdentityCenterServiceConfig{Backend: cfg.Backend} + cfg.IdentityCenter, err = local.NewIdentityCenterService(svcCfg) + if err != nil { + return nil, trace.Wrap(err) } } if cfg.CloudClients == nil { @@ -676,6 +683,7 @@ type Services struct { services.StaticHostUser services.AutoUpdateService services.ProvisioningStates + services.IdentityCenter } // GetWebSession returns existing web session described by req. diff --git a/lib/auth/helpers.go b/lib/auth/helpers.go index 227d9568699de..851cca043ad92 100644 --- a/lib/auth/helpers.go +++ b/lib/auth/helpers.go @@ -342,6 +342,7 @@ func NewTestAuthServer(cfg TestAuthServerConfig) (*TestAuthServer, error) { DiscoveryConfigs: svces.DiscoveryConfigs, DynamicAccess: svces.DynamicAccessExt, Events: svces.Events, + IdentityCenter: svces.IdentityCenter, Integrations: svces.Integrations, KubeWaitingContainers: svces.KubeWaitingContainer, Kubernetes: svces.Kubernetes, diff --git a/lib/auth/init.go b/lib/auth/init.go index 9ac7abc541db5..28a94a6e39546 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -328,6 +328,10 @@ type InitConfig struct { // Logger is the logger instance for the auth service to use. Logger *slog.Logger + + // IdentityCenter is the Identity Center state storage service to use in + // this node. + IdentityCenter services.IdentityCenter } // Init instantiates and configures an instance of AuthServer diff --git a/lib/services/identitycenter.go b/lib/services/identitycenter.go new file mode 100644 index 0000000000000..d3fbf6aca9756 --- /dev/null +++ b/lib/services/identitycenter.go @@ -0,0 +1,220 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package services + +import ( + "context" + + "google.golang.org/protobuf/proto" + + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" + "github.com/gravitational/teleport/lib/utils/pagination" +) + +// IdentityCenterAccount wraps a raw identity center record in a new type to +// allow it to implement the interfaces required for use with the Unified +// Resource listing. +// +// IdentityCenterAccount simply wraps a pointer to the underlying +// identitycenterv1.Account record, and can be treated as a reference-like type. +// Copies of an IdentityCenterAccount will point to the same record. +type IdentityCenterAccount struct { + // This wrapper needs to: + // - implement the interfaces required for use with the Unified Resource + // service. + // - expose the existing interfaces & methods on the underlying + // identitycenterv1.Account + // - avoid copying the underlying identitycenterv1.Account due to embedded + // mutexes in the protobuf-generated code + // + // Given those requirements, storing an embedded pointer seems to be the + // least-bad approach. + + *identitycenterv1.Account +} + +// CloneResource creates a deep copy of the underlying account resource +func (a IdentityCenterAccount) CloneResource() IdentityCenterAccount { + return IdentityCenterAccount{ + Account: proto.Clone(a.Account).(*identitycenterv1.Account), + } +} + +// IdentityCenterAccountID is a strongly-typed Identity Center account ID. +type IdentityCenterAccountID string + +// IdentityCenterAccountGetter provides read-only access to Identity Center +// Account records +type IdentityCenterAccountGetter interface { + // ListIdentityCenterAccounts provides a paged list of all known identity + // center accounts + ListIdentityCenterAccounts(context.Context, int, *pagination.PageRequestToken) ([]IdentityCenterAccount, pagination.NextPageToken, error) + + // GetIdentityCenterAccount fetches a specific Identity Center Account + GetIdentityCenterAccount(context.Context, IdentityCenterAccountID) (IdentityCenterAccount, error) +} + +// IdentityCenterAccounts defines read/write access to Identity Center account +// resources +type IdentityCenterAccounts interface { + IdentityCenterAccountGetter + + // CreateIdentityCenterAccount creates a new Identity Center Account record + CreateIdentityCenterAccount(context.Context, IdentityCenterAccount) (IdentityCenterAccount, error) + + // UpdateIdentityCenterAccount performs a conditional update on an Identity + // Center Account record, returning the updated record on success. + UpdateIdentityCenterAccount(context.Context, IdentityCenterAccount) (IdentityCenterAccount, error) + + // UpsertIdentityCenterAccount performs an *unconditional* upsert on an + // Identity Center Account record, returning the updated record on success. + // Be careful when mixing UpsertIdentityCenterAccount() with resources + // protected by optimistic locking + UpsertIdentityCenterAccount(context.Context, IdentityCenterAccount) (IdentityCenterAccount, error) + + // DeleteIdentityCenterAccount deletes an Identity Center Account record + DeleteIdentityCenterAccount(context.Context, IdentityCenterAccountID) error + + // DeleteAllIdentityCenterAccounts deletes all Identity Center Account records + DeleteAllIdentityCenterAccounts(context.Context) error +} + +// PrincipalAssignmentID is a strongly-typed ID for Identity Center Principal +// Assignments +type PrincipalAssignmentID string + +// IdentityCenterPrincipalAssignments defines operations on an Identity Center +// principal assignment database +type IdentityCenterPrincipalAssignments interface { + // ListPrincipalAssignments lists all PrincipalAssignment records in the + // service + ListPrincipalAssignments(context.Context, int, *pagination.PageRequestToken) ([]*identitycenterv1.PrincipalAssignment, pagination.NextPageToken, error) + + // CreatePrincipalAssignment creates a new Principal Assignment record in + // the service from the supplied in-memory representation. Returns the + // created record on success. + CreatePrincipalAssignment(context.Context, *identitycenterv1.PrincipalAssignment) (*identitycenterv1.PrincipalAssignment, error) + + // GetPrincipalAssignment fetches a specific Principal Assignment record. + GetPrincipalAssignment(context.Context, PrincipalAssignmentID) (*identitycenterv1.PrincipalAssignment, error) + + // UpdatePrincipalAssignment performs a conditional update on a Principal + // Assignment record + UpdatePrincipalAssignment(context.Context, *identitycenterv1.PrincipalAssignment) (*identitycenterv1.PrincipalAssignment, error) + + // UpsertPrincipalAssignment performs an unconditional update on a Principal + // Assignment record + UpsertPrincipalAssignment(context.Context, *identitycenterv1.PrincipalAssignment) (*identitycenterv1.PrincipalAssignment, error) + + // DeletePrincipalAssignment deletes a specific principal assignment record + DeletePrincipalAssignment(context.Context, PrincipalAssignmentID) error + + // DeleteAllPrincipalAssignments deletes all assignment record + DeleteAllPrincipalAssignments(context.Context) error +} + +// PermissionSetID is a strongly typed ID for an identitycenterv1.PermissionSet +type PermissionSetID string + +// IdentityCenterPermissionSets defines the operations to create and maintain +// identitycenterv1.PermissionSet records in the service. +type IdentityCenterPermissionSets interface { + // ListPermissionSets list the known Permission Sets + ListPermissionSets(context.Context, int, *pagination.PageRequestToken) ([]*identitycenterv1.PermissionSet, pagination.NextPageToken, error) + + // CreatePermissionSet creates a new PermissionSet record based on the + // supplied in-memory representation, returning the created record on + // success + CreatePermissionSet(context.Context, *identitycenterv1.PermissionSet) (*identitycenterv1.PermissionSet, error) + + // GetPermissionSet fetches a specific PermissionSet record + GetPermissionSet(context.Context, PermissionSetID) (*identitycenterv1.PermissionSet, error) + + // UpdatePermissionSet performs a conditional update on the supplied Identity + // Center Permission Set + UpdatePermissionSet(context.Context, *identitycenterv1.PermissionSet) (*identitycenterv1.PermissionSet, error) + + // DeletePermissionSet deletes a specific Identity Center PermissionSet + DeletePermissionSet(context.Context, PermissionSetID) error +} + +// IdentityCenterAccountAssignment wraps a raw identitycenterv1.AccountAssignment +// record in a new type to allow it to implement the interfaces required for use +// with the Unified Resource listing. IdentityCenterAccountAssignment simply +// wraps a pointer to the underlying account record, and can be treated as a +// reference-like type. +// +// Copies of an IdentityCenterAccountAssignment will point to the same record. +type IdentityCenterAccountAssignment struct { + // This wrapper needs to: + // - implement the interfaces required for use with the Unified Resource + // service. + // - expose the existing interfaces & methods on the underlying + // identitycenterv1.AccountAssignment + // - avoid copying the underlying identitycenterv1.AccountAssignment due to + // embedded mutexes in the protobuf-generated code + // + // Given those requirements, storing an embedded pointer seems to be the + // least-bad approach. + + *identitycenterv1.AccountAssignment +} + +// CloneResource creates a deep copy of the underlying account resource +func (a IdentityCenterAccountAssignment) CloneResource() IdentityCenterAccountAssignment { + return IdentityCenterAccountAssignment{ + AccountAssignment: proto.Clone(a.AccountAssignment).(*identitycenterv1.AccountAssignment), + } +} + +// IdentityCenterAccountAssignmentID is a strongly typed ID for an +// IdentityCenterAccountAssignment +type IdentityCenterAccountAssignmentID string + +// IdentityCenterAccountAssignments defines the operations to create and maintain +// Identity Center account assignment records in the service. +type IdentityCenterAccountAssignments interface { + // ListAccountAssignments lists all IdentityCenterAccountAssignment record + // known to the service + ListAccountAssignments(context.Context, int, *pagination.PageRequestToken) ([]IdentityCenterAccountAssignment, pagination.NextPageToken, error) + + // CreateAccountAssignment creates a new Account Assignment record in + // the service from the supplied in-memory representation. Returns the + // created record on success. + CreateAccountAssignment(context.Context, IdentityCenterAccountAssignment) (IdentityCenterAccountAssignment, error) + + // GetAccountAssignment fetches a specific Account Assignment record. + GetAccountAssignment(context.Context, IdentityCenterAccountAssignmentID) (IdentityCenterAccountAssignment, error) + + // UpdateAccountAssignment performs a conditional update on the supplied + // Account Assignment, returning the updated record on success. + UpdateAccountAssignment(context.Context, IdentityCenterAccountAssignment) (IdentityCenterAccountAssignment, error) + + // DeleteAccountAssignment deletes a specific account assignment + DeleteAccountAssignment(context.Context, IdentityCenterAccountAssignmentID) error + + // DeleteAllAccountAssignments deletes all known account assignments + DeleteAllAccountAssignments(context.Context) error +} + +// IdentityCenter combines all the resource managers used by the Identity Center plugin +type IdentityCenter interface { + IdentityCenterAccounts + IdentityCenterPermissionSets + IdentityCenterPrincipalAssignments + IdentityCenterAccountAssignments +} diff --git a/lib/services/identitycenter_test.go b/lib/services/identitycenter_test.go new file mode 100644 index 0000000000000..5cbc87493feed --- /dev/null +++ b/lib/services/identitycenter_test.go @@ -0,0 +1,97 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package services + +import ( + "testing" + + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" + "github.com/gravitational/teleport/api/types" +) + +func TestIdentityCenterAccountClone(t *testing.T) { + // GIVEN an Account Record + src := IdentityCenterAccount{ + Account: &identitycenterv1.Account{ + Kind: types.KindIdentityCenterAccount, + Version: types.V1, + Metadata: &headerv1.Metadata{Name: "some-account"}, + Spec: &identitycenterv1.AccountSpec{ + Id: "aws-account-id", + Arn: "arn:aws:sso::account-id:", + Description: "Test account", + PermissionSetInfo: []*identitycenterv1.PermissionSetInfo{ + { + Name: "original value", + Arn: "arn:aws:sso:::permissionSet/ic-instance/ps-instance", + }, + }, + }, + }, + } + + // WHEN I clone the resource + dst := src.CloneResource() + + // EXPECT that the resulting clone compares equally + require.Equal(t, src, dst) + + // WHEN I modify the source object in a way that would be shared with a + // shallow copy + src.Spec.PermissionSetInfo[0].Name = "some new value" + + // EXPECT that the cloned object DOES NOT inherit the update + require.NotEqual(t, src, dst) + require.Equal(t, "original value", dst.Spec.PermissionSetInfo[0].Name) +} + +func TestIdentityCenterAccountAssignmentClone(t *testing.T) { + // GIVEN an Account Assignment Record + src := IdentityCenterAccountAssignment{ + AccountAssignment: &identitycenterv1.AccountAssignment{ + Kind: types.KindIdentityCenterAccountAssignment, + Version: types.V1, + Metadata: &headerv1.Metadata{Name: "u-test@example.com"}, + Spec: &identitycenterv1.AccountAssignmentSpec{ + Display: "Some-Permission-set on Some-AWS-account", + PermissionSet: &identitycenterv1.PermissionSetInfo{ + Arn: "arn:aws:sso:::permissionSet/ic-instance/ps-instance", + Name: "original name", + }, + AccountName: "Some Account Name", + AccountId: "some account id", + }, + }, + } + + // WHEN I clone the resource + dst := src.CloneResource() + + // EXPECT that the resulting clone compares equally + require.Equal(t, src, dst) + + // WHEN I modify the source object in a way that would be shared with a + // shallow copy + src.Spec.PermissionSet.Name = "some new name" + + // EXPECT that the cloned object DOES NOT inherit the update + require.NotEqual(t, src, dst) + require.Equal(t, "original name", dst.Spec.PermissionSet.Name) +} diff --git a/lib/services/local/events_test.go b/lib/services/local/events_test.go index a57f07aadf2de..c4d7c91ed4e26 100644 --- a/lib/services/local/events_test.go +++ b/lib/services/local/events_test.go @@ -49,7 +49,8 @@ func fetchEvent(t *testing.T, w types.Watcher, timeout time.Duration) types.Even return ev } -func testContext(t *testing.T) context.Context { +func newTestContext(t *testing.T) context.Context { + t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) return ctx @@ -209,7 +210,7 @@ func TestWatchers(t *testing.T) { for _, test := range testCases { t.Run(test.name, func(t *testing.T) { t.Parallel() - ctx := testContext(t) + ctx := newTestContext(t) // GIVEN an empty back-end clock := clockwork.NewFakeClock() diff --git a/lib/services/local/identitycenter.go b/lib/services/local/identitycenter.go new file mode 100644 index 0000000000000..a24e433ef3b84 --- /dev/null +++ b/lib/services/local/identitycenter.go @@ -0,0 +1,399 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package local + +import ( + "context" + "log/slog" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local/generic" + "github.com/gravitational/teleport/lib/utils/pagination" +) + +const ( + identityCenterPageSize = 100 +) + +const ( + awsResourcePrefix = "identity_center" + awsAccountPrefix = "accounts" + awsPermissionSetPrefix = "permission_sets" + awsPrincipalAssignmentPrefix = "principal_assignments" + awsAccountAssignmentPrefix = "account_assignments" +) + +// IdentityCenterServiceConfig provides configuration parameters for an +// IdentityCenterService +type IdentityCenterServiceConfig struct { + // Backend is the storage backend to use for the service + Backend backend.Backend + + // Logger is the logger for the service to use. A default will be supplied + // if not specified. + Logger *slog.Logger +} + +// CheckAndSetDefaults validates the cfg and supplies defaults where +// appropriate. +func (cfg *IdentityCenterServiceConfig) CheckAndSetDefaults() error { + if cfg.Backend == nil { + return trace.BadParameter("must supply backend") + } + + if cfg.Logger == nil { + cfg.Logger = slog.Default().With(teleport.ComponentKey, "AWS-IC-LOCAL") + } + + return nil +} + +// IdentityCenterService handles low-level CRUD operations for the identity- +// center related resources +type IdentityCenterService struct { + accounts *generic.ServiceWrapper[*identitycenterv1.Account] + permissionSets *generic.ServiceWrapper[*identitycenterv1.PermissionSet] + principalAssignments *generic.ServiceWrapper[*identitycenterv1.PrincipalAssignment] + accountAssignments *generic.ServiceWrapper[*identitycenterv1.AccountAssignment] +} + +// compile-time assertion that the IdentityCenterService implements the +// services.IdentityCenter interface +var _ services.IdentityCenter = (*IdentityCenterService)(nil) + +// NewIdentityCenterService creates a new service for managing identity-center +// related resources +func NewIdentityCenterService(cfg IdentityCenterServiceConfig) (*IdentityCenterService, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + accountsSvc, err := generic.NewServiceWrapper(generic.ServiceWrapperConfig[*identitycenterv1.Account]{ + Backend: cfg.Backend, + ResourceKind: types.KindIdentityCenterAccount, + BackendPrefix: backend.NewKey(awsResourcePrefix, awsAccountPrefix), + MarshalFunc: services.MarshalProtoResource[*identitycenterv1.Account], + UnmarshalFunc: services.UnmarshalProtoResource[*identitycenterv1.Account], + }) + if err != nil { + return nil, trace.Wrap(err, "creating accounts service") + } + + permissionSetSvc, err := generic.NewServiceWrapper(generic.ServiceWrapperConfig[*identitycenterv1.PermissionSet]{ + Backend: cfg.Backend, + ResourceKind: types.KindIdentityCenterPermissionSet, + BackendPrefix: backend.NewKey(awsResourcePrefix, awsPermissionSetPrefix), + MarshalFunc: services.MarshalProtoResource[*identitycenterv1.PermissionSet], + UnmarshalFunc: services.UnmarshalProtoResource[*identitycenterv1.PermissionSet], + }) + if err != nil { + return nil, trace.Wrap(err, "creating permission sets service") + } + + principalsSvc, err := generic.NewServiceWrapper(generic.ServiceWrapperConfig[*identitycenterv1.PrincipalAssignment]{ + Backend: cfg.Backend, + ResourceKind: types.KindIdentityCenterPrincipalAssignment, + BackendPrefix: backend.NewKey(awsResourcePrefix, awsPrincipalAssignmentPrefix), + MarshalFunc: services.MarshalProtoResource[*identitycenterv1.PrincipalAssignment], + UnmarshalFunc: services.UnmarshalProtoResource[*identitycenterv1.PrincipalAssignment], + }) + if err != nil { + return nil, trace.Wrap(err, "creating principal assignments service") + } + + accountAssignmentsSvc, err := generic.NewServiceWrapper(generic.ServiceWrapperConfig[*identitycenterv1.AccountAssignment]{ + Backend: cfg.Backend, + ResourceKind: types.KindIdentityCenterAccountAssignment, + BackendPrefix: backend.NewKey(awsResourcePrefix, awsAccountAssignmentPrefix), + MarshalFunc: services.MarshalProtoResource[*identitycenterv1.AccountAssignment], + UnmarshalFunc: services.UnmarshalProtoResource[*identitycenterv1.AccountAssignment], + }) + if err != nil { + return nil, trace.Wrap(err, "creating account assignments service") + } + + svc := &IdentityCenterService{ + accounts: accountsSvc, + permissionSets: permissionSetSvc, + principalAssignments: principalsSvc, + accountAssignments: accountAssignmentsSvc, + } + + return svc, nil +} + +// ListIdentityCenterAccounts provides a paged list of all AWS accounts known +// to the Identity Center integration +func (svc *IdentityCenterService) ListIdentityCenterAccounts(ctx context.Context, pageSize int, page *pagination.PageRequestToken) ([]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") + } + + accounts, nextPage, err := svc.accounts.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err, "listing identity center assignment records") + } + + 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) + if err != nil { + return services.IdentityCenterAccount{}, trace.Wrap(err, "creating identity center account") + } + return services.IdentityCenterAccount{Account: created}, nil +} + +// GetIdentityCenterAccount fetches a specific Identity Center Account +func (svc *IdentityCenterService) GetIdentityCenterAccount(ctx context.Context, name services.IdentityCenterAccountID) (services.IdentityCenterAccount, error) { + acct, err := svc.accounts.GetResource(ctx, string(name)) + if err != nil { + return services.IdentityCenterAccount{}, trace.Wrap(err, "fetching identity center account") + } + return services.IdentityCenterAccount{Account: acct}, nil +} + +// UpdateIdentityCenterAccount performs a conditional update on an Identity +// Center Account record, returning the updated record on success. +func (svc *IdentityCenterService) UpdateIdentityCenterAccount(ctx context.Context, acct services.IdentityCenterAccount) (services.IdentityCenterAccount, error) { + updated, err := svc.accounts.ConditionalUpdateResource(ctx, acct.Account) + if err != nil { + return services.IdentityCenterAccount{}, trace.Wrap(err, "updating identity center account record") + } + return services.IdentityCenterAccount{Account: updated}, nil +} + +// UpsertIdentityCenterAccount performs an *unconditional* upsert on an +// Identity Center Account record, returning the updated record on success. +// Be careful when mixing UpsertIdentityCenterAccount() with resources +// protected by optimistic locking +func (svc *IdentityCenterService) UpsertIdentityCenterAccount(ctx context.Context, acct services.IdentityCenterAccount) (services.IdentityCenterAccount, error) { + updated, err := svc.accounts.UpsertResource(ctx, acct.Account) + if err != nil { + return services.IdentityCenterAccount{}, trace.Wrap(err, "upserting identity center account record") + } + return services.IdentityCenterAccount{Account: updated}, nil +} + +// DeleteIdentityCenterAccount deletes an Identity Center Account record +func (svc *IdentityCenterService) DeleteIdentityCenterAccount(ctx context.Context, name services.IdentityCenterAccountID) error { + return trace.Wrap(svc.accounts.DeleteResource(ctx, string(name))) +} + +// DeleteAllIdentityCenterAccounts deletes all Identity Center Account records +func (svc *IdentityCenterService) DeleteAllIdentityCenterAccounts(ctx context.Context) error { + return trace.Wrap(svc.accounts.DeleteAllResources(ctx)) +} + +// ListPrincipalAssignments lists all PrincipalAssignment records in the service +func (svc *IdentityCenterService) ListPrincipalAssignments(ctx context.Context, pageSize int, page *pagination.PageRequestToken) ([]*identitycenterv1.PrincipalAssignment, pagination.NextPageToken, error) { + if pageSize == 0 { + pageSize = identityCenterPageSize + } + + pageToken, err := page.Consume() + if err != nil { + return nil, "", trace.Wrap(err, "extracting page token") + } + + resp, nextPage, err := svc.principalAssignments.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err, "listing identity center assignment records") + } + return resp, pagination.NextPageToken(nextPage), nil +} + +// CreatePrincipalAssignment creates a new Principal Assignment record in the +// service from the supplied in-memory representation. Returns the created +// record on success. +func (svc *IdentityCenterService) CreatePrincipalAssignment(ctx context.Context, asmt *identitycenterv1.PrincipalAssignment) (*identitycenterv1.PrincipalAssignment, error) { + created, err := svc.principalAssignments.CreateResource(ctx, asmt) + if err != nil { + return nil, trace.Wrap(err, "creating principal assignment") + } + return created, nil +} + +// GetPrincipalAssignment fetches a specific Principal Assignment record. +func (svc *IdentityCenterService) GetPrincipalAssignment(ctx context.Context, name services.PrincipalAssignmentID) (*identitycenterv1.PrincipalAssignment, error) { + state, err := svc.principalAssignments.GetResource(ctx, string(name)) + if err != nil { + return nil, trace.Wrap(err, "fetching principal assignment") + } + return state, nil +} + +// UpdatePrincipalAssignment performs a conditional update on a Principal +// Assignment record +func (svc *IdentityCenterService) UpdatePrincipalAssignment(ctx context.Context, asmt *identitycenterv1.PrincipalAssignment) (*identitycenterv1.PrincipalAssignment, error) { + updated, err := svc.principalAssignments.ConditionalUpdateResource(ctx, asmt) + if err != nil { + return nil, trace.Wrap(err, "updating principal assignment record") + } + return updated, nil +} + +// UpsertPrincipalAssignment performs an unconditional update on a Principal +// Assignment record +func (svc *IdentityCenterService) UpsertPrincipalAssignment(ctx context.Context, asmt *identitycenterv1.PrincipalAssignment) (*identitycenterv1.PrincipalAssignment, error) { + updated, err := svc.principalAssignments.UpsertResource(ctx, asmt) + if err != nil { + return nil, trace.Wrap(err, "upserting principal assignment record") + } + return updated, nil +} + +// DeletePrincipalAssignment deletes a specific principal assignment record +func (svc *IdentityCenterService) DeletePrincipalAssignment(ctx context.Context, name services.PrincipalAssignmentID) error { + return trace.Wrap(svc.principalAssignments.DeleteResource(ctx, string(name))) +} + +// DeleteAllPrincipalAssignments deletes all assignment record +func (svc *IdentityCenterService) DeleteAllPrincipalAssignments(ctx context.Context) error { + return trace.Wrap(svc.principalAssignments.DeleteAllResources(ctx)) +} + +// ListPermissionSets list the known Permission Sets in the managed Identity Center +func (svc *IdentityCenterService) ListPermissionSets(ctx context.Context, pageSize int, page *pagination.PageRequestToken) ([]*identitycenterv1.PermissionSet, pagination.NextPageToken, error) { + if pageSize == 0 { + pageSize = identityCenterPageSize + } + pageToken, err := page.Consume() + if err != nil { + return nil, "", trace.Wrap(err, "extracting page token") + } + resp, nextPage, err := svc.permissionSets.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err, "listing identity center permission set records") + } + return resp, pagination.NextPageToken(nextPage), nil +} + +// CreatePermissionSet creates a new PermissionSet record based on the supplied +// in-memory representation, returning the created record on success. +func (svc *IdentityCenterService) CreatePermissionSet(ctx context.Context, asmt *identitycenterv1.PermissionSet) (*identitycenterv1.PermissionSet, error) { + created, err := svc.permissionSets.CreateResource(ctx, asmt) + if err != nil { + return nil, trace.Wrap(err, "creating identity center permission set") + } + return created, nil +} + +// GetPermissionSet fetches a specific PermissionSet record +func (svc *IdentityCenterService) GetPermissionSet(ctx context.Context, name services.PermissionSetID) (*identitycenterv1.PermissionSet, error) { + state, err := svc.permissionSets.GetResource(ctx, string(name)) + if err != nil { + return nil, trace.Wrap(err, "fetching permission set") + } + return state, nil +} + +// UpdatePermissionSet performs a conditional update on the supplied Identity +// Center Permission Set +func (svc *IdentityCenterService) UpdatePermissionSet(ctx context.Context, asmt *identitycenterv1.PermissionSet) (*identitycenterv1.PermissionSet, error) { + updated, err := svc.permissionSets.ConditionalUpdateResource(ctx, asmt) + if err != nil { + return nil, trace.Wrap(err, "updating permission set record") + } + return updated, nil +} + +// DeletePermissionSet deletes a specific Identity Center PermissionSet +func (svc *IdentityCenterService) DeletePermissionSet(ctx context.Context, name services.PermissionSetID) error { + return trace.Wrap(svc.permissionSets.DeleteResource(ctx, string(name))) +} + +// ListAccountAssignments lists all IdentityCenterAccountAssignment record +// known to the service +func (svc *IdentityCenterService) ListAccountAssignments(ctx context.Context, pageSize int, page *pagination.PageRequestToken) ([]services.IdentityCenterAccountAssignment, pagination.NextPageToken, error) { + if pageSize == 0 { + pageSize = identityCenterPageSize + } + pageToken, err := page.Consume() + if err != nil { + return nil, "", trace.Wrap(err, "extracting page token") + } + assignments, nextPage, err := svc.accountAssignments.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err, "listing identity center assignment records") + } + + result := make([]services.IdentityCenterAccountAssignment, len(assignments)) + for i, asmt := range assignments { + result[i] = services.IdentityCenterAccountAssignment{AccountAssignment: asmt} + } + + return result, pagination.NextPageToken(nextPage), nil +} + +// CreateAccountAssignment creates a new Account Assignment record in +// the service from the supplied in-memory representation. Returns the +// created record on success. +func (svc *IdentityCenterService) CreateAccountAssignment(ctx context.Context, asmt services.IdentityCenterAccountAssignment) (services.IdentityCenterAccountAssignment, error) { + created, err := svc.accountAssignments.CreateResource(ctx, asmt.AccountAssignment) + if err != nil { + return services.IdentityCenterAccountAssignment{}, trace.Wrap(err, "creating principal assignment") + } + return services.IdentityCenterAccountAssignment{AccountAssignment: created}, nil +} + +// GetAccountAssignment fetches a specific Account Assignment record. +func (svc *IdentityCenterService) GetAccountAssignment(ctx context.Context, name services.IdentityCenterAccountAssignmentID) (services.IdentityCenterAccountAssignment, error) { + asmt, err := svc.accountAssignments.GetResource(ctx, string(name)) + if err != nil { + return services.IdentityCenterAccountAssignment{}, trace.Wrap(err, "fetching principal assignment") + } + return services.IdentityCenterAccountAssignment{AccountAssignment: asmt}, nil +} + +// UpdateAccountAssignment performs a conditional update on the supplied +// Account Assignment, returning the updated record on success. +func (svc *IdentityCenterService) UpdateAccountAssignment(ctx context.Context, asmt services.IdentityCenterAccountAssignment) (services.IdentityCenterAccountAssignment, error) { + updated, err := svc.accountAssignments.ConditionalUpdateResource(ctx, asmt.AccountAssignment) + if err != nil { + return services.IdentityCenterAccountAssignment{}, trace.Wrap(err, "updating principal assignment record") + } + return services.IdentityCenterAccountAssignment{AccountAssignment: updated}, nil +} + +// DeleteAccountAssignment deletes a specific account assignment +func (svc *IdentityCenterService) DeleteAccountAssignment(ctx context.Context, name services.IdentityCenterAccountAssignmentID) error { + return trace.Wrap(svc.accountAssignments.DeleteResource(ctx, string(name))) +} + +// DeleteAllAccountAssignments deletes all known account assignments +func (svc *IdentityCenterService) DeleteAllAccountAssignments(ctx context.Context) error { + return trace.Wrap(svc.accountAssignments.DeleteAllResources(ctx)) +} diff --git a/lib/services/local/identitycenter_test.go b/lib/services/local/identitycenter_test.go new file mode 100644 index 0000000000000..a15c2a51f323b --- /dev/null +++ b/lib/services/local/identitycenter_test.go @@ -0,0 +1,298 @@ +// Teleport +// Copyright (C) 2024 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package local + +import ( + "context" + "fmt" + "testing" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + identitycenterv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/identitycenter/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/backend/lite" + "github.com/gravitational/teleport/lib/services" +) + +func newTestBackend(t *testing.T, ctx context.Context, clock clockwork.Clock) backend.Backend { + t.Helper() + sqliteBackend, err := lite.NewWithConfig(ctx, lite.Config{ + Path: t.TempDir(), + Clock: clock, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, sqliteBackend.Close()) + }) + return sqliteBackend +} + +func TestIdentityCenterResourceCRUD(t *testing.T) { + t.Parallel() + + const resourceID = "alpha" + + testCases := []struct { + name string + createResource func(*testing.T, context.Context, services.IdentityCenter, string) types.Resource153 + getResource func(context.Context, services.IdentityCenter, string) (types.Resource153, error) + updateResource func(context.Context, services.IdentityCenter, types.Resource153) (types.Resource153, error) + upsertResource func(context.Context, services.IdentityCenter, types.Resource153) (types.Resource153, error) + }{ + { + name: "Account", + createResource: func(subtestT *testing.T, subtestCtx context.Context, svc services.IdentityCenter, id string) types.Resource153 { + return makeTestIdentityCenterAccount(subtestT, subtestCtx, svc, id) + }, + getResource: func(subtestCtx context.Context, svc services.IdentityCenter, id string) (types.Resource153, error) { + return svc.GetIdentityCenterAccount(subtestCtx, services.IdentityCenterAccountID(id)) + }, + updateResource: func(subtestCtx context.Context, svc services.IdentityCenter, r types.Resource153) (types.Resource153, error) { + acct := r.(services.IdentityCenterAccount) + return svc.UpdateIdentityCenterAccount(subtestCtx, acct) + }, + upsertResource: func(subtestCtx context.Context, svc services.IdentityCenter, r types.Resource153) (types.Resource153, error) { + acct := r.(services.IdentityCenterAccount) + return svc.UpsertIdentityCenterAccount(subtestCtx, acct) + }, + }, + { + name: "PermissionSet", + createResource: func(subtestT *testing.T, subtestCtx context.Context, svc services.IdentityCenter, id string) types.Resource153 { + return makeTestIdentityCenterPermissionSet(subtestT, subtestCtx, svc, id) + }, + getResource: func(subtestCtx context.Context, svc services.IdentityCenter, id string) (types.Resource153, error) { + return svc.GetPermissionSet(subtestCtx, services.PermissionSetID(id)) + }, + updateResource: func(subtestCtx context.Context, svc services.IdentityCenter, r types.Resource153) (types.Resource153, error) { + ps := r.(*identitycenterv1.PermissionSet) + return svc.UpdatePermissionSet(subtestCtx, ps) + }, + }, + { + name: "AccountAssignment", + createResource: func(subtestT *testing.T, subtestCtx context.Context, svc services.IdentityCenter, id string) types.Resource153 { + return makeTestIdentityCenterAccountAssignment(subtestT, subtestCtx, svc, id) + }, + getResource: func(subtestCtx context.Context, svc services.IdentityCenter, id string) (types.Resource153, error) { + return svc.GetAccountAssignment(subtestCtx, services.IdentityCenterAccountAssignmentID(id)) + }, + updateResource: func(subtestCtx context.Context, svc services.IdentityCenter, r types.Resource153) (types.Resource153, error) { + asmt := r.(services.IdentityCenterAccountAssignment) + return svc.UpdateAccountAssignment(subtestCtx, asmt) + }, + }, + { + name: "PrincipalAssignment", + createResource: func(subtestT *testing.T, subtestCtx context.Context, svc services.IdentityCenter, id string) types.Resource153 { + return makeTestIdentityCenterPrincipalAssignment(subtestT, subtestCtx, svc, id) + }, + getResource: func(subtestCtx context.Context, svc services.IdentityCenter, id string) (types.Resource153, error) { + return svc.GetPrincipalAssignment(subtestCtx, services.PrincipalAssignmentID(id)) + }, + updateResource: func(subtestCtx context.Context, svc services.IdentityCenter, r types.Resource153) (types.Resource153, error) { + asmt := r.(*identitycenterv1.PrincipalAssignment) + return svc.UpdatePrincipalAssignment(subtestCtx, asmt) + }, + upsertResource: func(subtestCtx context.Context, svc services.IdentityCenter, r types.Resource153) (types.Resource153, error) { + asmt := r.(*identitycenterv1.PrincipalAssignment) + return svc.UpsertPrincipalAssignment(subtestCtx, asmt) + }, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + t.Run("OptimisticLocking", func(t *testing.T) { + const resourceID = "alpha" + + ctx := newTestContext(t) + clock := clockwork.NewFakeClock() + backend := newTestBackend(t, ctx, clock) + + // GIVEN an IdentityCenter service populated with a resource + uut, err := NewIdentityCenterService(IdentityCenterServiceConfig{Backend: backend}) + require.NoError(t, err) + createdResource := test.createResource(t, ctx, uut, resourceID) + + // WHEN I modify the backend record for that resource... + tmpResource, err := test.getResource(ctx, uut, resourceID) + require.NoError(t, err) + tmpResource.GetMetadata().Labels = map[string]string{"update": "1"} + updatedResource, err := test.updateResource(ctx, uut, tmpResource) + require.NoError(t, err) + + // EXPECT that any attempt to update backend via the original in-memory + // version of the resource fails with a comparison error + createdResource.GetMetadata().Labels = map[string]string{"update": "2"} + _, err = test.updateResource(ctx, uut, createdResource) + require.True(t, trace.IsCompareFailed(err), "expected a compare failed error, got %T (%s)", err, err) + + // EXPECT that the backend still reflects the first updated revision, + // rather than failed update + r, err := test.getResource(ctx, uut, resourceID) + require.NoError(t, err) + require.Equal(t, "1", r.GetMetadata().Labels["update"]) + + // WHEN I attempt update the backend via the latest revision of the + // record... + updatedResource.GetMetadata().Labels["update"] = "3" + _, err = test.updateResource(ctx, uut, updatedResource) + + // EXPECT the update to succeed, and the backend record to have been + // updated + require.NoError(t, err) + r, err = test.getResource(ctx, uut, resourceID) + require.NoError(t, err) + require.Equal(t, "3", r.GetMetadata().Labels["update"]) + }) + + t.Run("UnconditionalUpsert", func(t *testing.T) { + t.Parallel() + + if test.upsertResource == nil { + t.Skip(test.name + " does not support unconditional upsert") + } + + ctx := newTestContext(t) + clock := clockwork.NewFakeClock() + backend := newTestBackend(t, ctx, clock) + + // GIVEN an IdentityCenter service populated with a resource + uut, err := NewIdentityCenterService(IdentityCenterServiceConfig{Backend: backend}) + require.NoError(t, err) + originalResource := test.createResource(t, ctx, uut, resourceID) + + // GIVEN that the backend record for that resource has been changed + // between us looking up the original resource and us committing + // any changes to it... + tmpResource, err := test.getResource(ctx, uut, resourceID) + require.NoError(t, err) + tmpResource.GetMetadata().Labels = map[string]string{"update": "1"} + _, err = test.updateResource(ctx, uut, tmpResource) + require.NoError(t, err) + + // WHEN I attempt to Update the modified original resource + _, err = test.updateResource(ctx, uut, originalResource) + + // EXPECT the Update to fail due to the changed underlying record + require.True(t, trace.IsCompareFailed(err), "expected a compare failed error, got %T (%s)", err, err) + + // WHEN I attempt to Upsert the modified original resource + originalResource.GetMetadata().Labels = map[string]string{"update": "2"} + _, err = test.upsertResource(ctx, uut, originalResource) + + // EXPECT that an upsert will succeed, even though the underlying + // record has changed + require.NoError(t, err) + + // EXPECT that the backend reflects the updated values from the + // upsert + r, err := test.getResource(ctx, uut, resourceID) + require.NoError(t, err) + require.Equal(t, "2", r.GetMetadata().Labels["update"]) + }) + }) + } +} + +func makeTestIdentityCenterAccount(t *testing.T, ctx context.Context, svc services.IdentityCenter, id string) services.IdentityCenterAccount { + t.Helper() + created, err := svc.CreateIdentityCenterAccount(ctx, services.IdentityCenterAccount{ + Account: &identitycenterv1.Account{ + Kind: types.KindIdentityCenterAccount, + Version: types.V1, + Metadata: &headerv1.Metadata{Name: id}, + Spec: &identitycenterv1.AccountSpec{ + Id: "aws-account-id-" + id, + Arn: fmt.Sprintf("arn:aws:sso::%s:", id), + Description: "Test account " + id, + PermissionSetInfo: []*identitycenterv1.PermissionSetInfo{ + { + Name: "dummy", + Arn: "arn:aws:sso:::permissionSet/ic-instance/ps-instance", + }, + }, + }, + }, + }) + require.NoError(t, err) + return created +} + +func makeTestIdentityCenterPermissionSet(t *testing.T, ctx context.Context, svc services.IdentityCenter, id string) *identitycenterv1.PermissionSet { + t.Helper() + created, err := svc.CreatePermissionSet(ctx, &identitycenterv1.PermissionSet{ + Kind: types.KindIdentityCenterPermissionSet, + Version: types.V1, + Metadata: &headerv1.Metadata{Name: id}, + Spec: &identitycenterv1.PermissionSetSpec{ + Arn: fmt.Sprintf("arn:aws:sso:::permissionSet/ic-instance/%s", id), + Name: "aws-permission-set-" + id, + Description: "Test permission set " + id, + }, + }) + require.NoError(t, err) + return created +} + +func makeTestIdentityCenterAccountAssignment(t *testing.T, ctx context.Context, svc services.IdentityCenter, id string) services.IdentityCenterAccountAssignment { + t.Helper() + created, err := svc.CreateAccountAssignment(ctx, services.IdentityCenterAccountAssignment{ + AccountAssignment: &identitycenterv1.AccountAssignment{ + Kind: types.KindIdentityCenterAccountAssignment, + Version: types.V1, + Metadata: &headerv1.Metadata{Name: id}, + Spec: &identitycenterv1.AccountAssignmentSpec{ + Display: "Some-Permission-set on Some-AWS-account", + PermissionSet: &identitycenterv1.PermissionSetInfo{ + Arn: "arn:aws:sso:::permissionSet/ic-instance/ps-instance", + Name: "some permission set", + }, + AccountName: "Some Account Name", + AccountId: "some account id", + }, + }, + }) + require.NoError(t, err) + return created +} + +func makeTestIdentityCenterPrincipalAssignment(t *testing.T, ctx context.Context, svc services.IdentityCenterPrincipalAssignments, id string) *identitycenterv1.PrincipalAssignment { + t.Helper() + created, err := svc.CreatePrincipalAssignment(ctx, &identitycenterv1.PrincipalAssignment{ + Kind: types.KindIdentityCenterPrincipalAssignment, + Version: types.V1, + Metadata: &headerv1.Metadata{Name: id}, + Spec: &identitycenterv1.PrincipalAssignmentSpec{ + PrincipalType: identitycenterv1.PrincipalType_PRINCIPAL_TYPE_USER, + PrincipalId: id, + ExternalIdSource: "scim", + ExternalId: "some external id", + }, + Status: &identitycenterv1.PrincipalAssignmentStatus{ + ProvisioningState: identitycenterv1.ProvisioningState_PROVISIONING_STATE_STALE, + }, + }) + require.NoError(t, err) + return created +}