diff --git a/cmd/serve.go b/cmd/serve.go index 2280d7995..4982a6dcb 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -395,7 +395,7 @@ func buildAPIDependencies( svUserRepo := postgres.NewServiceUserRepository(dbc) scUserCredRepo := postgres.NewServiceUserCredentialRepository(dbc) - serviceUserService := serviceuser.NewService(logger, svUserRepo, scUserCredRepo, relationService) + serviceUserService := serviceuser.NewService(logger, svUserRepo, scUserCredRepo, relationService, auditRecordRepository) var mailDialer mailer.Dialer = mailer.NewMockDialer() if cfg.App.Mailer.SMTPHost != "" && cfg.App.Mailer.SMTPHost != "smtp.example.com" { @@ -432,7 +432,7 @@ func buildAPIDependencies( // back here because role.Service depends on permission.Service permissionService.SetRoleService(roleService) policyService := policy.NewService(policyPGRepository, relationService, roleService) - userService := user.NewService(userRepository, relationService, sessionService) + userService := user.NewService(userRepository, relationService, sessionService, auditRecordRepository) patValidator := userpat.NewValidator(logger, userPATRepo, cfg.App.PAT) authnService := authenticate.NewService(logger, cfg.App.Authentication, postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator) diff --git a/config/sample.config.yaml b/config/sample.config.yaml index a90840e71..a0738bdbb 100644 --- a/config/sample.config.yaml +++ b/config/sample.config.yaml @@ -185,6 +185,14 @@ app: # UUIDs/slugs of existing users can also be provided instead of email ids # but in that case a new user will not be created. users: [] + # authoritative makes the `users` list above the single source of truth for + # human superusers. When true, at every startup any human holding the platform + # admin (superuser) relation that is NOT in `users` is demoted. Service accounts + # are never affected by reconciliation. + # WARNING: with an empty or misconfigured `users` list this demotes ALL human + # superusers. Recovery is to fix `users` and restart (or rely on a service-account + # superuser). Defaults to false (additive only — the historical behavior). + authoritative: false # smtp configuration for sending emails mailer: smtp_host: smtp.example.com diff --git a/core/serviceuser/mocks/audit_record_repository.go b/core/serviceuser/mocks/audit_record_repository.go new file mode 100644 index 000000000..87c61b8a3 --- /dev/null +++ b/core/serviceuser/mocks/audit_record_repository.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/raystack/frontier/core/auditrecord/models" + mock "github.com/stretchr/testify/mock" +) + +// AuditRecordRepository is an autogenerated mock type for the AuditRecordRepository type +type AuditRecordRepository struct { + mock.Mock +} + +type AuditRecordRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *AuditRecordRepository) EXPECT() *AuditRecordRepository_Expecter { + return &AuditRecordRepository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, auditRecord +func (_m *AuditRecordRepository) Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) { + ret := _m.Called(ctx, auditRecord) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 models.AuditRecord + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, models.AuditRecord) (models.AuditRecord, error)); ok { + return rf(ctx, auditRecord) + } + if rf, ok := ret.Get(0).(func(context.Context, models.AuditRecord) models.AuditRecord); ok { + r0 = rf(ctx, auditRecord) + } else { + r0 = ret.Get(0).(models.AuditRecord) + } + + if rf, ok := ret.Get(1).(func(context.Context, models.AuditRecord) error); ok { + r1 = rf(ctx, auditRecord) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AuditRecordRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type AuditRecordRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - auditRecord models.AuditRecord +func (_e *AuditRecordRepository_Expecter) Create(ctx interface{}, auditRecord interface{}) *AuditRecordRepository_Create_Call { + return &AuditRecordRepository_Create_Call{Call: _e.mock.On("Create", ctx, auditRecord)} +} + +func (_c *AuditRecordRepository_Create_Call) Run(run func(ctx context.Context, auditRecord models.AuditRecord)) *AuditRecordRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.AuditRecord)) + }) + return _c +} + +func (_c *AuditRecordRepository_Create_Call) Return(_a0 models.AuditRecord, _a1 error) *AuditRecordRepository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AuditRecordRepository_Create_Call) RunAndReturn(run func(context.Context, models.AuditRecord) (models.AuditRecord, error)) *AuditRecordRepository_Create_Call { + _c.Call.Return(run) + return _c +} + +// NewAuditRecordRepository creates a new instance of AuditRecordRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuditRecordRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *AuditRecordRepository { + mock := &AuditRecordRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/serviceuser/service.go b/core/serviceuser/service.go index f9a8f8df6..f479b6b67 100644 --- a/core/serviceuser/service.go +++ b/core/serviceuser/service.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log/slog" + "time" "golang.org/x/crypto/sha3" @@ -18,8 +19,10 @@ import ( "github.com/google/uuid" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/raystack/frontier/pkg/utils" ) @@ -51,20 +54,26 @@ type MembershipService interface { ListPrincipalIDsByResource(ctx context.Context, resourceID, resourceType, principalType string) ([]string, error) } +type AuditRecordRepository interface { + Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) +} + type Service struct { - log *slog.Logger - repo Repository - credRepo CredentialRepository - relationService RelationService - membershipService MembershipService + log *slog.Logger + repo Repository + credRepo CredentialRepository + relationService RelationService + membershipService MembershipService + auditRecordRepository AuditRecordRepository } -func NewService(logger *slog.Logger, repo Repository, credRepo CredentialRepository, relService RelationService) *Service { +func NewService(logger *slog.Logger, repo Repository, credRepo CredentialRepository, relService RelationService, auditRecordRepository AuditRecordRepository) *Service { return &Service{ - log: logger, - repo: repo, - credRepo: credRepo, - relationService: relService, + log: logger, + repo: repo, + credRepo: credRepo, + relationService: relService, + auditRecordRepository: auditRecordRepository, } } @@ -467,29 +476,46 @@ func (s Service) Sudo(ctx context.Context, id string, relationName string) error }, RelationName: relationName, }) - return err + if err != nil { + return err + } + + // audit the grant for both admin and member relations + event := pkgAuditRecord.PlatformAdminAddedEvent + if relationName == schema.MemberRelationName { + event = pkgAuditRecord.PlatformMemberAddedEvent + } + return s.recordPlatformAuditRecord(ctx, currentUser, event, relationName) } -// UnSudo remove platform permissions to user -// only remove the 'member' relation if it exists -func (s Service) UnSudo(ctx context.Context, id string) error { +// UnSudo removes a platform relation (admin or member) from a service user. +// It removes the exact relation requested — an `admin` relation can now actually +// be stripped. Both admin and member grants/removals are audited. +func (s Service) UnSudo(ctx context.Context, id, relationName string) error { + switch relationName { + case schema.AdminRelationName, schema.MemberRelationName: + default: + return fmt.Errorf("invalid relation name, possible options are: %s, %s", schema.MemberRelationName, schema.AdminRelationName) + } + currentUser, err := s.Get(ctx, id) if err != nil { return err } - relationName := schema.MemberRelationName - // to check if the user has member relation, we need to check if the user has `check` - // permission on platform - if ok, err := s.IsSudo(ctx, currentUser.ID, schema.PlatformCheckPermission); err != nil { + // Only act (and audit) when the specific relation actually exists, so the + // revoke event reflects a real state change. Checking the relation directly + // is precise for both admin and member. + present, err := s.IsSudo(ctx, currentUser.ID, relationName) + if err != nil { return err - } else if !ok { - // not needed + } + if !present { return nil } // unmark su - err = s.relationService.Delete(ctx, relation.Relation{ + if err := s.relationService.Delete(ctx, relation.Relation{ Object: relation.Object{ ID: schema.PlatformID, Namespace: schema.PlatformNamespace, @@ -499,6 +525,36 @@ func (s Service) UnSudo(ctx context.Context, id string) error { Namespace: schema.ServiceUserPrincipal, }, RelationName: relationName, + }); err != nil { + return err + } + + event := pkgAuditRecord.PlatformAdminRemovedEvent + if relationName == schema.MemberRelationName { + event = pkgAuditRecord.PlatformMemberRemovedEvent + } + return s.recordPlatformAuditRecord(ctx, currentUser, event, relationName) +} + +// recordPlatformAuditRecord writes an audit record for a platform relation +// (admin or member) grant/revoke on a service user. Actor is left empty so the +// repository enriches it from context (acting principal, or system actor at boot). +func (s Service) recordPlatformAuditRecord(ctx context.Context, su ServiceUser, event pkgAuditRecord.Event, relationName string) error { + _, err := s.auditRecordRepository.Create(ctx, models.AuditRecord{ + Event: event, + Resource: models.Resource{ + ID: schema.PlatformID, + Type: pkgAuditRecord.PlatformType, + Name: schema.PlatformID, + }, + Target: &models.Target{ + ID: su.ID, + Type: pkgAuditRecord.ServiceUserType, + Name: su.Title, + }, + OrgID: schema.PlatformOrgID.String(), + OccurredAt: time.Now().UTC(), + Metadata: map[string]any{"relation": relationName}, }) return err } diff --git a/core/serviceuser/service_test.go b/core/serviceuser/service_test.go index 8c295b0fd..49a682306 100644 --- a/core/serviceuser/service_test.go +++ b/core/serviceuser/service_test.go @@ -7,22 +7,136 @@ import ( "log/slog" "testing" + "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/serviceuser" "github.com/raystack/frontier/core/serviceuser/mocks" "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" + "github.com/stretchr/testify/mock" ) -func newTestService(t *testing.T) (*serviceuser.Service, *mocks.Repository, *mocks.CredentialRepository, *mocks.RelationService, *mocks.MembershipService) { +func newTestService(t *testing.T) (*serviceuser.Service, *mocks.Repository, *mocks.CredentialRepository, *mocks.RelationService, *mocks.MembershipService, *mocks.AuditRecordRepository) { t.Helper() repo := mocks.NewRepository(t) credRepo := mocks.NewCredentialRepository(t) relSvc := mocks.NewRelationService(t) memSvc := mocks.NewMembershipService(t) + auditRepo := mocks.NewAuditRecordRepository(t) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - svc := serviceuser.NewService(logger, repo, credRepo, relSvc) + svc := serviceuser.NewService(logger, repo, credRepo, relSvc, auditRepo) svc.SetMembershipService(memSvc) - return svc, repo, credRepo, relSvc, memSvc + return svc, repo, credRepo, relSvc, memSvc, auditRepo +} + +func TestService_Sudo(t *testing.T) { + ctx := context.Background() + const suID = "550e8400-e29b-41d4-a716-446655440000" + // platformRel builds the platform relation tuple used by IsSudo's check and by + // Create/Delete (field order in the literal is irrelevant for struct equality). + platformRel := func(rel string) relation.Relation { + return relation.Relation{ + Object: relation.Object{ID: schema.PlatformID, Namespace: schema.PlatformNamespace}, + Subject: relation.Subject{ID: suID, Namespace: schema.ServiceUserPrincipal}, + RelationName: rel, + } + } + + t.Run("grants admin relation and audits the grant", func(t *testing.T) { + svc, repo, _, rel, _, audit := newTestService(t) + repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil) + rel.On("CheckPermission", ctx, platformRel(schema.PlatformSudoPermission)).Return(false, nil) + rel.On("Create", ctx, platformRel(schema.AdminRelationName)).Return(relation.Relation{}, nil) + audit.On("Create", ctx, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformAdminAddedEvent && r.Target != nil && r.Target.ID == suID + })).Return(models.AuditRecord{}, nil) + + if err := svc.Sudo(ctx, suID, schema.AdminRelationName); err != nil { + t.Fatalf("Sudo() error = %v", err) + } + }) + + t.Run("grants member relation and audits the grant", func(t *testing.T) { + svc, repo, _, rel, _, audit := newTestService(t) + repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil) + rel.On("CheckPermission", ctx, platformRel(schema.PlatformCheckPermission)).Return(false, nil) + rel.On("Create", ctx, platformRel(schema.MemberRelationName)).Return(relation.Relation{}, nil) + audit.On("Create", ctx, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformMemberAddedEvent && r.Target != nil && r.Target.ID == suID + })).Return(models.AuditRecord{}, nil) + + if err := svc.Sudo(ctx, suID, schema.MemberRelationName); err != nil { + t.Fatalf("Sudo() error = %v", err) + } + }) +} + +func TestService_UnSudo(t *testing.T) { + ctx := context.Background() + const suID = "550e8400-e29b-41d4-a716-446655440000" + platformRel := func(rel string) relation.Relation { + return relation.Relation{ + Object: relation.Object{ID: schema.PlatformID, Namespace: schema.PlatformNamespace}, + Subject: relation.Subject{ID: suID, Namespace: schema.ServiceUserPrincipal}, + RelationName: rel, + } + } + + t.Run("removes admin relation and audits the revoke", func(t *testing.T) { + svc, repo, _, rel, _, audit := newTestService(t) + repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil) + // UnSudo checks the relation directly (admin), not the superuser permission + rel.On("CheckPermission", ctx, platformRel(schema.AdminRelationName)).Return(true, nil) + rel.On("Delete", ctx, platformRel(schema.AdminRelationName)).Return(nil) + audit.On("Create", ctx, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformAdminRemovedEvent && r.Target != nil && r.Target.ID == suID + })).Return(models.AuditRecord{}, nil) + + if err := svc.UnSudo(ctx, suID, schema.AdminRelationName); err != nil { + t.Fatalf("UnSudo() error = %v", err) + } + }) + + t.Run("admin removal is a no-op when the relation is absent", func(t *testing.T) { + svc, repo, _, rel, _, _ := newTestService(t) + repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil) + rel.On("CheckPermission", ctx, platformRel(schema.AdminRelationName)).Return(false, nil) + + if err := svc.UnSudo(ctx, suID, schema.AdminRelationName); err != nil { + t.Fatalf("UnSudo() error = %v", err) + } + }) + + t.Run("removes member relation and audits the revoke", func(t *testing.T) { + svc, repo, _, rel, _, audit := newTestService(t) + repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil) + rel.On("CheckPermission", ctx, platformRel(schema.MemberRelationName)).Return(true, nil) + rel.On("Delete", ctx, platformRel(schema.MemberRelationName)).Return(nil) + audit.On("Create", ctx, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformMemberRemovedEvent && r.Target != nil && r.Target.ID == suID + })).Return(models.AuditRecord{}, nil) + + if err := svc.UnSudo(ctx, suID, schema.MemberRelationName); err != nil { + t.Fatalf("UnSudo() error = %v", err) + } + }) + + t.Run("member removal is a no-op when the relation is absent", func(t *testing.T) { + svc, repo, _, rel, _, _ := newTestService(t) + repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil) + rel.On("CheckPermission", ctx, platformRel(schema.MemberRelationName)).Return(false, nil) + + if err := svc.UnSudo(ctx, suID, schema.MemberRelationName); err != nil { + t.Fatalf("UnSudo() error = %v", err) + } + }) + + t.Run("rejects an invalid relation name", func(t *testing.T) { + svc, _, _, _, _, _ := newTestService(t) + if err := svc.UnSudo(ctx, suID, "owner"); err == nil { + t.Fatal("UnSudo() expected error for invalid relation, got nil") + } + }) } func TestService_Delete(t *testing.T) { @@ -103,7 +217,7 @@ func TestService_Delete(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, repo, cred, rel, mem := newTestService(t) + svc, repo, cred, rel, mem, _ := newTestService(t) tt.setup(repo, cred, rel, mem) err := svc.Delete(ctx, suID) @@ -147,7 +261,7 @@ func TestService_Get(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, _ := newTestService(t) + svc, repo, _, _, _, _ := newTestService(t) tt.setup(repo) _, err := svc.Get(ctx, tt.id) @@ -214,7 +328,7 @@ func TestService_ListByOrg(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, repo, _, _, mem := newTestService(t) + svc, repo, _, _, mem, _ := newTestService(t) tt.setup(repo, mem) got, err := svc.ListByOrg(ctx, orgID) diff --git a/core/user/mocks/audit_record_repository.go b/core/user/mocks/audit_record_repository.go new file mode 100644 index 000000000..87c61b8a3 --- /dev/null +++ b/core/user/mocks/audit_record_repository.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/raystack/frontier/core/auditrecord/models" + mock "github.com/stretchr/testify/mock" +) + +// AuditRecordRepository is an autogenerated mock type for the AuditRecordRepository type +type AuditRecordRepository struct { + mock.Mock +} + +type AuditRecordRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *AuditRecordRepository) EXPECT() *AuditRecordRepository_Expecter { + return &AuditRecordRepository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, auditRecord +func (_m *AuditRecordRepository) Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) { + ret := _m.Called(ctx, auditRecord) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 models.AuditRecord + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, models.AuditRecord) (models.AuditRecord, error)); ok { + return rf(ctx, auditRecord) + } + if rf, ok := ret.Get(0).(func(context.Context, models.AuditRecord) models.AuditRecord); ok { + r0 = rf(ctx, auditRecord) + } else { + r0 = ret.Get(0).(models.AuditRecord) + } + + if rf, ok := ret.Get(1).(func(context.Context, models.AuditRecord) error); ok { + r1 = rf(ctx, auditRecord) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AuditRecordRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type AuditRecordRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - auditRecord models.AuditRecord +func (_e *AuditRecordRepository_Expecter) Create(ctx interface{}, auditRecord interface{}) *AuditRecordRepository_Create_Call { + return &AuditRecordRepository_Create_Call{Call: _e.mock.On("Create", ctx, auditRecord)} +} + +func (_c *AuditRecordRepository_Create_Call) Run(run func(ctx context.Context, auditRecord models.AuditRecord)) *AuditRecordRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(models.AuditRecord)) + }) + return _c +} + +func (_c *AuditRecordRepository_Create_Call) Return(_a0 models.AuditRecord, _a1 error) *AuditRecordRepository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AuditRecordRepository_Create_Call) RunAndReturn(run func(context.Context, models.AuditRecord) (models.AuditRecord, error)) *AuditRecordRepository_Create_Call { + _c.Call.Return(run) + return _c +} + +// NewAuditRecordRepository creates a new instance of AuditRecordRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuditRecordRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *AuditRecordRepository { + mock := &AuditRecordRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/user/service.go b/core/user/service.go index 1f8ec0e85..5c9c39357 100644 --- a/core/user/service.go +++ b/core/user/service.go @@ -14,8 +14,10 @@ import ( "github.com/raystack/frontier/pkg/utils" + "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/raystack/frontier/pkg/errors" "github.com/raystack/frontier/pkg/str" ) @@ -34,19 +36,25 @@ type SessionService interface { DeleteByUserID(ctx context.Context, userID string) error } +type AuditRecordRepository interface { + Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) +} + type Service struct { - repository Repository - relationService RelationService - sessionService SessionService - Now func() time.Time + repository Repository + relationService RelationService + sessionService SessionService + auditRecordRepository AuditRecordRepository + Now func() time.Time } func NewService(repository Repository, relationRepo RelationService, - sessionService SessionService) *Service { + sessionService SessionService, auditRecordRepository AuditRecordRepository) *Service { return &Service{ - repository: repository, - relationService: relationRepo, - sessionService: sessionService, + repository: repository, + relationService: relationRepo, + sessionService: sessionService, + auditRecordRepository: auditRecordRepository, Now: func() time.Time { return time.Now().UTC() }, @@ -203,29 +211,42 @@ func (s Service) Sudo(ctx context.Context, id string, relationName string) error }, RelationName: relationName, }) - return err + if err != nil { + return err + } + + // audit the grant for both admin and member relations + return s.recordPlatformAuditRecord(ctx, currentUser, platformAddedEvent(relationName), relationName) } -// UnSudo remove platform permissions to user -// only remove the 'member' relation if it exists -func (s Service) UnSudo(ctx context.Context, id string) error { +// UnSudo removes a platform relation (admin or member) from a user. +// It removes the exact relation requested — an `admin` relation can now actually +// be stripped. Both admin and member grants/removals are audited. +func (s Service) UnSudo(ctx context.Context, id, relationName string) error { + switch relationName { + case schema.AdminRelationName, schema.MemberRelationName: + default: + return fmt.Errorf("invalid relation name, possible options are: %s, %s", schema.MemberRelationName, schema.AdminRelationName) + } + currentUser, err := s.GetByID(ctx, id) if err != nil { return err } - relationName := schema.MemberRelationName - // to check if the user has member relation, we need to check if the user has `check` - // permission on platform - if ok, err := s.IsSudo(ctx, currentUser.ID, schema.PlatformCheckPermission); err != nil { + // Only act (and audit) when the specific relation actually exists, so the + // revoke event reflects a real state change. Checking the relation directly + // is precise for both admin and member. + present, err := s.IsSudo(ctx, currentUser.ID, relationName) + if err != nil { return err - } else if !ok { - // not needed + } + if !present { return nil } // unmark su - err = s.relationService.Delete(ctx, relation.Relation{ + if err := s.relationService.Delete(ctx, relation.Relation{ Object: relation.Object{ ID: schema.PlatformID, Namespace: schema.PlatformNamespace, @@ -235,6 +256,49 @@ func (s Service) UnSudo(ctx context.Context, id string) error { Namespace: schema.UserPrincipal, }, RelationName: relationName, + }); err != nil { + return err + } + + return s.recordPlatformAuditRecord(ctx, currentUser, platformRemovedEvent(relationName), relationName) +} + +// platformAddedEvent / platformRemovedEvent map a platform relation to its +// audit event. relationName is always admin or member at the call sites. +func platformAddedEvent(relationName string) pkgAuditRecord.Event { + if relationName == schema.MemberRelationName { + return pkgAuditRecord.PlatformMemberAddedEvent + } + return pkgAuditRecord.PlatformAdminAddedEvent +} + +func platformRemovedEvent(relationName string) pkgAuditRecord.Event { + if relationName == schema.MemberRelationName { + return pkgAuditRecord.PlatformMemberRemovedEvent + } + return pkgAuditRecord.PlatformAdminRemovedEvent +} + +// recordPlatformAuditRecord writes an audit record for a platform relation +// (admin or member) grant/revoke. Actor is left empty so the repository enriches +// it from context — the acting principal on request paths, or the system actor +// (uuid.Nil) for boot-time/config-driven changes. +func (s Service) recordPlatformAuditRecord(ctx context.Context, u User, event pkgAuditRecord.Event, relationName string) error { + _, err := s.auditRecordRepository.Create(ctx, models.AuditRecord{ + Event: event, + Resource: models.Resource{ + ID: schema.PlatformID, + Type: pkgAuditRecord.PlatformType, + Name: schema.PlatformID, + }, + Target: &models.Target{ + ID: u.ID, + Type: pkgAuditRecord.UserType, + Name: u.Name, + }, + OrgID: schema.PlatformOrgID.String(), + OccurredAt: s.Now(), + Metadata: map[string]any{"relation": relationName}, }) return err } diff --git a/core/user/service_test.go b/core/user/service_test.go index 1b4e451d9..71bc452cd 100644 --- a/core/user/service_test.go +++ b/core/user/service_test.go @@ -8,21 +8,24 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/core/user/mocks" "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/raystack/frontier/pkg/str" "github.com/stretchr/testify/mock" ) -func mockService(t *testing.T) (*mocks.Repository, *mocks.RelationService, *mocks.SessionService) { +func mockService(t *testing.T) (*mocks.Repository, *mocks.RelationService, *mocks.SessionService, *mocks.AuditRecordRepository) { t.Helper() repo := mocks.NewRepository(t) relationService := mocks.NewRelationService(t) sessionService := mocks.NewSessionService(t) - return repo, relationService, sessionService + auditRecordRepository := mocks.NewAuditRecordRepository(t) + return repo, relationService, sessionService, auditRecordRepository } func TestService_GetByID(t *testing.T) { @@ -43,12 +46,12 @@ func TestService_GetByID(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByID(mock.Anything, testID.String()).Return(user.User{ ID: testID.String(), Name: "test", }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -61,13 +64,13 @@ func TestService_GetByID(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByEmail(mock.Anything, "test@test.com").Return(user.User{ ID: testID.String(), Name: "test", Email: "test@test.com", }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -79,12 +82,12 @@ func TestService_GetByID(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByName(mock.Anything, "test").Return(user.User{ ID: testID.String(), Name: "test", }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -93,9 +96,9 @@ func TestService_GetByID(t *testing.T) { want: user.User{}, wantErr: true, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByName(mock.Anything, "invalid").Return(user.User{}, errors.New("not found")) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -150,7 +153,7 @@ func TestService_Create(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().Create(mock.Anything, user.User{ Name: "test", Email: "test@email.com", @@ -173,7 +176,7 @@ func TestService_Create(t *testing.T) { CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -185,13 +188,13 @@ func TestService_Create(t *testing.T) { want: user.User{}, wantErr: true, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().Create(mock.Anything, user.User{ Name: "test ", Email: "test", State: user.Enabled, }).Return(user.User{}, errors.New("failed to create")) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -235,7 +238,7 @@ func TestService_List(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().List(mock.Anything, user.Filter{ State: user.Enabled, }).Return([]user.User{ @@ -248,7 +251,7 @@ func TestService_List(t *testing.T) { Name: "test-2", }, }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -302,7 +305,7 @@ func TestService_Update(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().UpdateByEmail(mock.Anything, user.User{ ID: "test@email.com", Name: "test", @@ -324,7 +327,7 @@ func TestService_Update(t *testing.T) { CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -353,7 +356,7 @@ func TestService_Update(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().UpdateByName(mock.Anything, user.User{ ID: "test", Name: "test", @@ -375,7 +378,7 @@ func TestService_Update(t *testing.T) { CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -404,7 +407,7 @@ func TestService_Update(t *testing.T) { }, wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().UpdateByID(mock.Anything, user.User{ ID: testID.String(), Name: "test", @@ -426,7 +429,7 @@ func TestService_Update(t *testing.T) { CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -438,12 +441,12 @@ func TestService_Update(t *testing.T) { want: user.User{}, wantErr: true, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().UpdateByName(mock.Anything, user.User{ Name: "test ", Email: "test", }).Return(user.User{}, errors.New("failed to update")) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -474,10 +477,10 @@ func TestService_Disable(t *testing.T) { name: "disable user with valid uuid", id: validID, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().SetState(mock.Anything, validID, user.Disabled).Return(nil) sessionService.EXPECT().DeleteByUserID(mock.Anything, validID).Return(nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -485,8 +488,8 @@ func TestService_Disable(t *testing.T) { id: "not-a-uuid", wantErr: user.ErrInvalidID, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) - return user.NewService(repo, relationService, sessionService) + repo, relationService, sessionService, auditRecordRepository := mockService(t) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -494,8 +497,8 @@ func TestService_Disable(t *testing.T) { id: "", wantErr: user.ErrInvalidID, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) - return user.NewService(repo, relationService, sessionService) + repo, relationService, sessionService, auditRecordRepository := mockService(t) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -503,9 +506,9 @@ func TestService_Disable(t *testing.T) { id: validID, wantErr: user.ErrNotExist, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().SetState(mock.Anything, validID, user.Disabled).Return(user.ErrNotExist) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -536,9 +539,9 @@ func TestService_Enable(t *testing.T) { name: "enable user with valid uuid", id: validID, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().SetState(mock.Anything, validID, user.Enabled).Return(nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -546,8 +549,8 @@ func TestService_Enable(t *testing.T) { id: "not-a-uuid", wantErr: user.ErrInvalidID, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) - return user.NewService(repo, relationService, sessionService) + repo, relationService, sessionService, auditRecordRepository := mockService(t) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -555,8 +558,8 @@ func TestService_Enable(t *testing.T) { id: "", wantErr: user.ErrInvalidID, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) - return user.NewService(repo, relationService, sessionService) + repo, relationService, sessionService, auditRecordRepository := mockService(t) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -564,9 +567,9 @@ func TestService_Enable(t *testing.T) { id: validID, wantErr: user.ErrNotExist, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().SetState(mock.Anything, validID, user.Enabled).Return(user.ErrNotExist) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -597,13 +600,13 @@ func TestService_Delete(t *testing.T) { id: "test-id", wantErr: false, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) relationService.EXPECT().Delete(mock.Anything, relation.Relation{Subject: relation.Subject{ ID: "test-id", Namespace: schema.UserPrincipal, }}).Return(nil) repo.EXPECT().Delete(mock.Anything, "test-id").Return(nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -611,12 +614,12 @@ func TestService_Delete(t *testing.T) { id: "test-id", wantErr: true, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) relationService.EXPECT().Delete(mock.Anything, relation.Relation{Subject: relation.Subject{ ID: "test-id", Namespace: schema.UserPrincipal, }}).Return(errors.New("failed to delete relation")) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -649,7 +652,7 @@ func TestService_Sudo(t *testing.T) { relationName: schema.AdminRelationName, }, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{ ID: "test-id", Name: "test", @@ -695,7 +698,11 @@ func TestService_Sudo(t *testing.T) { }, RelationName: schema.AdminRelationName, }).Return(relation.Relation{}, nil) - return user.NewService(repo, relationService, sessionService) + + auditRecordRepository.EXPECT().Create(mock.Anything, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformAdminAddedEvent && r.Target != nil && r.Target.ID == "test-id" + })).Return(models.AuditRecord{}, nil) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -706,7 +713,7 @@ func TestService_Sudo(t *testing.T) { relationName: schema.AdminRelationName, }, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{ ID: "test-id", Name: "test", @@ -740,7 +747,7 @@ func TestService_Sudo(t *testing.T) { Status: true, }, }, nil) - return user.NewService(repo, relationService, sessionService) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { @@ -751,7 +758,7 @@ func TestService_Sudo(t *testing.T) { relationName: schema.MemberRelationName, }, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByEmail(mock.Anything, "test@test.com").Return(user.User{}, user.ErrNotExist) repo.EXPECT().Create(mock.Anything, user.User{ @@ -805,7 +812,11 @@ func TestService_Sudo(t *testing.T) { }, RelationName: schema.MemberRelationName, }).Return(relation.Relation{}, nil) - return user.NewService(repo, relationService, sessionService) + + auditRecordRepository.EXPECT().Create(mock.Anything, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformMemberAddedEvent && r.Target != nil && r.Target.ID == "test-id" + })).Return(models.AuditRecord{}, nil) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } @@ -821,8 +832,38 @@ func TestService_Sudo(t *testing.T) { func TestService_UnSudo(t *testing.T) { type args struct { - id string + id string + relationName string + } + + adminCheckRelations := []relation.Relation{ + { + Subject: relation.Subject{ + ID: "test-id", + Namespace: schema.UserPrincipal, + }, + Object: relation.Object{ + ID: schema.PlatformID, + Namespace: schema.PlatformNamespace, + }, + RelationName: schema.AdminRelationName, + }, + } + + memberCheckRelations := []relation.Relation{ + { + Subject: relation.Subject{ + ID: "test-id", + Namespace: schema.UserPrincipal, + }, + Object: relation.Object{ + ID: schema.PlatformID, + Namespace: schema.PlatformNamespace, + }, + RelationName: schema.MemberRelationName, + }, } + tests := []struct { name string setup func() *user.Service @@ -830,47 +871,77 @@ func TestService_UnSudo(t *testing.T) { wantErr bool }{ { - name: "remove user member permission of platform", + name: "removes admin relation and audits the revoke", wantErr: false, args: args{ - id: "test-id", + id: "test-id", + relationName: schema.AdminRelationName, }, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{ ID: "test-id", Name: "test", }, nil) - relationService.EXPECT().BatchCheckPermission(mock.Anything, []relation.Relation{ - { - Subject: relation.Subject{ - ID: "test-id", - Namespace: schema.UserPrincipal, - }, - Object: relation.Object{ - ID: schema.PlatformID, - Namespace: schema.PlatformNamespace, - }, - RelationName: schema.PlatformCheckPermission, + relationService.EXPECT().BatchCheckPermission(mock.Anything, adminCheckRelations). + Return([]relation.CheckPair{{Relation: adminCheckRelations[0], Status: true}}, nil) + + relationService.EXPECT().Delete(mock.Anything, relation.Relation{ + Object: relation.Object{ + ID: schema.PlatformID, + Namespace: schema.PlatformNamespace, }, - }).Return([]relation.CheckPair{ - { - Relation: relation.Relation{ - Subject: relation.Subject{ - ID: "test-id", - Namespace: schema.UserPrincipal, - }, - Object: relation.Object{ - ID: schema.PlatformID, - Namespace: schema.PlatformNamespace, - }, - RelationName: schema.PlatformCheckPermission, - }, - Status: true, + Subject: relation.Subject{ + ID: "test-id", + Namespace: schema.UserPrincipal, }, + RelationName: schema.AdminRelationName, + }).Return(nil) + + auditRecordRepository.EXPECT().Create(mock.Anything, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformAdminRemovedEvent && r.Target != nil && r.Target.ID == "test-id" + })).Return(models.AuditRecord{}, nil) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) + }, + }, + { + name: "admin removal is a no-op when user is not a superuser", + wantErr: false, + args: args{ + id: "test-id", + relationName: schema.AdminRelationName, + }, + setup: func() *user.Service { + repo, relationService, sessionService, auditRecordRepository := mockService(t) + repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{ + ID: "test-id", + Name: "test", + }, nil) + + relationService.EXPECT().BatchCheckPermission(mock.Anything, adminCheckRelations). + Return([]relation.CheckPair{{Relation: adminCheckRelations[0], Status: false}}, nil) + // no Delete, no audit + return user.NewService(repo, relationService, sessionService, auditRecordRepository) + }, + }, + { + name: "removes member relation and audits the revoke", + wantErr: false, + args: args{ + id: "test-id", + relationName: schema.MemberRelationName, + }, + setup: func() *user.Service { + repo, relationService, sessionService, auditRecordRepository := mockService(t) + repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{ + ID: "test-id", + Name: "test", }, nil) + relationService.EXPECT().BatchCheckPermission(mock.Anything, memberCheckRelations). + Return([]relation.CheckPair{{Relation: memberCheckRelations[0], Status: true}}, nil) + relationService.EXPECT().Delete(mock.Anything, relation.Relation{ Object: relation.Object{ ID: schema.PlatformID, @@ -882,58 +953,50 @@ func TestService_UnSudo(t *testing.T) { }, RelationName: schema.MemberRelationName, }).Return(nil) - return user.NewService(repo, relationService, sessionService) + + auditRecordRepository.EXPECT().Create(mock.Anything, mock.MatchedBy(func(r models.AuditRecord) bool { + return r.Event == pkgAuditRecord.PlatformMemberRemovedEvent && r.Target != nil && r.Target.ID == "test-id" + })).Return(models.AuditRecord{}, nil) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, { - name: "don't remove user member if already removed", + name: "member removal is a no-op when the relation is absent", wantErr: false, args: args{ - id: "test-id", + id: "test-id", + relationName: schema.MemberRelationName, }, setup: func() *user.Service { - repo, relationService, sessionService := mockService(t) + repo, relationService, sessionService, auditRecordRepository := mockService(t) repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{ ID: "test-id", Name: "test", }, nil) - relationService.EXPECT().BatchCheckPermission(mock.Anything, []relation.Relation{ - { - Subject: relation.Subject{ - ID: "test-id", - Namespace: schema.UserPrincipal, - }, - Object: relation.Object{ - ID: schema.PlatformID, - Namespace: schema.PlatformNamespace, - }, - RelationName: schema.PlatformCheckPermission, - }, - }).Return([]relation.CheckPair{ - { - Relation: relation.Relation{ - Subject: relation.Subject{ - ID: "test-id", - Namespace: schema.UserPrincipal, - }, - Object: relation.Object{ - ID: schema.PlatformID, - Namespace: schema.PlatformNamespace, - }, - RelationName: schema.PlatformCheckPermission, - }, - Status: false, - }, - }, nil) - return user.NewService(repo, relationService, sessionService) + relationService.EXPECT().BatchCheckPermission(mock.Anything, memberCheckRelations). + Return([]relation.CheckPair{{Relation: memberCheckRelations[0], Status: false}}, nil) + // no Delete, no audit + return user.NewService(repo, relationService, sessionService, auditRecordRepository) + }, + }, + { + name: "rejects an invalid relation name", + wantErr: true, + args: args{ + id: "test-id", + relationName: "owner", + }, + setup: func() *user.Service { + repo, relationService, sessionService, auditRecordRepository := mockService(t) + return user.NewService(repo, relationService, sessionService, auditRecordRepository) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := tt.setup() - if err := s.UnSudo(context.Background(), tt.args.id); (err != nil) != tt.wantErr { + if err := s.UnSudo(context.Background(), tt.args.id, tt.args.relationName); (err != nil) != tt.wantErr { t.Errorf("UnSudo() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/api/v1beta1connect/interfaces.go b/internal/api/v1beta1connect/interfaces.go index e590241a6..0853fe1ad 100644 --- a/internal/api/v1beta1connect/interfaces.go +++ b/internal/api/v1beta1connect/interfaces.go @@ -142,7 +142,7 @@ type UserService interface { Disable(ctx context.Context, id string) error IsSudo(ctx context.Context, id string, permissionName string) (bool, error) Sudo(ctx context.Context, id string, relationName string) error - UnSudo(ctx context.Context, id string) error + UnSudo(ctx context.Context, id string, relationName string) error Search(ctx context.Context, rql *rql.Query) (user.SearchUserResponse, error) Export(ctx context.Context) ([]byte, string, error) } @@ -280,7 +280,7 @@ type ServiceUserService interface { ListByOrg(ctx context.Context, orgID string) ([]serviceuser.ServiceUser, error) IsSudo(ctx context.Context, id string, permissionName string) (bool, error) Sudo(ctx context.Context, id string, relationName string) error - UnSudo(ctx context.Context, id string) error + UnSudo(ctx context.Context, id string, relationName string) error GetByIDs(ctx context.Context, ids []string) ([]serviceuser.ServiceUser, error) } diff --git a/internal/api/v1beta1connect/mocks/service_user_service.go b/internal/api/v1beta1connect/mocks/service_user_service.go index 94bc82c5b..a742990dd 100644 --- a/internal/api/v1beta1connect/mocks/service_user_service.go +++ b/internal/api/v1beta1connect/mocks/service_user_service.go @@ -1070,17 +1070,17 @@ func (_c *ServiceUserService_Sudo_Call) RunAndReturn(run func(context.Context, s return _c } -// UnSudo provides a mock function with given fields: ctx, id -func (_m *ServiceUserService) UnSudo(ctx context.Context, id string) error { - ret := _m.Called(ctx, id) +// UnSudo provides a mock function with given fields: ctx, id, relationName +func (_m *ServiceUserService) UnSudo(ctx context.Context, id string, relationName string) error { + ret := _m.Called(ctx, id, relationName) if len(ret) == 0 { panic("no return value specified for UnSudo") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = rf(ctx, id) + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, id, relationName) } else { r0 = ret.Error(0) } @@ -1096,13 +1096,14 @@ type ServiceUserService_UnSudo_Call struct { // UnSudo is a helper method to define mock.On call // - ctx context.Context // - id string -func (_e *ServiceUserService_Expecter) UnSudo(ctx interface{}, id interface{}) *ServiceUserService_UnSudo_Call { - return &ServiceUserService_UnSudo_Call{Call: _e.mock.On("UnSudo", ctx, id)} +// - relationName string +func (_e *ServiceUserService_Expecter) UnSudo(ctx interface{}, id interface{}, relationName interface{}) *ServiceUserService_UnSudo_Call { + return &ServiceUserService_UnSudo_Call{Call: _e.mock.On("UnSudo", ctx, id, relationName)} } -func (_c *ServiceUserService_UnSudo_Call) Run(run func(ctx context.Context, id string)) *ServiceUserService_UnSudo_Call { +func (_c *ServiceUserService_UnSudo_Call) Run(run func(ctx context.Context, id string, relationName string)) *ServiceUserService_UnSudo_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) + run(args[0].(context.Context), args[1].(string), args[2].(string)) }) return _c } @@ -1112,7 +1113,7 @@ func (_c *ServiceUserService_UnSudo_Call) Return(_a0 error) *ServiceUserService_ return _c } -func (_c *ServiceUserService_UnSudo_Call) RunAndReturn(run func(context.Context, string) error) *ServiceUserService_UnSudo_Call { +func (_c *ServiceUserService_UnSudo_Call) RunAndReturn(run func(context.Context, string, string) error) *ServiceUserService_UnSudo_Call { _c.Call.Return(run) return _c } diff --git a/internal/api/v1beta1connect/mocks/user_service.go b/internal/api/v1beta1connect/mocks/user_service.go index c06f82018..630943a07 100644 --- a/internal/api/v1beta1connect/mocks/user_service.go +++ b/internal/api/v1beta1connect/mocks/user_service.go @@ -5,10 +5,9 @@ package mocks import ( context "context" + user "github.com/raystack/frontier/core/user" rql "github.com/raystack/salt/rql" mock "github.com/stretchr/testify/mock" - - user "github.com/raystack/frontier/core/user" ) // UserService is an autogenerated mock type for the UserService type @@ -635,17 +634,17 @@ func (_c *UserService_Sudo_Call) RunAndReturn(run func(context.Context, string, return _c } -// UnSudo provides a mock function with given fields: ctx, id -func (_m *UserService) UnSudo(ctx context.Context, id string) error { - ret := _m.Called(ctx, id) +// UnSudo provides a mock function with given fields: ctx, id, relationName +func (_m *UserService) UnSudo(ctx context.Context, id string, relationName string) error { + ret := _m.Called(ctx, id, relationName) if len(ret) == 0 { panic("no return value specified for UnSudo") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { - r0 = rf(ctx, id) + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, id, relationName) } else { r0 = ret.Error(0) } @@ -661,13 +660,14 @@ type UserService_UnSudo_Call struct { // UnSudo is a helper method to define mock.On call // - ctx context.Context // - id string -func (_e *UserService_Expecter) UnSudo(ctx interface{}, id interface{}) *UserService_UnSudo_Call { - return &UserService_UnSudo_Call{Call: _e.mock.On("UnSudo", ctx, id)} +// - relationName string +func (_e *UserService_Expecter) UnSudo(ctx interface{}, id interface{}, relationName interface{}) *UserService_UnSudo_Call { + return &UserService_UnSudo_Call{Call: _e.mock.On("UnSudo", ctx, id, relationName)} } -func (_c *UserService_UnSudo_Call) Run(run func(ctx context.Context, id string)) *UserService_UnSudo_Call { +func (_c *UserService_UnSudo_Call) Run(run func(ctx context.Context, id string, relationName string)) *UserService_UnSudo_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) + run(args[0].(context.Context), args[1].(string), args[2].(string)) }) return _c } @@ -677,7 +677,7 @@ func (_c *UserService_UnSudo_Call) Return(_a0 error) *UserService_UnSudo_Call { return _c } -func (_c *UserService_UnSudo_Call) RunAndReturn(run func(context.Context, string) error) *UserService_UnSudo_Call { +func (_c *UserService_UnSudo_Call) RunAndReturn(run func(context.Context, string, string) error) *UserService_UnSudo_Call { _c.Call.Return(run) return _c } diff --git a/internal/api/v1beta1connect/platform.go b/internal/api/v1beta1connect/platform.go index 4289d5cce..111d27df8 100644 --- a/internal/api/v1beta1connect/platform.go +++ b/internal/api/v1beta1connect/platform.go @@ -33,13 +33,22 @@ func (h *ConnectHandler) AddPlatformUser(ctx context.Context, req *connect.Reque } func (h *ConnectHandler) RemovePlatformUser(ctx context.Context, req *connect.Request[frontierv1beta1.RemovePlatformUserRequest]) (*connect.Response[frontierv1beta1.RemovePlatformUserResponse], error) { + // Remove the principal from the platform entirely: strip both the admin + // (superuser) and member (check) relations. Each UnSudo is a no-op for a + // relation the principal doesn't hold. + platformRelations := []string{schema.AdminRelationName, schema.MemberRelationName} + if req.Msg.GetUserId() != "" { - if err := h.userService.UnSudo(ctx, req.Msg.GetUserId()); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RemovePlatformUser.UserUnSudo: user_id=%s: %w", req.Msg.GetUserId(), err)) + for _, relationName := range platformRelations { + if err := h.userService.UnSudo(ctx, req.Msg.GetUserId(), relationName); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RemovePlatformUser.UserUnSudo: user_id=%s relation=%s: %w", req.Msg.GetUserId(), relationName, err)) + } } } else if req.Msg.GetServiceuserId() != "" { - if err := h.serviceUserService.UnSudo(ctx, req.Msg.GetServiceuserId()); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RemovePlatformUser.ServiceUserUnSudo: service_user_id=%s: %w", req.Msg.GetServiceuserId(), err)) + for _, relationName := range platformRelations { + if err := h.serviceUserService.UnSudo(ctx, req.Msg.GetServiceuserId(), relationName); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RemovePlatformUser.ServiceUserUnSudo: service_user_id=%s relation=%s: %w", req.Msg.GetServiceuserId(), relationName, err)) + } } } else { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) diff --git a/internal/api/v1beta1connect/platform_test.go b/internal/api/v1beta1connect/platform_test.go new file mode 100644 index 000000000..091a97ce6 --- /dev/null +++ b/internal/api/v1beta1connect/platform_test.go @@ -0,0 +1,50 @@ +package v1beta1connect + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/raystack/frontier/internal/api/v1beta1connect/mocks" + "github.com/raystack/frontier/internal/bootstrap/schema" + frontierv1beta1 "github.com/raystack/frontier/proto/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestHandler_RemovePlatformUser(t *testing.T) { + t.Run("removes both admin and member relations for a user", func(t *testing.T) { + userSvc := mocks.NewUserService(t) + // both platform relations are stripped; each UnSudo is a no-op for a relation + // the user doesn't hold. + userSvc.EXPECT().UnSudo(mock.Anything, "u1", schema.AdminRelationName).Return(nil) + userSvc.EXPECT().UnSudo(mock.Anything, "u1", schema.MemberRelationName).Return(nil) + + h := &ConnectHandler{userService: userSvc} + resp, err := h.RemovePlatformUser(context.Background(), connect.NewRequest(&frontierv1beta1.RemovePlatformUserRequest{ + UserId: "u1", + })) + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("removes both admin and member relations for a service user", func(t *testing.T) { + serviceUserSvc := mocks.NewServiceUserService(t) + serviceUserSvc.EXPECT().UnSudo(mock.Anything, "s1", schema.AdminRelationName).Return(nil) + serviceUserSvc.EXPECT().UnSudo(mock.Anything, "s1", schema.MemberRelationName).Return(nil) + + h := &ConnectHandler{serviceUserService: serviceUserSvc} + resp, err := h.RemovePlatformUser(context.Background(), connect.NewRequest(&frontierv1beta1.RemovePlatformUserRequest{ + ServiceuserId: "s1", + })) + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("rejects a request with neither id", func(t *testing.T) { + h := &ConnectHandler{} + resp, err := h.RemovePlatformUser(context.Background(), connect.NewRequest(&frontierv1beta1.RemovePlatformUserRequest{})) + assert.Error(t, err) + assert.Nil(t, resp) + }) +} diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index af97e16a2..04319c02d 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -16,6 +16,7 @@ import ( "github.com/raystack/frontier/core/policy" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/role" + "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/internal/bootstrap/schema" ) @@ -42,10 +43,13 @@ type RoleService interface { type RelationService interface { Create(ctx context.Context, rel relation.Relation) (relation.Relation, error) Delete(ctx context.Context, rel relation.Relation) error + List(ctx context.Context, flt relation.Filter) ([]relation.Relation, error) } type UserService interface { Sudo(ctx context.Context, id string, relationName string) error + UnSudo(ctx context.Context, id string, relationName string) error + GetByID(ctx context.Context, id string) (user.User, error) } type FileService interface { @@ -88,6 +92,12 @@ type AdminConfig struct { // Users are a list of email-ids/uuids which needs to be promoted as superusers // if email is provided and user doesn't exist, user is created by default Users []string `yaml:"users" mapstructure:"users"` + + // Authoritative, when true, makes the configured Users list the single source + // of truth for human superusers: at boot any human holding the platform admin + // relation that is NOT in Users is demoted. Service accounts are never touched. + // WARNING: with an empty/misconfigured list this demotes ALL human superusers. + Authoritative bool `yaml:"authoritative" mapstructure:"authoritative" default:"false"` } type Service struct { @@ -258,7 +268,9 @@ func filterDefaultAppNamespacePermissions(permissions []schema.ResourcePermissio return filteredPermissions } -// MakeSuperUsers promote ordinary users to superuser +// MakeSuperUsers promotes the configured users to superuser. When the admin +// config is Authoritative, it then demotes any human superuser no longer present +// in the config (see reconcileSuperUsers). func (s Service) MakeSuperUsers(ctx context.Context) error { for _, userID := range s.adminConfig.Users { userID = strings.TrimSpace(userID) @@ -267,6 +279,65 @@ func (s Service) MakeSuperUsers(ctx context.Context) error { return err } } + + if !s.adminConfig.Authoritative { + return nil + } + return s.reconcileSuperUsers(ctx) +} + +// reconcileSuperUsers demotes any human user holding the platform admin relation +// that is not present in the configured admin list. Service accounts and the +// member relation are never touched. This makes config the source of truth for +// the human-superuser set; with an empty/misconfigured list it demotes everyone. +func (s Service) reconcileSuperUsers(ctx context.Context) error { + // Desired set: resolve each config entry to its canonical user ID so the diff + // is keyed by ID (an admin listed by both email and UUID collapses to one). + desiredIDs := make(map[string]struct{}, len(s.adminConfig.Users)) + for _, entry := range s.adminConfig.Users { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + u, err := s.userService.GetByID(ctx, entry) + if err != nil { + // Skip only genuinely missing users (e.g. a UUID/slug that was never + // created) — they can't be admins anyway. Any other error (a transient + // backend/DB failure) must abort: otherwise a real configured admin + // could look "absent from config" and be wrongly demoted below. + if errors.Is(err, user.ErrNotExist) { + slog.WarnContext(ctx, "skipping unresolvable admin config entry during reconciliation", "entry", entry, "err", err.Error()) + continue + } + return fmt.Errorf("reconciling superusers: resolving configured admin %q: %w", entry, err) + } + desiredIDs[u.ID] = struct{}{} + } + + // Current set: every relation on the platform object. + relations, err := s.relationService.List(ctx, relation.Filter{ + Object: relation.Object{ + ID: schema.PlatformID, + Namespace: schema.PlatformNamespace, + }, + }) + if err != nil { + return fmt.Errorf("reconciling superusers: listing platform relations: %w", err) + } + + for _, rel := range relations { + // Only reconcile human-user admins; leave service accounts and members alone. + if rel.RelationName != schema.AdminRelationName || rel.Subject.Namespace != schema.UserPrincipal { + continue + } + if _, ok := desiredIDs[rel.Subject.ID]; ok { + continue + } + slog.InfoContext(ctx, "demoting superuser not present in admin config", "user_id", rel.Subject.ID) + if err := s.userService.UnSudo(ctx, rel.Subject.ID, schema.AdminRelationName); err != nil { + return fmt.Errorf("reconciling superusers: demoting user %s: %w", rel.Subject.ID, err) + } + } return nil } diff --git a/internal/bootstrap/service_test.go b/internal/bootstrap/service_test.go index 68c8f2784..94f8bc3ad 100644 --- a/internal/bootstrap/service_test.go +++ b/internal/bootstrap/service_test.go @@ -7,6 +7,7 @@ import ( "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/role" + "github.com/raystack/frontier/core/user" "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -55,6 +56,144 @@ func (m *mockRelationService) Delete(ctx context.Context, rel relation.Relation) return args.Error(0) } +func (m *mockRelationService) List(ctx context.Context, flt relation.Filter) ([]relation.Relation, error) { + args := m.Called(ctx, flt) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]relation.Relation), args.Error(1) +} + +// mockUserService implements bootstrap.UserService +type mockUserService struct { + mock.Mock +} + +func (m *mockUserService) Sudo(ctx context.Context, id, relationName string) error { + return m.Called(ctx, id, relationName).Error(0) +} + +func (m *mockUserService) UnSudo(ctx context.Context, id, relationName string) error { + return m.Called(ctx, id, relationName).Error(0) +} + +func (m *mockUserService) GetByID(ctx context.Context, id string) (user.User, error) { + args := m.Called(ctx, id) + return args.Get(0).(user.User), args.Error(1) +} + +func platformAdminRel(id, ns string) relation.Relation { + return relation.Relation{ + Object: relation.Object{ID: schema.PlatformID, Namespace: schema.PlatformNamespace}, + Subject: relation.Subject{ID: id, Namespace: ns}, + RelationName: schema.AdminRelationName, + } +} + +func TestMakeSuperUsers_Authoritative(t *testing.T) { + platformFilter := relation.Filter{ + Object: relation.Object{ID: schema.PlatformID, Namespace: schema.PlatformNamespace}, + } + + t.Run("demotes only human admins absent from config", func(t *testing.T) { + userSvc := new(mockUserService) + relSvc := new(mockRelationService) + + // additive promote of the configured admin + userSvc.On("Sudo", mock.Anything, "keep@x.com", schema.AdminRelationName).Return(nil) + // reconcile resolves config entry -> canonical id + userSvc.On("GetByID", mock.Anything, "keep@x.com").Return(user.User{ID: "keep-id"}, nil) + + memberRel := platformAdminRel("member-id", schema.UserPrincipal) + memberRel.RelationName = schema.MemberRelationName + relSvc.On("List", mock.Anything, platformFilter).Return([]relation.Relation{ + platformAdminRel("keep-id", schema.UserPrincipal), // desired -> keep + platformAdminRel("drop-id", schema.UserPrincipal), // not desired -> demote + platformAdminRel("su-id", schema.ServiceUserPrincipal), // service account -> skip + memberRel, // member relation -> skip + }, nil) + + // only the human admin absent from config is demoted; an unexpected + // UnSudo on any other id would panic the mock and fail the test. + userSvc.On("UnSudo", mock.Anything, "drop-id", schema.AdminRelationName).Return(nil) + + s := Service{ + adminConfig: AdminConfig{Users: []string{"keep@x.com"}, Authoritative: true}, + userService: userSvc, + relationService: relSvc, + } + + assert.NoError(t, s.MakeSuperUsers(context.Background())) + userSvc.AssertExpectations(t) + relSvc.AssertExpectations(t) + userSvc.AssertNotCalled(t, "UnSudo", mock.Anything, "keep-id", schema.AdminRelationName) + userSvc.AssertNotCalled(t, "UnSudo", mock.Anything, "su-id", schema.AdminRelationName) + userSvc.AssertNotCalled(t, "UnSudo", mock.Anything, "member-id", schema.AdminRelationName) + }) + + t.Run("additive only, no demotion, when not authoritative", func(t *testing.T) { + userSvc := new(mockUserService) + relSvc := new(mockRelationService) + userSvc.On("Sudo", mock.Anything, "keep@x.com", schema.AdminRelationName).Return(nil) + + s := Service{ + adminConfig: AdminConfig{Users: []string{"keep@x.com"}, Authoritative: false}, + userService: userSvc, + relationService: relSvc, + } + + assert.NoError(t, s.MakeSuperUsers(context.Background())) + userSvc.AssertExpectations(t) + relSvc.AssertNotCalled(t, "List", mock.Anything, mock.Anything) + userSvc.AssertNotCalled(t, "UnSudo", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("aborts (no demotions) when resolving a configured admin fails with a non-not-found error", func(t *testing.T) { + userSvc := new(mockUserService) + relSvc := new(mockRelationService) + + userSvc.On("Sudo", mock.Anything, "keep@x.com", schema.AdminRelationName).Return(nil) + userSvc.On("GetByID", mock.Anything, "keep@x.com").Return(user.User{}, errors.New("db timeout")) + + s := Service{ + adminConfig: AdminConfig{Users: []string{"keep@x.com"}, Authoritative: true}, + userService: userSvc, + relationService: relSvc, + } + + // A transient resolve failure must abort before listing/demotion, so a real + // admin is never wrongly demoted on a backend blip. + assert.Error(t, s.MakeSuperUsers(context.Background())) + relSvc.AssertNotCalled(t, "List", mock.Anything, mock.Anything) + userSvc.AssertNotCalled(t, "UnSudo", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("skips a configured entry that resolves to no user (not-found)", func(t *testing.T) { + userSvc := new(mockUserService) + relSvc := new(mockRelationService) + + userSvc.On("Sudo", mock.Anything, "missing-user", schema.AdminRelationName).Return(nil) + userSvc.On("GetByID", mock.Anything, "missing-user").Return(user.User{}, user.ErrNotExist) + + // A genuinely missing entry is skipped (not aborted); reconciliation continues + // and still demotes admins absent from config. + relSvc.On("List", mock.Anything, platformFilter).Return([]relation.Relation{ + platformAdminRel("drop-id", schema.UserPrincipal), + }, nil) + userSvc.On("UnSudo", mock.Anything, "drop-id", schema.AdminRelationName).Return(nil) + + s := Service{ + adminConfig: AdminConfig{Users: []string{"missing-user"}, Authoritative: true}, + userService: userSvc, + relationService: relSvc, + } + + assert.NoError(t, s.MakeSuperUsers(context.Background())) + userSvc.AssertExpectations(t) + relSvc.AssertExpectations(t) + }) +} + func Test_migratePATRelations(t *testing.T) { t.Run("should create PAT wildcards for allowed permissions", func(t *testing.T) { roleSvc := new(mockRoleService) diff --git a/pkg/auditrecord/consts.go b/pkg/auditrecord/consts.go index 845e4773f..28feec17c 100644 --- a/pkg/auditrecord/consts.go +++ b/pkg/auditrecord/consts.go @@ -64,6 +64,12 @@ const ( // Session Events SessionRevokedEvent Event = "session.revoked" + // Platform Events + PlatformAdminAddedEvent Event = "platform.admin_added" + PlatformAdminRemovedEvent Event = "platform.admin_removed" + PlatformMemberAddedEvent Event = "platform.member_added" + PlatformMemberRemovedEvent Event = "platform.member_removed" + // Resource Events ResourceCreatedEvent Event = "resource.created" @@ -93,6 +99,7 @@ const ( BillingTransactionType EntityType = "billing_transaction" SessionType EntityType = "session" PATType EntityType = "pat" + PlatformType EntityType = "platform" ) // String returns the string representation of the event