Skip to content

feat: implement SetProjectMemberRole RPC#1481

Open
whoAbhishekSah wants to merge 12 commits intomainfrom
feat/set-project-member-role
Open

feat: implement SetProjectMemberRole RPC#1481
whoAbhishekSah wants to merge 12 commits intomainfrom
feat/set-project-member-role

Conversation

@whoAbhishekSah
Copy link
Copy Markdown
Member

Summary

  • Add SetProjectMemberRole RPC for atomic role assignment on project members
  • Enforces minimum-owner constraint (cannot remove last project owner)
  • Validates role is valid for project scope
  • Authorization checks update permission on the project

Closes #1461 (partially - RemoveProjectMember will be a separate PR)

Proton PR: raystack/proton#456

Test plan

  • Unit tests for service layer pass
  • E2E tests for role assignment, validation, and owner constraint
  • Verify authorization interceptor enforces update permission

🤖 Generated with Claude Code

whoAbhishekSah and others added 2 commits March 27, 2026 09:44
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add handler, service, and authorization for atomically changing
a user's role in a project. Enforces minimum-owner constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Mar 30, 2026 4:18am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cbf9c0fa-760d-48a1-b685-6a56bb922692

📥 Commits

Reviewing files that changed from the base of the PR and between cdfc3a9 and 174be71.

📒 Files selected for processing (1)
  • pkg/server/connect_interceptors/authorization.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/server/connect_interceptors/authorization.go

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a SetProjectMemberRole endpoint to change project member roles with validation and authorization.
  • Bug Fixes

    • Improved validation, explicit error mapping, and membership checks for role assignment flows.
  • Tests

    • Added comprehensive tests and new mocks covering role retrieval, principal lookup, policy listing, deletion, and creation.
  • Chores

    • Updated Proton commit used for protobuf generation.

Walkthrough

Adds a server-side RPC to set a project member's role and implements end-to-end support: service logic, Connect handler, interface/mocks, tests, domain errors, authorization entry, and updates Makefile PROTON_COMMIT.

Changes

Cohort / File(s) Summary
Build config
Makefile
Updated PROTON_COMMIT hash used by make proto.
Server wiring
cmd/serve.go
Passed new roleService into project.NewService(...) when assembling API dependencies.
Domain errors
core/project/errors.go, internal/api/v1beta1connect/errors.go
Added errors: ErrInvalidProjectRole, ErrNotOrgMember, ErrInvalidPrincipalType (API errors file adds ErrInvalidProjectRole).
Project service implementation
core/project/service.go
Added RoleService interface and roleService dependency; implemented Service.SetMemberRole(...) plus helpers validatePrincipal and validateProjectRole; added required service method signatures (Get/Delete/Get).
Service tests
core/project/service_test.go
Added mocks.RoleService to test harness, adapted existing NewService calls, and added TestService_SetMemberRole with table-driven scenarios and mock expectations.
Core mocks
core/project/mocks/...
role_service.go, policy_service.go, group_service.go, serviceuser_service.go
Added autogenerated RoleService mock and typed mock methods/expecters: PolicyService.Delete, GroupService.Get, ServiceuserService.Get, plus helpers for configuring calls/returns.
API interface & mocks
internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/mocks/project_service.go
Added ProjectService.SetMemberRole(...) to Connect interface and corresponding mock expecter/call helpers.
API handler
internal/api/v1beta1connect/project.go
Added ConnectHandler.SetProjectMemberRole(...) with request validation, service invocation, structured logging, and domain→Connect error mapping.
Authorization interceptor
pkg/server/connect_interceptors/authorization.go
Added authorization validation entry for FrontierService/SetProjectMemberRole, checking update on the project resource.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • rsbh
🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive Proton commit update in Makefile appears related to protobuf generation for the RPC, but PR description references proton PR #456 without explicit confirmation of what changes are needed. Verify that the Proton commit hash f51485d9a328b15f0a8d0dc14fcb405438e7ba93 includes the SetProjectMemberRole RPC proto definition from raystack/proton#456.
✅ Passed checks (1 passed)
Check name Status Explanation
Linked Issues check ✅ Passed All core requirements from issue #1461 have been implemented: SetProjectMemberRole RPC handles both add and role change as atomic operations, validates project role scopes, enforces authorization with update permission, and includes unit tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/project/service_test.go (1)

23-35: ⚠️ Potential issue | 🟠 Major

Add direct unit tests for SetMemberRole behavior (currently missing).

This update wires new dependencies, but the new project-member role mutation path is not covered here. Please add focused tests for role validation, non-member handling, and last-owner protection to reduce regression risk in the new RPC path.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 56252940-859b-4c10-83dd-9b50d88a457e

📥 Commits

Reviewing files that changed from the base of the PR and between d05d9e5 and b32bbf7.

⛔ Files ignored due to path filters (3)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
  • proto/v1beta1/frontier.pb.validate.go is excluded by !proto/**
  • proto/v1beta1/frontierv1beta1connect/frontier.connect.go is excluded by !proto/**
📒 Files selected for processing (12)
  • Makefile
  • cmd/serve.go
  • core/project/errors.go
  • core/project/mocks/policy_service.go
  • core/project/mocks/role_service.go
  • core/project/service.go
  • core/project/service_test.go
  • internal/api/v1beta1connect/errors.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/project_service.go
  • internal/api/v1beta1connect/project.go
  • pkg/server/connect_interceptors/authorization.go

…berRole

Project access can come from org-level permissions, so we don't enforce
minimum-owner constraint or require existing membership at project level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coveralls
Copy link
Copy Markdown

coveralls commented Mar 27, 2026

Pull Request Test Coverage Report for Build 23633134429

Details

  • 64 of 111 (57.66%) changed or added relevant lines in 4 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage increased (+0.05%) to 41.284%

Changes Missing Coverage Covered Lines Changed/Added Lines %
cmd/serve.go 0 1 0.0%
pkg/server/connect_interceptors/authorization.go 0 4 0.0%
core/project/service.go 64 70 91.43%
internal/api/v1beta1connect/project.go 0 36 0.0%
Totals Coverage Status
Change from base Build 23540166648: 0.05%
Covered Lines: 14787
Relevant Lines: 35818

💛 - Coveralls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…emberRole

Each principal type has its own field in the request (user_id,
service_user_id, group_id). Validation checks that the principal
exists and belongs to the org that owns the project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
core/project/service.go (1)

360-363: ⚠️ Potential issue | 🟠 Major

The last-owner invariant is gone.

Demoting the only direct owner now succeeds and leaves the project with zero project-scoped owners. That regresses the owner-constraint behavior this RPC is supposed to preserve; if org-level fallback is intentionally replacing that invariant, the contract/tests need to change too.

Also applies to: 377-390


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7a3f72ab-6cdf-4e25-beb2-3c66806bc560

📥 Commits

Reviewing files that changed from the base of the PR and between b32bbf7 and fc069d7.

📒 Files selected for processing (3)
  • core/project/errors.go
  • core/project/service.go
  • internal/api/v1beta1connect/project.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/project/errors.go
  • internal/api/v1beta1connect/project.go

…ate ID fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 test cases covering:
- validation errors (project/user/serviceuser/group not found)
- invalid principal type
- org membership check
- role validation (not found, wrong scope)
- success with no existing policies (add member)
- success replacing existing policies (role change)
- service user and group principal types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
core/project/service.go (1)

362-402: ⚠️ Potential issue | 🟠 Major

Use the resolved project ID (prj.ID) consistently.

Lines 380 and 396 use the raw projectID parameter instead of prj.ID. Since s.Get() at line 366 resolves both UUIDs and names, passing a project name will cause the policy filter and create operations to use the name string instead of the canonical UUID.

🐛 Proposed fix
 	existingPolicies, err := s.policyService.List(ctx, policy.Filter{
-		ProjectID:     projectID,
+		ProjectID:     prj.ID,
 		PrincipalID:   principalID,
 		PrincipalType: principalType,
 	})
@@
 	_, err = s.policyService.Create(ctx, policy.Policy{
 		RoleID:        newRoleID,
-		ResourceID:    projectID,
+		ResourceID:    prj.ID,
 		ResourceType:  schema.ProjectNamespace,
 		PrincipalID:   principalID,
 		PrincipalType: principalType,
 	})

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2d62ec5-29b5-4805-9f36-2c26ab203e72

📥 Commits

Reviewing files that changed from the base of the PR and between fc069d7 and 1d15a38.

⛔ Files ignored due to path filters (2)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
  • proto/v1beta1/frontier.pb.validate.go is excluded by !proto/**
📒 Files selected for processing (9)
  • Makefile
  • core/project/errors.go
  • core/project/mocks/group_service.go
  • core/project/mocks/serviceuser_service.go
  • core/project/service.go
  • core/project/service_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/project_service.go
  • internal/api/v1beta1connect/project.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • Makefile
  • internal/api/v1beta1connect/project.go
  • core/project/errors.go

…sion

Bug 1: Service users and groups don't have org-level policies.
Check their OrgID/OrganizationID field directly instead of querying
for org policies.

Bug 2: Use PolicyManagePermission instead of UpdatePermission for
authorization, matching SetOrganizationMemberRole behavior. Prevents
org viewers from managing project roles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
core/project/service_test.go (1)

59-60: Remove redundant _ = roleService no-op assignments.

These lines add noise and can be dropped since roleService is passed to project.NewService(...) in the same scope.

Also applies to: 77-78, 122-123, 140-141


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d83355dc-3728-44ea-b279-794bb4b06969

📥 Commits

Reviewing files that changed from the base of the PR and between 1d15a38 and cdfc3a9.

📒 Files selected for processing (3)
  • core/project/service.go
  • core/project/service_test.go
  • pkg/server/connect_interceptors/authorization.go
✅ Files skipped from review due to trivial changes (1)
  • pkg/server/connect_interceptors/authorization.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/project/service.go

Comment on lines +977 to +1245
func TestService_SetMemberRole(t *testing.T) {
ctx := context.Background()
projectID := uuid.New().String()
orgID := uuid.New().String()
userID := uuid.New().String()
suID := uuid.New().String()
groupID := uuid.New().String()
roleID := uuid.New().String()

tests := []struct {
name string
projectID string
principalID string
principalType string
roleID string
setup func(*mocks.Repository, *mocks.UserService, *mocks.ServiceuserService, *mocks.GroupService, *mocks.PolicyService, *mocks.RoleService)
wantErr error
}{
{
name: "should return error if project does not exist",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{}, project.ErrNotExist)
},
wantErr: project.ErrNotExist,
},
{
name: "should return error if user does not exist",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{}, user.ErrNotExist)
},
wantErr: user.ErrNotExist,
},
{
name: "should return error if service user does not exist",
projectID: projectID,
principalID: suID,
principalType: schema.ServiceUserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
suserSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{}, serviceuser.ErrNotExist)
},
wantErr: serviceuser.ErrNotExist,
},
{
name: "should return error if group does not exist",
projectID: projectID,
principalID: groupID,
principalType: schema.GroupPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
groupSvc.EXPECT().Get(ctx, groupID).Return(group.Group{}, group.ErrNotExist)
},
wantErr: group.ErrNotExist,
},
{
name: "should return error for invalid principal type",
projectID: projectID,
principalID: userID,
principalType: "invalid",
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
},
wantErr: project.ErrInvalidPrincipalType,
},
{
name: "should return error if user is not an org member",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
OrgID: orgID,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{}, nil)
},
wantErr: project.ErrNotOrgMember,
},
{
name: "should return error if role does not exist",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
OrgID: orgID,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{{ID: "p1"}}, nil)
roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{}, role.ErrNotExist)
},
wantErr: role.ErrNotExist,
},
{
name: "should return error if role scope is not project",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
OrgID: orgID,
PrincipalID: userID,
PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{{ID: "p1"}}, nil)
roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.OrganizationNamespace}}, nil)
},
wantErr: project.ErrInvalidProjectRole,
},
{
name: "should succeed for user with no existing project policies",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
OrgID: orgID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{{ID: "org-p1"}}, nil)
roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{}, nil)
policySvc.EXPECT().Create(ctx, policy.Policy{
RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace,
PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return(policy.Policy{}, nil)
},
wantErr: nil,
},
{
name: "should replace existing policies on role change",
projectID: projectID,
principalID: userID,
principalType: schema.UserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
userSvc.EXPECT().GetByID(ctx, userID).Return(user.User{ID: userID}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
OrgID: orgID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{{ID: "org-p1"}}, nil)
roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return([]policy.Policy{{ID: "old-p1"}, {ID: "old-p2"}}, nil)
policySvc.EXPECT().Delete(ctx, "old-p1").Return(nil)
policySvc.EXPECT().Delete(ctx, "old-p2").Return(nil)
policySvc.EXPECT().Create(ctx, policy.Policy{
RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace,
PrincipalID: userID, PrincipalType: schema.UserPrincipal,
}).Return(policy.Policy{}, nil)
},
wantErr: nil,
},
{
name: "should return error if service user belongs to different org",
projectID: projectID,
principalID: suID,
principalType: schema.ServiceUserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
suserSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: "different-org"}, nil)
},
wantErr: project.ErrNotOrgMember,
},
{
name: "should return error if group belongs to different org",
projectID: projectID,
principalID: groupID,
principalType: schema.GroupPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
groupSvc.EXPECT().Get(ctx, groupID).Return(group.Group{ID: groupID, OrganizationID: "different-org"}, nil)
},
wantErr: project.ErrNotOrgMember,
},
{
name: "should succeed for service user principal",
projectID: projectID,
principalID: suID,
principalType: schema.ServiceUserPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
suserSvc.EXPECT().Get(ctx, suID).Return(serviceuser.ServiceUser{ID: suID, OrgID: orgID}, nil)
roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: suID, PrincipalType: schema.ServiceUserPrincipal,
}).Return([]policy.Policy{}, nil)
policySvc.EXPECT().Create(ctx, policy.Policy{
RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace,
PrincipalID: suID, PrincipalType: schema.ServiceUserPrincipal,
}).Return(policy.Policy{}, nil)
},
wantErr: nil,
},
{
name: "should succeed for group principal",
projectID: projectID,
principalID: groupID,
principalType: schema.GroupPrincipal,
roleID: roleID,
setup: func(repo *mocks.Repository, userSvc *mocks.UserService, suserSvc *mocks.ServiceuserService, groupSvc *mocks.GroupService, policySvc *mocks.PolicyService, roleSvc *mocks.RoleService) {
repo.EXPECT().GetByID(ctx, projectID).Return(project.Project{ID: projectID, Organization: organization.Organization{ID: orgID}}, nil)
groupSvc.EXPECT().Get(ctx, groupID).Return(group.Group{ID: groupID, OrganizationID: orgID}, nil)
roleSvc.EXPECT().Get(ctx, roleID).Return(role.Role{ID: roleID, Scopes: []string{schema.ProjectNamespace}}, nil)
policySvc.EXPECT().List(ctx, policy.Filter{
ProjectID: projectID, PrincipalID: groupID, PrincipalType: schema.GroupPrincipal,
}).Return([]policy.Policy{}, nil)
policySvc.EXPECT().Create(ctx, policy.Policy{
RoleID: roleID, ResourceID: projectID, ResourceType: schema.ProjectNamespace,
PrincipalID: groupID, PrincipalType: schema.GroupPrincipal,
}).Return(policy.Policy{}, nil)
},
wantErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := mocks.NewRepository(t)
userSvc := mocks.NewUserService(t)
suserSvc := mocks.NewServiceuserService(t)
groupSvc := mocks.NewGroupService(t)
policySvc := mocks.NewPolicyService(t)
roleSvc := mocks.NewRoleService(t)
relationSvc := mocks.NewRelationService(t)
authnSvc := mocks.NewAuthnService(t)

if tt.setup != nil {
tt.setup(repo, userSvc, suserSvc, groupSvc, policySvc, roleSvc)
}

svc := project.NewService(repo, relationSvc, userSvc, policySvc, authnSvc, suserSvc, groupSvc, roleSvc)
err := svc.SetMemberRole(ctx, tt.projectID, tt.principalID, tt.principalType, tt.roleID)

if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing unit case for minimum-owner protection invariant.

TestService_SetMemberRole covers many paths, but it doesn’t assert the “cannot remove/downgrade the last project owner” rule. That invariant is central to this RPC; please add an explicit negative test for it to prevent silent regressions.

UpdatePermission is the correct check - project owners and managers
can manage members. The authz bypass seen in testing is caused by
stale direct owner relations (tracked in #1478), not a wrong
permission choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add high-level RPCs for project member management to remove client-side policy manipulation

2 participants