diff --git a/apikey/store.go b/apikey/store.go index 161c60b..f39faf1 100644 --- a/apikey/store.go +++ b/apikey/store.go @@ -19,3 +19,9 @@ type TokenStore interface { // RevokeToken records tokenID as revoked. RevokeToken(ctx context.Context, tokenID string, revokedAt time.Time) error } + +// TokenMetadataLister lists token metadata without secret material. +type TokenMetadataLister interface { + // ListPrincipalTokenMetadata returns API-token metadata for principalID. + ListPrincipalTokenMetadata(ctx context.Context, principalID string) ([]TokenMetadata, error) +} diff --git a/apikey/types.go b/apikey/types.go index a4ca347..7e9d124 100644 --- a/apikey/types.go +++ b/apikey/types.go @@ -37,6 +37,27 @@ type IssuedToken struct { IdentityLink authkit.LinkIdentityRequest } +// TokenMetadata describes an API token without its secret material. +type TokenMetadata struct { + // ID is the stable lookup identifier embedded in the token. + ID string + + // PrincipalID identifies the principal the token authenticates as. + PrincipalID string + + // Name is an optional human-readable token label. + Name string + + // ExpiresAt is the time after which the token must no longer authenticate. + ExpiresAt time.Time + + // LastUsedAt records the last successful token verification time when known. + LastUsedAt *time.Time + + // RevokedAt records when the token was revoked. + RevokedAt *time.Time +} + // StoredToken is the storage representation of an API token. type StoredToken struct { // ID is the stable lookup identifier embedded in the token. diff --git a/errors.go b/errors.go index adad79b..d45d9b4 100644 --- a/errors.go +++ b/errors.go @@ -17,4 +17,7 @@ var ( // ErrProvisioningRuleNotFound indicates that a provisioning rule does not exist. ErrProvisioningRuleNotFound = errors.New("authkit: provisioning rule not found") + + // ErrPrincipalNotFound indicates that a principal does not exist. + ErrPrincipalNotFound = errors.New("authkit: principal not found") ) diff --git a/internal/storetest/storetest.go b/internal/storetest/storetest.go index 205f0f1..8536fff 100644 --- a/internal/storetest/storetest.go +++ b/internal/storetest/storetest.go @@ -3,6 +3,7 @@ package storetest import ( "context" "crypto/sha256" + "sort" "sync" "testing" "time" @@ -17,6 +18,8 @@ import ( const ( concurrentProvisionAttempts = 8 + listedTokenMetadataCount = 2 + secondTokenRevokeOffset = 2 * time.Hour testAction = "notes:read" testProvider = "oidc" testProvisioningRuleID = "engineering-readers" @@ -28,9 +31,13 @@ const ( // Store is the complete storage surface exercised by Run. type Store interface { authkit.PrincipalCreator + authkit.PrincipalFinder + authkit.PrincipalLister authkit.RoleCreator authkit.RoleActionGranter authkit.PrincipalRoleAssigner + authkit.PrincipalRoleUnassigner + authkit.PrincipalRoleAssignmentLister authkit.PrincipalActionResolver authkit.ProvisioningRuleCreator authkit.ProvisioningRuleUpdater @@ -41,6 +48,7 @@ type Store interface { authkit.IdentityProvisioner authkit.PrincipalResolver apikey.TokenStore + apikey.TokenMetadataLister oidc.ProviderTrustStore } @@ -90,6 +98,60 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { assert.Empty(t, principal) }) + t.Run("find and list principals", func(t *testing.T) { + store := newStore(t) + first, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testDisplayName, + Attributes: map[string]any{ + "team": "platform", + }, + }) + require.NoError(t, err) + second, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "Deploy service", + }) + require.NoError(t, err) + + found, err := store.FindPrincipal(context.Background(), first.ID) + require.NoError(t, err) + assert.Equal(t, first, found) + + first.Attributes["team"] = "changed" + found.Attributes["team"] = "changed from found" + + foundAgain, err := store.FindPrincipal(context.Background(), first.ID) + require.NoError(t, err) + assert.Equal(t, "platform", foundAgain.Attributes["team"]) + + principals, err := store.ListPrincipals(context.Background()) + require.NoError(t, err) + want := []authkit.Principal{foundAgain, second} + sort.Slice(want, func(i, j int) bool { + return want[i].ID < want[j].ID + }) + assert.Equal(t, want, principals) + + for i := range principals { + if principals[i].ID == first.ID { + principals[i].Attributes["team"] = "changed from list" + } + } + foundAfterListMutation, err := store.FindPrincipal(context.Background(), first.ID) + require.NoError(t, err) + assert.Equal(t, "platform", foundAfterListMutation.Attributes["team"]) + }) + + t.Run("find principal missing behavior", func(t *testing.T) { + store := newStore(t) + + found, err := store.FindPrincipal(context.Background(), "missing") + + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Empty(t, found) + }) + t.Run("create role", func(t *testing.T) { store := newStore(t) req := roleRequest() @@ -200,6 +262,41 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { assert.Equal(t, []string{testAction}, actions) }) + t.Run("list and unassign principal roles", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, "writers") + createRole(t, store, "readers") + for _, assignment := range []authkit.AssignPrincipalRoleRequest{ + {PrincipalID: principal.ID, RoleID: "writers"}, + {PrincipalID: principal.ID, RoleID: "readers"}, + } { + require.NoError(t, store.AssignPrincipalRole(context.Background(), assignment)) + } + + assignments, err := store.ListPrincipalRoleAssignments(context.Background(), principal.ID) + require.NoError(t, err) + assert.Equal(t, []authkit.PrincipalRoleAssignment{ + {PrincipalID: principal.ID, RoleID: "readers"}, + {PrincipalID: principal.ID, RoleID: "writers"}, + }, assignments) + + require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "writers", + })) + require.NoError(t, store.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "writers", + })) + + assignments, err = store.ListPrincipalRoleAssignments(context.Background(), principal.ID) + require.NoError(t, err) + assert.Equal(t, []authkit.PrincipalRoleAssignment{ + {PrincipalID: principal.ID, RoleID: "readers"}, + }, assignments) + }) + t.Run("assign principal role validates request", func(t *testing.T) { store := newStore(t) principal := createPrincipal(t, store) @@ -244,6 +341,54 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { } }) + t.Run("unassign and list principal roles validate request", func(t *testing.T) { + store := newStore(t) + principal := createPrincipal(t, store) + createRole(t, store, testRoleID) + + tests := []struct { + name string + req authkit.UnassignPrincipalRoleRequest + }{ + { + name: "missing principal ID", + req: authkit.UnassignPrincipalRoleRequest{ + RoleID: testRoleID, + }, + }, + { + name: "missing role ID", + req: authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + }, + }, + { + name: "missing principal", + req: authkit.UnassignPrincipalRoleRequest{ + PrincipalID: "missing", + RoleID: testRoleID, + }, + }, + { + name: "missing role", + req: authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "missing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Error(t, store.UnassignPrincipalRole(context.Background(), tt.req)) + }) + } + + assignments, err := store.ListPrincipalRoleAssignments(context.Background(), "missing") + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Nil(t, assignments) + }) + t.Run("resolve principal actions returns distinct sorted actions", func(t *testing.T) { store := newStore(t) principal := createPrincipal(t, store) @@ -929,6 +1074,39 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { return runErr }, }, + { + name: "find principal", + run: func() error { + _, runErr := store.FindPrincipal(ctx, principal.ID) + + return runErr + }, + }, + { + name: "list principals", + run: func() error { + _, runErr := store.ListPrincipals(ctx) + + return runErr + }, + }, + { + name: "unassign principal role", + run: func() error { + return store.UnassignPrincipalRole(ctx, authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: testRoleID, + }) + }, + }, + { + name: "list principal role assignments", + run: func() error { + _, runErr := store.ListPrincipalRoleAssignments(ctx, principal.ID) + + return runErr + }, + }, { name: "link identity", run: func() error { @@ -974,6 +1152,14 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { return runErr }, }, + { + name: "list principal token metadata", + run: func() error { + _, runErr := store.ListPrincipalTokenMetadata(ctx, principal.ID) + + return runErr + }, + }, { name: "update token last used", run: func() error { @@ -1104,6 +1290,53 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { assert.Equal(t, revokedAt, *foundAgain.RevokedAt) }) + t.Run("list principal token metadata", func(t *testing.T) { + store := newStore(t) + first := createPrincipal(t, store) + second := createPrincipal(t, store) + now := fixedStoreTime() + firstToken := createToken(t, store, now, first.ID) + secondToken := tokenFixture(now, first.ID) + secondToken.ID = "token_2" + secondToken.Name = "second token" + require.NoError(t, store.CreateToken(context.Background(), secondToken)) + otherToken := tokenFixture(now, second.ID) + otherToken.ID = "token_3" + require.NoError(t, store.CreateToken(context.Background(), otherToken)) + usedAt := now.Add(time.Hour) + revokedAt := now.Add(secondTokenRevokeOffset) + require.NoError(t, store.UpdateTokenLastUsed(context.Background(), secondToken.ID, usedAt)) + require.NoError(t, store.RevokeToken(context.Background(), secondToken.ID, revokedAt)) + + tokens, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID) + require.NoError(t, err) + require.Len(t, tokens, listedTokenMetadataCount) + assert.Equal(t, apikey.TokenMetadata{ + ID: firstToken.ID, + PrincipalID: first.ID, + Name: firstToken.Name, + ExpiresAt: firstToken.ExpiresAt, + }, tokens[0]) + assert.Equal(t, secondToken.ID, tokens[1].ID) + assert.Equal(t, first.ID, tokens[1].PrincipalID) + assert.Equal(t, secondToken.Name, tokens[1].Name) + assert.Equal(t, secondToken.ExpiresAt, tokens[1].ExpiresAt) + require.NotNil(t, tokens[1].LastUsedAt) + require.NotNil(t, tokens[1].RevokedAt) + assert.Equal(t, usedAt, *tokens[1].LastUsedAt) + assert.Equal(t, revokedAt, *tokens[1].RevokedAt) + + *tokens[1].LastUsedAt = now + *tokens[1].RevokedAt = now + listedAgain, err := store.ListPrincipalTokenMetadata(context.Background(), first.ID) + require.NoError(t, err) + require.Len(t, listedAgain, listedTokenMetadataCount) + require.NotNil(t, listedAgain[1].LastUsedAt) + require.NotNil(t, listedAgain[1].RevokedAt) + assert.Equal(t, usedAt, *listedAgain[1].LastUsedAt) + assert.Equal(t, revokedAt, *listedAgain[1].RevokedAt) + }) + t.Run("token missing behavior", func(t *testing.T) { store := newStore(t) now := fixedStoreTime() @@ -1118,6 +1351,10 @@ func Run(t *testing.T, newStore func(t *testing.T) Store) { apikey.ErrTokenNotFound, ) require.ErrorIs(t, store.RevokeToken(context.Background(), "missing", now), apikey.ErrTokenNotFound) + + tokens, err := store.ListPrincipalTokenMetadata(context.Background(), "missing") + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Nil(t, tokens) }) t.Run("api token service integration", func(t *testing.T) { diff --git a/management/service.go b/management/service.go index c97011e..a730bd3 100644 --- a/management/service.go +++ b/management/service.go @@ -23,6 +23,12 @@ type Options struct { // PrincipalCreator creates internal principals. PrincipalCreator authkit.PrincipalCreator + // PrincipalFinder finds internal principals. + PrincipalFinder authkit.PrincipalFinder + + // PrincipalLister lists internal principals. + PrincipalLister authkit.PrincipalLister + // RoleCreator creates local roles. RoleCreator authkit.RoleCreator @@ -32,6 +38,12 @@ type Options struct { // PrincipalRoleAssigner assigns principals to roles. PrincipalRoleAssigner authkit.PrincipalRoleAssigner + // PrincipalRoleUnassigner removes principals from roles. + PrincipalRoleUnassigner authkit.PrincipalRoleUnassigner + + // PrincipalRoleAssignmentLister lists role assignments for principals. + PrincipalRoleAssignmentLister authkit.PrincipalRoleAssignmentLister + // ProvisioningRuleCreator creates provisioning rules. ProvisioningRuleCreator authkit.ProvisioningRuleCreator @@ -52,14 +64,21 @@ type Options struct { // APITokens issues and revokes API tokens. APITokens APITokens + + // APITokenMetadataLister lists API-token metadata. + APITokenMetadataLister apikey.TokenMetadataLister } // Service composes common authkit management operations. type Service struct { principalCreator authkit.PrincipalCreator + principalFinder authkit.PrincipalFinder + principalLister authkit.PrincipalLister roleCreator authkit.RoleCreator roleActionGranter authkit.RoleActionGranter principalRoleAssigner authkit.PrincipalRoleAssigner + principalRoleUnassigner authkit.PrincipalRoleUnassigner + roleAssignmentLister authkit.PrincipalRoleAssignmentLister provisioningRuleCreator authkit.ProvisioningRuleCreator provisioningRuleUpdater authkit.ProvisioningRuleUpdater provisioningRuleDeleter authkit.ProvisioningRuleDeleter @@ -67,15 +86,20 @@ type Service struct { provisioningRuleLister authkit.ProvisioningRuleLister identityLinker authkit.IdentityLinker apiTokens APITokens + apiTokenMetadataLister apikey.TokenMetadataLister } // NewService constructs a management service from opts. func NewService(opts Options) *Service { return &Service{ principalCreator: opts.PrincipalCreator, + principalFinder: opts.PrincipalFinder, + principalLister: opts.PrincipalLister, roleCreator: opts.RoleCreator, roleActionGranter: opts.RoleActionGranter, principalRoleAssigner: opts.PrincipalRoleAssigner, + principalRoleUnassigner: opts.PrincipalRoleUnassigner, + roleAssignmentLister: opts.PrincipalRoleAssignmentLister, provisioningRuleCreator: opts.ProvisioningRuleCreator, provisioningRuleUpdater: opts.ProvisioningRuleUpdater, provisioningRuleDeleter: opts.ProvisioningRuleDeleter, @@ -83,6 +107,7 @@ func NewService(opts Options) *Service { provisioningRuleLister: opts.ProvisioningRuleLister, identityLinker: opts.IdentityLinker, apiTokens: opts.APITokens, + apiTokenMetadataLister: opts.APITokenMetadataLister, } } @@ -103,6 +128,34 @@ func (s *Service) CreatePrincipal( return principal, nil } +// FindPrincipal returns a principal by ID. +func (s *Service) FindPrincipal(ctx context.Context, id string) (authkit.Principal, error) { + if s.principalFinder == nil { + return authkit.Principal{}, errors.New("management: principal finder is required") + } + + principal, err := s.principalFinder.FindPrincipal(ctx, id) + if err != nil { + return authkit.Principal{}, fmt.Errorf("management: find principal: %w", err) + } + + return principal, nil +} + +// ListPrincipals returns principals. +func (s *Service) ListPrincipals(ctx context.Context) ([]authkit.Principal, error) { + if s.principalLister == nil { + return nil, errors.New("management: principal lister is required") + } + + principals, err := s.principalLister.ListPrincipals(ctx) + if err != nil { + return nil, fmt.Errorf("management: list principals: %w", err) + } + + return principals, nil +} + // CreateRole creates a local role. func (s *Service) CreateRole( ctx context.Context, @@ -146,6 +199,36 @@ func (s *Service) AssignPrincipalRole(ctx context.Context, req authkit.AssignPri return nil } +// UnassignPrincipalRole removes a principal from a local role. +func (s *Service) UnassignPrincipalRole(ctx context.Context, req authkit.UnassignPrincipalRoleRequest) error { + if s.principalRoleUnassigner == nil { + return errors.New("management: principal role unassigner is required") + } + + if err := s.principalRoleUnassigner.UnassignPrincipalRole(ctx, req); err != nil { + return fmt.Errorf("management: unassign principal role: %w", err) + } + + return nil +} + +// ListPrincipalRoleAssignments returns role assignments for a principal. +func (s *Service) ListPrincipalRoleAssignments( + ctx context.Context, + principalID string, +) ([]authkit.PrincipalRoleAssignment, error) { + if s.roleAssignmentLister == nil { + return nil, errors.New("management: principal role assignment lister is required") + } + + assignments, err := s.roleAssignmentLister.ListPrincipalRoleAssignments(ctx, principalID) + if err != nil { + return nil, fmt.Errorf("management: list principal role assignments: %w", err) + } + + return assignments, nil +} + // CreateProvisioningRule creates a provisioning rule. func (s *Service) CreateProvisioningRule( ctx context.Context, @@ -283,3 +366,20 @@ func (s *Service) RevokeAPIToken(ctx context.Context, tokenID string) error { return nil } + +// ListPrincipalAPITokenMetadata returns API-token metadata for a principal. +func (s *Service) ListPrincipalAPITokenMetadata( + ctx context.Context, + principalID string, +) ([]apikey.TokenMetadata, error) { + if s.apiTokenMetadataLister == nil { + return nil, errors.New("management: API token metadata lister is required") + } + + tokens, err := s.apiTokenMetadataLister.ListPrincipalTokenMetadata(ctx, principalID) + if err != nil { + return nil, fmt.Errorf("management: list principal API token metadata: %w", err) + } + + return tokens, nil +} diff --git a/management/service_test.go b/management/service_test.go index a77ce26..60b48c1 100644 --- a/management/service_test.go +++ b/management/service_test.go @@ -97,6 +97,24 @@ func TestServiceCoreMethodsRequirePorts(t *testing.T) { }, want: "management: identity linker is required", }, + { + name: "find principal", + run: func() error { + _, err := service.FindPrincipal(context.Background(), testPrincipalID) + + return err + }, + want: "management: principal finder is required", + }, + { + name: "list principals", + run: func() error { + _, err := service.ListPrincipals(context.Background()) + + return err + }, + want: "management: principal lister is required", + }, { name: "issue API token missing API token service", run: func() error { @@ -116,6 +134,15 @@ func TestServiceCoreMethodsRequirePorts(t *testing.T) { }, want: "management: API tokens service is required", }, + { + name: "list API token metadata", + run: func() error { + _, err := service.ListPrincipalAPITokenMetadata(context.Background(), testPrincipalID) + + return err + }, + want: "management: API token metadata lister is required", + }, } for _, tt := range tests { @@ -164,6 +191,30 @@ func TestServiceCreatePrincipal(t *testing.T) { assert.Equal(t, []authkit.CreatePrincipalRequest{req}, creator.requests) } +func TestServiceFindAndListPrincipals(t *testing.T) { + principals := newFakePrincipalCreator() + principals.principal = authkit.Principal{ + ID: testPrincipalID, + Kind: authkit.PrincipalKindService, + DisplayName: testPrincipalName, + } + principals.principals = []authkit.Principal{principals.principal} + service := management.NewService(management.Options{ + PrincipalFinder: principals, + PrincipalLister: principals, + }) + + found, err := service.FindPrincipal(context.Background(), testPrincipalID) + require.NoError(t, err) + assert.Equal(t, principals.principal, found) + assert.Equal(t, []string{testPrincipalID}, principals.findIDs) + + listed, err := service.ListPrincipals(context.Background()) + require.NoError(t, err) + assert.Equal(t, principals.principals, listed) + assert.Equal(t, 1, principals.listCalls) +} + func TestNewServiceDoesNotRequireRolePorts(t *testing.T) { service := management.NewService(management.Options{ PrincipalCreator: newFakePrincipalCreator(), @@ -231,6 +282,34 @@ func TestServiceAssignPrincipalRole(t *testing.T) { assert.Equal(t, []authkit.AssignPrincipalRoleRequest{req}, roles.assignRequests) } +func TestServiceUnassignPrincipalRole(t *testing.T) { + roles := newFakeRoleStore() + service := newServiceWithRoles(t, newFakePrincipalCreator(), roles, newFakeIdentityLinker(), newFakeAPITokens()) + req := authkit.UnassignPrincipalRoleRequest{ + PrincipalID: testPrincipalID, + RoleID: "notes-reader", + } + + err := service.UnassignPrincipalRole(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, []authkit.UnassignPrincipalRoleRequest{req}, roles.unassignRequests) +} + +func TestServiceListPrincipalRoleAssignments(t *testing.T) { + roles := newFakeRoleStore() + roles.assignments = []authkit.PrincipalRoleAssignment{ + {PrincipalID: testPrincipalID, RoleID: "notes-reader"}, + } + service := newServiceWithRoles(t, newFakePrincipalCreator(), roles, newFakeIdentityLinker(), newFakeAPITokens()) + + assignments, err := service.ListPrincipalRoleAssignments(context.Background(), testPrincipalID) + + require.NoError(t, err) + assert.Equal(t, roles.assignments, assignments) + assert.Equal(t, []string{testPrincipalID}, roles.listPrincipalIDs) +} + func TestServiceWrapsRoleErrors(t *testing.T) { roleErr := errors.New("role failed") @@ -264,6 +343,23 @@ func TestServiceWrapsRoleErrors(t *testing.T) { }) }, }, + { + name: "unassign principal role", + run: func(service *management.Service) error { + return service.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ + PrincipalID: testPrincipalID, + RoleID: "notes-reader", + }) + }, + }, + { + name: "list principal role assignments", + run: func(service *management.Service) error { + _, err := service.ListPrincipalRoleAssignments(context.Background(), testPrincipalID) + + return err + }, + }, } for _, tt := range tests { @@ -322,6 +418,23 @@ func TestServiceRoleMethodsRequireRolePorts(t *testing.T) { }) }, }, + { + name: "unassign principal role", + run: func() error { + return service.UnassignPrincipalRole(context.Background(), authkit.UnassignPrincipalRoleRequest{ + PrincipalID: testPrincipalID, + RoleID: "notes-reader", + }) + }, + }, + { + name: "list principal role assignments", + run: func() error { + _, runErr := service.ListPrincipalRoleAssignments(context.Background(), testPrincipalID) + + return runErr + }, + }, } for _, tt := range tests { @@ -640,6 +753,25 @@ func TestServiceRevokeAPITokenReturnsError(t *testing.T) { require.ErrorIs(t, err, revokeErr) } +func TestServiceListPrincipalAPITokenMetadata(t *testing.T) { + apiTokens := newFakeAPITokens() + apiTokens.metadata = []apikey.TokenMetadata{{ + ID: testTokenID, + PrincipalID: testPrincipalID, + Name: testTokenName, + ExpiresAt: fixedTime().Add(time.Hour), + }} + service := management.NewService(management.Options{ + APITokenMetadataLister: apiTokens, + }) + + tokens, err := service.ListPrincipalAPITokenMetadata(context.Background(), testPrincipalID) + + require.NoError(t, err) + assert.Equal(t, apiTokens.metadata, tokens) + assert.Equal(t, []string{testPrincipalID}, apiTokens.metadataPrincipalIDs) +} + func TestServicePropagatesContextCancellation(t *testing.T) { now := fixedTime() store := memory.NewStore() @@ -674,6 +806,22 @@ func TestServicePropagatesContextCancellation(t *testing.T) { return runErr }, }, + { + name: "find principal", + run: func() error { + _, runErr := service.FindPrincipal(ctx, principal.ID) + + return runErr + }, + }, + { + name: "list principals", + run: func() error { + _, runErr := service.ListPrincipals(ctx) + + return runErr + }, + }, { name: "link identity", run: func() error { @@ -710,6 +858,23 @@ func TestServicePropagatesContextCancellation(t *testing.T) { }) }, }, + { + name: "unassign principal role", + run: func() error { + return service.UnassignPrincipalRole(ctx, authkit.UnassignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: "notes-reader", + }) + }, + }, + { + name: "list principal role assignments", + run: func() error { + _, runErr := service.ListPrincipalRoleAssignments(ctx, principal.ID) + + return runErr + }, + }, { name: "create provisioning rule", run: func() error { @@ -765,6 +930,14 @@ func TestServicePropagatesContextCancellation(t *testing.T) { return service.RevokeAPIToken(ctx, token.ID) }, }, + { + name: "list API token metadata", + run: func() error { + _, runErr := service.ListPrincipalAPITokenMetadata(ctx, principal.ID) + + return runErr + }, + }, } for _, tt := range tests { @@ -829,12 +1002,14 @@ func newServiceWithRoles( t.Helper() service := management.NewService(management.Options{ - PrincipalCreator: creator, - RoleCreator: roles, - RoleActionGranter: roles, - PrincipalRoleAssigner: roles, - IdentityLinker: linker, - APITokens: apiTokens, + PrincipalCreator: creator, + RoleCreator: roles, + RoleActionGranter: roles, + PrincipalRoleAssigner: roles, + PrincipalRoleUnassigner: roles, + PrincipalRoleAssignmentLister: roles, + IdentityLinker: linker, + APITokens: apiTokens, }) return service @@ -851,17 +1026,19 @@ func newServiceWithProvisioningRules( t.Helper() service := management.NewService(management.Options{ - PrincipalCreator: creator, - RoleCreator: roles, - RoleActionGranter: roles, - PrincipalRoleAssigner: roles, - ProvisioningRuleCreator: rules, - ProvisioningRuleUpdater: rules, - ProvisioningRuleDeleter: rules, - ProvisioningRuleFinder: rules, - ProvisioningRuleLister: rules, - IdentityLinker: linker, - APITokens: apiTokens, + PrincipalCreator: creator, + RoleCreator: roles, + RoleActionGranter: roles, + PrincipalRoleAssigner: roles, + PrincipalRoleUnassigner: roles, + PrincipalRoleAssignmentLister: roles, + ProvisioningRuleCreator: rules, + ProvisioningRuleUpdater: rules, + ProvisioningRuleDeleter: rules, + ProvisioningRuleFinder: rules, + ProvisioningRuleLister: rules, + IdentityLinker: linker, + APITokens: apiTokens, }) return service @@ -874,13 +1051,32 @@ func newManagementService( ) *management.Service { t.Helper() - return newServiceWithProvisioningRules(t, store, store, store, store, tokenService) + return management.NewService(management.Options{ + PrincipalCreator: store, + PrincipalFinder: store, + PrincipalLister: store, + RoleCreator: store, + RoleActionGranter: store, + PrincipalRoleAssigner: store, + PrincipalRoleUnassigner: store, + PrincipalRoleAssignmentLister: store, + ProvisioningRuleCreator: store, + ProvisioningRuleUpdater: store, + ProvisioningRuleDeleter: store, + ProvisioningRuleFinder: store, + ProvisioningRuleLister: store, + IdentityLinker: store, + APITokens: tokenService, + APITokenMetadataLister: store, + }) } type roleStore interface { authkit.RoleCreator authkit.RoleActionGranter authkit.PrincipalRoleAssigner + authkit.PrincipalRoleUnassigner + authkit.PrincipalRoleAssignmentLister } type provisioningRuleStore interface { @@ -927,9 +1123,12 @@ func provisioningRule() authkit.ProvisioningRule { } type fakePrincipalCreator struct { - requests []authkit.CreatePrincipalRequest - principal authkit.Principal - err error + requests []authkit.CreatePrincipalRequest + findIDs []string + listCalls int + principal authkit.Principal + principals []authkit.Principal + err error } func (f *fakePrincipalCreator) CreatePrincipal( @@ -944,6 +1143,24 @@ func (f *fakePrincipalCreator) CreatePrincipal( return f.principal, nil } +func (f *fakePrincipalCreator) FindPrincipal(_ context.Context, id string) (authkit.Principal, error) { + f.findIDs = append(f.findIDs, id) + if f.err != nil { + return authkit.Principal{}, f.err + } + + return f.principal, nil +} + +func (f *fakePrincipalCreator) ListPrincipals(_ context.Context) ([]authkit.Principal, error) { + f.listCalls++ + if f.err != nil { + return nil, f.err + } + + return f.principals, nil +} + type fakeIdentityLinker struct { requests []authkit.LinkIdentityRequest identity authkit.ExternalIdentity @@ -963,11 +1180,14 @@ func (f *fakeIdentityLinker) LinkIdentity( } type fakeRoleStore struct { - createRequests []authkit.CreateRoleRequest - grantRequests []authkit.GrantRoleActionRequest - assignRequests []authkit.AssignPrincipalRoleRequest - role authkit.Role - err error + createRequests []authkit.CreateRoleRequest + grantRequests []authkit.GrantRoleActionRequest + assignRequests []authkit.AssignPrincipalRoleRequest + unassignRequests []authkit.UnassignPrincipalRoleRequest + listPrincipalIDs []string + role authkit.Role + assignments []authkit.PrincipalRoleAssignment + err error } func (f *fakeRoleStore) CreateRole( @@ -1006,6 +1226,30 @@ func (f *fakeRoleStore) AssignPrincipalRole( return nil } +func (f *fakeRoleStore) UnassignPrincipalRole( + _ context.Context, + req authkit.UnassignPrincipalRoleRequest, +) error { + f.unassignRequests = append(f.unassignRequests, req) + if f.err != nil { + return f.err + } + + return nil +} + +func (f *fakeRoleStore) ListPrincipalRoleAssignments( + _ context.Context, + principalID string, +) ([]authkit.PrincipalRoleAssignment, error) { + f.listPrincipalIDs = append(f.listPrincipalIDs, principalID) + if f.err != nil { + return nil, f.err + } + + return f.assignments, nil +} + type fakeProvisioningRuleStore struct { createRequests []authkit.CreateProvisioningRuleRequest updateRequests []authkit.UpdateProvisioningRuleRequest @@ -1073,11 +1317,14 @@ func (f *fakeProvisioningRuleStore) ListProvisioningRules( } type fakeAPITokens struct { - issueRequests []apikey.IssueRequest - issued apikey.IssuedToken - issueErr error - revokedIDs []string - revokeErr error + issueRequests []apikey.IssueRequest + issued apikey.IssuedToken + issueErr error + revokedIDs []string + revokeErr error + metadataPrincipalIDs []string + metadata []apikey.TokenMetadata + metadataErr error } func (f *fakeAPITokens) IssueToken( @@ -1100,3 +1347,15 @@ func (f *fakeAPITokens) RevokeToken(_ context.Context, tokenID string) error { return nil } + +func (f *fakeAPITokens) ListPrincipalTokenMetadata( + _ context.Context, + principalID string, +) ([]apikey.TokenMetadata, error) { + f.metadataPrincipalIDs = append(f.metadataPrincipalIDs, principalID) + if f.metadataErr != nil { + return nil, f.metadataErr + } + + return f.metadata, nil +} diff --git a/services.go b/services.go index 57b0d15..5701dfd 100644 --- a/services.go +++ b/services.go @@ -44,6 +44,15 @@ type AssignPrincipalRoleRequest struct { RoleID string } +// UnassignPrincipalRoleRequest describes a request to remove a principal from a role. +type UnassignPrincipalRoleRequest struct { + // PrincipalID identifies the principal losing the role. + PrincipalID string + + // RoleID identifies the role to remove from the principal. + RoleID string +} + // CreateProvisioningRuleRequest describes a request to create a provisioning rule. type CreateProvisioningRuleRequest struct { // ID is the stable application-owned provisioning rule identifier. @@ -128,6 +137,18 @@ type PrincipalCreator interface { CreatePrincipal(ctx context.Context, req CreatePrincipalRequest) (Principal, error) } +// PrincipalFinder finds internal principals. +type PrincipalFinder interface { + // FindPrincipal returns the principal identified by id. + FindPrincipal(ctx context.Context, id string) (Principal, error) +} + +// PrincipalLister lists internal principals. +type PrincipalLister interface { + // ListPrincipals returns all principals. + ListPrincipals(ctx context.Context) ([]Principal, error) +} + // RoleCreator creates admin-managed local roles. type RoleCreator interface { // CreateRole creates a local role from req. @@ -146,6 +167,18 @@ type PrincipalRoleAssigner interface { AssignPrincipalRole(ctx context.Context, req AssignPrincipalRoleRequest) error } +// PrincipalRoleUnassigner removes principals from roles. +type PrincipalRoleUnassigner interface { + // UnassignPrincipalRole removes req.PrincipalID from req.RoleID. + UnassignPrincipalRole(ctx context.Context, req UnassignPrincipalRoleRequest) error +} + +// PrincipalRoleAssignmentLister lists role assignments for principals. +type PrincipalRoleAssignmentLister interface { + // ListPrincipalRoleAssignments returns all role assignments for principalID. + ListPrincipalRoleAssignments(ctx context.Context, principalID string) ([]PrincipalRoleAssignment, error) +} + // PrincipalActionResolver resolves effective authorization actions for principals. type PrincipalActionResolver interface { // ResolvePrincipalActions returns the distinct actions granted to principalID. diff --git a/store/memory/store.go b/store/memory/store.go index c679db5..f6913fd 100644 --- a/store/memory/store.go +++ b/store/memory/store.go @@ -76,6 +76,46 @@ func (s *Store) CreatePrincipal(ctx context.Context, req authkit.CreatePrincipal return clonePrincipal(principal), nil } +// FindPrincipal returns a principal by ID. +func (s *Store) FindPrincipal(ctx context.Context, id string) (authkit.Principal, error) { + if err := ctx.Err(); err != nil { + return authkit.Principal{}, err + } + if id == "" { + return authkit.Principal{}, errors.New("memory: principal ID is required") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + principal, ok := s.principals[id] + if !ok { + return authkit.Principal{}, authkit.ErrPrincipalNotFound + } + + return clonePrincipal(principal), nil +} + +// ListPrincipals returns all principals sorted by ID. +func (s *Store) ListPrincipals(ctx context.Context) ([]authkit.Principal, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + s.mu.RLock() + defer s.mu.RUnlock() + + principals := make([]authkit.Principal, 0, len(s.principals)) + for _, principal := range s.principals { + principals = append(principals, clonePrincipal(principal)) + } + sort.Slice(principals, func(i, j int) bool { + return principals[i].ID < principals[j].ID + }) + + return principals, nil +} + // CreateRole creates a local role in the store. func (s *Store) CreateRole(ctx context.Context, req authkit.CreateRoleRequest) (authkit.Role, error) { if err := ctx.Err(); err != nil { @@ -153,6 +193,68 @@ func (s *Store) AssignPrincipalRole(ctx context.Context, req authkit.AssignPrinc return nil } +// UnassignPrincipalRole removes a principal from a local role. +func (s *Store) UnassignPrincipalRole(ctx context.Context, req authkit.UnassignPrincipalRoleRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if req.PrincipalID == "" { + return errors.New("memory: principal ID is required") + } + if req.RoleID == "" { + return errors.New("memory: role ID is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.principals[req.PrincipalID]; !ok { + return authkit.ErrPrincipalNotFound + } + if _, ok := s.roles[req.RoleID]; !ok { + return fmt.Errorf("memory: role %q does not exist", req.RoleID) + } + delete(s.principalRoles[req.PrincipalID], req.RoleID) + if len(s.principalRoles[req.PrincipalID]) == 0 { + delete(s.principalRoles, req.PrincipalID) + } + + return nil +} + +// ListPrincipalRoleAssignments returns role assignments for a principal. +func (s *Store) ListPrincipalRoleAssignments( + ctx context.Context, + principalID string, +) ([]authkit.PrincipalRoleAssignment, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if principalID == "" { + return nil, errors.New("memory: principal ID is required") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if _, ok := s.principals[principalID]; !ok { + return nil, authkit.ErrPrincipalNotFound + } + + assignments := make([]authkit.PrincipalRoleAssignment, 0, len(s.principalRoles[principalID])) + for roleID := range s.principalRoles[principalID] { + assignments = append(assignments, authkit.PrincipalRoleAssignment{ + PrincipalID: principalID, + RoleID: roleID, + }) + } + sort.Slice(assignments, func(i, j int) bool { + return assignments[i].RoleID < assignments[j].RoleID + }) + + return assignments, nil +} + // ResolvePrincipalActions returns the distinct actions granted to principalID through roles. func (s *Store) ResolvePrincipalActions(ctx context.Context, principalID string) ([]string, error) { if err := ctx.Err(); err != nil { diff --git a/store/memory/store_test.go b/store/memory/store_test.go index 9af2b67..086473d 100644 --- a/store/memory/store_test.go +++ b/store/memory/store_test.go @@ -22,9 +22,13 @@ const ( func TestStoreSatisfiesAuthkitContracts(t *testing.T) { var _ authkit.PrincipalCreator = (*Store)(nil) + var _ authkit.PrincipalFinder = (*Store)(nil) + var _ authkit.PrincipalLister = (*Store)(nil) var _ authkit.RoleCreator = (*Store)(nil) var _ authkit.RoleActionGranter = (*Store)(nil) var _ authkit.PrincipalRoleAssigner = (*Store)(nil) + var _ authkit.PrincipalRoleUnassigner = (*Store)(nil) + var _ authkit.PrincipalRoleAssignmentLister = (*Store)(nil) var _ authkit.PrincipalActionResolver = (*Store)(nil) var _ authkit.IdentityLinker = (*Store)(nil) var _ authkit.IdentityProvisioner = (*Store)(nil) @@ -35,6 +39,7 @@ func TestStoreSatisfiesAuthkitContracts(t *testing.T) { var _ authkit.ProvisioningRuleFinder = (*Store)(nil) var _ authkit.ProvisioningRuleLister = (*Store)(nil) var _ apikey.TokenStore = (*Store)(nil) + var _ apikey.TokenMetadataLister = (*Store)(nil) var _ oidc.ProviderSource = (*Store)(nil) var _ oidc.ProviderTrustStore = (*Store)(nil) diff --git a/store/memory/token.go b/store/memory/token.go index bc2984a..9da182e 100644 --- a/store/memory/token.go +++ b/store/memory/token.go @@ -3,8 +3,10 @@ package memory import ( "context" "errors" + "sort" "time" + "github.com/meigma/authkit" "github.com/meigma/authkit/apikey" ) @@ -87,6 +89,39 @@ func (s *Store) RevokeToken(ctx context.Context, tokenID string, revokedAt time. return nil } +// ListPrincipalTokenMetadata returns API-token metadata for principalID. +func (s *Store) ListPrincipalTokenMetadata( + ctx context.Context, + principalID string, +) ([]apikey.TokenMetadata, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if principalID == "" { + return nil, errors.New("memory: principal ID is required") + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if _, ok := s.principals[principalID]; !ok { + return nil, authkit.ErrPrincipalNotFound + } + + tokens := make([]apikey.TokenMetadata, 0) + for _, token := range s.tokens { + if token.PrincipalID != principalID { + continue + } + tokens = append(tokens, tokenMetadataFromStored(token)) + } + sort.Slice(tokens, func(i, j int) bool { + return tokens[i].ID < tokens[j].ID + }) + + return tokens, nil +} + func cloneStoredToken(token apikey.StoredToken) apikey.StoredToken { token.LastUsedAt = cloneTime(token.LastUsedAt) token.RevokedAt = cloneTime(token.RevokedAt) @@ -94,6 +129,17 @@ func cloneStoredToken(token apikey.StoredToken) apikey.StoredToken { return token } +func tokenMetadataFromStored(token apikey.StoredToken) apikey.TokenMetadata { + return apikey.TokenMetadata{ + ID: token.ID, + PrincipalID: token.PrincipalID, + Name: token.Name, + ExpiresAt: token.ExpiresAt, + LastUsedAt: cloneTime(token.LastUsedAt), + RevokedAt: cloneTime(token.RevokedAt), + } +} + func cloneTime(value *time.Time) *time.Time { if value == nil { return nil diff --git a/store/postgres/store.go b/store/postgres/store.go index 6cd7980..e7b7d15 100644 --- a/store/postgres/store.go +++ b/store/postgres/store.go @@ -77,6 +77,64 @@ func (s *Store) CreatePrincipal( return createPrincipal(ctx, s.pool, req) } +// FindPrincipal returns a principal by ID. +func (s *Store) FindPrincipal(ctx context.Context, id string) (authkit.Principal, error) { + if err := ctx.Err(); err != nil { + return authkit.Principal{}, err + } + if id == "" { + return authkit.Principal{}, errors.New("postgres: principal ID is required") + } + + principal, err := scanPrincipal(s.pool.QueryRow( + ctx, + `select id, kind, display_name, coalesce(attributes::text, '') + from authkit_principals + where id = $1`, + id, + )) + if errors.Is(err, pgx.ErrNoRows) { + return authkit.Principal{}, authkit.ErrPrincipalNotFound + } + if err != nil { + return authkit.Principal{}, err + } + + return principal, nil +} + +// ListPrincipals returns all principals sorted by ID. +func (s *Store) ListPrincipals(ctx context.Context) ([]authkit.Principal, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + rows, err := s.pool.Query( + ctx, + `select id, kind, display_name, coalesce(attributes::text, '') + from authkit_principals + order by id`, + ) + if err != nil { + return nil, fmt.Errorf("postgres: list principals: %w", err) + } + defer rows.Close() + + var principals []authkit.Principal + for rows.Next() { + principal, scanErr := scanPrincipal(rows) + if scanErr != nil { + return nil, scanErr + } + principals = append(principals, principal) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("postgres: read principals: %w", err) + } + + return principals, nil +} + // CreateRole creates a local role in PostgreSQL. func (s *Store) CreateRole(ctx context.Context, req authkit.CreateRoleRequest) (authkit.Role, error) { if err := ctx.Err(); err != nil { @@ -169,6 +227,93 @@ func (s *Store) AssignPrincipalRole(ctx context.Context, req authkit.AssignPrinc return nil } +// UnassignPrincipalRole removes a principal from a local role. +func (s *Store) UnassignPrincipalRole(ctx context.Context, req authkit.UnassignPrincipalRoleRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if req.PrincipalID == "" { + return errors.New("postgres: principal ID is required") + } + if req.RoleID == "" { + return errors.New("postgres: role ID is required") + } + + exists, err := s.principalExists(ctx, req.PrincipalID) + if err != nil { + return err + } + if !exists { + return authkit.ErrPrincipalNotFound + } + roleExists, err := s.roleExists(ctx, req.RoleID) + if err != nil { + return err + } + if !roleExists { + return fmt.Errorf("postgres: role %q does not exist", req.RoleID) + } + + if _, err := s.pool.Exec( + ctx, + `delete from authkit_principal_roles where principal_id = $1 and role_id = $2`, + req.PrincipalID, + req.RoleID, + ); err != nil { + return fmt.Errorf("postgres: unassign principal role: %w", err) + } + + return nil +} + +// ListPrincipalRoleAssignments returns role assignments for a principal. +func (s *Store) ListPrincipalRoleAssignments( + ctx context.Context, + principalID string, +) ([]authkit.PrincipalRoleAssignment, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if principalID == "" { + return nil, errors.New("postgres: principal ID is required") + } + + exists, err := s.principalExists(ctx, principalID) + if err != nil { + return nil, err + } + if !exists { + return nil, authkit.ErrPrincipalNotFound + } + + rows, err := s.pool.Query( + ctx, + `select principal_id, role_id + from authkit_principal_roles + where principal_id = $1 + order by role_id`, + principalID, + ) + if err != nil { + return nil, fmt.Errorf("postgres: list principal role assignments: %w", err) + } + defer rows.Close() + + var assignments []authkit.PrincipalRoleAssignment + for rows.Next() { + var assignment authkit.PrincipalRoleAssignment + if err := rows.Scan(&assignment.PrincipalID, &assignment.RoleID); err != nil { + return nil, fmt.Errorf("postgres: scan principal role assignment: %w", err) + } + assignments = append(assignments, assignment) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("postgres: read principal role assignments: %w", err) + } + + return assignments, nil +} + // ResolvePrincipalActions returns the distinct actions granted to principalID through roles. func (s *Store) ResolvePrincipalActions(ctx context.Context, principalID string) ([]string, error) { if err := ctx.Err(); err != nil { @@ -725,6 +870,54 @@ func (s *Store) RevokeToken(ctx context.Context, tokenID string, revokedAt time. return nil } +// ListPrincipalTokenMetadata returns API-token metadata for principalID. +func (s *Store) ListPrincipalTokenMetadata( + ctx context.Context, + principalID string, +) ([]apikey.TokenMetadata, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if principalID == "" { + return nil, errors.New("postgres: principal ID is required") + } + + exists, err := s.principalExists(ctx, principalID) + if err != nil { + return nil, err + } + if !exists { + return nil, authkit.ErrPrincipalNotFound + } + + rows, err := s.pool.Query( + ctx, + `select id, principal_id, name, expires_at, last_used_at, revoked_at + from authkit_api_tokens + where principal_id = $1 + order by id`, + principalID, + ) + if err != nil { + return nil, fmt.Errorf("postgres: list principal API token metadata: %w", err) + } + defer rows.Close() + + var tokens []apikey.TokenMetadata + for rows.Next() { + token, scanErr := scanTokenMetadata(rows) + if scanErr != nil { + return nil, scanErr + } + tokens = append(tokens, token) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("postgres: read principal API token metadata: %w", err) + } + + return tokens, nil +} + // TrustProvider stores provider as trusted for its issuer. func (s *Store) TrustProvider(ctx context.Context, provider oidc.Provider) (oidc.Provider, error) { if err := ctx.Err(); err != nil { @@ -839,6 +1032,37 @@ func (s *Store) principalExists(ctx context.Context, principalID string) (bool, return exists, nil } +func (s *Store) roleExists(ctx context.Context, roleID string) (bool, error) { + var exists bool + if err := s.pool.QueryRow( + ctx, + `select exists(select 1 from authkit_roles where id = $1)`, + roleID, + ).Scan(&exists); err != nil { + return false, fmt.Errorf("postgres: find role: %w", err) + } + + return exists, nil +} + +func scanPrincipal(row scanner) (authkit.Principal, error) { + var principal authkit.Principal + var kind string + var attributes string + if err := row.Scan(&principal.ID, &kind, &principal.DisplayName, &attributes); err != nil { + return authkit.Principal{}, err + } + + principal.Kind = authkit.PrincipalKind(kind) + attrs, err := decodeAttributes(attributes) + if err != nil { + return authkit.Principal{}, err + } + principal.Attributes = attrs + + return principal, nil +} + func findProvisionedIdentity( ctx context.Context, query rowQuerier, @@ -1132,6 +1356,28 @@ func (s *Store) findToken(ctx context.Context, tokenID string) (apikey.StoredTok return token, nil } +func scanTokenMetadata(row scanner) (apikey.TokenMetadata, error) { + var token apikey.TokenMetadata + var lastUsedAt pgtype.Timestamptz + var revokedAt pgtype.Timestamptz + if err := row.Scan( + &token.ID, + &token.PrincipalID, + &token.Name, + &token.ExpiresAt, + &lastUsedAt, + &revokedAt, + ); err != nil { + return apikey.TokenMetadata{}, err + } + + token.ExpiresAt = token.ExpiresAt.UTC() + token.LastUsedAt = timeFromTimestamptz(lastUsedAt) + token.RevokedAt = timeFromTimestamptz(revokedAt) + + return token, nil +} + func encodeAttributes(attrs map[string]any) (string, error) { if len(attrs) == 0 { return "", nil diff --git a/store/postgres/store_test.go b/store/postgres/store_test.go index eaabd59..237ea96 100644 --- a/store/postgres/store_test.go +++ b/store/postgres/store_test.go @@ -14,9 +14,13 @@ import ( func TestStoreSatisfiesAuthkitContracts(_ *testing.T) { var _ authkit.PrincipalCreator = (*Store)(nil) + var _ authkit.PrincipalFinder = (*Store)(nil) + var _ authkit.PrincipalLister = (*Store)(nil) var _ authkit.RoleCreator = (*Store)(nil) var _ authkit.RoleActionGranter = (*Store)(nil) var _ authkit.PrincipalRoleAssigner = (*Store)(nil) + var _ authkit.PrincipalRoleUnassigner = (*Store)(nil) + var _ authkit.PrincipalRoleAssignmentLister = (*Store)(nil) var _ authkit.PrincipalActionResolver = (*Store)(nil) var _ authkit.IdentityLinker = (*Store)(nil) var _ authkit.IdentityProvisioner = (*Store)(nil) @@ -27,6 +31,7 @@ func TestStoreSatisfiesAuthkitContracts(_ *testing.T) { var _ authkit.ProvisioningRuleFinder = (*Store)(nil) var _ authkit.ProvisioningRuleLister = (*Store)(nil) var _ apikey.TokenStore = (*Store)(nil) + var _ apikey.TokenMetadataLister = (*Store)(nil) var _ oidc.ProviderSource = (*Store)(nil) var _ oidc.ProviderTrustStore = (*Store)(nil) } diff --git a/types.go b/types.go index 15e400c..318024a 100644 --- a/types.go +++ b/types.go @@ -53,6 +53,15 @@ type Role struct { Description string } +// PrincipalRoleAssignment describes one local role assigned to a principal. +type PrincipalRoleAssignment struct { + // PrincipalID identifies the principal receiving the role. + PrincipalID string + + // RoleID identifies the assigned role. + RoleID string +} + // ProvisioningRule describes an admin-managed rule for initial role assignment. type ProvisioningRule struct { // ID is the stable application-owned provisioning rule identifier.