diff --git a/cmd/serve.go b/cmd/serve.go index f6128349c..88e83f35e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -424,7 +424,7 @@ func buildAPIDependencies( groupRepository := postgres.NewGroupRepository(dbc) organizationRepository := postgres.NewOrganizationRepository(dbc) - roleService := role.NewService(roleRepository, relationService, permissionService, auditRecordRepository) + roleService := role.NewService(roleRepository, relationService, permissionService, auditRecordRepository, cfg.App.PAT.DeniedPermissionsSet()) policyService := policy.NewService(policyPGRepository, relationService, roleService) userService := user.NewService(userRepository, relationService, policyService, roleService) authnService := authenticate.NewService(logger, cfg.App.Authentication, @@ -433,7 +433,7 @@ func buildAPIDependencies( organizationService := organization.NewService(organizationRepository, relationService, userService, authnService, policyService, preferenceService, auditRecordRepository) - userPATService := userpat.NewService(logger, userPATRepo, cfg.App.PAT, organizationService, auditRecordRepository) + userPATService := userpat.NewService(logger, userPATRepo, cfg.App.PAT, organizationService, roleService, policyService, auditRecordRepository) auditRecordService := auditrecord.NewService(auditRecordRepository, userService, serviceUserService, sessionService) @@ -548,6 +548,8 @@ func buildAPIDependencies( permissionService, userService, authzSchemaRepository, + relationService, + cfg.App.PAT.DeniedPermissionsSet(), planService, planBlobRepository, ) diff --git a/core/authenticate/mocks/authenticator_func.go b/core/authenticate/mocks/authenticator_func.go new file mode 100644 index 000000000..51e9f3cf9 --- /dev/null +++ b/core/authenticate/mocks/authenticator_func.go @@ -0,0 +1,95 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + authenticate "github.com/raystack/frontier/core/authenticate" + + mock "github.com/stretchr/testify/mock" +) + +// AuthenticatorFunc is an autogenerated mock type for the AuthenticatorFunc type +type AuthenticatorFunc struct { + mock.Mock +} + +type AuthenticatorFunc_Expecter struct { + mock *mock.Mock +} + +func (_m *AuthenticatorFunc) EXPECT() *AuthenticatorFunc_Expecter { + return &AuthenticatorFunc_Expecter{mock: &_m.Mock} +} + +// Execute provides a mock function with given fields: ctx, s +func (_m *AuthenticatorFunc) Execute(ctx context.Context, s *authenticate.Service) (authenticate.Principal, bool) { + ret := _m.Called(ctx, s) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 authenticate.Principal + var r1 bool + if rf, ok := ret.Get(0).(func(context.Context, *authenticate.Service) (authenticate.Principal, bool)); ok { + return rf(ctx, s) + } + if rf, ok := ret.Get(0).(func(context.Context, *authenticate.Service) authenticate.Principal); ok { + r0 = rf(ctx, s) + } else { + r0 = ret.Get(0).(authenticate.Principal) + } + + if rf, ok := ret.Get(1).(func(context.Context, *authenticate.Service) bool); ok { + r1 = rf(ctx, s) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// AuthenticatorFunc_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type AuthenticatorFunc_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +// - ctx context.Context +// - s *authenticate.Service +func (_e *AuthenticatorFunc_Expecter) Execute(ctx interface{}, s interface{}) *AuthenticatorFunc_Execute_Call { + return &AuthenticatorFunc_Execute_Call{Call: _e.mock.On("Execute", ctx, s)} +} + +func (_c *AuthenticatorFunc_Execute_Call) Run(run func(ctx context.Context, s *authenticate.Service)) *AuthenticatorFunc_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*authenticate.Service)) + }) + return _c +} + +func (_c *AuthenticatorFunc_Execute_Call) Return(_a0 authenticate.Principal, _a1 bool) *AuthenticatorFunc_Execute_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *AuthenticatorFunc_Execute_Call) RunAndReturn(run func(context.Context, *authenticate.Service) (authenticate.Principal, bool)) *AuthenticatorFunc_Execute_Call { + _c.Call.Return(run) + return _c +} + +// NewAuthenticatorFunc creates a new instance of AuthenticatorFunc. 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 NewAuthenticatorFunc(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthenticatorFunc { + mock := &AuthenticatorFunc{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/policy/policy.go b/core/policy/policy.go index 3602138e6..ce2aaecf6 100644 --- a/core/policy/policy.go +++ b/core/policy/policy.go @@ -25,6 +25,7 @@ type Policy struct { ResourceType string `json:"resource_type"` PrincipalID string `json:"principal_id"` PrincipalType string `json:"principal_type"` + GrantRelation string `json:"grant_relation"` Metadata metadata.Metadata CreatedAt time.Time diff --git a/core/policy/service.go b/core/policy/service.go index 58ef105aa..93075d4c6 100644 --- a/core/policy/service.go +++ b/core/policy/service.go @@ -2,6 +2,7 @@ package policy import ( "context" + "fmt" "github.com/raystack/frontier/pkg/utils" @@ -54,6 +55,16 @@ func (s Service) Create(ctx context.Context, policy Policy) (Policy, error) { return Policy{}, err } policy.RoleID = policyRole.ID + if policy.GrantRelation == "" { + policy.GrantRelation = schema.RoleGrantRelationName + } + if policy.GrantRelation != schema.RoleGrantRelationName && policy.GrantRelation != schema.PATGrantRelationName { + return Policy{}, fmt.Errorf("invalid grant_relation value: %q", policy.GrantRelation) + } + if policy.GrantRelation == schema.PATGrantRelationName && policy.PrincipalType != schema.PATPrincipal { + return Policy{}, fmt.Errorf("%q relation requires principal type %q, got %q", + schema.PATGrantRelationName, schema.PATPrincipal, policy.PrincipalType) + } createdPolicy, err := s.repository.Upsert(ctx, policy) if err != nil { @@ -126,7 +137,7 @@ func (s Service) AssignRole(ctx context.Context, pol Policy) error { ID: pol.ID, Namespace: schema.RoleBindingNamespace, }, - RelationName: schema.RoleGrantRelationName, + RelationName: pol.GrantRelation, }) if err != nil { return err diff --git a/core/policy/service_test.go b/core/policy/service_test.go index 946e6881c..8e897f29c 100644 --- a/core/policy/service_test.go +++ b/core/policy/service_test.go @@ -92,6 +92,33 @@ func TestService_Create(t *testing.T) { return policy.NewService(repo, relationService, roleService) }, }, + { + name: "reject invalid grant_relation value", + policy: policy.Policy{ + RoleID: "role-id", + GrantRelation: "invalid_relation", + }, + wantErr: true, + setup: func() *policy.Service { + repo, roleService, relationService := mockService(t) + roleService.On("Get", ctx, "role-id").Return(role.Role{ID: "role-id"}, nil) + return policy.NewService(repo, relationService, roleService) + }, + }, + { + name: "reject pat_granted for non-PAT principal", + policy: policy.Policy{ + RoleID: "role-id", + PrincipalType: schema.UserPrincipal, + GrantRelation: schema.PATGrantRelationName, + }, + wantErr: true, + setup: func() *policy.Service { + repo, roleService, relationService := mockService(t) + roleService.On("Get", ctx, "role-id").Return(role.Role{ID: "role-id"}, nil) + return policy.NewService(repo, relationService, roleService) + }, + }, { name: "create policy successfully", policy: policy.Policy{ @@ -109,6 +136,7 @@ func TestService_Create(t *testing.T) { ResourceType: schema.ProjectNamespace, PrincipalID: "user-id", PrincipalType: schema.UserPrincipal, + GrantRelation: schema.RoleGrantRelationName, }, setup: func() *policy.Service { repo, roleService, relationService := mockService(t) @@ -120,6 +148,7 @@ func TestService_Create(t *testing.T) { ResourceType: schema.ProjectNamespace, PrincipalID: "user-id", PrincipalType: schema.UserPrincipal, + GrantRelation: schema.RoleGrantRelationName, }).Return(policy.Policy{ ID: "policy-id", RoleID: "role-id", @@ -127,6 +156,7 @@ func TestService_Create(t *testing.T) { ResourceType: schema.ProjectNamespace, PrincipalID: "user-id", PrincipalType: schema.UserPrincipal, + GrantRelation: schema.RoleGrantRelationName, }, nil) // assign role @@ -184,6 +214,7 @@ func TestService_Create(t *testing.T) { ResourceType: schema.ProjectNamespace, PrincipalID: "group-id", PrincipalType: schema.GroupPrincipal, + GrantRelation: schema.RoleGrantRelationName, }, setup: func() *policy.Service { repo, roleService, relationService := mockService(t) @@ -195,6 +226,7 @@ func TestService_Create(t *testing.T) { ResourceType: schema.ProjectNamespace, PrincipalID: "group-id", PrincipalType: schema.GroupPrincipal, + GrantRelation: schema.RoleGrantRelationName, }).Return(policy.Policy{ ID: "policy-id", RoleID: "role-id", @@ -202,6 +234,7 @@ func TestService_Create(t *testing.T) { ResourceType: schema.ProjectNamespace, PrincipalID: "group-id", PrincipalType: schema.GroupPrincipal, + GrantRelation: schema.RoleGrantRelationName, }, nil) // assign role diff --git a/core/role/service.go b/core/role/service.go index 98db2716a..006de33ec 100644 --- a/core/role/service.go +++ b/core/role/service.go @@ -31,14 +31,17 @@ type Service struct { relationService RelationService permissionService PermissionService auditRecordRepository AuditRecordRepository + patDeniedPerms map[string]struct{} } -func NewService(repository Repository, relationService RelationService, permissionService PermissionService, auditRecordRepository AuditRecordRepository) *Service { +func NewService(repository Repository, relationService RelationService, permissionService PermissionService, + auditRecordRepository AuditRecordRepository, patDeniedPerms map[string]struct{}) *Service { return &Service{ repository: repository, relationService: relationService, permissionService: permissionService, auditRecordRepository: auditRecordRepository, + patDeniedPerms: patDeniedPerms, } } @@ -112,7 +115,7 @@ func (s Service) createRolePermissionRelation(ctx context.Context, roleID string Namespace: schema.RoleNamespace, }, Subject: relation.Subject{ - ID: "*", // all principles who have role will have access + ID: "*", // all principals who have role will have access Namespace: schema.UserPrincipal, }, RelationName: perm, @@ -127,7 +130,7 @@ func (s Service) createRolePermissionRelation(ctx context.Context, roleID string Namespace: schema.RoleNamespace, }, Subject: relation.Subject{ - ID: "*", // all principles who have role will have access + ID: "*", // all principals who have role will have access Namespace: schema.ServiceUserPrincipal, }, RelationName: perm, @@ -135,6 +138,23 @@ func (s Service) createRolePermissionRelation(ctx context.Context, roleID string if err != nil { return err } + // do the same with PAT (skip denied permissions) + if _, denied := s.patDeniedPerms[perm]; !denied { + _, err = s.relationService.Create(ctx, relation.Relation{ + Object: relation.Object{ + ID: roleID, + Namespace: schema.RoleNamespace, + }, + Subject: relation.Subject{ + ID: "*", // all principals who have role will have access + Namespace: schema.PATPrincipal, + }, + RelationName: perm, + }) + if err != nil { + return err + } + } } return nil } @@ -151,7 +171,7 @@ func (s Service) deleteRolePermissionRelations(ctx context.Context, roleID strin Namespace: schema.RoleNamespace, }, Subject: relation.Subject{ - ID: "*", // all principles who have role will have access + ID: "*", // all principals who have role will have access Namespace: schema.UserPrincipal, }, }) @@ -165,13 +185,27 @@ func (s Service) deleteRolePermissionRelations(ctx context.Context, roleID strin Namespace: schema.RoleNamespace, }, Subject: relation.Subject{ - ID: "*", // all principles who have role will have access + ID: "*", // all principals who have role will have access Namespace: schema.ServiceUserPrincipal, }, }) if err != nil { return err } + // do the same with PAT + err = s.relationService.Delete(ctx, relation.Relation{ + Object: relation.Object{ + ID: roleID, + Namespace: schema.RoleNamespace, + }, + Subject: relation.Subject{ + ID: "*", + Namespace: schema.PATPrincipal, + }, + }) + if err != nil { + return err + } return nil } diff --git a/core/role/service_test.go b/core/role/service_test.go index 4eea0c612..6ea10f1a7 100644 --- a/core/role/service_test.go +++ b/core/role/service_test.go @@ -33,7 +33,7 @@ func Test_Get(t *testing.T) { mockRepository.On("Get", mock.Anything, mockID).Return(expectedRole, nil).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) res, err := svc.Get(context.Background(), mockID) assert.Equal(t, nil, err) @@ -49,7 +49,7 @@ func Test_Get(t *testing.T) { mockRepository.On("GetByName", mock.Anything, "", mockSlug).Return(expectedRole, nil).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) res, err := svc.Get(context.Background(), mockSlug) assert.Equal(t, nil, err) @@ -62,7 +62,7 @@ func Test_Get(t *testing.T) { mockRepository.On("Get", mock.Anything, mockID).Return(role.Role{}, expectedErr).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) _, err := svc.Get(context.Background(), mockID) assert.NotNil(t, err) @@ -92,7 +92,7 @@ func Test_List(t *testing.T) { mockRepository.On("List", mock.Anything, f).Return(expectedRoles, nil).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) res, err := svc.List(context.Background(), f) assert.Equal(t, nil, err) @@ -104,7 +104,7 @@ func Test_List(t *testing.T) { f := role.Filter{} mockRepository.On("List", mock.Anything, f).Return(nil, expectedErr).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) _, err := svc.List(context.Background(), f) assert.NotNil(t, err) @@ -129,7 +129,7 @@ func Test_Upsert(t *testing.T) { mockPermissionSvc.On("Get", mock.Anything, "app_project_viewer").Return(permission.Permission{}, nil).Once() mockPermissionSvc.On("Get", mock.Anything, nonExistentPermission).Return(permission.Permission{}, expectedErr).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) _, err := svc.Upsert(context.Background(), roleToBeUpserted) assert.NotNil(t, err) @@ -153,7 +153,7 @@ func Test_Upsert(t *testing.T) { mockPermissionSvc.On("Get", mock.Anything, "app_project_viewer").Return(permissionForRole, nil).Once() mockRepository.On("Upsert", mock.Anything, role.Role{ID: roleToBeUpserted.ID, Permissions: []string{slugForPermission}}).Return(role.Role{}, expectedErr).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) _, err := svc.Upsert(context.Background(), roleToBeUpserted) assert.NotNil(t, err) @@ -194,7 +194,7 @@ func Test_Upsert(t *testing.T) { expectedErr := errors.New("Error creating user role relation") mockRelationSvc.On("Create", mock.Anything, userRoleRelation).Return(relation.Relation{}, expectedErr).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) _, err := svc.Upsert(context.Background(), roleToBeUpserted) assert.NotNil(t, err) @@ -247,13 +247,70 @@ func Test_Upsert(t *testing.T) { } mockRelationSvc.On("Create", mock.Anything, serviceUserRoleRelation).Return(relation.Relation{}, nil).Once() + patRoleRelation := relation.Relation{ + Object: relation.Object{ + ID: roleWithPermSlug.ID, + Namespace: schema.RoleNamespace, + }, + Subject: relation.Subject{ + ID: "*", + Namespace: schema.PATPrincipal, + }, + RelationName: slugForPermission, + } + mockRelationSvc.On("Create", mock.Anything, patRoleRelation).Return(relation.Relation{}, nil).Once() + // Mock audit record repository mockAuditRecordRepo.On("Create", mock.Anything, mock.Anything).Return(auditrecord.AuditRecord{}, nil).Once() - svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo) + svc := role.NewService(mockRepository, mockRelationSvc, mockPermissionSvc, mockAuditRecordRepo, nil) roleCreated, err := svc.Upsert(context.Background(), roleToBeUpserted) assert.Nil(t, err) assert.Equal(t, roleWithPermSlug, roleCreated) }) + + t.Run("should skip PAT wildcard for denied permissions", func(t *testing.T) { + repo := mocks.NewRepository(t) + relSvc := mocks.NewRelationService(t) + permSvc := mocks.NewPermissionService(t) + auditRepo := auditMocks.NewRepository(t) + + perm := permission.Permission{ + ID: "perm-1", + Name: "administer", + NamespaceID: "organization", + } + slug := perm.GenerateSlug() + + permSvc.On("Get", mock.Anything, "app_organization_administer").Return(perm, nil).Once() + repo.On("Upsert", mock.Anything, mock.Anything).Return(role.Role{ + ID: "role-1", + Permissions: []string{slug}, + }, nil).Once() + + // only user and serviceuser wildcards — NO PAT wildcard + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.UserPrincipal}, + RelationName: slug, + }).Return(relation.Relation{}, nil).Once() + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.ServiceUserPrincipal}, + RelationName: slug, + }).Return(relation.Relation{}, nil).Once() + + auditRepo.On("Create", mock.Anything, mock.Anything).Return(auditrecord.AuditRecord{}, nil).Once() + + deniedPerms := map[string]struct{}{slug: {}} + svc := role.NewService(repo, relSvc, permSvc, auditRepo, deniedPerms) + _, err := svc.Upsert(context.Background(), role.Role{ + ID: "role-1", + Permissions: []string{"app_organization_administer"}, + }) + + assert.Nil(t, err) + relSvc.AssertExpectations(t) // ensures PAT Create was NOT called + }) } diff --git a/core/userpat/config.go b/core/userpat/config.go index 5bd2756c8..3dc64c718 100644 --- a/core/userpat/config.go +++ b/core/userpat/config.go @@ -3,12 +3,12 @@ package userpat import "time" type Config struct { - Enabled bool `yaml:"enabled" mapstructure:"enabled" default:"false"` - Prefix string `yaml:"prefix" mapstructure:"prefix" default:"fpt"` - MaxPerUserPerOrg int64 `yaml:"max_per_user_per_org" mapstructure:"max_per_user_per_org" default:"50"` - MaxLifetime string `yaml:"max_lifetime" mapstructure:"max_lifetime" default:"8760h"` - DefaultLifetime string `yaml:"default_lifetime" mapstructure:"default_lifetime" default:"2160h"` - DeniedRoles []string `yaml:"denied_roles" mapstructure:"denied_roles"` + Enabled bool `yaml:"enabled" mapstructure:"enabled" default:"false"` + Prefix string `yaml:"prefix" mapstructure:"prefix" default:"fpt"` + MaxPerUserPerOrg int64 `yaml:"max_per_user_per_org" mapstructure:"max_per_user_per_org" default:"50"` + MaxLifetime string `yaml:"max_lifetime" mapstructure:"max_lifetime" default:"8760h"` + DefaultLifetime string `yaml:"default_lifetime" mapstructure:"default_lifetime" default:"2160h"` + DeniedPermissions []string `yaml:"denied_permissions" mapstructure:"denied_permissions"` } func (c Config) MaxExpiry() time.Duration { @@ -18,3 +18,12 @@ func (c Config) MaxExpiry() time.Duration { } return d } + +// DeniedPermissionsSet returns denied permissions as a set for efficient lookups. +func (c Config) DeniedPermissionsSet() map[string]struct{} { + m := make(map[string]struct{}, len(c.DeniedPermissions)) + for _, p := range c.DeniedPermissions { + m[p] = struct{}{} + } + return m +} diff --git a/core/userpat/errors.go b/core/userpat/errors.go index 0a2fc9856..297069d6b 100644 --- a/core/userpat/errors.go +++ b/core/userpat/errors.go @@ -3,12 +3,15 @@ package userpat import "errors" var ( - ErrNotFound = errors.New("personal access token not found") - ErrConflict = errors.New("personal access token with this name already exists") - ErrExpired = errors.New("personal access token has expired") - ErrInvalidToken = errors.New("personal access token is invalid") - ErrLimitExceeded = errors.New("maximum number of personal access tokens reached") - ErrDisabled = errors.New("personal access tokens are not enabled") - ErrExpiryExceeded = errors.New("expiry exceeds maximum allowed lifetime") - ErrExpiryInPast = errors.New("expiry must be in the future") + ErrNotFound = errors.New("personal access token not found") + ErrConflict = errors.New("personal access token with this name already exists") + ErrExpired = errors.New("personal access token has expired") + ErrInvalidToken = errors.New("personal access token is invalid") + ErrLimitExceeded = errors.New("maximum number of personal access tokens reached") + ErrDisabled = errors.New("personal access tokens are not enabled") + ErrExpiryExceeded = errors.New("expiry exceeds maximum allowed lifetime") + ErrExpiryInPast = errors.New("expiry must be in the future") + ErrDeniedRole = errors.New("one or more requested roles not permissible for personal access tokens") + ErrUnsupportedScope = errors.New("role scope is not supported for personal access tokens") + ErrRoleNotFound = errors.New("one or more requested roles do not exist") ) diff --git a/core/userpat/mocks/policy_service.go b/core/userpat/mocks/policy_service.go new file mode 100644 index 000000000..9469a8f9a --- /dev/null +++ b/core/userpat/mocks/policy_service.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + policy "github.com/raystack/frontier/core/policy" + mock "github.com/stretchr/testify/mock" +) + +// PolicyService is an autogenerated mock type for the PolicyService type +type PolicyService struct { + mock.Mock +} + +type PolicyService_Expecter struct { + mock *mock.Mock +} + +func (_m *PolicyService) EXPECT() *PolicyService_Expecter { + return &PolicyService_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, pol +func (_m *PolicyService) Create(ctx context.Context, pol policy.Policy) (policy.Policy, error) { + ret := _m.Called(ctx, pol) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 policy.Policy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, policy.Policy) (policy.Policy, error)); ok { + return rf(ctx, pol) + } + if rf, ok := ret.Get(0).(func(context.Context, policy.Policy) policy.Policy); ok { + r0 = rf(ctx, pol) + } else { + r0 = ret.Get(0).(policy.Policy) + } + + if rf, ok := ret.Get(1).(func(context.Context, policy.Policy) error); ok { + r1 = rf(ctx, pol) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PolicyService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type PolicyService_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - pol policy.Policy +func (_e *PolicyService_Expecter) Create(ctx interface{}, pol interface{}) *PolicyService_Create_Call { + return &PolicyService_Create_Call{Call: _e.mock.On("Create", ctx, pol)} +} + +func (_c *PolicyService_Create_Call) Run(run func(ctx context.Context, pol policy.Policy)) *PolicyService_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(policy.Policy)) + }) + return _c +} + +func (_c *PolicyService_Create_Call) Return(_a0 policy.Policy, _a1 error) *PolicyService_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PolicyService_Create_Call) RunAndReturn(run func(context.Context, policy.Policy) (policy.Policy, error)) *PolicyService_Create_Call { + _c.Call.Return(run) + return _c +} + +// NewPolicyService creates a new instance of PolicyService. 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 NewPolicyService(t interface { + mock.TestingT + Cleanup(func()) +}) *PolicyService { + mock := &PolicyService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/userpat/mocks/role_service.go b/core/userpat/mocks/role_service.go new file mode 100644 index 000000000..5863d8d87 --- /dev/null +++ b/core/userpat/mocks/role_service.go @@ -0,0 +1,153 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + role "github.com/raystack/frontier/core/role" + mock "github.com/stretchr/testify/mock" +) + +// RoleService is an autogenerated mock type for the RoleService type +type RoleService struct { + mock.Mock +} + +type RoleService_Expecter struct { + mock *mock.Mock +} + +func (_m *RoleService) EXPECT() *RoleService_Expecter { + return &RoleService_Expecter{mock: &_m.Mock} +} + +// Get provides a mock function with given fields: ctx, id +func (_m *RoleService) Get(ctx context.Context, id string) (role.Role, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 role.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (role.Role, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) role.Role); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(role.Role) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RoleService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type RoleService_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *RoleService_Expecter) Get(ctx interface{}, id interface{}) *RoleService_Get_Call { + return &RoleService_Get_Call{Call: _e.mock.On("Get", ctx, id)} +} + +func (_c *RoleService_Get_Call) Run(run func(ctx context.Context, id string)) *RoleService_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *RoleService_Get_Call) Return(_a0 role.Role, _a1 error) *RoleService_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RoleService_Get_Call) RunAndReturn(run func(context.Context, string) (role.Role, error)) *RoleService_Get_Call { + _c.Call.Return(run) + return _c +} + +// List provides a mock function with given fields: ctx, f +func (_m *RoleService) List(ctx context.Context, f role.Filter) ([]role.Role, error) { + ret := _m.Called(ctx, f) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []role.Role + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, role.Filter) ([]role.Role, error)); ok { + return rf(ctx, f) + } + if rf, ok := ret.Get(0).(func(context.Context, role.Filter) []role.Role); ok { + r0 = rf(ctx, f) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]role.Role) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, role.Filter) error); ok { + r1 = rf(ctx, f) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RoleService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type RoleService_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - f role.Filter +func (_e *RoleService_Expecter) List(ctx interface{}, f interface{}) *RoleService_List_Call { + return &RoleService_List_Call{Call: _e.mock.On("List", ctx, f)} +} + +func (_c *RoleService_List_Call) Run(run func(ctx context.Context, f role.Filter)) *RoleService_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(role.Filter)) + }) + return _c +} + +func (_c *RoleService_List_Call) Return(_a0 []role.Role, _a1 error) *RoleService_List_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *RoleService_List_Call) RunAndReturn(run func(context.Context, role.Filter) ([]role.Role, error)) *RoleService_List_Call { + _c.Call.Return(run) + return _c +} + +// NewRoleService creates a new instance of RoleService. 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 NewRoleService(t interface { + mock.TestingT + Cleanup(func()) +}) *RoleService { + mock := &RoleService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/userpat/service.go b/core/userpat/service.go index 0d22dcdde..78ce57e50 100644 --- a/core/userpat/service.go +++ b/core/userpat/service.go @@ -8,10 +8,14 @@ import ( "fmt" "io" "maps" + "slices" "time" "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/organization" + "github.com/raystack/frontier/core/policy" + "github.com/raystack/frontier/core/role" + "github.com/raystack/frontier/internal/bootstrap/schema" pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/raystack/salt/log" "golang.org/x/crypto/sha3" @@ -21,6 +25,15 @@ type OrganizationService interface { GetRaw(ctx context.Context, id string) (organization.Organization, error) } +type RoleService interface { + Get(ctx context.Context, id string) (role.Role, error) + List(ctx context.Context, f role.Filter) ([]role.Role, error) +} + +type PolicyService interface { + Create(ctx context.Context, pol policy.Policy) (policy.Policy, error) +} + type AuditRecordRepository interface { Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) } @@ -30,16 +43,23 @@ type Service struct { config Config logger log.Logger orgService OrganizationService + roleService RoleService + policyService PolicyService auditRecordRepository AuditRecordRepository + deniedPerms map[string]struct{} } -func NewService(logger log.Logger, repo Repository, config Config, orgService OrganizationService, auditRecordRepository AuditRecordRepository) *Service { +func NewService(logger log.Logger, repo Repository, config Config, orgService OrganizationService, + roleService RoleService, policyService PolicyService, auditRecordRepository AuditRecordRepository) *Service { return &Service{ repo: repo, config: config, logger: logger, orgService: orgService, + roleService: roleService, + policyService: policyService, auditRecordRepository: auditRecordRepository, + deniedPerms: config.DeniedPermissionsSet(), } } @@ -47,7 +67,7 @@ type CreateRequest struct { UserID string OrgID string Title string - Roles []string + RoleIDs []string ProjectIDs []string ExpiresAt time.Time Metadata map[string]any @@ -84,6 +104,11 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (PAT, string, e return PAT{}, "", ErrLimitExceeded } + roles, err := s.resolveAndValidateRoles(ctx, req.RoleIDs) + if err != nil { + return PAT{}, "", err + } + patValue, secretHash, err := s.generatePAT() if err != nil { return PAT{}, "", err @@ -103,11 +128,13 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (PAT, string, e return PAT{}, "", err } - // TODO: create policies for roles + project_ids + if err := s.createPolicies(ctx, created.ID, req.OrgID, roles, req.ProjectIDs); err != nil { + return PAT{}, "", fmt.Errorf("creating policies: %w", err) + } // TODO: move audit record creation into the same transaction as PAT creation to avoid partial state where PAT exists but audit record doesn't. if err := s.createAuditRecord(ctx, pkgAuditRecord.PATCreatedEvent, created, created.CreatedAt, map[string]any{ - "roles": req.Roles, + "role_ids": req.RoleIDs, "project_ids": req.ProjectIDs, }); err != nil { s.logger.Error("failed to create audit record for PAT", "pat_id", created.ID, "error", err) @@ -148,6 +175,129 @@ func (s *Service) createAuditRecord(ctx context.Context, event pkgAuditRecord.Ev return nil } +// resolveAndValidateRoles fetches the requested roles and validates they are allowed for PATs. +// All validation (existence, permissions, scopes) happens here so callers can fail fast +// before persisting any state. +func (s *Service) resolveAndValidateRoles(ctx context.Context, roleIDs []string) ([]role.Role, error) { + if len(roleIDs) == 0 { + return nil, nil + } + + roles, err := s.roleService.List(ctx, role.Filter{IDs: roleIDs}) + if err != nil { + return nil, fmt.Errorf("fetching roles: %w", err) + } + if len(roles) != len(roleIDs) { + var missing []string + for _, id := range roleIDs { + if !slices.ContainsFunc(roles, func(r role.Role) bool { return r.ID == id }) { + missing = append(missing, id) + } + } + return nil, fmt.Errorf("role IDs not found: %v: %w", missing, ErrRoleNotFound) + } + + if err := s.validateRolePermissions(roles); err != nil { + return nil, err + } + + for _, r := range roles { + if len(r.Scopes) == 0 { + return nil, fmt.Errorf("role %s has scopes %v: %w", r.Name, r.Scopes, ErrUnsupportedScope) + } + for _, scope := range r.Scopes { + if scope != schema.ProjectNamespace && scope != schema.OrganizationNamespace { + return nil, fmt.Errorf("role %s has scopes %v: %w", r.Name, r.Scopes, ErrUnsupportedScope) + } + } + } + + return roles, nil +} + +// createPolicies creates SpiceDB policies for the PAT based on the already-validated roles and project scope. +// Each role is categorized by its Scopes field: +// - Org-scoped role -> policy on the org with default "granted" relation +// - Project-scoped role, all projects (projectIDs empty) -> policy on org with "pat_granted" relation +// - Project-scoped role, specific projects -> one policy per project with default "granted" relation +func (s *Service) createPolicies(ctx context.Context, patID, orgID string, roles []role.Role, projectIDs []string) error { + for _, r := range roles { + var err error + switch { + case slices.Contains(r.Scopes, schema.ProjectNamespace): + err = s.createProjectScopedPolicies(ctx, patID, orgID, r, projectIDs) + case slices.Contains(r.Scopes, schema.OrganizationNamespace): + err = s.createOrgScopedPolicy(ctx, patID, orgID, r) + default: + err = fmt.Errorf("role %s has scopes %v: %w", r.Name, r.Scopes, ErrUnsupportedScope) + } + if err != nil { + return err + } + } + return nil +} + +// validateRolePermissions checks that none of the roles contain denied permissions. +func (s *Service) validateRolePermissions(roles []role.Role) error { + for _, r := range roles { + for _, perm := range r.Permissions { + if _, denied := s.deniedPerms[perm]; denied { + return fmt.Errorf("role %s has denied permission %s: %w", r.Name, perm, ErrDeniedRole) + } + } + } + return nil +} + +// createOrgScopedPolicy creates a policy on the org with the default "granted" relation. +func (s *Service) createOrgScopedPolicy(ctx context.Context, patID, orgID string, r role.Role) error { + if _, err := s.policyService.Create(ctx, policy.Policy{ + RoleID: r.ID, + ResourceID: orgID, + ResourceType: schema.OrganizationNamespace, + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + }); err != nil { + return fmt.Errorf("creating org policy for role %s: %w", r.Name, err) + } + return nil +} + +// createProjectScopedPolicies creates policies for a project-scoped role. +// If projectIDs is empty, it creates a single policy on the org with "pat_granted" relation +// (cascades to all projects). Otherwise, it creates one policy per project with default "granted". +func (s *Service) createProjectScopedPolicies(ctx context.Context, patID, orgID string, r role.Role, projectIDs []string) error { + if len(projectIDs) == 0 { + // all projects -> policy on org with "pat_granted" + if _, err := s.policyService.Create(ctx, policy.Policy{ + RoleID: r.ID, + ResourceID: orgID, + ResourceType: schema.OrganizationNamespace, + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + GrantRelation: schema.PATGrantRelationName, + }); err != nil { + return fmt.Errorf("creating org pat_granted policy for role %s: %w", r.Name, err) + } + return nil + } + + // specific projects -> one policy per project + for _, projectID := range projectIDs { + if _, err := s.policyService.Create(ctx, policy.Policy{ + RoleID: r.ID, + ResourceID: projectID, + ResourceType: schema.ProjectNamespace, + PrincipalID: patID, + PrincipalType: schema.PATPrincipal, + }); err != nil { + return fmt.Errorf("creating project policy for role %s on project %s: %w", r.Name, projectID, err) + } + } + return nil +} + // generatePAT creates a random PAT string with the configured prefix and returns // the plaintext value along with its SHA3-256 hash for storage. // The hash is computed over the raw secret bytes (not the formatted PAT string) diff --git a/core/userpat/service_test.go b/core/userpat/service_test.go index 916da2026..eb97c8260 100644 --- a/core/userpat/service_test.go +++ b/core/userpat/service_test.go @@ -12,8 +12,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/organization" + "github.com/raystack/frontier/core/policy" + "github.com/raystack/frontier/core/role" "github.com/raystack/frontier/core/userpat" "github.com/raystack/frontier/core/userpat/mocks" + "github.com/raystack/frontier/internal/bootstrap/schema" "github.com/raystack/salt/log" "github.com/stretchr/testify/mock" "golang.org/x/crypto/sha3" @@ -26,15 +29,25 @@ var defaultConfig = userpat.Config{ MaxLifetime: "8760h", } -func newSuccessMocks(t *testing.T) (*mocks.OrganizationService, *mocks.AuditRecordRepository) { +func newSuccessMocks(t *testing.T) (*mocks.OrganizationService, *mocks.RoleService, *mocks.PolicyService, *mocks.AuditRecordRepository) { t.Helper() orgSvc := mocks.NewOrganizationService(t) orgSvc.On("GetRaw", mock.Anything, mock.Anything). Return(organization.Organization{ID: "org-1", Title: "Test Org"}, nil).Maybe() + roleSvc := mocks.NewRoleService(t) + roleSvc.On("List", mock.Anything, mock.Anything). + Return([]role.Role{{ + ID: "role-1", + Name: "test-role", + Scopes: []string{schema.OrganizationNamespace}, + }}, nil).Maybe() + policySvc := mocks.NewPolicyService(t) + policySvc.On("Create", mock.Anything, mock.Anything). + Return(policy.Policy{}, nil).Maybe() auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(models.AuditRecord{}, nil).Maybe() - return orgSvc, auditRepo + return orgSvc, roleSvc, policySvc, auditRepo } func TestService_Create(t *testing.T) { @@ -45,7 +58,7 @@ func TestService_Create(t *testing.T) { wantErr bool wantErrIs error wantErrMsg string - validateFunc func(t *testing.T, got userpat.PAT, patValue string) + validateFunc func(t *testing.T, got userpat.PAT, tokenValue string) }{ { name: "should return ErrDisabled when PAT feature is disabled", @@ -53,7 +66,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: true, @@ -64,7 +77,7 @@ func TestService_Create(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) return userpat.NewService(log.NewNoop(), repo, userpat.Config{ Enabled: false, - }, orgSvc, auditRepo) + }, orgSvc, nil, nil, auditRepo) }, }, { @@ -73,7 +86,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: true, @@ -84,7 +97,7 @@ func TestService_Create(t *testing.T) { Return(int64(0), errors.New("db connection failed")) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) }, }, { @@ -93,7 +106,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: true, @@ -104,7 +117,7 @@ func TestService_Create(t *testing.T) { Return(int64(50), nil) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) }, }, { @@ -113,7 +126,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: true, @@ -124,7 +137,7 @@ func TestService_Create(t *testing.T) { Return(int64(55), nil) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) }, }, { @@ -133,7 +146,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: true, @@ -146,7 +159,11 @@ func TestService_Create(t *testing.T) { Return(userpat.PAT{}, errors.New("insert failed")) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + roleSvc := mocks.NewRoleService(t) + roleSvc.On("List", mock.Anything, mock.Anything).Return([]role.Role{{ + ID: "role-1", Name: "test-role", Scopes: []string{schema.OrganizationNamespace}, + }}, nil).Maybe() + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) }, }, { @@ -155,7 +172,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: true, @@ -168,7 +185,11 @@ func TestService_Create(t *testing.T) { Return(userpat.PAT{}, userpat.ErrConflict) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + roleSvc := mocks.NewRoleService(t) + roleSvc.On("List", mock.Anything, mock.Anything).Return([]role.Role{{ + ID: "role-1", Name: "test-role", Scopes: []string{schema.OrganizationNamespace}, + }}, nil).Maybe() + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) }, }, { @@ -177,7 +198,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ProjectIDs: []string{"proj-1"}, ExpiresAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), Metadata: map[string]any{"env": "staging"}, @@ -217,10 +238,10 @@ func TestService_Create(t *testing.T) { ExpiresAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), CreatedAt: time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC), }, nil) - orgSvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) }, - validateFunc: func(t *testing.T, got userpat.PAT, patValue string) { + validateFunc: func(t *testing.T, got userpat.PAT, tokenValue string) { t.Helper() if got.ID != "pat-id-1" { t.Errorf("Create() ID = %v, want %v", got.ID, "pat-id-1") @@ -228,8 +249,8 @@ func TestService_Create(t *testing.T) { if got.UserID != "user-1" { t.Errorf("Create() UserID = %v, want %v", got.UserID, "user-1") } - if patValue == "" { - t.Error("Create() patValue should not be empty") + if tokenValue == "" { + t.Error("Create() tokenValue should not be empty") } }, }, @@ -239,7 +260,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: false, @@ -249,15 +270,15 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) }, - validateFunc: func(t *testing.T, got userpat.PAT, patValue string) { + validateFunc: func(t *testing.T, got userpat.PAT, tokenValue string) { t.Helper() - if !strings.HasPrefix(patValue, "fpt_") { - t.Errorf("token should start with prefix fpt_, got %v", patValue) + if !strings.HasPrefix(tokenValue, "fpt_") { + t.Errorf("token should start with prefix fpt_, got %v", tokenValue) } - parts := strings.SplitN(patValue, "_", 2) + parts := strings.SplitN(tokenValue, "_", 2) if len(parts) != 2 { t.Fatal("token should have format prefix_secret") } @@ -276,7 +297,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: false, @@ -286,13 +307,12 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) }, - validateFunc: func(t *testing.T, got userpat.PAT, patValue string) { + validateFunc: func(t *testing.T, got userpat.PAT, tokenValue string) { t.Helper() - // extract the raw secret from the token and verify sha3-256 produces a valid hash - parts := strings.SplitN(patValue, "_", 2) + parts := strings.SplitN(tokenValue, "_", 2) if len(parts) != 2 { t.Fatal("token should have format prefix_secret") } @@ -313,7 +333,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: false, @@ -323,18 +343,18 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, auditRepo := newSuccessMocks(t) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) return userpat.NewService(log.NewNoop(), repo, userpat.Config{ Enabled: true, Prefix: "custom", MaxPerUserPerOrg: 50, MaxLifetime: "8760h", - }, orgSvc, auditRepo) + }, orgSvc, roleSvc, policySvc, auditRepo) }, - validateFunc: func(t *testing.T, got userpat.PAT, patValue string) { + validateFunc: func(t *testing.T, got userpat.PAT, tokenValue string) { t.Helper() - if !strings.HasPrefix(patValue, "custom_") { - t.Errorf("token should start with custom_, got %v", patValue) + if !strings.HasPrefix(tokenValue, "custom_") { + t.Errorf("token should start with custom_, got %v", tokenValue) } }, }, @@ -344,7 +364,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: false, @@ -354,8 +374,8 @@ func TestService_Create(t *testing.T) { Return(int64(49), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) }, }, { @@ -364,7 +384,7 @@ func TestService_Create(t *testing.T) { UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }, wantErr: false, @@ -374,15 +394,15 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := tt.setup() - got, patValue, err := s.Create(context.Background(), tt.req) + got, tokenValue, err := s.Create(context.Background(), tt.req) if (err != nil) != tt.wantErr { t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr) return @@ -396,7 +416,7 @@ func TestService_Create(t *testing.T) { return } if tt.validateFunc != nil { - tt.validateFunc(t, got, patValue) + tt.validateFunc(t, got, tokenValue) } }) } @@ -409,14 +429,14 @@ func TestService_Create_UniquePATs(t *testing.T) { repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil).Times(2) - orgSvc, auditRepo := newSuccessMocks(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) req := userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), } @@ -444,14 +464,14 @@ func TestService_Create_HashVerification(t *testing.T) { }). Return(userpat.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, auditRepo := newSuccessMocks(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, auditRepo) + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) - _, patValue, err := svc.Create(context.Background(), userpat.CreateRequest{ + _, tokenValue, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", Title: "my-token", - Roles: []string{"role-1"}, + RoleIDs: []string{"role-1"}, ExpiresAt: time.Now().Add(24 * time.Hour), }) if err != nil { @@ -459,7 +479,7 @@ func TestService_Create_HashVerification(t *testing.T) { } // extract the raw secret bytes from the token value - parts := strings.SplitN(patValue, "_", 2) + parts := strings.SplitN(tokenValue, "_", 2) if len(parts) != 2 { t.Fatal("token should have format prefix_secret") } @@ -474,6 +494,720 @@ func TestService_Create_HashVerification(t *testing.T) { } } +func TestService_CreatePolicies_OrgScopedRole(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). + Return(userpat.PAT{ID: "pat-1", OrgID: "org-1", CreatedAt: time.Now()}, nil) + + orgSvc := mocks.NewOrganizationService(t) + orgSvc.On("GetRaw", mock.Anything, mock.Anything). + Return(organization.Organization{ID: "org-1", Title: "Test Org"}, nil).Maybe() + auditRepo := mocks.NewAuditRecordRepository(t) + auditRepo.On("Create", mock.Anything, mock.Anything).Return(models.AuditRecord{}, nil).Maybe() + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"org-role-1"}}).Return([]role.Role{{ + ID: "org-role-1", + Name: "org_viewer", + Permissions: []string{"app_organization_get"}, + Scopes: []string{schema.OrganizationNamespace}, + }}, nil) + + policySvc := mocks.NewPolicyService(t) + policySvc.EXPECT().Create(mock.Anything, policy.Policy{ + RoleID: "org-role-1", + ResourceID: "org-1", + ResourceType: schema.OrganizationNamespace, + PrincipalID: "pat-1", + PrincipalType: schema.PATPrincipal, + }).Return(policy.Policy{ID: "pol-1"}, nil) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "org-token", + RoleIDs: []string{"org-role-1"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } +} + +func TestService_CreatePolicies_ProjectScopedAllProjects(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). + Return(userpat.PAT{ID: "pat-1", OrgID: "org-1", CreatedAt: time.Now()}, nil) + + orgSvc := mocks.NewOrganizationService(t) + orgSvc.On("GetRaw", mock.Anything, mock.Anything). + Return(organization.Organization{ID: "org-1", Title: "Test Org"}, nil).Maybe() + auditRepo := mocks.NewAuditRecordRepository(t) + auditRepo.On("Create", mock.Anything, mock.Anything).Return(models.AuditRecord{}, nil).Maybe() + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"proj-role-1"}}).Return([]role.Role{{ + ID: "proj-role-1", + Name: "proj_viewer", + Permissions: []string{"app_project_get"}, + Scopes: []string{schema.ProjectNamespace}, + }}, nil) + + policySvc := mocks.NewPolicyService(t) + policySvc.EXPECT().Create(mock.Anything, policy.Policy{ + RoleID: "proj-role-1", + ResourceID: "org-1", + ResourceType: schema.OrganizationNamespace, + PrincipalID: "pat-1", + PrincipalType: schema.PATPrincipal, + GrantRelation: schema.PATGrantRelationName, + }).Return(policy.Policy{ID: "pol-1"}, nil) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "all-projects-token", + RoleIDs: []string{"proj-role-1"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } +} + +func TestService_CreatePolicies_ProjectScopedSpecificProjects(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). + Return(userpat.PAT{ID: "pat-1", OrgID: "org-1", CreatedAt: time.Now()}, nil) + + orgSvc := mocks.NewOrganizationService(t) + orgSvc.On("GetRaw", mock.Anything, mock.Anything). + Return(organization.Organization{ID: "org-1", Title: "Test Org"}, nil).Maybe() + auditRepo := mocks.NewAuditRecordRepository(t) + auditRepo.On("Create", mock.Anything, mock.Anything).Return(models.AuditRecord{}, nil).Maybe() + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"proj-role-1"}}).Return([]role.Role{{ + ID: "proj-role-1", + Name: "proj_viewer", + Permissions: []string{"app_project_get"}, + Scopes: []string{schema.ProjectNamespace}, + }}, nil) + + policySvc := mocks.NewPolicyService(t) + policySvc.EXPECT().Create(mock.Anything, policy.Policy{ + RoleID: "proj-role-1", + ResourceID: "proj-a", + ResourceType: schema.ProjectNamespace, + PrincipalID: "pat-1", + PrincipalType: schema.PATPrincipal, + }).Return(policy.Policy{ID: "pol-1"}, nil) + policySvc.EXPECT().Create(mock.Anything, policy.Policy{ + RoleID: "proj-role-1", + ResourceID: "proj-b", + ResourceType: schema.ProjectNamespace, + PrincipalID: "pat-1", + PrincipalType: schema.PATPrincipal, + }).Return(policy.Policy{ID: "pol-2"}, nil) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "specific-projects-token", + RoleIDs: []string{"proj-role-1"}, + ProjectIDs: []string{"proj-a", "proj-b"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } +} + +func TestService_CreatePolicies_DeniedPermission(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + // repo.Create should NOT be called — validation fails before token creation + + orgSvc := mocks.NewOrganizationService(t) + auditRepo := mocks.NewAuditRecordRepository(t) + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"admin-role"}}).Return([]role.Role{{ + ID: "admin-role", + Name: "org_admin", + Permissions: []string{"app_organization_administer", "app_organization_get"}, + Scopes: []string{schema.OrganizationNamespace}, + }}, nil) + + policySvc := mocks.NewPolicyService(t) + + cfg := defaultConfig + cfg.DeniedPermissions = []string{"app_organization_administer"} + + svc := userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "admin-token", + RoleIDs: []string{"admin-role"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err == nil { + t.Fatal("Create() expected error for denied permission, got nil") + } + if !errors.Is(err, userpat.ErrDeniedRole) { + t.Errorf("Create() error = %v, want ErrDeniedRole", err) + } +} + +func TestService_CreatePolicies_RoleFetchError(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + // repo.Create should NOT be called — role fetch fails before token creation + + orgSvc := mocks.NewOrganizationService(t) + auditRepo := mocks.NewAuditRecordRepository(t) + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"bad-role"}}). + Return(nil, errors.New("role not found")) + + policySvc := mocks.NewPolicyService(t) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "bad-token", + RoleIDs: []string{"bad-role"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err == nil { + t.Fatal("Create() expected error for bad role, got nil") + } + if !strings.Contains(err.Error(), "fetching roles") { + t.Errorf("Create() error = %v, want error containing 'fetching roles'", err) + } +} + +func TestService_CreatePolicies_UnsupportedScope(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + // repo.Create should NOT be called — scope validation fails before token creation + + orgSvc := mocks.NewOrganizationService(t) + auditRepo := mocks.NewAuditRecordRepository(t) + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"group-role"}}).Return([]role.Role{{ + ID: "group-role", + Name: "group_owner", + Permissions: []string{"app_group_administer"}, + Scopes: []string{schema.GroupNamespace}, + }}, nil) + + policySvc := mocks.NewPolicyService(t) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "group-token", + RoleIDs: []string{"group-role"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err == nil { + t.Fatal("Create() expected error for unsupported scope, got nil") + } + if !errors.Is(err, userpat.ErrUnsupportedScope) { + t.Errorf("Create() error = %v, want ErrUnsupportedScope", err) + } +} + +func TestService_CreatePolicies_MissingRoleID(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + // repo.Create should NOT be called — role count mismatch fails before token creation + + orgSvc := mocks.NewOrganizationService(t) + auditRepo := mocks.NewAuditRecordRepository(t) + + roleSvc := mocks.NewRoleService(t) + // request 2 roles but only 1 found + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"role-a", "role-b"}}).Return([]role.Role{{ + ID: "role-a", + Name: "role_a", + Scopes: []string{schema.OrganizationNamespace}, + }}, nil) + + policySvc := mocks.NewPolicyService(t) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "missing-role-token", + RoleIDs: []string{"role-a", "role-b"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err == nil { + t.Fatal("Create() expected error for missing role, got nil") + } + if !errors.Is(err, userpat.ErrRoleNotFound) { + t.Errorf("Create() error = %v, want ErrRoleNotFound", err) + } + if !strings.Contains(err.Error(), "role-b") { + t.Errorf("Create() error = %v, want error mentioning missing role ID 'role-b'", err) + } +} + +func TestService_CreatePolicies_NoRoles(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). + Return(userpat.PAT{ID: "pat-1", OrgID: "org-1", CreatedAt: time.Now()}, nil) + + orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "no-roles-token", + RoleIDs: nil, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } +} + +// policyKey creates a comparable string for a policy to enable set comparison. +// Format: "roleID→resourceType:resourceID(grantRelation)" +func policyKey(p policy.Policy) string { + grant := "granted" + if p.GrantRelation != "" { + grant = p.GrantRelation + } + return p.RoleID + "→" + p.ResourceType + ":" + p.ResourceID + "(" + grant + ")" +} + +// TestService_CreatePolicies_ScopeMatrix is a comprehensive table-driven test that +// verifies the exact set of policies created for every role/project combination. +// +// The test captures every policyService.Create call and compares the full set against +// expected policies. Because testify mock records all calls, any EXTRA unexpected policy +// creation is caught — e.g. a PAT scoped to proj-1 must NOT produce a policy on proj-2. +func TestService_CreatePolicies_ScopeMatrix(t *testing.T) { + type wantPolicy struct { + RoleID string + ResourceID string + ResourceType string + Grant string // "granted" (default) or "pat_granted" + } + + tests := []struct { + name string + roleIDs []string + projectIDs []string + roles []role.Role + want []wantPolicy + config *userpat.Config // nil = use defaultConfig + wantErr bool + wantErrIs error + wantErrMsg string + }{ + { + name: "ex1: org_manager + project_owner, all projects", + roleIDs: []string{"org-mgr-id", "proj-owner-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "org-mgr-id", Name: "app_organization_manager", Permissions: []string{"app_organization_get", "app_organization_update"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "proj-owner-id", Name: "app_project_owner", Permissions: []string{"app_project_get", "app_project_update", "app_project_delete"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "org-mgr-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + {RoleID: "proj-owner-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "pat_granted"}, + }, + }, + { + name: "ex2: org_viewer + project_viewer, all projects", + roleIDs: []string{"org-viewer-id", "proj-viewer-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "proj-viewer-id", Name: "app_project_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "org-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + {RoleID: "proj-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "pat_granted"}, + }, + }, + { + name: "ex3: org_viewer + project_owner, specific projects", + roleIDs: []string{"org-viewer-id", "proj-owner-id"}, + projectIDs: []string{"proj-1", "proj-2"}, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "proj-owner-id", Name: "app_project_owner", Permissions: []string{"app_project_get", "app_project_update", "app_project_delete"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "org-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + {RoleID: "proj-owner-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + {RoleID: "proj-owner-id", ResourceID: "proj-2", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + }, + }, + { + name: "ex4: org_viewer only, no project access", + roleIDs: []string{"org-viewer-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "org-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + }, + }, + + // ── Multiple roles of same scope ───────────────────────────────── + + { + name: "multiple org roles create separate org policies", + roleIDs: []string{"org-viewer-id", "org-billing-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "org-billing-id", Name: "app_organization_billing_viewer", Permissions: []string{"app_organization_billingview"}, Scopes: []string{schema.OrganizationNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "org-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + {RoleID: "org-billing-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + }, + }, + { + name: "multiple project roles, all projects → separate pat_granted policies", + roleIDs: []string{"proj-viewer-id", "proj-editor-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "proj-viewer-id", Name: "app_project_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, + {ID: "proj-editor-id", Name: "app_project_editor", Permissions: []string{"app_project_get", "app_project_update"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "proj-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "pat_granted"}, + {RoleID: "proj-editor-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "pat_granted"}, + }, + }, + { + name: "multiple project roles, specific projects → policy per role per project", + roleIDs: []string{"proj-viewer-id", "proj-editor-id"}, + projectIDs: []string{"proj-1", "proj-2"}, + roles: []role.Role{ + {ID: "proj-viewer-id", Name: "app_project_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, + {ID: "proj-editor-id", Name: "app_project_editor", Permissions: []string{"app_project_get", "app_project_update"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "proj-viewer-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + {RoleID: "proj-viewer-id", ResourceID: "proj-2", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + {RoleID: "proj-editor-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + {RoleID: "proj-editor-id", ResourceID: "proj-2", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + }, + }, + + // ── Scope isolation ────────────────────────────────────────────── + + { + name: "project role scoped to proj-1 only: no policy on proj-2", + roleIDs: []string{"proj-viewer-id"}, + projectIDs: []string{"proj-1"}, + roles: []role.Role{ + {ID: "proj-viewer-id", Name: "app_project_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, + }, + // Only proj-1 gets a policy. If code mistakenly creates a policy + // on any other project, the captured set won't match. + want: []wantPolicy{ + {RoleID: "proj-viewer-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + }, + }, + { + name: "org role does not create project policies even when projectIDs provided", + roleIDs: []string{"org-viewer-id"}, + projectIDs: []string{"proj-1", "proj-2"}, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + }, + // Org-scoped role ignores projectIDs entirely — only org policy created + want: []wantPolicy{ + {RoleID: "org-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + }, + }, + { + name: "mixed roles with specific projects: org on org, project on projects only", + roleIDs: []string{"org-viewer-id", "proj-editor-id"}, + projectIDs: []string{"proj-1"}, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "proj-editor-id", Name: "app_project_editor", Permissions: []string{"app_project_get", "app_project_update"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "org-viewer-id", ResourceID: "org-1", ResourceType: schema.OrganizationNamespace, Grant: "granted"}, + {RoleID: "proj-editor-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + }, + }, + { + name: "single project role, single project", + roleIDs: []string{"proj-viewer-id"}, + projectIDs: []string{"proj-1"}, + roles: []role.Role{ + {ID: "proj-viewer-id", Name: "app_project_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "proj-viewer-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + }, + }, + { + name: "single project role, three projects", + roleIDs: []string{"proj-viewer-id"}, + projectIDs: []string{"proj-1", "proj-2", "proj-3"}, + roles: []role.Role{ + {ID: "proj-viewer-id", Name: "app_project_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, + }, + want: []wantPolicy{ + {RoleID: "proj-viewer-id", ResourceID: "proj-1", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + {RoleID: "proj-viewer-id", ResourceID: "proj-2", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + {RoleID: "proj-viewer-id", ResourceID: "proj-3", ResourceType: schema.ProjectNamespace, Grant: "granted"}, + }, + }, + + // ── Error cases ────────────────────────────────────────────────── + + { + name: "denied permission blocks all policy creation", + roleIDs: []string{"org-viewer-id", "org-admin-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "org-admin-id", Name: "app_organization_admin", Permissions: []string{"app_organization_administer"}, Scopes: []string{schema.OrganizationNamespace}}, + }, + config: &userpat.Config{ + Enabled: true, + Prefix: "fpt", + MaxPerUserPerOrg: 50, + MaxLifetime: "8760h", + DeniedPermissions: []string{"app_organization_administer"}, + }, + want: nil, // no policies should be created + wantErr: true, + wantErrIs: userpat.ErrDeniedRole, + }, + { + name: "unsupported scope rejects before any policy creation", + roleIDs: []string{"org-viewer-id", "group-role-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "group-role-id", Name: "app_group_manager", Permissions: []string{"app_group_get"}, Scopes: []string{schema.GroupNamespace}}, + }, + want: nil, // scope validation happens upfront — no token or policies created + wantErr: true, + wantErrIs: userpat.ErrUnsupportedScope, + }, + { + name: "role with mixed supported and unsupported scopes is rejected", + roleIDs: []string{"mixed-scope-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "mixed-scope-id", Name: "mixed_role", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace, schema.GroupNamespace}}, + }, + want: nil, + wantErr: true, + wantErrIs: userpat.ErrUnsupportedScope, + }, + { + name: "role with empty scopes is unsupported", + roleIDs: []string{"no-scope-id"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "no-scope-id", Name: "custom_role", Permissions: []string{"app_organization_get"}, Scopes: nil}, + }, + want: nil, + wantErr: true, + wantErrIs: userpat.ErrUnsupportedScope, + }, + { + name: "role count mismatch: requested 2 but found 1", + roleIDs: []string{"role-a", "role-b"}, + projectIDs: nil, + roles: []role.Role{ + {ID: "role-a", Name: "role_a", Scopes: []string{schema.OrganizationNamespace}}, + }, + want: nil, + wantErr: true, + wantErrIs: userpat.ErrRoleNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := defaultConfig + if tt.config != nil { + cfg = *tt.config + } + + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + // Only mock repo.Create for success cases — validation errors fail before token creation + if !tt.wantErr { + repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). + Return(userpat.PAT{ID: "pat-1", OrgID: "org-1", CreatedAt: time.Now()}, nil) + } + + orgSvc := mocks.NewOrganizationService(t) + orgSvc.On("GetRaw", mock.Anything, mock.Anything). + Return(organization.Organization{ID: "org-1", Title: "Test Org"}, nil).Maybe() + auditRepo := mocks.NewAuditRecordRepository(t) + auditRepo.On("Create", mock.Anything, mock.Anything).Return(models.AuditRecord{}, nil).Maybe() + + // --- roleService: return the test's roles + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: tt.roleIDs}).Return(tt.roles, nil) + + // --- policyService: capture all Create calls + var captured []policy.Policy + policySvc := mocks.NewPolicyService(t) + policySvc.On("Create", mock.Anything, mock.AnythingOfType("policy.Policy")). + Run(func(args mock.Arguments) { + captured = append(captured, args.Get(1).(policy.Policy)) + }). + Return(policy.Policy{ID: "pol-gen"}, nil).Maybe() + + svc := userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "test-token", + RoleIDs: tt.roleIDs, + ProjectIDs: tt.projectIDs, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + + // --- assert error + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { + t.Errorf("error = %v, want %v", err, tt.wantErrIs) + } + if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %v, want containing %q", err, tt.wantErrMsg) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + // --- assert exact policy set + if tt.want == nil && len(captured) > 0 { + t.Errorf("expected no policies but got %d: %v", len(captured), captured) + return + } + if tt.want == nil { + return + } + + // build expected key set + wantKeys := make(map[string]bool, len(tt.want)) + for _, w := range tt.want { + grant := w.Grant + if grant == "" { + grant = "granted" + } + key := w.RoleID + "→" + w.ResourceType + ":" + w.ResourceID + "(" + grant + ")" + wantKeys[key] = true + } + + // build captured key set + gotKeys := make(map[string]bool, len(captured)) + for _, c := range captured { + key := policyKey(c) + gotKeys[key] = true + + // also verify common fields on every captured policy + if c.PrincipalID != "pat-1" { + t.Errorf("policy %s: PrincipalID = %q, want %q", key, c.PrincipalID, "pat-1") + } + if c.PrincipalType != schema.PATPrincipal { + t.Errorf("policy %s: PrincipalType = %q, want %q", key, c.PrincipalType, schema.PATPrincipal) + } + } + + if len(wantKeys) != len(gotKeys) { + t.Errorf("policy count: want %d, got %d\nwant: %v\ngot: %v", len(wantKeys), len(gotKeys), wantKeys, gotKeys) + return + } + + for key := range wantKeys { + if !gotKeys[key] { + t.Errorf("missing expected policy: %s", key) + } + } + for key := range gotKeys { + if !wantKeys[key] { + t.Errorf("unexpected policy created: %s", key) + } + } + }) + } +} + +func TestService_CreatePolicies_PolicyCreateFailure(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.PAT")). + Return(userpat.PAT{ID: "pat-1", OrgID: "org-1", CreatedAt: time.Now()}, nil) + + orgSvc := mocks.NewOrganizationService(t) + auditRepo := mocks.NewAuditRecordRepository(t) + + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, role.Filter{IDs: []string{"org-viewer-id", "org-billing-id"}}). + Return([]role.Role{ + {ID: "org-viewer-id", Name: "app_organization_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, + {ID: "org-billing-id", Name: "app_organization_billing", Permissions: []string{"app_organization_billingview"}, Scopes: []string{schema.OrganizationNamespace}}, + }, nil) + + // first policy Create succeeds, second fails + policySvc := mocks.NewPolicyService(t) + policySvc.On("Create", mock.Anything, mock.MatchedBy(func(p policy.Policy) bool { + return p.RoleID == "org-viewer-id" + })).Return(policy.Policy{ID: "pol-1"}, nil) + policySvc.On("Create", mock.Anything, mock.MatchedBy(func(p policy.Policy) bool { + return p.RoleID == "org-billing-id" + })).Return(policy.Policy{}, errors.New("spicedb unavailable")) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "fail-token", + RoleIDs: []string{"org-viewer-id", "org-billing-id"}, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err == nil { + t.Fatal("expected error when policyService.Create fails, got nil") + } + if !strings.Contains(err.Error(), "spicedb unavailable") { + t.Errorf("error = %v, want containing 'spicedb unavailable'", err) + } +} + func TestConfig_MaxExpiry(t *testing.T) { tests := []struct { name string diff --git a/internal/api/v1beta1connect/user_pat.go b/internal/api/v1beta1connect/user_pat.go index 9713cf436..35a54918f 100644 --- a/internal/api/v1beta1connect/user_pat.go +++ b/internal/api/v1beta1connect/user_pat.go @@ -36,7 +36,7 @@ func (h *ConnectHandler) CreateCurrentUserPAT(ctx context.Context, request *conn UserID: principal.User.ID, OrgID: request.Msg.GetOrgId(), Title: request.Msg.GetTitle(), - Roles: request.Msg.GetRoleIds(), + RoleIDs: request.Msg.GetRoleIds(), ProjectIDs: request.Msg.GetProjectIds(), ExpiresAt: request.Msg.GetExpiresAt().AsTime(), Metadata: metadata.BuildFromProto(request.Msg.GetMetadata()), @@ -53,6 +53,12 @@ func (h *ConnectHandler) CreateCurrentUserPAT(ctx context.Context, request *conn return nil, connect.NewError(connect.CodeAlreadyExists, err) case errors.Is(err, userpat.ErrLimitExceeded): return nil, connect.NewError(connect.CodeResourceExhausted, err) + case errors.Is(err, userpat.ErrRoleNotFound): + return nil, connect.NewError(connect.CodeInvalidArgument, userpat.ErrRoleNotFound) + case errors.Is(err, userpat.ErrDeniedRole): + return nil, connect.NewError(connect.CodeInvalidArgument, userpat.ErrDeniedRole) + case errors.Is(err, userpat.ErrUnsupportedScope): + return nil, connect.NewError(connect.CodeInvalidArgument, userpat.ErrUnsupportedScope) default: return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError) } diff --git a/internal/api/v1beta1connect/user_pat_test.go b/internal/api/v1beta1connect/user_pat_test.go index 53ff7940f..2ccd9191c 100644 --- a/internal/api/v1beta1connect/user_pat_test.go +++ b/internal/api/v1beta1connect/user_pat_test.go @@ -2,6 +2,7 @@ package v1beta1connect import ( "context" + "fmt" "testing" "time" @@ -39,7 +40,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { as.EXPECT().GetPrincipal(mock.Anything).Return(authenticate.Principal{}, errors.ErrUnauthenticated) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -57,7 +57,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { Type: schema.ServiceUserPrincipal, }, nil) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -77,7 +76,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { }, nil) ps.EXPECT().ValidateExpiry(mock.AnythingOfType("time.Time")).Return(userpat.ErrExpiryInPast) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -118,7 +116,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). Return(userpat.PAT{}, "", userpat.ErrDisabled) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -140,7 +137,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). Return(userpat.PAT{}, "", userpat.ErrConflict) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -162,7 +158,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). Return(userpat.PAT{}, "", userpat.ErrLimitExceeded) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -172,6 +167,69 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { want: nil, wantErr: connect.NewError(connect.CodeResourceExhausted, userpat.ErrLimitExceeded), }, + { + name: "should return invalid argument when role is not found", + setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { + as.EXPECT().GetPrincipal(mock.Anything).Return(authenticate.Principal{ + ID: testUserID, + Type: schema.UserPrincipal, + User: &user.User{ID: testUserID}, + }, nil) + ps.EXPECT().ValidateExpiry(mock.AnythingOfType("time.Time")).Return(nil) + ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). + Return(userpat.PAT{}, "", fmt.Errorf("fetching roles: %w", userpat.ErrRoleNotFound)) + }, + request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ + Title: "my-token", + OrgId: testOrgID, + RoleIds: []string{testRoleID}, + ExpiresAt: timestamppb.New(testTime), + }), + want: nil, + wantErr: connect.NewError(connect.CodeInvalidArgument, userpat.ErrRoleNotFound), + }, + { + name: "should return invalid argument when role is denied", + setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { + as.EXPECT().GetPrincipal(mock.Anything).Return(authenticate.Principal{ + ID: testUserID, + Type: schema.UserPrincipal, + User: &user.User{ID: testUserID}, + }, nil) + ps.EXPECT().ValidateExpiry(mock.AnythingOfType("time.Time")).Return(nil) + ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). + Return(userpat.PAT{}, "", fmt.Errorf("creating policies: %w", userpat.ErrDeniedRole)) + }, + request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ + Title: "my-token", + OrgId: testOrgID, + RoleIds: []string{testRoleID}, + ExpiresAt: timestamppb.New(testTime), + }), + want: nil, + wantErr: connect.NewError(connect.CodeInvalidArgument, userpat.ErrDeniedRole), + }, + { + name: "should return invalid argument when role scope is unsupported", + setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { + as.EXPECT().GetPrincipal(mock.Anything).Return(authenticate.Principal{ + ID: testUserID, + Type: schema.UserPrincipal, + User: &user.User{ID: testUserID}, + }, nil) + ps.EXPECT().ValidateExpiry(mock.AnythingOfType("time.Time")).Return(nil) + ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). + Return(userpat.PAT{}, "", fmt.Errorf("creating policies: %w", userpat.ErrUnsupportedScope)) + }, + request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ + Title: "my-token", + OrgId: testOrgID, + RoleIds: []string{testRoleID}, + ExpiresAt: timestamppb.New(testTime), + }), + want: nil, + wantErr: connect.NewError(connect.CodeInvalidArgument, userpat.ErrUnsupportedScope), + }, { name: "should return internal error for unknown service failure", setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { @@ -184,7 +242,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { ps.EXPECT().Create(mock.Anything, mock.AnythingOfType("userpat.CreateRequest")). Return(userpat.PAT{}, "", errors.New("unexpected error")) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -195,7 +252,7 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { wantErr: connect.NewError(connect.CodeInternal, ErrInternalServerError), }, { - name: "should create token successfully and return response", + name: "should create PAT successfully and return response", setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { as.EXPECT().GetPrincipal(mock.Anything).Return(authenticate.Principal{ ID: testUserID, @@ -207,7 +264,7 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { return req.UserID == testUserID && req.OrgID == testOrgID && req.Title == "my-token" && - len(req.Roles) == 1 && req.Roles[0] == testRoleID + len(req.RoleIDs) == 1 && req.RoleIDs[0] == testRoleID })).Return(userpat.PAT{ ID: "pat-1", UserID: testUserID, @@ -218,7 +275,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { UpdatedAt: testCreatedAt, }, "fpt_abc123", nil) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, @@ -240,7 +296,7 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { wantErr: nil, }, { - name: "should create token with metadata", + name: "should create PAT with metadata", setup: func(ps *mocks.UserPATService, as *mocks.AuthnService) { as.EXPECT().GetPrincipal(mock.Anything).Return(authenticate.Principal{ ID: testUserID, @@ -260,7 +316,6 @@ func TestHandler_CreateCurrentUserPAT(t *testing.T) { Metadata: metadata.Metadata{"env": "staging"}, }, "fpt_xyz789", nil) }, - request: connect.NewRequest(&frontierv1beta1.CreateCurrentUserPATRequest{ Title: "my-token", OrgId: testOrgID, diff --git a/internal/bootstrap/generator.go b/internal/bootstrap/generator.go index eb71c0efb..03dce65ce 100644 --- a/internal/bootstrap/generator.go +++ b/internal/bootstrap/generator.go @@ -143,6 +143,8 @@ func ApplyServiceDefinitionOverAZSchema(serviceDef *schema.ServiceDefinition, ex aznamespace.TupleToUserset("platform", "superuser"), aznamespace.TupleToUserset("granted", "app_organization_administer"), aznamespace.TupleToUserset("granted", fqPermissionName), + aznamespace.TupleToUserset(schema.PATGrantRelationName, "app_project_administer"), + aznamespace.TupleToUserset(schema.PATGrantRelationName, fqPermissionName), ), nil) if err != nil { return nil, err @@ -177,6 +179,7 @@ func ApplyServiceDefinitionOverAZSchema(serviceDef *schema.ServiceDefinition, ex nsRel, err := aznamespace.Relation(fqPermissionName, nil, aznamespace.AllowedPublicNamespace(schema.UserPrincipal), aznamespace.AllowedPublicNamespace(schema.ServiceUserPrincipal), + aznamespace.AllowedPublicNamespace(schema.PATPrincipal), ) if err != nil { return nil, err diff --git a/internal/bootstrap/schema/base_schema.zed b/internal/bootstrap/schema/base_schema.zed index 9472ed649..7e50b426d 100644 --- a/internal/bootstrap/schema/base_schema.zed +++ b/internal/bootstrap/schema/base_schema.zed @@ -7,6 +7,11 @@ definition app/serviceuser { permission manage = org->serviceusermanage + user } +definition app/pat { + relation user: app/user + relation org: app/organization +} + definition app/platform { relation admin: app/user | app/serviceuser relation member: app/user | app/serviceuser @@ -28,6 +33,7 @@ definition app/organization { // relations relation platform: app/platform relation granted: app/rolebinding + relation pat_granted: app/rolebinding relation member: app/user | app/group#member | app/serviceuser relation owner: app/user | app/serviceuser @@ -51,11 +57,11 @@ definition app/organization { permission billingview = platform->superuser + granted->app_organization_administer + granted->app_organization_billingview + owner // synthetic permissions - project - permission project_delete = platform->superuser + granted->app_organization_administer + granted->app_project_delete + owner - permission project_update = platform->superuser + granted->app_organization_administer + granted->app_project_update + owner - permission project_get = platform->superuser + granted->app_organization_administer + granted->app_project_get + owner - permission project_policymanage = platform->superuser + granted->app_organization_administer + granted->app_project_policymanage + owner - permission project_resourcelist = platform->superuser + granted->app_organization_administer + granted->app_project_resourcelist + owner + permission project_delete = platform->superuser + granted->app_organization_administer + granted->app_project_delete + pat_granted->app_project_administer + pat_granted->app_project_delete + owner + permission project_update = platform->superuser + granted->app_organization_administer + granted->app_project_update + pat_granted->app_project_administer + pat_granted->app_project_update + owner + permission project_get = platform->superuser + granted->app_organization_administer + granted->app_project_get + pat_granted->app_project_administer + pat_granted->app_project_get + owner + permission project_policymanage = platform->superuser + granted->app_organization_administer + granted->app_project_policymanage + pat_granted->app_project_administer + pat_granted->app_project_policymanage + owner + permission project_resourcelist = platform->superuser + granted->app_organization_administer + granted->app_project_resourcelist + pat_granted->app_project_administer + pat_granted->app_project_resourcelist + owner // synthetic permissions - group permission group_delete = platform->superuser + granted->app_organization_administer + granted->app_group_delete + owner @@ -91,7 +97,7 @@ definition app/project { } definition app/rolebinding { - relation bearer: app/user | app/group#member | app/serviceuser + relation bearer: app/user | app/group#member | app/serviceuser | app/pat relation role: app/role // org @@ -128,33 +134,33 @@ definition app/rolebinding { definition app/role { // org - relation app_organization_administer: app/user:* | app/serviceuser:* - relation app_organization_delete: app/user:* | app/serviceuser:* - relation app_organization_update: app/user:* | app/serviceuser:* - relation app_organization_get: app/user:* | app/serviceuser:* - relation app_organization_rolemanage: app/user:* | app/serviceuser:* - relation app_organization_policymanage: app/user:* | app/serviceuser:* - relation app_organization_projectlist: app/user:* | app/serviceuser:* - relation app_organization_grouplist: app/user:* | app/serviceuser:* - relation app_organization_invitationlist: app/user:* | app/serviceuser:* - relation app_organization_projectcreate: app/user:* | app/serviceuser:* - relation app_organization_groupcreate: app/user:* | app/serviceuser:* - relation app_organization_invitationcreate: app/user:* | app/serviceuser:* - relation app_organization_serviceusermanage: app/user:* | app/serviceuser:* - relation app_organization_billingmanage: app/user:* | app/serviceuser:* - relation app_organization_billingview: app/user:* | app/serviceuser:* - - // project - relation app_project_administer: app/user:* | app/serviceuser:* - relation app_project_delete: app/user:* | app/serviceuser:* - relation app_project_update: app/user:* | app/serviceuser:* - relation app_project_get: app/user:* | app/serviceuser:* - relation app_project_policymanage: app/user:* | app/serviceuser:* - relation app_project_resourcelist: app/user:* | app/serviceuser:* - - // group - relation app_group_administer: app/user:* | app/serviceuser:* - relation app_group_delete: app/user:* | app/serviceuser:* - relation app_group_update: app/user:* | app/serviceuser:* - relation app_group_get: app/user:* | app/serviceuser:* + relation app_organization_administer: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_delete: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_update: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_get: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_rolemanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_policymanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_projectlist: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_grouplist: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_invitationlist: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_projectcreate: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_groupcreate: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_invitationcreate: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_serviceusermanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_billingmanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_billingview: app/user:* | app/serviceuser:* | app/pat:* + + // project + relation app_project_administer: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_delete: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_update: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_get: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_policymanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_resourcelist: app/user:* | app/serviceuser:* | app/pat:* + + // group + relation app_group_administer: app/user:* | app/serviceuser:* | app/pat:* + relation app_group_delete: app/user:* | app/serviceuser:* | app/pat:* + relation app_group_update: app/user:* | app/serviceuser:* | app/pat:* + relation app_group_get: app/user:* | app/serviceuser:* | app/pat:* } \ No newline at end of file diff --git a/internal/bootstrap/schema/schema.go b/internal/bootstrap/schema/schema.go index 4d3e8b7b0..3beb2650d 100644 --- a/internal/bootstrap/schema/schema.go +++ b/internal/bootstrap/schema/schema.go @@ -256,7 +256,7 @@ func IsSystemNamespace(namespace string) bool { return namespace == OrganizationNamespace || namespace == ProjectNamespace || namespace == UserPrincipal || namespace == ServiceUserPrincipal || namespace == SuperUserPrincipal || namespace == GroupPrincipal || - namespace == PlatformNamespace + namespace == PATPrincipal || namespace == PlatformNamespace } // IsValidPermissionName checks if the provided name is a valid permission name diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index efa63e321..1c954be1d 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -14,6 +14,7 @@ import ( "github.com/raystack/frontier/core/namespace" "github.com/raystack/frontier/core/permission" + "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/role" "github.com/raystack/frontier/internal/bootstrap/schema" ) @@ -33,9 +34,15 @@ type PermissionService interface { type RoleService interface { Get(ctx context.Context, id string) (role.Role, error) + List(ctx context.Context, f role.Filter) ([]role.Role, error) Upsert(ctx context.Context, toCreate role.Role) (role.Role, error) } +type RelationService interface { + Create(ctx context.Context, rel relation.Relation) (relation.Relation, error) + Delete(ctx context.Context, rel relation.Relation) error +} + type UserService interface { Sudo(ctx context.Context, id string, relationName string) error } @@ -71,6 +78,8 @@ type Service struct { permissionService PermissionService authzEngine AuthzEngine userService UserService + relationService RelationService + patDeniedPerms map[string]struct{} planService PlanService planLocalRepo BillingPlanRepository @@ -84,6 +93,8 @@ func NewBootstrapService( actionService PermissionService, userService UserService, authzEngine AuthzEngine, + relationService RelationService, + patDeniedPerms map[string]struct{}, planService PlanService, planLocalRepo BillingPlanRepository, ) *Service { @@ -97,6 +108,8 @@ func NewBootstrapService( authzEngine: authzEngine, planService: planService, planLocalRepo: planLocalRepo, + relationService: relationService, + patDeniedPerms: patDeniedPerms, } } @@ -223,6 +236,11 @@ func (s Service) MigrateRoles(ctx context.Context) error { return err } } + + // backfill PAT wildcard tuples for all existing roles + if err = s.migratePATRelations(ctx); err != nil { + return fmt.Errorf("migrating PAT role relations: %w", err) + } return nil } @@ -249,6 +267,42 @@ func (s Service) createRole(ctx context.Context, orgID string, defRole schema.Ro return nil } +// migratePATRelations ensures app/pat:* wildcard tuples are in sync with the current +// denied_permissions config for all existing roles. Runs on every bootstrap: +// - Creates app/pat:* for allowed permissions (idempotent via SpiceDB Touch) +// - Deletes app/pat:* for denied permissions (removes stale wildcards if config changed) +func (s Service) migratePATRelations(ctx context.Context) error { + roles, err := s.roleService.List(ctx, role.Filter{}) + if err != nil { + return fmt.Errorf("listing roles for PAT migration: %w", err) + } + for _, r := range roles { + for _, perm := range r.Permissions { + rel := relation.Relation{ + Object: relation.Object{ + ID: r.ID, + Namespace: schema.RoleNamespace, + }, + Subject: relation.Subject{ + ID: "*", + Namespace: schema.PATPrincipal, + }, + RelationName: perm, + } + if _, denied := s.patDeniedPerms[perm]; denied { + if err := s.relationService.Delete(ctx, rel); err != nil { + return fmt.Errorf("deleting PAT wildcard for role %s denied permission %s: %w", r.Name, perm, err) + } + continue + } + if _, err := s.relationService.Create(ctx, rel); err != nil { + return fmt.Errorf("creating PAT wildcard for role %s permission %s: %w", r.Name, perm, err) + } + } + } + return nil +} + func (s Service) migrateServiceDefinitionToDB(ctx context.Context, appServiceDefinition schema.ServiceDefinition) error { // iterate over definition resources for _, perm := range appServiceDefinition.Permissions { diff --git a/internal/bootstrap/service_test.go b/internal/bootstrap/service_test.go new file mode 100644 index 000000000..b5fc59d55 --- /dev/null +++ b/internal/bootstrap/service_test.go @@ -0,0 +1,237 @@ +package bootstrap + +import ( + "context" + "errors" + "testing" + + "github.com/raystack/frontier/core/relation" + "github.com/raystack/frontier/core/role" + "github.com/raystack/frontier/internal/bootstrap/schema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// mockRoleService implements bootstrap.RoleService +type mockRoleService struct { + mock.Mock +} + +func (m *mockRoleService) Get(ctx context.Context, id string) (role.Role, error) { + args := m.Called(ctx, id) + return args.Get(0).(role.Role), args.Error(1) +} + +func (m *mockRoleService) List(ctx context.Context, f role.Filter) ([]role.Role, error) { + args := m.Called(ctx, f) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]role.Role), args.Error(1) +} + +func (m *mockRoleService) Upsert(ctx context.Context, toCreate role.Role) (role.Role, error) { + args := m.Called(ctx, toCreate) + return args.Get(0).(role.Role), args.Error(1) +} + +// mockRelationService implements bootstrap.RelationService +type mockRelationService struct { + mock.Mock +} + +func (m *mockRelationService) Create(ctx context.Context, rel relation.Relation) (relation.Relation, error) { + args := m.Called(ctx, rel) + return args.Get(0).(relation.Relation), args.Error(1) +} + +func (m *mockRelationService) Delete(ctx context.Context, rel relation.Relation) error { + args := m.Called(ctx, rel) + return args.Error(0) +} + +func Test_migratePATRelations(t *testing.T) { + t.Run("should create PAT wildcards for allowed permissions", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{ + {ID: "role-1", Name: "viewer", Permissions: []string{"app_organization_get"}}, + }, nil) + + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_get", + }).Return(relation.Relation{}, nil).Once() + + svc := Service{roleService: roleSvc, relationService: relSvc} + err := svc.migratePATRelations(context.Background()) + + assert.NoError(t, err) + relSvc.AssertExpectations(t) + }) + + t.Run("should delete PAT wildcards for denied permissions", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{ + {ID: "role-1", Name: "admin", Permissions: []string{"app_organization_administer"}}, + }, nil) + + relSvc.On("Delete", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_administer", + }).Return(nil).Once() + + svc := Service{ + roleService: roleSvc, + relationService: relSvc, + patDeniedPerms: map[string]struct{}{"app_organization_administer": {}}, + } + err := svc.migratePATRelations(context.Background()) + + assert.NoError(t, err) + relSvc.AssertExpectations(t) + }) + + t.Run("should handle mixed allowed and denied permissions across roles", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{ + {ID: "role-1", Name: "manager", Permissions: []string{ + "app_organization_administer", // denied + "app_organization_get", // allowed + "app_organization_update", // allowed + }}, + {ID: "role-2", Name: "viewer", Permissions: []string{ + "app_organization_get", // allowed + }}, + }, nil) + + // role-1: delete denied, create allowed + relSvc.On("Delete", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_administer", + }).Return(nil).Once() + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_get", + }).Return(relation.Relation{}, nil).Once() + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_update", + }).Return(relation.Relation{}, nil).Once() + + // role-2: create allowed + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-2", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_get", + }).Return(relation.Relation{}, nil).Once() + + svc := Service{ + roleService: roleSvc, + relationService: relSvc, + patDeniedPerms: map[string]struct{}{"app_organization_administer": {}}, + } + err := svc.migratePATRelations(context.Background()) + + assert.NoError(t, err) + relSvc.AssertExpectations(t) + }) + + t.Run("should be a no-op for empty roles list", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{}, nil) + + svc := Service{roleService: roleSvc, relationService: relSvc} + err := svc.migratePATRelations(context.Background()) + + assert.NoError(t, err) + relSvc.AssertNotCalled(t, "Create") + relSvc.AssertNotCalled(t, "Delete") + }) + + t.Run("should return error when listing roles fails", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return(nil, errors.New("db error")) + + svc := Service{roleService: roleSvc, relationService: relSvc} + err := svc.migratePATRelations(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "listing roles for PAT migration") + }) + + t.Run("should return error when creating relation fails", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{ + {ID: "role-1", Name: "viewer", Permissions: []string{"app_organization_get"}}, + }, nil) + + relSvc.On("Create", mock.Anything, mock.Anything). + Return(relation.Relation{}, errors.New("spicedb unavailable")).Once() + + svc := Service{roleService: roleSvc, relationService: relSvc} + err := svc.migratePATRelations(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "creating PAT wildcard for role viewer") + }) + + t.Run("should return error when deleting denied relation fails", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{ + {ID: "role-1", Name: "admin", Permissions: []string{"app_organization_administer"}}, + }, nil) + + relSvc.On("Delete", mock.Anything, mock.Anything). + Return(errors.New("spicedb unavailable")).Once() + + svc := Service{ + roleService: roleSvc, + relationService: relSvc, + patDeniedPerms: map[string]struct{}{"app_organization_administer": {}}, + } + err := svc.migratePATRelations(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "deleting PAT wildcard for role admin denied permission") + }) + + t.Run("should handle nil denied permissions as all allowed", func(t *testing.T) { + roleSvc := new(mockRoleService) + relSvc := new(mockRelationService) + + roleSvc.On("List", mock.Anything, role.Filter{}).Return([]role.Role{ + {ID: "role-1", Name: "admin", Permissions: []string{"app_organization_administer"}}, + }, nil) + + relSvc.On("Create", mock.Anything, relation.Relation{ + Object: relation.Object{ID: "role-1", Namespace: schema.RoleNamespace}, + Subject: relation.Subject{ID: "*", Namespace: schema.PATPrincipal}, + RelationName: "app_organization_administer", + }).Return(relation.Relation{}, nil).Once() + + svc := Service{roleService: roleSvc, relationService: relSvc} // nil patDeniedPerms = all allowed + err := svc.migratePATRelations(context.Background()) + + assert.NoError(t, err) + relSvc.AssertNotCalled(t, "Delete") + }) +} diff --git a/internal/bootstrap/testdata/compiled_schema.zed b/internal/bootstrap/testdata/compiled_schema.zed index bf1a8f3ec..8a01e09a1 100644 --- a/internal/bootstrap/testdata/compiled_schema.zed +++ b/internal/bootstrap/testdata/compiled_schema.zed @@ -26,12 +26,12 @@ definition app/invitation { definition app/organization { permission billingmanage = platform->superuser + granted->app_organization_administer + granted->app_organization_billingmanage + owner permission billingview = platform->superuser + granted->app_organization_administer + granted->app_organization_billingview + owner - permission compute_order_create = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_create - permission compute_order_delete = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_delete - permission compute_order_get = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_get - permission compute_order_update = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_update - permission compute_receipt_get = owner + platform->superuser + granted->app_organization_administer + granted->compute_receipt_get - permission compute_receipt_update = owner + platform->superuser + granted->app_organization_administer + granted->compute_receipt_update + permission compute_order_create = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_create + pat_granted->app_project_administer + pat_granted->compute_order_create + permission compute_order_delete = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_delete + pat_granted->app_project_administer + pat_granted->compute_order_delete + permission compute_order_get = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_get + pat_granted->app_project_administer + pat_granted->compute_order_get + permission compute_order_update = owner + platform->superuser + granted->app_organization_administer + granted->compute_order_update + pat_granted->app_project_administer + pat_granted->compute_order_update + permission compute_receipt_get = owner + platform->superuser + granted->app_organization_administer + granted->compute_receipt_get + pat_granted->app_project_administer + pat_granted->compute_receipt_get + permission compute_receipt_update = owner + platform->superuser + granted->app_organization_administer + granted->compute_receipt_update + pat_granted->app_project_administer + pat_granted->compute_receipt_update permission delete = platform->superuser + granted->app_organization_administer + granted->app_organization_delete + owner permission get = platform->superuser + granted->app_organization_administer + granted->app_organization_get + granted->app_organization_update + owner + member relation granted: app/rolebinding @@ -50,17 +50,18 @@ definition app/organization { // org permission membership = member + owner relation owner: app/user | app/serviceuser + relation pat_granted: app/rolebinding // relations relation platform: app/platform permission policymanage = platform->superuser + granted->app_organization_administer + granted->app_organization_policymanage + owner // synthetic permissions - project - permission project_delete = platform->superuser + granted->app_organization_administer + granted->app_project_delete + owner - permission project_get = platform->superuser + granted->app_organization_administer + granted->app_project_get + owner - permission project_policymanage = platform->superuser + granted->app_organization_administer + granted->app_project_policymanage + owner - permission project_resourcelist = platform->superuser + granted->app_organization_administer + granted->app_project_resourcelist + owner - permission project_update = platform->superuser + granted->app_organization_administer + granted->app_project_update + owner + permission project_delete = platform->superuser + granted->app_organization_administer + granted->app_project_delete + pat_granted->app_project_administer + pat_granted->app_project_delete + owner + permission project_get = platform->superuser + granted->app_organization_administer + granted->app_project_get + pat_granted->app_project_administer + pat_granted->app_project_get + owner + permission project_policymanage = platform->superuser + granted->app_organization_administer + granted->app_project_policymanage + pat_granted->app_project_administer + pat_granted->app_project_policymanage + owner + permission project_resourcelist = platform->superuser + granted->app_organization_administer + granted->app_project_resourcelist + pat_granted->app_project_administer + pat_granted->app_project_resourcelist + owner + permission project_update = platform->superuser + granted->app_organization_administer + granted->app_project_update + pat_granted->app_project_administer + pat_granted->app_project_update + owner permission projectcreate = platform->superuser + granted->app_organization_administer + granted->app_organization_projectcreate + owner permission projectlist = platform->superuser + granted->app_organization_administer + granted->app_organization_projectlist + owner permission rolemanage = platform->superuser + granted->app_organization_administer + granted->app_organization_rolemanage + owner @@ -68,6 +69,11 @@ definition app/organization { permission update = platform->superuser + granted->app_organization_administer + granted->app_organization_update + owner } +definition app/pat { + relation org: app/organization + relation user: app/user +} + definition app/platform { relation admin: app/user | app/serviceuser permission check = admin + member @@ -97,41 +103,41 @@ definition app/project { definition app/role { // group - relation app_group_administer: app/user:* | app/serviceuser:* - relation app_group_delete: app/user:* | app/serviceuser:* - relation app_group_get: app/user:* | app/serviceuser:* - relation app_group_update: app/user:* | app/serviceuser:* + relation app_group_administer: app/user:* | app/serviceuser:* | app/pat:* + relation app_group_delete: app/user:* | app/serviceuser:* | app/pat:* + relation app_group_get: app/user:* | app/serviceuser:* | app/pat:* + relation app_group_update: app/user:* | app/serviceuser:* | app/pat:* // org - relation app_organization_administer: app/user:* | app/serviceuser:* - relation app_organization_billingmanage: app/user:* | app/serviceuser:* - relation app_organization_billingview: app/user:* | app/serviceuser:* - relation app_organization_delete: app/user:* | app/serviceuser:* - relation app_organization_get: app/user:* | app/serviceuser:* - relation app_organization_groupcreate: app/user:* | app/serviceuser:* - relation app_organization_grouplist: app/user:* | app/serviceuser:* - relation app_organization_invitationcreate: app/user:* | app/serviceuser:* - relation app_organization_invitationlist: app/user:* | app/serviceuser:* - relation app_organization_policymanage: app/user:* | app/serviceuser:* - relation app_organization_projectcreate: app/user:* | app/serviceuser:* - relation app_organization_projectlist: app/user:* | app/serviceuser:* - relation app_organization_rolemanage: app/user:* | app/serviceuser:* - relation app_organization_serviceusermanage: app/user:* | app/serviceuser:* - relation app_organization_update: app/user:* | app/serviceuser:* + relation app_organization_administer: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_billingmanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_billingview: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_delete: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_get: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_groupcreate: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_grouplist: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_invitationcreate: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_invitationlist: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_policymanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_projectcreate: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_projectlist: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_rolemanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_serviceusermanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_organization_update: app/user:* | app/serviceuser:* | app/pat:* // project - relation app_project_administer: app/user:* | app/serviceuser:* - relation app_project_delete: app/user:* | app/serviceuser:* - relation app_project_get: app/user:* | app/serviceuser:* - relation app_project_policymanage: app/user:* | app/serviceuser:* - relation app_project_resourcelist: app/user:* | app/serviceuser:* - relation app_project_update: app/user:* | app/serviceuser:* - relation compute_order_create: app/user:* | app/serviceuser:* - relation compute_order_delete: app/user:* | app/serviceuser:* - relation compute_order_get: app/user:* | app/serviceuser:* - relation compute_order_update: app/user:* | app/serviceuser:* - relation compute_receipt_get: app/user:* | app/serviceuser:* - relation compute_receipt_update: app/user:* | app/serviceuser:* + relation app_project_administer: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_delete: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_get: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_policymanage: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_resourcelist: app/user:* | app/serviceuser:* | app/pat:* + relation app_project_update: app/user:* | app/serviceuser:* | app/pat:* + relation compute_order_create: app/user:* | app/serviceuser:* | app/pat:* + relation compute_order_delete: app/user:* | app/serviceuser:* | app/pat:* + relation compute_order_get: app/user:* | app/serviceuser:* | app/pat:* + relation compute_order_update: app/user:* | app/serviceuser:* | app/pat:* + relation compute_receipt_get: app/user:* | app/serviceuser:* | app/pat:* + relation compute_receipt_update: app/user:* | app/serviceuser:* | app/pat:* } definition app/rolebinding { @@ -165,7 +171,7 @@ definition app/rolebinding { permission app_project_policymanage = bearer & role->app_project_policymanage permission app_project_resourcelist = bearer & role->app_project_resourcelist permission app_project_update = bearer & role->app_project_update - relation bearer: app/user | app/group#member | app/serviceuser + relation bearer: app/user | app/group#member | app/serviceuser | app/pat permission compute_order_create = bearer & role->compute_order_create permission compute_order_delete = bearer & role->compute_order_delete permission compute_order_get = bearer & role->compute_order_get diff --git a/internal/store/postgres/migrations/20260226100000_add_grant_relation_to_policies.down.sql b/internal/store/postgres/migrations/20260226100000_add_grant_relation_to_policies.down.sql new file mode 100644 index 000000000..57cf915bb --- /dev/null +++ b/internal/store/postgres/migrations/20260226100000_add_grant_relation_to_policies.down.sql @@ -0,0 +1 @@ +ALTER TABLE policies DROP COLUMN IF EXISTS grant_relation; \ No newline at end of file diff --git a/internal/store/postgres/migrations/20260226100000_add_grant_relation_to_policies.up.sql b/internal/store/postgres/migrations/20260226100000_add_grant_relation_to_policies.up.sql new file mode 100644 index 000000000..553055b36 --- /dev/null +++ b/internal/store/postgres/migrations/20260226100000_add_grant_relation_to_policies.up.sql @@ -0,0 +1 @@ +ALTER TABLE policies ADD COLUMN IF NOT EXISTS grant_relation TEXT NOT NULL DEFAULT 'granted'; \ No newline at end of file diff --git a/internal/store/postgres/policy.go b/internal/store/postgres/policy.go index 2f8171a01..051376b96 100644 --- a/internal/store/postgres/policy.go +++ b/internal/store/postgres/policy.go @@ -15,6 +15,7 @@ type Policy struct { ResourceType string `db:"resource_type"` PrincipalID string `db:"principal_id"` PrincipalType string `db:"principal_type"` + GrantRelation string `db:"grant_relation"` Metadata []byte `db:"metadata"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -27,6 +28,7 @@ type PolicyCols struct { ResourceID string `db:"resource_id"` PrincipalID string `db:"principal_id"` PrincipalType string `db:"principal_type"` + GrantRelation string `db:"grant_relation"` Metadata []byte `db:"metadata"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -47,6 +49,7 @@ func (from Policy) transformToPolicy() (policy.Policy, error) { ResourceType: from.ResourceType, PrincipalID: from.PrincipalID, PrincipalType: from.PrincipalType, + GrantRelation: from.GrantRelation, Metadata: unmarshalledMetadata, CreatedAt: from.CreatedAt, UpdatedAt: from.UpdatedAt, diff --git a/internal/store/postgres/policy_repository.go b/internal/store/postgres/policy_repository.go index 47b4778c9..477dc52db 100644 --- a/internal/store/postgres/policy_repository.go +++ b/internal/store/postgres/policy_repository.go @@ -36,6 +36,7 @@ func (r PolicyRepository) buildListQuery() *goqu.SelectDataset { "p.principal_id", "p.principal_type", "p.role_id", + "p.grant_relation", ).From(goqu.T(TABLE_POLICIES).As("p")) } @@ -199,10 +200,12 @@ func (r PolicyRepository) Upsert(ctx context.Context, pol policy.Policy) (policy "resource_id": pol.ResourceID, "principal_id": pol.PrincipalID, "principal_type": pol.PrincipalType, + "grant_relation": pol.GrantRelation, "metadata": marshaledMetadata, }).OnConflict(goqu.DoUpdate("role_id, resource_id, resource_type, principal_id, principal_type", goqu.Record{ - "metadata": marshaledMetadata, - "updated_at": goqu.L("now()"), + "grant_relation": pol.GrantRelation, + "metadata": marshaledMetadata, + "updated_at": goqu.L("now()"), })).Returning(&PolicyCols{}).ToSQL() if err != nil { return policy.Policy{}, fmt.Errorf("%w: %w", queryErr, err) @@ -268,8 +271,9 @@ func (r PolicyRepository) Update(ctx context.Context, toUpdate policy.Policy) (s query, params, err := dialect.Update(TABLE_POLICIES).Set( goqu.Record{ - "metadata": marshaledMetadata, - "updated_at": goqu.L("now()"), + "grant_relation": toUpdate.GrantRelation, + "metadata": marshaledMetadata, + "updated_at": goqu.L("now()"), }).Where(goqu.Ex{ "id": toUpdate.ID, }).Returning("id", "updated_at").ToSQL() @@ -476,6 +480,7 @@ func (r PolicyRepository) buildPolicyAuditRecord(ctx context.Context, tx *sqlx.T "role_id": pol.RoleID, "principal_id": pol.PrincipalID, "principal_type": pol.PrincipalType, + "grant_relation": pol.GrantRelation, } for k, v := range additionalMetadata { targetMetadata[k] = v