Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions core/userpat/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,63 @@ func (s *Service) createPolicies(ctx context.Context, patID, orgID string, roles
return nil
}

// ListAllowedRoles returns predefined roles that are valid for PAT assignment.
// It lists platform roles filtered by scopes and removes any role containing
// a denied permission. If scopes is empty, defaults to org + project scopes.
// Accepts short aliases (e.g. "project", "org") which are normalized to full
// namespace form (e.g. "app/project", "app/organization").
func (s *Service) ListAllowedRoles(ctx context.Context, scopes []string) ([]role.Role, error) {
if !s.config.Enabled {
return nil, paterrors.ErrDisabled
}

if len(scopes) == 0 {
scopes = []string{schema.OrganizationNamespace, schema.ProjectNamespace}
} else {
for i, scope := range scopes {
scopes[i] = schema.ParseNamespaceAliasIfRequired(scope)
}
allowedScopes := []string{schema.OrganizationNamespace, schema.ProjectNamespace}
scopes = pkgUtils.Deduplicate(scopes)
for _, scope := range scopes {
if !slices.Contains(allowedScopes, scope) {
return nil, fmt.Errorf("scope %q: %w", scope, paterrors.ErrUnsupportedScope)
}
}
}

roles, err := s.roleService.List(ctx, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: scopes,
})
if err != nil {
return nil, fmt.Errorf("listing roles: %w", err)
}

allowed := make([]role.Role, 0, len(roles))
for _, r := range roles {
if !s.hasAnyDeniedPermission(r) {
allowed = append(allowed, r)
}
}
return allowed, nil
}

// hasAnyDeniedPermission returns true if the role contains at least one denied permission.
func (s *Service) hasAnyDeniedPermission(r role.Role) bool {
for _, perm := range r.Permissions {
if _, denied := s.deniedPerms[perm]; denied {
return true
}
}
return false
}

// 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, paterrors.ErrDeniedRole)
}
if s.hasAnyDeniedPermission(r) {
return fmt.Errorf("role %s contains a denied permission: %w", r.Name, paterrors.ErrDeniedRole)
}
}
return nil
Expand Down
275 changes: 275 additions & 0 deletions core/userpat/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,281 @@ func TestService_CreatePolicies_PolicyCreateFailure(t *testing.T) {
}
}

func TestService_ListAllowedRoles(t *testing.T) {
tests := []struct {
name string
scopes []string
setup func() *userpat.Service
wantErr bool
wantErrIs error
wantCount int
wantIDs []string
}{
{
name: "should return ErrDisabled when PAT feature is disabled",
wantErr: true,
wantErrIs: paterrors.ErrDisabled,
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
return userpat.NewService(log.NewNoop(), repo, userpat.Config{
Enabled: false,
}, orgSvc, nil, nil, auditRepo)
},
},
{
name: "should propagate role service error",
wantErr: true,
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
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)
},
},
{
name: "should filter out roles with denied permissions",
wantErr: false,
wantCount: 2,
wantIDs: []string{"org-viewer-id", "proj-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.OrganizationNamespace, schema.ProjectNamespace},
}).Return([]role.Role{
{ID: "org-viewer-id", Name: "org_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}},
{ID: "org-admin-id", Name: "org_admin", Permissions: []string{"app_organization_administer", "app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}},
{ID: "proj-viewer-id", Name: "proj_viewer", Permissions: []string{"app_project_get"}, Scopes: []string{schema.ProjectNamespace}},
}, nil)
cfg := defaultConfig
cfg.DeniedPermissions = []string{"app_organization_administer"}
return userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, nil, auditRepo)
},
},
{
name: "should return empty slice when all roles are denied",
wantErr: false,
wantCount: 0,
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.OrganizationNamespace, schema.ProjectNamespace},
}).Return([]role.Role{
{ID: "admin-id", Name: "org_admin", Permissions: []string{"app_organization_administer"}, Scopes: []string{schema.OrganizationNamespace}},
}, nil)
cfg := defaultConfig
cfg.DeniedPermissions = []string{"app_organization_administer"}
return userpat.NewService(log.NewNoop(), repo, cfg, orgSvc, roleSvc, nil, auditRepo)
},
},
{
name: "should return empty slice when no roles exist",
wantErr: false,
wantCount: 0,
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
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)
},
},
{
name: "should return all roles when no denied permissions configured",
wantErr: false,
wantCount: 3,
wantIDs: []string{"org-viewer-id", "org-admin-id", "proj-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.OrganizationNamespace, schema.ProjectNamespace},
}).Return([]role.Role{
{ID: "org-viewer-id", Name: "org_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}},
{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)
},
},
{
name: "should normalize short alias 'project' to app/project",
scopes: []string{"project"},
wantErr: false,
wantCount: 1,
wantIDs: []string{"proj-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.ProjectNamespace},
}).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)
},
},
{
name: "should normalize short alias 'org' to app/organization",
scopes: []string{"org"},
wantErr: false,
wantCount: 1,
wantIDs: []string{"org-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.OrganizationNamespace},
}).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)
},
},
{
name: "should accept full namespace app/project",
scopes: []string{schema.ProjectNamespace},
wantErr: false,
wantCount: 1,
wantIDs: []string{"proj-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.ProjectNamespace},
}).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)
},
},
{
name: "should reject unsupported scope like group",
scopes: []string{"group"},
wantErr: true,
wantErrIs: paterrors.ErrUnsupportedScope,
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo)
},
},
{
name: "should reject unknown scope",
scopes: []string{"unknown"},
wantErr: true,
wantErrIs: paterrors.ErrUnsupportedScope,
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
return userpat.NewService(log.NewNoop(), repo, defaultConfig, orgSvc, nil, nil, auditRepo)
},
},
{
name: "should deduplicate repeated scopes",
scopes: []string{"project", "project", "project"},
wantErr: false,
wantCount: 1,
wantIDs: []string{"proj-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.ProjectNamespace},
}).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)
},
},
{
name: "should deduplicate mixed aliases and full namespaces",
scopes: []string{"project", schema.ProjectNamespace, "org", schema.OrganizationNamespace},
wantErr: false,
wantCount: 3,
wantIDs: []string{"org-viewer-id", "org-admin-id", "proj-viewer-id"},
setup: func() *userpat.Service {
repo := mocks.NewRepository(t)
orgSvc := mocks.NewOrganizationService(t)
auditRepo := mocks.NewAuditRecordRepository(t)
roleSvc := mocks.NewRoleService(t)
roleSvc.EXPECT().List(mock.Anything, role.Filter{
OrgID: schema.PlatformOrgID.String(),
Scopes: []string{schema.ProjectNamespace, schema.OrganizationNamespace},
}).Return([]role.Role{
{ID: "org-viewer-id", Name: "org_viewer", Permissions: []string{"app_organization_get"}, Scopes: []string{schema.OrganizationNamespace}},
{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)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := tt.setup()
got, err := svc.ListAllowedRoles(context.Background(), tt.scopes)
if (err != nil) != tt.wantErr {
t.Errorf("ListAllowedRoles() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) {
t.Errorf("ListAllowedRoles() error = %v, wantErrIs %v", err, tt.wantErrIs)
return
}
if !tt.wantErr {
if len(got) != tt.wantCount {
t.Errorf("ListAllowedRoles() returned %d roles, want %d", len(got), tt.wantCount)
}
if tt.wantIDs != nil {
var gotIDs []string
for _, r := range got {
gotIDs = append(gotIDs, r.ID)
}
if diff := cmp.Diff(tt.wantIDs, gotIDs); diff != "" {
t.Errorf("ListAllowedRoles() IDs mismatch (-want +got):\n%s", diff)
}
}
}
})
}
}

func TestConfig_MaxExpiry(t *testing.T) {
tests := []struct {
name string
Expand Down
1 change: 1 addition & 0 deletions internal/api/v1beta1connect/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,5 @@ type UserPATService interface {
Create(ctx context.Context, req userpat.CreateRequest) (models.PAT, string, error)
List(ctx context.Context, userID, orgID string, query *rql.Query) (models.PATList, error)
Get(ctx context.Context, userID, id string) (models.PAT, error)
ListAllowedRoles(ctx context.Context, scopes []string) ([]role.Role, error)
}
Loading
Loading