Skip to content

feat: delete AddOrganizationUsers RPC and migrate all org.AddMember callers to membership#1548

Open
whoAbhishekSah wants to merge 13 commits intomainfrom
feat/remove-add-org-users-rpc
Open

feat: delete AddOrganizationUsers RPC and migrate all org.AddMember callers to membership#1548
whoAbhishekSah wants to merge 13 commits intomainfrom
feat/remove-add-org-users-rpc

Conversation

@whoAbhishekSah
Copy link
Copy Markdown
Member

@whoAbhishekSah whoAbhishekSah commented Apr 17, 2026

Summary

Deletes the AddOrganizationUsers RPC (no SDK usage, replaced by AddOrganizationMembers) and migrates all callers of org.AddMember / org.AddUsers to use membership.AddOrganizationMember. Both functions are then deleted from the organization service.

Wins

  • Invitation uses the invited role: Old flow created 2 policies (hardcoded viewer + invite role). New flow creates 1 policy with the invite's role directly via membership. No more stacked policies.
  • Fewer SpiceDB calls: Domain auto-join dropped a redundant ListByUser (SpiceDB LookupResources) — AddOrganizationMember already checks membership internally
  • Fewer cross-service dependencies: policyService removed from invitation service entirely
  • Stricter validation at all call sites: User exists + enabled, org exists + enabled, role scope — none of which the old AddMember checked
  • Compensating cleanup: If relation creation fails after policy, the orphaned policy is cleaned up (old code left it)
  • Consistent audit: All member additions now go through the same audit path in the membership package

Behavioral audit

Call site Old New Risk
org.Create AddMember("owner", principal) — no validation membership.AddOrganizationMember(ownerRole) — validates user, role, already-member Low — rejects non-user principals (org creation is user-only in practice)
org.AdminCreate Same, hardcodes UserPrincipal Same None
invitation.Accept AddMember(viewer) + policyService.Create(inviteRole) — 2 policies membership.AddOrganizationMember(inviteRole) — 1 policy with invited role Improved — user gets invited role, not viewer + invited role stacked
domain.Service ListByUser + AddMember(viewer) membership.AddOrganizationMember(viewer) — handles ErrAlreadyMember None — saves a SpiceDB call

What's deleted

  • AddOrganizationUsers handler + auth interceptor + tests
  • org.AddUsers(), org.AddMember(), mapPrincipalTypeToAuditType()
  • AddMember from domain/invitation OrgService interfaces
  • Redundant ListByUser in domain auto-join
  • policyService dependency from invitation service
  • Raw policyService.Create loop in invitation accept

Related

🤖 Generated with Claude Code

whoAbhishekSah and others added 5 commits April 17, 2026 15:07
…mbers

AddOrganizationUsers had no SDK usage and was replaced by
AddOrganizationMembers (AdminService) which takes explicit roles
and returns per-member results.

Removed:
- AddOrganizationUsers handler
- AddUsers from OrganizationService interface
- org.AddUsers() service function
- Auth interceptor entry
- Handler tests

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

org.Create and AdminCreate now call membership.AddOrganizationMember
instead of org.AddMember to add the creator as owner. Membership
dependency injected via SetMembershipService() setter to break
circular init order.

Behavioral change: new code validates user exists+enabled and role
scope. Old code trusted the authn principal blindly. Non-user
principals (serviceuser/PAT) are now rejected — org creation is
user-only in practice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
invitation.Accept now calls membershipSvc.AddOrganizationMember
instead of orgSvc.AddMember to add user on invitation acceptance.
Uses RoleOrganizationViewer (same as old MemberRole).

No behavioral change in practice — caller already checks
isUserOrgMember before calling, so ErrAlreadyMember won't trigger.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Domain auto-join now calls membershipSvc.AddOrganizationMember
instead of orgSvc.AddMember. Uses RoleOrganizationViewer.
Removed AddMember from domain's OrgService interface.

No behavioral change in practice — caller already checks
ListByUser for existing membership before calling.

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

vercel bot commented Apr 17, 2026

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

Project Deployment Actions Updated (UTC)
frontier Error Error Apr 20, 2026 6:11am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

Warning

Rate limit exceeded

@whoAbhishekSah has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 25 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 2 minutes and 25 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 32de17e8-d366-47c1-bdca-631cfc3a3334

📥 Commits

Reviewing files that changed from the base of the PR and between f4cad42 and 96e4d3b.

📒 Files selected for processing (6)
  • core/organization/errors.go
  • core/organization/service.go
  • internal/api/v1beta1connect/organization.go
  • test/e2e/regression/api_test.go
  • test/e2e/regression/onboarding_test.go
  • test/e2e/regression/serviceusers_test.go
📝 Walkthrough

Walkthrough

Introduces a dedicated MembershipService and migrates organization membership creation to it. Removes legacy AddMember/AddUsers flows, updates service constructors and mocks, deletes the AddOrganizationUsers API handler and its auth entry, and updates tests to use the new MembershipService.

Changes

Cohort / File(s) Summary
Service Interface & Logic
core/domain/service.go, core/organization/service.go, core/invitation/service.go
Adds a MembershipService interface and wiring; removes OrgService.AddMember/AddUsers; replaces internal membership creation with AddOrganizationMember(...); treats already-member errors as success; removes previous policy/relation creation logic tied to AddMember.
Server Wiring
cmd/serve.go
Wires membershipService into organizationService via SetMembershipService(...) and passes it into domain/invitation service constructors.
API Surface & Handler Removal
internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/organization.go
Removes AddUsers from the OrganizationService interface and deletes ConnectHandler.AddOrganizationUsers.
Authorization Config
pkg/server/connect_interceptors/authorization.go
Removes authorization validation entry for /raystack.frontier.v1beta1.FrontierService/AddOrganizationUsers.
Mocks Added
core/invitation/mocks/membership_service.go, core/organization/mocks/membership_service.go
Adds autogenerated mocks for the new MembershipService (expecter/call helpers and NewMembershipService).
Mocks Removed/Updated
core/invitation/mocks/organization_service.go, internal/api/v1beta1connect/mocks/organization_service.go
Removes mock methods and helpers for deleted AddMember/AddUsers methods.
Tests Updated/Removed
core/invitation/service_test.go, core/organization/service_test.go, internal/api/v1beta1connect/organization_test.go, test/e2e/regression/serviceusers_test.go
Updates tests to inject MembershipService mock; removes TestService_AddMember and TestHandler_AddOrganizationUsers; adjusts an e2e case expecting creation to fail for service users.
Other
(various)
Minor signature and wiring updates across services and constructors to accept the new membership dependency.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • rohilsurana
  • rsbh
  • AmanGIT07

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.

Caution

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

⚠️ Outside diff range comments (1)
core/organization/service.go (1)

89-112: ⚠️ Potential issue | 🟠 Major

Nil-pointer risk: membershipService is not enforced before use.

NewService doesn't accept membershipService, and the field defaults to nil. Any caller that constructs the service via NewService and then calls Create (line 217) or AdminCreate (line 463) before SetMembershipService(...) is invoked will hit a nil-pointer dereference inside AddOrganizationMember. cmd/serve.go wires it correctly today, but tests, CLI tools, or future call sites can easily miss the setter — and the failure mode is a panic at request time rather than an init-time error.

Consider one of the following hardenings:

Option A — Defensive check with clear error (minimal change)
 func (s Service) Create(ctx context.Context, org Organization) (Organization, error) {
+  if s.membershipService == nil {
+    return Organization{}, fmt.Errorf("organization service: membership service not configured")
+  }
   principal, err := s.authnService.GetPrincipal(ctx)

(and the analogous guard at the top of AdminCreate).

Option B — Return an error from the setter and validate wiring at startup
-func (s *Service) SetMembershipService(ms MembershipService) {
-  s.membershipService = ms
+func (s *Service) SetMembershipService(ms MembershipService) error {
+  if ms == nil {
+    return errors.New("membership service is nil")
+  }
+  s.membershipService = ms
+  return nil
 }

This at least surfaces misuse at the wiring boundary in cmd/serve.go.

Option A is the safer runtime guard; Option B catches misconfiguration earlier. Either is preferable to a silent panic.

🧹 Nitpick comments (2)
core/invitation/service_test.go (1)

73-75: Consider adding a test for the new Accept membership path.

This PR rewires Accept(...) to call membershipSvc.AddOrganizationMember(...) with schema.RoleOrganizationViewer, but no test asserts that expectation. Adding a TestService_Accept case that mocks membershipSvc.EXPECT().AddOrganizationMember(ctx, orgID, userID, schema.UserPrincipal, schema.RoleOrganizationViewer).Return(nil) would lock in the behavior and guard against regressions (e.g., role or principal type drift).

core/domain/service.go (1)

32-34: Interface declaration — consider consolidating.

The same MembershipService interface shape (AddOrganizationMember(ctx, orgID, principalID, principalType, roleID) error) is now declared independently in core/domain/service.go, core/organization/service.go, and core/invitation/service.go. That's idiomatic Go (consumer-defined interfaces), but if the contract evolves (e.g., a bulk variant or additional parameters), you'll need to update each site. Fine to keep as-is; worth noting for future consistency.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 73cd3d8b-e244-4537-9b5e-2828bb72d5a8

📥 Commits

Reviewing files that changed from the base of the PR and between a8a19f3 and 3d1f062.

📒 Files selected for processing (14)
  • cmd/serve.go
  • core/domain/service.go
  • core/invitation/mocks/membership_service.go
  • core/invitation/mocks/organization_service.go
  • core/invitation/service.go
  • core/invitation/service_test.go
  • core/organization/mocks/membership_service.go
  • core/organization/service.go
  • core/organization/service_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/organization_service.go
  • internal/api/v1beta1connect/organization.go
  • internal/api/v1beta1connect/organization_test.go
  • pkg/server/connect_interceptors/authorization.go
💤 Files with no reviewable changes (7)
  • pkg/server/connect_interceptors/authorization.go
  • internal/api/v1beta1connect/interfaces.go
  • core/organization/service_test.go
  • internal/api/v1beta1connect/organization_test.go
  • internal/api/v1beta1connect/organization.go
  • internal/api/v1beta1connect/mocks/organization_service.go
  • core/invitation/mocks/organization_service.go

@coveralls
Copy link
Copy Markdown

coveralls commented Apr 17, 2026

Coverage Report for CI Build 24651258705

Coverage decreased (-0.09%) to 41.718%

Details

  • Coverage decreased (-0.09%) from the base build.
  • Patch coverage: 35 uncovered changes across 5 files (2 of 37 lines covered, 5.41%).
  • 13 coverage regressions across 3 files.

Uncovered Changes

File Changed Covered %
core/domain/service.go 11 0 0.0%
core/invitation/service.go 12 2 16.67%
core/organization/service.go 8 0 0.0%
cmd/serve.go 4 0 0.0%
internal/api/v1beta1connect/organization.go 2 0 0.0%

Coverage Regressions

13 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
core/organization/service.go 8 26.04%
core/invitation/service.go 4 22.73%
core/domain/service.go 1 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 36802
Covered Lines: 15353
Line Coverage: 41.72%
Coverage Strength: 11.85 hits per line

💛 - Coveralls

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

Add comments at each call site explaining that Add is used because
the user is not yet a member (org just created, or verified not a
member by the caller). Set would fail with ErrNotMember.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AddOrganizationMember already checks if the user is a member and
returns ErrAlreadyMember. No need for a separate ListByUser call
before Add — just handle the error. Saves a SpiceDB LookupResources
round-trip on the happy path.

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

Accept invitation now uses the first role from invite.RoleIDs (falls
back to viewer if empty) via membership.AddOrganizationMember. This
replaces the old two-step flow: hardcoded viewer AddMember + raw
policyService.Create loop for invite roles.

Also removes policyService dependency from invitation service entirely
— one fewer cross-service dependency.

If multiple role support is needed in the future, the comment at the
role selection marks where to change.

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 (3)
core/organization/service.go (1)

108-112: Consider a nil guard on membershipService to fail fast.

membershipService is injected via setter after construction, so any caller that forgets to wire it (a real risk as new bootstrap paths are added, or in tests that construct organization.Service directly) will hit a nil-pointer panic inside Create/AdminCreate only when the code path is exercised. A defensive check — either at call sites (lines 206, 453) or a validation in SetMembershipService/at the start of Create/AdminCreate — would turn a latent panic into a clear error. Not blocking; just a robustness nit given the intentional setter-injection design.

core/invitation/service_test.go (1)

36-91: Add tests for the new Accept membership flow.

The Coveralls report flags 2 of 4 changed lines in core/invitation/service.go as uncovered, and the only test here exercises the "already member" short-circuit in Create. Adding cases for Accept that verify: (a) membershipSvc.AddOrganizationMember is called with the role from invite.RoleIDs[0] when conf.WithRoles=true, (b) it falls back to schema.RoleOrganizationViewer when WithRoles=false or RoleIDs is empty, and (c) membership.ErrAlreadyMember is swallowed and the acceptance continues to group add / audit / delete, would lock in the new behavior and recover coverage.

core/invitation/service.go (1)

305-314: getConfig is re-evaluated at accept time — double-check this is intended.

orgRoleID here depends on the current value of conf.WithRoles at accept time, but invite.RoleIDs was only persisted when WithRoles was true at create time (see line 137-140 in Create). The asymmetry has two quirks:

  • If WithRoles was true at create and false at accept, invite.RoleIDs is populated but will be ignored — user silently gets viewer.
  • If WithRoles was false at create and true at accept, RoleIDs is empty anyway, so viewer is applied (fine).

The first case is probably fine as platform-level policy, but if WithRoles was the intent at creation, honoring the stored RoleIDs unconditionally (or at least logging when they are dropped at accept) would be more predictable.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 17415b4e-af3b-4abb-b7bd-acf7770c20f2

📥 Commits

Reviewing files that changed from the base of the PR and between 3d1f062 and 97a9695.

📒 Files selected for processing (5)
  • cmd/serve.go
  • core/domain/service.go
  • core/invitation/service.go
  • core/invitation/service_test.go
  • core/organization/service.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/domain/service.go

Comment on lines 300 to 324
userOb, userOrgMember, err := s.isUserOrgMember(ctx, invite.OrgID, invite.UserEmailID)
if err != nil {
return err
}

// Determine the org role to assign.
// Currently only the first role ID from the invitation is used. If multiple
// role support is needed in the future, this is the place to change — either
// loop over invite.RoleIDs calling SetOrganizationMemberRole for each, or
// extend the membership package to accept multiple roles.
orgRoleID := schema.RoleOrganizationViewer
conf := s.getConfig(ctx)
if conf.WithRoles && len(invite.RoleIDs) > 0 {
orgRoleID = invite.RoleIDs[0]
}

if !userOrgMember {
// if not, add user to the organization
if err = s.orgSvc.AddMember(ctx, invite.OrgID, schema.MemberRelationName, authenticate.Principal{
ID: userOb.ID,
Type: schema.UserPrincipal,
}); err != nil {
// User is not yet a member — add with the invitation's role.
// ErrAlreadyMember is possible in a race (user added between invite creation
// and acceptance) — treat as success since the user is already in the org.
err = s.membershipSvc.AddOrganizationMember(ctx, invite.OrgID, userOb.ID, schema.UserPrincipal, orgRoleID)
if err != nil && !errors.Is(err, membership.ErrAlreadyMember) {
return 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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's see the full Accept method to understand the complete flow
find . -name "service.go" -path "*/invitation/*" -type f

Repository: raystack/frontier

Length of output: 89


🏁 Script executed:

# Get the full Accept method
head -400 core/invitation/service.go | tail -150

Repository: raystack/frontier

Length of output: 4127


🏁 Script executed:

# Also check the broader context around line 300-324 and earlier
sed -n '250,330p' core/invitation/service.go

Repository: raystack/frontier

Length of output: 2645


🏁 Script executed:

# Look for any role upgrade logic or SetOrganizationMemberRole calls in Accept
rg -A 5 -B 5 "SetOrganizationMemberRole|SetMemberRole" core/invitation/service.go

Repository: raystack/frontier

Length of output: 545


🏁 Script executed:

# Check if there are tests for Accept behavior with existing members
rg -A 10 "Accept.*exist|already.*member|TestAccept" core/invitation/service_test.go | head -100

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Let's also check the git history to see what was removed
git log --oneline -20 core/invitation/service.go

Repository: raystack/frontier

Length of output: 152


🏁 Script executed:

# Get the git diff for the commit that removed policy CRUD
git show 97a96952 -- core/invitation/service.go | head -300

Repository: raystack/frontier

Length of output: 9998


🏁 Script executed:

# Also check if there are any tests that show the expected behavior for existing members accepting invites
rg -B 5 -A 15 "TestAccept.*existing|already.*member" core/invitation/service_test.go

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Let's search for tests related to Accept to understand the expected behavior
rg -B 3 -A 20 "func.*Accept" core/invitation/service_test.go | head -150

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check if there's any test for existing members accepting invites
rg -B 5 -A 15 "already.*member|existing.*member" core/invitation/service_test.go

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Let's also check the membership service to understand AddOrganizationMember behavior
rg -B 2 -A 10 "AddOrganizationMember" core/membership/service.go | head -50

Repository: raystack/frontier

Length of output: 699


🏁 Script executed:

# Try to find the previous version of the Accept method before the refactor
# Look for any backup or see if there's another branch
git log --all --oneline -- core/invitation/service.go | head -10

Repository: raystack/frontier

Length of output: 753


🏁 Script executed:

# Check the previous commit to see what Accept looked like
git show 97a96952^:core/invitation/service.go 2>/dev/null | sed -n '/func.*Accept/,/^func/p' | head -100

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check PR discussion or related comments
git log --format="%B" 97a96952

Repository: raystack/frontier

Length of output: 679


🏁 Script executed:

# Try to get the previous version by checking 79323c02 (the commit before)
git show 79323c02:core/invitation/service.go 2>/dev/null | sed -n '/func.*Accept/,/^func/p' | head -100

Repository: raystack/frontier

Length of output: 2765


🏁 Script executed:

# Let's look at the diff more carefully - check if core/invitation/service.go existed before
git log --diff-filter=A --name-only --pretty=format: -- core/invitation/service.go | head -1

Repository: raystack/frontier

Length of output: 87


🏁 Script executed:

# Check git history to see if this file was renamed or created fresh
git log --oneline --follow -- core/invitation/service.go | tail -20

Repository: raystack/frontier

Length of output: 152


Behavior change confirmed: invite roles are no longer applied to users who are already org members.

Previously, Accept created per-role org policies via a policyService.Create loop that ran regardless of existing membership status. Now, when userOrgMember == true, the role assignment block is entirely skipped. This is an intentional design change (per the refactoring that removed raw policy CRUD in favor of AddOrganizationMember).

The code comment acknowledges this and suggests the upgrade path for future multi-role support: loop over invite.RoleIDs calling SetOrganizationMemberRole (or equivalent) inside the role-assignment block. If accepting an invite should upgrade an existing member's role, implement that suggestion; otherwise, document explicitly that already-member invitations are role no-ops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
org.Create now rejects non-user principals upfront. Service users
should not create organizations — they are bound to an existing org
at creation time.

Updated e2e test to expect failure instead of success when a service
user attempts to create an org.

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

All 14 e2e test calls (12 in api_test.go, 2 in onboarding_test.go)
migrated from the deleted AddOrganizationUsers RPC to the new
AddOrganizationMembers AdminService RPC with explicit viewer role.

Role UUID looked up once in SetupSuite via ListRoles.

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.

2 participants