diff --git a/cmd/serve.go b/cmd/serve.go index b543857f5..3020de5e8 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -420,8 +420,6 @@ func buildAPIDependencies( organizationService := organization.NewService(organizationRepository, relationService, userService, authnService, policyService, preferenceService, auditRecordRepository, roleService) - userPATService := userpat.NewService(logger, userPATRepo, cfg.App.PAT, organizationService, roleService, policyService, auditRecordRepository) - auditRecordService := auditrecord.NewService(auditRecordRepository, userService, serviceUserService, sessionService) orgKycRepository := postgres.NewOrgKycRepository(dbc) @@ -466,6 +464,8 @@ func buildAPIDependencies( projectService := project.NewService(projectRepository, relationService, userService, policyService, authnService, serviceUserService, groupService) + userPATService := userpat.NewService(logger, userPATRepo, cfg.App.PAT, organizationService, roleService, policyService, projectService, auditRecordRepository) + orgPATsRepository := postgres.NewOrgPATsRepository(dbc) orgPATsService := orgpats.NewService(orgPATsRepository, projectService) diff --git a/core/userpat/errors/errors.go b/core/userpat/errors/errors.go index 8af9be8e2..b403dd7c4 100644 --- a/core/userpat/errors/errors.go +++ b/core/userpat/errors/errors.go @@ -16,4 +16,5 @@ var ( ErrUnsupportedScope = errors.New("role scope is not supported for personal access tokens") ErrScopeMismatch = errors.New("role does not support the specified scope") ErrRoleNotFound = errors.New("one or more requested roles do not exist") + ErrProjectForbidden = errors.New("user does not have access to one or more specified projects") ) diff --git a/core/userpat/mocks/project_service.go b/core/userpat/mocks/project_service.go new file mode 100644 index 000000000..b9643e796 --- /dev/null +++ b/core/userpat/mocks/project_service.go @@ -0,0 +1,100 @@ +// 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" + + project "github.com/raystack/frontier/core/project" +) + +// ProjectService is an autogenerated mock type for the ProjectService type +type ProjectService struct { + mock.Mock +} + +type ProjectService_Expecter struct { + mock *mock.Mock +} + +func (_m *ProjectService) EXPECT() *ProjectService_Expecter { + return &ProjectService_Expecter{mock: &_m.Mock} +} + +// ListByUser provides a mock function with given fields: ctx, principal, flt +func (_m *ProjectService) ListByUser(ctx context.Context, principal authenticate.Principal, flt project.Filter) ([]project.Project, error) { + ret := _m.Called(ctx, principal, flt) + + if len(ret) == 0 { + panic("no return value specified for ListByUser") + } + + var r0 []project.Project + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, authenticate.Principal, project.Filter) ([]project.Project, error)); ok { + return rf(ctx, principal, flt) + } + if rf, ok := ret.Get(0).(func(context.Context, authenticate.Principal, project.Filter) []project.Project); ok { + r0 = rf(ctx, principal, flt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]project.Project) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, authenticate.Principal, project.Filter) error); ok { + r1 = rf(ctx, principal, flt) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ProjectService_ListByUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListByUser' +type ProjectService_ListByUser_Call struct { + *mock.Call +} + +// ListByUser is a helper method to define mock.On call +// - ctx context.Context +// - principal authenticate.Principal +// - flt project.Filter +func (_e *ProjectService_Expecter) ListByUser(ctx interface{}, principal interface{}, flt interface{}) *ProjectService_ListByUser_Call { + return &ProjectService_ListByUser_Call{Call: _e.mock.On("ListByUser", ctx, principal, flt)} +} + +func (_c *ProjectService_ListByUser_Call) Run(run func(ctx context.Context, principal authenticate.Principal, flt project.Filter)) *ProjectService_ListByUser_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(authenticate.Principal), args[2].(project.Filter)) + }) + return _c +} + +func (_c *ProjectService_ListByUser_Call) Return(_a0 []project.Project, _a1 error) *ProjectService_ListByUser_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ProjectService_ListByUser_Call) RunAndReturn(run func(context.Context, authenticate.Principal, project.Filter) ([]project.Project, error)) *ProjectService_ListByUser_Call { + _c.Call.Return(run) + return _c +} + +// NewProjectService creates a new instance of ProjectService. 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 NewProjectService(t interface { + mock.TestingT + Cleanup(func()) +}) *ProjectService { + mock := &ProjectService{} + 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 60a7b4dbb..dcf9d48fa 100644 --- a/core/userpat/service.go +++ b/core/userpat/service.go @@ -12,8 +12,10 @@ import ( "time" "github.com/raystack/frontier/core/auditrecord/models" + "github.com/raystack/frontier/core/authenticate" "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/core/policy" + "github.com/raystack/frontier/core/project" "github.com/raystack/frontier/core/role" paterrors "github.com/raystack/frontier/core/userpat/errors" patmodels "github.com/raystack/frontier/core/userpat/models" @@ -40,6 +42,10 @@ type PolicyService interface { Delete(ctx context.Context, id string) error } +type ProjectService interface { + ListByUser(ctx context.Context, principal authenticate.Principal, flt project.Filter) ([]project.Project, error) +} + type AuditRecordRepository interface { Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) } @@ -51,12 +57,13 @@ type Service struct { orgService OrganizationService roleService RoleService policyService PolicyService + projectService ProjectService auditRecordRepository AuditRecordRepository deniedPerms map[string]struct{} } func NewService(logger log.Logger, repo Repository, config Config, orgService OrganizationService, - roleService RoleService, policyService PolicyService, auditRecordRepository AuditRecordRepository) *Service { + roleService RoleService, policyService PolicyService, projectService ProjectService, auditRecordRepository AuditRecordRepository) *Service { return &Service{ repo: repo, config: config, @@ -64,6 +71,7 @@ func NewService(logger log.Logger, repo Repository, config Config, orgService Or orgService: orgService, roleService: roleService, policyService: policyService, + projectService: projectService, auditRecordRepository: auditRecordRepository, deniedPerms: config.DeniedPermissionsSet(), } @@ -221,6 +229,9 @@ func (s *Service) Update(ctx context.Context, toUpdate patmodels.PAT) (patmodels if err := s.validateScopes(ctx, toUpdate.Scopes); err != nil { return patmodels.PAT{}, err } + if err := s.validateProjectAccess(ctx, toUpdate.UserID, existing.OrgID, toUpdate.Scopes); err != nil { + return patmodels.PAT{}, err + } oldTitle, oldScopes, err := s.captureOldScope(ctx, &existing) if err != nil { @@ -339,6 +350,9 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (patmodels.PAT, if err := s.validateScopes(ctx, req.Scopes); err != nil { return patmodels.PAT{}, "", err } + if err := s.validateProjectAccess(ctx, req.UserID, req.OrgID, req.Scopes); err != nil { + return patmodels.PAT{}, "", err + } patValue, secretHash, err := s.generatePAT() if err != nil { @@ -468,6 +482,45 @@ func (s *Service) validateScopes(ctx context.Context, scopes []patmodels.PATScop return nil } +// validateProjectAccess checks that the user has access to all project resource IDs in the scopes. +func (s *Service) validateProjectAccess(ctx context.Context, userID, orgID string, scopes []patmodels.PATScope) error { + var projectIDs []string + for _, sc := range scopes { + if sc.ResourceType == schema.ProjectNamespace { + projectIDs = append(projectIDs, sc.ResourceIDs...) + } + } + if len(projectIDs) == 0 { + return nil + } + + principal := authenticate.Principal{ + ID: userID, + Type: schema.UserPrincipal, + } + userProjects, err := s.projectService.ListByUser(ctx, principal, project.Filter{OrgID: orgID}) + if err != nil { + return fmt.Errorf("listing user projects: %w", err) + } + + userProjectSet := make(map[string]bool, len(userProjects)) + for _, p := range userProjects { + userProjectSet[p.ID] = true + } + + var forbidden []string + for _, id := range projectIDs { + if !userProjectSet[id] { + forbidden = append(forbidden, id) + } + } + if len(forbidden) > 0 { + s.logger.Error("user does not have access to projects", "project_ids", forbidden) + return paterrors.ErrProjectForbidden + } + return nil +} + // createPolicies creates SpiceDB policies from pre-validated scopes. func (s *Service) createPolicies(ctx context.Context, patID, orgID string, scopes []patmodels.PATScope) error { for _, sc := range scopes { diff --git a/core/userpat/service_test.go b/core/userpat/service_test.go index 58216f112..155ba6105 100644 --- a/core/userpat/service_test.go +++ b/core/userpat/service_test.go @@ -13,6 +13,7 @@ import ( auditmodels "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/organization" "github.com/raystack/frontier/core/policy" + "github.com/raystack/frontier/core/project" "github.com/raystack/frontier/core/role" "github.com/raystack/frontier/core/userpat" paterrors "github.com/raystack/frontier/core/userpat/errors" @@ -31,7 +32,7 @@ var defaultConfig = userpat.Config{ MaxLifetime: "8760h", } -func newSuccessMocks(t *testing.T) (*mocks.OrganizationService, *mocks.RoleService, *mocks.PolicyService, *mocks.AuditRecordRepository) { +func newSuccessMocks(t *testing.T) (*mocks.OrganizationService, *mocks.RoleService, *mocks.PolicyService, *mocks.ProjectService, *mocks.AuditRecordRepository) { t.Helper() orgSvc := mocks.NewOrganizationService(t) orgSvc.On("GetRaw", mock.Anything, mock.Anything). @@ -54,10 +55,11 @@ func newSuccessMocks(t *testing.T) (*mocks.OrganizationService, *mocks.RoleServi Return(policy.Policy{}, nil).Maybe() policySvc.On("List", mock.Anything, mock.Anything). Return([]policy.Policy{}, nil).Maybe() + projSvc := mocks.NewProjectService(t) auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, nil).Maybe() - return orgSvc, roleSvc, policySvc, auditRepo + return orgSvc, roleSvc, policySvc, projSvc, auditRepo } func TestService_Create(t *testing.T) { @@ -89,7 +91,7 @@ func TestService_Create(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) return userpat.NewService(log.NewNoop(), repo, userpat.Config{ Enabled: false, - }, orgSvc, nil, nil, auditRepo) + }, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -109,7 +111,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, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -129,7 +131,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, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -149,7 +151,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, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -175,7 +177,7 @@ func TestService_Create(t *testing.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) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -201,7 +203,7 @@ func TestService_Create(t *testing.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) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -249,8 +251,8 @@ func TestService_Create(t *testing.T) { ExpiresAt: futureExpiry, CreatedAt: time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC), }, nil) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, validateFunc: func(t *testing.T, got models.PAT, tokenValue string) { t.Helper() @@ -281,8 +283,8 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, validateFunc: func(t *testing.T, got models.PAT, tokenValue string) { t.Helper() @@ -318,8 +320,8 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, validateFunc: func(t *testing.T, got models.PAT, tokenValue string) { t.Helper() @@ -354,13 +356,13 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, roleSvc, policySvc, 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, roleSvc, policySvc, auditRepo) + }, orgSvc, roleSvc, policySvc, nil, auditRepo) }, validateFunc: func(t *testing.T, got models.PAT, tokenValue string) { t.Helper() @@ -385,8 +387,8 @@ func TestService_Create(t *testing.T) { Return(int64(49), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, }, { @@ -405,8 +407,8 @@ func TestService_Create(t *testing.T) { Return(int64(0), nil) repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, }, } @@ -440,8 +442,8 @@ func TestService_Create_UniquePATs(t *testing.T) { repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil).Times(2) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) req := userpat.CreateRequest{ UserID: "user-1", @@ -475,8 +477,8 @@ func TestService_Create_HashVerification(t *testing.T) { }). Return(models.PAT{ID: "pat-1", OrgID: "org-1"}, nil) - orgSvc, roleSvc, policySvc, auditRepo := newSuccessMocks(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, tokenValue, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", @@ -538,7 +540,7 @@ func TestService_CreatePolicies_OrgScopedRole(t *testing.T) { }).Return(policy.Policy{ID: "pol-1"}, nil) policySvc.On("List", mock.Anything, mock.Anything).Return([]policy.Policy{}, nil).Maybe() - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -585,7 +587,7 @@ func TestService_CreatePolicies_ProjectScopedAllProjects(t *testing.T) { }).Return(policy.Policy{ID: "pol-1"}, nil) policySvc.On("List", mock.Anything, mock.Anything).Return([]policy.Policy{}, nil).Maybe() - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -638,7 +640,12 @@ func TestService_CreatePolicies_ProjectScopedSpecificProjects(t *testing.T) { }).Return(policy.Policy{ID: "pol-2"}, nil) policySvc.On("List", mock.Anything, mock.Anything).Return([]policy.Policy{}, nil).Maybe() - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + projSvc := mocks.NewProjectService(t) + projSvc.On("ListByUser", mock.Anything, mock.Anything, mock.Anything).Return([]project.Project{ + {ID: "proj-a"}, {ID: "proj-b"}, + }, nil).Maybe() + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, projSvc, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -672,7 +679,7 @@ func TestService_CreatePolicies_DeniedPermission(t *testing.T) { cfg := defaultConfig cfg.DeniedPermissions = []string{"app_organization_administer"} - svc := userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -702,7 +709,7 @@ func TestService_CreatePolicies_RoleFetchError(t *testing.T) { policySvc := mocks.NewPolicyService(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -736,7 +743,7 @@ func TestService_CreatePolicies_UnsupportedScope(t *testing.T) { policySvc := mocks.NewPolicyService(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -770,7 +777,7 @@ func TestService_CreatePolicies_MissingRoleID(t *testing.T) { policySvc := mocks.NewPolicyService(t) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -798,8 +805,8 @@ func TestService_CreatePolicies_NoRoles(t *testing.T) { repo.EXPECT().Create(mock.Anything, mock.AnythingOfType("models.PAT")). Return(models.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) + orgSvc, roleSvc, policySvc, _, auditRepo := newSuccessMocks(t) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", @@ -1157,7 +1164,12 @@ func TestService_CreatePolicies_ScopeMatrix(t *testing.T) { Return(policy.Policy{ID: "pol-gen"}, nil).Maybe() policySvc.On("List", mock.Anything, mock.Anything).Return([]policy.Policy{}, nil).Maybe() - svc := userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, policySvc, auditRepo) + projSvc := mocks.NewProjectService(t) + projSvc.On("ListByUser", mock.Anything, mock.Anything, mock.Anything).Return([]project.Project{ + {ID: "proj-1"}, {ID: "proj-2"}, {ID: "proj-3"}, {ID: "proj-a"}, {ID: "proj-b"}, + }, nil).Maybe() + + svc := userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, policySvc, projSvc, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -1264,7 +1276,7 @@ func TestService_CreatePolicies_PolicyCreateFailure(t *testing.T) { return p.RoleID == "org-billing-id" })).Return(policy.Policy{}, errors.New("spicedb unavailable")) - svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ UserID: "user-1", OrgID: "org-1", @@ -1303,7 +1315,7 @@ func TestService_ListAllowedRoles(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) return userpat.NewService(log.NewNoop(), repo, userpat.Config{ Enabled: false, - }, orgSvc, nil, nil, auditRepo) + }, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -1318,7 +1330,7 @@ func TestService_ListAllowedRoles(t *testing.T) { OrgID: schema.PlatformOrgID.String(), Scopes: []string{schema.OrganizationNamespace, schema.ProjectNamespace}, }).Return(nil, errors.New("db connection failed")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1341,7 +1353,7 @@ func TestService_ListAllowedRoles(t *testing.T) { }, nil) cfg := defaultConfig cfg.DeniedPermissions = []string{"app_organization_administer"} - return userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1361,7 +1373,7 @@ func TestService_ListAllowedRoles(t *testing.T) { }, nil) cfg := defaultConfig cfg.DeniedPermissions = []string{"app_organization_administer"} - return userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1377,7 +1389,7 @@ func TestService_ListAllowedRoles(t *testing.T) { OrgID: schema.PlatformOrgID.String(), Scopes: []string{schema.OrganizationNamespace, schema.ProjectNamespace}, }).Return([]role.Role{}, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1398,7 +1410,7 @@ func TestService_ListAllowedRoles(t *testing.T) { {ID: "org-admin-id", Name: "org_admin", Permissions: []string{"app_organization_administer"}, Scopes: []string{schema.OrganizationNamespace}}, {ID: "proj-viewer-id", Name: "proj_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, }, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1418,7 +1430,7 @@ func TestService_ListAllowedRoles(t *testing.T) { }).Return([]role.Role{ {ID: "proj-viewer-id", Name: "proj_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, }, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1438,7 +1450,7 @@ func TestService_ListAllowedRoles(t *testing.T) { }).Return([]role.Role{ {ID: "org-viewer-id", Name: "org_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, }, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1458,7 +1470,7 @@ func TestService_ListAllowedRoles(t *testing.T) { }).Return([]role.Role{ {ID: "proj-viewer-id", Name: "proj_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, }, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1470,7 +1482,7 @@ func TestService_ListAllowedRoles(t *testing.T) { repo := mocks.NewRepository(t) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -1482,7 +1494,7 @@ func TestService_ListAllowedRoles(t *testing.T) { repo := mocks.NewRepository(t) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, }, { @@ -1502,7 +1514,7 @@ func TestService_ListAllowedRoles(t *testing.T) { }).Return([]role.Role{ {ID: "proj-viewer-id", Name: "proj_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, }, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, { @@ -1524,7 +1536,7 @@ func TestService_ListAllowedRoles(t *testing.T) { {ID: "org-admin-id", Name: "org_admin", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}}, {ID: "proj-viewer-id", Name: "proj_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}}, }, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, nil, nil, auditRepo) }, }, } @@ -1615,10 +1627,10 @@ func TestService_Get(t *testing.T) { patID: "pat-1", setup: func() *userpat.Service { repo := mocks.NewRepository(t) - orgSvc, _, policySvc, auditRepo := newSuccessMocks(t) + orgSvc, _, policySvc, _, auditRepo := newSuccessMocks(t) return userpat.NewService(log.NewNoop(), repo, userpat.Config{ Enabled: false, - }, orgSvc, nil, policySvc, auditRepo) + }, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: true, wantErrIs: paterrors.ErrDisabled, @@ -1631,8 +1643,8 @@ func TestService_Get(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(models.PAT{}, paterrors.ErrNotFound) - orgSvc, _, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + orgSvc, _, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -1645,8 +1657,8 @@ func TestService_Get(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(testPAT, nil) - orgSvc, _, policySvc, auditRepo := newSuccessMocks(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + orgSvc, _, policySvc, _, auditRepo := newSuccessMocks(t) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -1659,12 +1671,12 @@ func TestService_Get(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(testPAT, nil) - orgSvc, _, policySvc, auditRepo := newSuccessMocks(t) + orgSvc, _, policySvc, _, auditRepo := newSuccessMocks(t) policySvc.On("List", mock.Anything, mock.Anything). Return([]policy.Policy{ {RoleID: "role-1", ResourceType: "app/organization", ResourceID: "org-1"}, }, nil).Maybe() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -1681,7 +1693,7 @@ func TestService_Get(t *testing.T) { policySvc.On("List", mock.Anything, mock.Anything). Return(nil, errors.New("spicedb down")) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: true, }, @@ -1739,7 +1751,7 @@ func TestService_Delete(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) return userpat.NewService(log.NewNoop(), repo, userpat.Config{ Enabled: false, - }, orgSvc, nil, nil, auditRepo) + }, orgSvc, nil, nil, nil, auditRepo) }, wantErr: true, wantErrIs: paterrors.ErrDisabled, @@ -1754,7 +1766,7 @@ func TestService_Delete(t *testing.T) { Return(models.PAT{}, paterrors.ErrNotFound) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -1769,7 +1781,7 @@ func TestService_Delete(t *testing.T) { Return(testPAT, nil) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -1786,7 +1798,7 @@ func TestService_Delete(t *testing.T) { Return(errors.New("db error")) orgSvc := mocks.NewOrganizationService(t) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, nil, auditRepo) }, wantErr: true, }, @@ -1807,7 +1819,7 @@ func TestService_Delete(t *testing.T) { PrincipalType: schema.PATPrincipal, }).Return(nil, errors.New("spicedb down")) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: true, }, @@ -1830,7 +1842,7 @@ func TestService_Delete(t *testing.T) { policySvc.EXPECT().Delete(mock.Anything, "pol-1"). Return(errors.New("spicedb unavailable")) auditRepo := mocks.NewAuditRecordRepository(t) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: true, }, @@ -1860,7 +1872,7 @@ func TestService_Delete(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, nil).Maybe() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -1885,7 +1897,7 @@ func TestService_Delete(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, nil).Maybe() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -1910,7 +1922,7 @@ func TestService_Delete(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, errors.New("audit db down")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -1984,7 +1996,7 @@ func TestService_Update(t *testing.T) { setup: func() *userpat.Service { return userpat.NewService(log.NewNoop(), nil, userpat.Config{ Enabled: false, - }, nil, nil, nil, nil) + }, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrDisabled, @@ -1996,7 +2008,7 @@ func TestService_Update(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(models.PAT{}, paterrors.ErrNotFound) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -2013,7 +2025,7 @@ func TestService_Update(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(testPAT, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -2028,7 +2040,7 @@ func TestService_Update(t *testing.T) { roleSvc := mocks.NewRoleService(t) roleSvc.EXPECT().List(mock.Anything, mock.Anything). Return(nil, paterrors.ErrRoleNotFound) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrRoleNotFound, @@ -2050,7 +2062,7 @@ func TestService_Update(t *testing.T) { }).Return([]policy.Policy{}, nil) repo.EXPECT().Update(mock.Anything, mock.Anything). Return(models.PAT{}, errors.New("db error")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil, nil) }, wantErr: true, }, @@ -2071,7 +2083,7 @@ func TestService_Update(t *testing.T) { }).Return([]policy.Policy{}, nil) repo.EXPECT().Update(mock.Anything, mock.Anything). Return(models.PAT{}, paterrors.ErrConflict) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrConflict, @@ -2099,7 +2111,7 @@ func TestService_Update(t *testing.T) { PrincipalID: "pat-1", PrincipalType: schema.PATPrincipal, }).Return(nil, errors.New("spicedb down")).Once() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil, nil) }, wantErr: true, }, @@ -2124,7 +2136,7 @@ func TestService_Update(t *testing.T) { // TOCTOU re-check returns not found (concurrent delete) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(models.PAT{}, paterrors.ErrNotFound).Once() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, policySvc, nil, nil) }, wantErr: true, }, @@ -2159,7 +2171,7 @@ func TestService_Update(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, nil).Maybe() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -2191,7 +2203,7 @@ func TestService_Update(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, errors.New("audit db down")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -2268,7 +2280,7 @@ func TestService_Regenerate(t *testing.T) { setup: func() *userpat.Service { return userpat.NewService(log.NewNoop(), nil, userpat.Config{ Enabled: false, - }, nil, nil, nil, nil) + }, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrDisabled, @@ -2282,7 +2294,7 @@ func TestService_Regenerate(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(models.PAT{}, paterrors.ErrNotFound) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -2296,7 +2308,7 @@ func TestService_Regenerate(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(activePAT, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrNotFound, @@ -2310,7 +2322,7 @@ func TestService_Regenerate(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().GetByID(mock.Anything, "pat-1"). Return(activePAT, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrExpiryInPast, @@ -2326,7 +2338,7 @@ func TestService_Regenerate(t *testing.T) { Return(expiredPAT, nil) repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1"). Return(int64(50), nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrLimitExceeded, @@ -2352,7 +2364,7 @@ func TestService_Regenerate(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, nil).Maybe() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -2367,7 +2379,7 @@ func TestService_Regenerate(t *testing.T) { Return(activePAT, nil) repo.EXPECT().Regenerate(mock.Anything, "pat-1", mock.Anything, mock.Anything). Return(models.PAT{}, errors.New("db error")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, }, @@ -2393,7 +2405,7 @@ func TestService_Regenerate(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, nil).Maybe() - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -2417,7 +2429,7 @@ func TestService_Regenerate(t *testing.T) { auditRepo := mocks.NewAuditRecordRepository(t) auditRepo.On("Create", mock.Anything, mock.Anything). Return(auditmodels.AuditRecord{}, errors.New("audit db down")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, auditRepo) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, policySvc, nil, auditRepo) }, wantErr: false, }, @@ -2462,7 +2474,7 @@ func TestService_IsTitleAvailable(t *testing.T) { setup: func() *userpat.Service { return userpat.NewService(log.NewNoop(), nil, userpat.Config{ Enabled: false, - }, nil, nil, nil, nil) + }, nil, nil, nil, nil, nil) }, wantErr: true, wantErrIs: paterrors.ErrDisabled, @@ -2476,7 +2488,7 @@ func TestService_IsTitleAvailable(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().IsTitleAvailable(mock.Anything, "user-1", "org-1", "new-token"). Return(true, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantAvailable: true, }, @@ -2489,7 +2501,7 @@ func TestService_IsTitleAvailable(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().IsTitleAvailable(mock.Anything, "user-1", "org-1", "existing-token"). Return(false, nil) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantAvailable: false, }, @@ -2502,7 +2514,7 @@ func TestService_IsTitleAvailable(t *testing.T) { repo := mocks.NewRepository(t) repo.EXPECT().IsTitleAvailable(mock.Anything, "user-1", "org-1", "my-token"). Return(false, errors.New("db error")) - return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil) + return userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, nil, nil, nil, nil) }, wantErr: true, }, @@ -2530,3 +2542,106 @@ func TestService_IsTitleAvailable(t *testing.T) { }) } } + +func TestService_ValidateProjectAccess(t *testing.T) { + t.Run("should reject project user has no access to", func(t *testing.T) { + repo := mocks.NewRepository(t) + repo.EXPECT().CountActive(mock.Anything, "user-1", "org-1").Return(int64(0), nil) + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, mock.Anything).Return([]role.Role{ + {ID: "role-1", Name: "proj_viewer", Scopes: []string{schema.ProjectNamespace}, Permissions: []string{"app_project_get"}}, + }, nil) + projSvc := mocks.NewProjectService(t) + projSvc.On("ListByUser", mock.Anything, mock.Anything, mock.Anything).Return([]project.Project{ + {ID: "proj-in-org"}, + }, nil) + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, nil, roleSvc, nil, projSvc, nil) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "cross-org-test", + Scopes: []models.PATScope{ + {RoleID: "role-1", ResourceType: schema.ProjectNamespace, ResourceIDs: []string{"proj-not-in-org"}}, + }, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, paterrors.ErrProjectForbidden) { + t.Errorf("expected ErrProjectForbidden, got %v", err) + } + }) + + t.Run("should allow project user has access to", func(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("models.PAT")). + Return(models.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() + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, mock.Anything).Return([]role.Role{ + {ID: "role-1", Name: "proj_viewer", Scopes: []string{schema.ProjectNamespace}, Permissions: []string{"app_project_get"}}, + }, nil) + policySvc := mocks.NewPolicyService(t) + policySvc.On("Create", mock.Anything, mock.Anything).Return(policy.Policy{}, nil).Maybe() + policySvc.On("List", mock.Anything, mock.Anything).Return([]policy.Policy{}, nil).Maybe() + projSvc := mocks.NewProjectService(t) + projSvc.On("ListByUser", mock.Anything, mock.Anything, mock.Anything).Return([]project.Project{ + {ID: "proj-in-org"}, + }, nil) + auditRepo := mocks.NewAuditRecordRepository(t) + auditRepo.On("Create", mock.Anything, mock.Anything).Return(auditmodels.AuditRecord{}, nil).Maybe() + + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, projSvc, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "valid-project-test", + Scopes: []models.PATScope{ + {RoleID: "role-1", ResourceType: schema.ProjectNamespace, ResourceIDs: []string{"proj-in-org"}}, + }, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("should skip validation for all-projects scope", func(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("models.PAT")). + Return(models.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() + roleSvc := mocks.NewRoleService(t) + roleSvc.EXPECT().List(mock.Anything, mock.Anything).Return([]role.Role{ + {ID: "role-1", Name: "proj_viewer", Scopes: []string{schema.ProjectNamespace}, Permissions: []string{"app_project_get"}}, + }, nil) + policySvc := mocks.NewPolicyService(t) + policySvc.On("Create", mock.Anything, mock.Anything).Return(policy.Policy{}, nil).Maybe() + policySvc.On("List", mock.Anything, mock.Anything).Return([]policy.Policy{}, nil).Maybe() + auditRepo := mocks.NewAuditRecordRepository(t) + auditRepo.On("Create", mock.Anything, mock.Anything).Return(auditmodels.AuditRecord{}, nil).Maybe() + + // No projectService mock needed — all-projects scope has empty ResourceIDs, skips validation + svc := userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, roleSvc, policySvc, nil, auditRepo) + _, _, err := svc.Create(context.Background(), userpat.CreateRequest{ + UserID: "user-1", + OrgID: "org-1", + Title: "all-projects-test", + Scopes: []models.PATScope{ + {RoleID: "role-1", ResourceType: schema.ProjectNamespace, ResourceIDs: nil}, + }, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/internal/api/v1beta1connect/user_pat.go b/internal/api/v1beta1connect/user_pat.go index 6d8f20829..6560b7e2c 100644 --- a/internal/api/v1beta1connect/user_pat.go +++ b/internal/api/v1beta1connect/user_pat.go @@ -65,6 +65,8 @@ func (h *ConnectHandler) CreateCurrentUserPAT(ctx context.Context, request *conn return nil, connect.NewError(connect.CodeInvalidArgument, paterrors.ErrUnsupportedScope) case errors.Is(err, paterrors.ErrScopeMismatch): return nil, connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, paterrors.ErrProjectForbidden): + return nil, connect.NewError(connect.CodeInvalidArgument, err) default: return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError) } @@ -215,6 +217,8 @@ func (h *ConnectHandler) UpdateCurrentUserPAT(ctx context.Context, request *conn return nil, connect.NewError(connect.CodeInvalidArgument, paterrors.ErrUnsupportedScope) case errors.Is(err, paterrors.ErrScopeMismatch): return nil, connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, paterrors.ErrProjectForbidden): + return nil, connect.NewError(connect.CodeInvalidArgument, err) default: return nil, connect.NewError(connect.CodeInternal, ErrInternalServerError) }