Skip to content

feat: add RemoveOrganizationMember RPC and remove RemoveOrganizationUser#1550

Open
rohilsurana wants to merge 1 commit intomainfrom
feat/remove-organization-member
Open

feat: add RemoveOrganizationMember RPC and remove RemoveOrganizationUser#1550
rohilsurana wants to merge 1 commit intomainfrom
feat/remove-organization-member

Conversation

@rohilsurana
Copy link
Copy Markdown
Member

Summary

  • Add RemoveOrganizationMember RPC to FrontierService, replacing the old RemoveOrganizationUser
  • New method supports all principal types (not just users) and performs a full cascade removal: org policies, project policies, group policies, and SpiceDB relations at org and group levels
  • Moves removal logic from deleter service into the membership package, consistent with the AddOrganizationMember and SetOrganizationMemberRole patterns

Changes

  • Proto: Update PROTON_COMMIT to b9da8ed (adds RemoveOrganizationMember, removes RemoveOrganizationUser)
  • core/membership/service.go: Add RemoveOrganizationMember, removeRelations helper, auditOrgMemberRemoved, and ProjectService/GroupService interfaces
  • Handler: Replace RemoveOrganizationUser handler with RemoveOrganizationMember, delegating entirely to membership service
  • Authorization: Update interceptor for the new RPC name and request fields
  • Wiring: Pass projectService and groupService to membership service constructor
  • Tests: Rewrite handler tests, update service test constructors, update e2e test

Cascade behavior

When a member is removed from an org:

  1. Validate membership exists (else ErrNotMember)
  2. Validate last-owner constraint (else ErrLastOwnerRole)
  3. List all policies for the principal, delete those belonging to org's projects, groups, and the org itself
  4. Remove SpiceDB relations (owner + member) at org level and for each org group
  5. Audit log the removal

Proto PR: raystack/proton#475

@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 Ready Ready Preview, Comment Apr 17, 2026 11:16am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Introduced RemoveOrganizationMember endpoint enabling flexible removal of organization members by principal type, expanding beyond user-specific removal.

Walkthrough

The PR renames the RemoveOrganizationUser operation to RemoveOrganizationMember and refactors its implementation to use a centralized membership service instead of user-deletion logic. It updates the Proton commit, adds ProjectService and GroupService dependencies to the membership service, and creates corresponding mocks for testing.

Changes

Cohort / File(s) Summary
Proto Dependency Update
Makefile
Updated PROTON_COMMIT hash to fetch latest protobuf definitions for organization member operations.
Membership Service Core
core/membership/service.go
Added ProjectService and GroupService interfaces; implemented RemoveOrganizationMember with policy validation, relation deletion, and audit logging.
Membership Service Mocks
core/membership/mocks/group_service.go, core/membership/mocks/project_service.go
Added autogenerated mock implementations for ProjectService and GroupService with List method support.
Membership Service Tests
core/membership/service_test.go
Updated test setup to wire ProjectService and GroupService mocks into membership service constructor.
Service Dependency Wiring
cmd/serve.go
Added projectService and groupService dependencies to membershipService initialization in buildAPIDependencies.
API Handler Interface & Mocks
internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/mocks/membership_service.go
Added RemoveOrganizationMember method signature to MembershipService interface and corresponding mock implementation with Call helpers.
API Handler Implementation
internal/api/v1beta1connect/organization.go
Renamed RemoveOrganizationUser handler to RemoveOrganizationMember; replaced user-deletion logic with membershipService.RemoveOrganizationMember call and updated error handling.
API Handler Tests & Authorization
internal/api/v1beta1connect/organization_test.go, pkg/server/connect_interceptors/authorization.go
Updated test to use RemoveOrganizationMember with new request/response types; updated authorization procedure mapping and resource ID extraction.
End-to-End Tests
test/e2e/regression/api_test.go
Updated test call to RemoveOrganizationMember with new request structure including OrgId, PrincipalId, and PrincipalType.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • whoAbhishekSah
  • AmanGIT07
  • rsbh

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.

@coveralls
Copy link
Copy Markdown

Coverage Report for CI Build 24562341248

Coverage decreased (-0.1%) to 41.69%

Details

  • Coverage decreased (-0.1%) from the base build.
  • Patch coverage: 105 uncovered changes across 4 files (18 of 123 lines covered, 14.63%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/membership/service.go 98 2 2.04%
internal/api/v1beta1connect/organization.go 20 16 80.0%
pkg/server/connect_interceptors/authorization.go 3 0 0.0%
cmd/serve.go 2 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 37023
Covered Lines: 15435
Line Coverage: 41.69%
Coverage Strength: 11.79 hits per line

💛 - Coveralls

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: 3

🧹 Nitpick comments (4)
test/e2e/regression/api_test.go (1)

290-294: Strengthen this regression to assert cascade cleanup.

This swap only proves the principal disappears from ListUsers. The new RPC’s risky behavior is the cascade through project/group policies and relations, and that can regress while this test still passes. Add at least one post-removal access check against a project, group, or resource created under the org.

internal/api/v1beta1connect/organization_test.go (1)

1009-1111: Consider adding a non-user principal test case.

The PR objective highlights that RemoveOrganizationMember "supports all principal types (not limited to users)", but every test case hardcodes schema.UserPrincipal. A table entry exercising schema.ServiceUserPrincipal would guard against regressions in principal-type plumbing from the handler through the service.

core/membership/service.go (2)

225-242: Consider validating principalType at the entry.

Unlike AddOrganizationMember / SetOrganizationMemberRole, there's no validatePrincipal (or even a simple allowlist check) for principalType. An empty or unknown value silently falls through to policyService.List, almost always returns zero rows, and surfaces as ErrNotMember — indistinguishable from a legitimate "not a member" case. Rejecting unknown principal types up front (via ErrInvalidPrincipal or similar) would give callers clearer errors and prevent misleading audit entries for malformed inputs.


262-268: Broad policyService.List query for cross-org users.

policy.Filter{PrincipalID, PrincipalType} with no OrgID returns the principal's policies across every org they belong to; the subsequent switch then filters down to this org's resources. For users who are members of many orgs, this fetches significantly more rows than necessary.

If policy.Filter supports it, scoping project/group lookups to the pre-computed orgProjectIDs/orgGroupIDs (or filtering per-resource-type with OrgID) would be tighter. Not a correctness issue, but worth considering for hot paths.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7d8d6e4c-3662-440a-b319-6bc66d03f48d

📥 Commits

Reviewing files that changed from the base of the PR and between a8a19f3 and 4606aec.

⛔ Files ignored due to path filters (2)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
  • proto/v1beta1/frontierv1beta1connect/frontier.connect.go is excluded by !proto/**
📒 Files selected for processing (12)
  • Makefile
  • cmd/serve.go
  • core/membership/mocks/group_service.go
  • core/membership/mocks/project_service.go
  • core/membership/service.go
  • core/membership/service_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/membership_service.go
  • internal/api/v1beta1connect/organization.go
  • internal/api/v1beta1connect/organization_test.go
  • pkg/server/connect_interceptors/authorization.go
  • test/e2e/regression/api_test.go

Comment on lines +575 to +595
func (s *Service) auditOrgMemberRemoved(ctx context.Context, org organization.Organization, principalID, principalType string) {
s.auditRecordRepository.Create(ctx, auditrecord.AuditRecord{
Event: pkgAuditRecord.OrganizationMemberRemovedEvent,
Resource: auditrecord.Resource{
ID: org.ID,
Type: pkgAuditRecord.OrganizationType,
Name: org.Title,
},
Target: &auditrecord.Target{
ID: principalID,
Type: pkgAuditRecord.UserType,
},
OrgID: org.ID,
OccurredAt: time.Now(),
})

audit.GetAuditor(ctx, org.ID).Log(audit.OrgMemberDeletedEvent, audit.Target{
ID: principalID,
Type: principalType,
})
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '^\s*[A-Za-z][A-Za-z0-9]*\s*=\s*"(user|service_?user|app|principal)"' --type=go -g 'pkg/auditrecord/**'
rg -nP '\bType\b\s*=\s*"' --type=go -g 'pkg/auditrecord/**' -C1

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Find audit record related files
find . -name "*.go" -type f | xargs grep -l "AuditRecord" | head -20

Repository: raystack/frontier

Length of output: 769


🏁 Script executed:

# Search for audit type constants (different patterns)
rg -i "const.*type.*=" --type=go -g "*.go" | grep -i "audit\|user\|service" | head -30

Repository: raystack/frontier

Length of output: 401


🏁 Script executed:

# Look for schema principal type definitions
rg -n "UserPrincipal\|ServiceUserPrincipal" --type=go -g "*.go" | head -20

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Search for existing audit type assignments
rg -n "Target.*Type.*:" --type=go -A2 -B2 | head -50

Repository: raystack/frontier

Length of output: 1557


🏁 Script executed:

cat -n core/auditrecord/auditrecord.go | head -150

Repository: raystack/frontier

Length of output: 581


🏁 Script executed:

# Search for UserType and other type constants
rg -n "const.*Type\s*=" --type=go core/auditrecord/

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Find principal type constants
rg -n "UserPrincipal\|ServiceUserPrincipal" --type=go | head -20

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check the membership service RemoveOrganizationMember method
sed -n '225,311p' core/membership/service.go

Repository: raystack/frontier

Length of output: 2840


🏁 Script executed:

# Check how auditOrgMemberAdded and auditOrgMemberRoleChanged handle types for comparison
sed -n '600,700p' core/membership/service.go

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Find the imports in membership/service.go to locate pkgAuditRecord
head -50 core/membership/service.go | grep -E "import|auditrecord|audit"

Repository: raystack/frontier

Length of output: 224


🏁 Script executed:

# Read the models file to see Target structure
cat -n core/auditrecord/models/models.go

Repository: raystack/frontier

Length of output: 3556


🏁 Script executed:

# Search for type constant definitions (trying different patterns)
rg -n 'const.*=.*"(user|service|app)' --type=go | grep -i audit | head -20

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Find where audit types are defined
find . -name "*.go" -type f | xargs grep -l "OrganizationMemberRemovedEvent\|UserType" | head -10

Repository: raystack/frontier

Length of output: 277


🏁 Script executed:

cat -n pkg/auditrecord/consts.go

Repository: raystack/frontier

Length of output: 4248


🏁 Script executed:

# Also check the auditOrgMemberRemoved function in context
sed -n '575,610p' core/membership/service.go

Repository: raystack/frontier

Length of output: 698


🏁 Script executed:

# Check auditOrgMemberAdded for comparison
rg -n "func.*auditOrgMemberAdded" core/membership/service.go -A 25

Repository: raystack/frontier

Length of output: 920


🏁 Script executed:

# Verify schema principal type constants
rg -n "ServiceUserPrincipal\|UserPrincipal" --type=go pkg/schema/ | head -20

Repository: raystack/frontier

Length of output: 112


🏁 Script executed:

# Check the schema package structure
find . -path ./pkg/schema -name "*.go" -type f | head -5

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Read schema.go or similar to find principal type constants
rg -n "const.*Principal" --type=go | grep -E "schema|principal" | head -20

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Find where schema is imported
rg "schema\." --type=go core/membership/service.go | head -20

Repository: raystack/frontier

Length of output: 1400


🏁 Script executed:

# Search for schema package location
find . -name "*.go" -type f | xargs grep -l "const.*ServiceUserPrincipal" | head -5

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Broader search for ServiceUserPrincipal
rg "ServiceUserPrincipal" --type=go | head -20

Repository: raystack/frontier

Length of output: 3651


🏁 Script executed:

# Check RemoveOrganizationMember for schema usage
rg -n "schema\." core/membership/service.go | head -20

Repository: raystack/frontier

Length of output: 1476


🏁 Script executed:

# Find schema constant definitions
find . -name "*.go" -type f | xargs grep -l "const.*UserPrincipal.*=" | head -5

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check what principalType values are actually used in the codebase
rg -n 'PrincipalType.*=' --type=go | grep -v "^test/" | head -30

Repository: raystack/frontier

Length of output: 1823


🏁 Script executed:

# Verify the validatePrincipal method to understand supported types
rg -n "func.*validatePrincipal" core/membership/service.go -A 20

Repository: raystack/frontier

Length of output: 798


🏁 Script executed:

# Verify schema constants for UserPrincipal and ServiceUserPrincipal
find . -name "*.go" -path "*/pkg/schema/*" -o -path "*/pkg/*schema*" -name "*.go" | head -10

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Search for schema package definition more broadly
rg -n "const.*UserPrincipal\s*=" --type=go | head -20

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check how principalType is used elsewhere for audit purposes
rg -n "audit.*principalType\|principalType.*audit" --type=go -B 2 -A 2 | head -40

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Find where schema is defined by looking at imports
rg -n 'import.*".*schema' --type=go core/membership/service.go

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Search in the entire repo for schema constant definitions
rg "const \(" --type=go -A 50 | grep -E "UserPrincipal|ServiceUserPrincipal" | head -20

Repository: raystack/frontier

Length of output: 264


🏁 Script executed:

# Look for the schema package in any location
find . -type f -name "*.go" | xargs grep -l 'package schema' | head -5

Repository: raystack/frontier

Length of output: 141


🏁 Script executed:

# Check pkg directory structure
ls -la pkg/ | head -30

Repository: raystack/frontier

Length of output: 1073


🏁 Script executed:

cat -n internal/bootstrap/schema/schema.go | head -200

Repository: raystack/frontier

Length of output: 7332


🏁 Script executed:

# Also check what constants are defined in that file
rg -n "const.*Principal" internal/bootstrap/schema/schema.go

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check if service users are actually removed in any tests or code
rg "RemoveOrganizationMember.*ServiceUserPrincipal\|ServiceUserPrincipal.*RemoveOrganizationMember" --type=go

Repository: raystack/frontier

Length of output: 43


🏁 Script executed:

# Check the broader pattern - does anything actually call RemoveOrganizationMember with non-user principals
rg -n "RemoveOrganizationMember" --type=go | grep -v "func\|interface\|mock" | head -20

Repository: raystack/frontier

Length of output: 2561


🏁 Script executed:

# Find the API handler for RemoveOrganizationMember
rg -n "func.*RemoveOrganizationMember" --type=go internal/api/ | head -10

Repository: raystack/frontier

Length of output: 1594


🏁 Script executed:

# Check the handler implementation
rg -n "RemoveOrganizationMember" internal/api/v1beta1connect/organization.go -A 10 | head -40

Repository: raystack/frontier

Length of output: 1745


Audit record hardcodes UserType despite method accepting any principal type.

RemoveOrganizationMember (lines 225–311) accepts any principalType without validation and passes it to auditOrgMemberRemoved. However, auditOrgMemberRemoved unconditionally writes Target.Type: pkgAuditRecord.UserType to the audit repository, while correctly passing principalType to audit.GetAuditor(...).Log(). Removing a service user (or other principals) will produce an incorrect audit record.

This differs from auditOrgMemberAdded and auditOrgMemberRoleChanged, which safely hardcode UserType because their callers enforce validatePrincipal (which only accepts users currently). Since RemoveOrganizationMember has no such guard, the hardcoded type must be dynamic.

Map principalType to the appropriate pkgAuditRecord constant (e.g., UserType for schema.UserPrincipal, ServiceUserType for schema.ServiceUserPrincipal).

Comment on lines +517 to +520
case errors.Is(err, user.ErrNotExist):
return nil, connect.NewError(connect.CodeNotFound, ErrUserNotExist)
case errors.Is(err, user.ErrDisabled):
return nil, connect.NewError(connect.CodeFailedPrecondition, 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

Unreachable error branches: user.ErrNotExist / user.ErrDisabled.

membership.Service.RemoveOrganizationMember (see core/membership/service.go lines 225-311) never invokes validatePrincipal and never returns user.ErrNotExist or user.ErrDisabled — it only surfaces orgService.Get errors, ErrNotMember, ErrLastOwnerRole, and wrapped policy/relation errors. These two case arms are dead code and create a misleading impression that the handler validates principal existence.

Either drop them, or (preferable if you want symmetry with AddOrganizationMember) have the service validate the principal before cascading removal.

🧹 Proposed cleanup
 		case errors.Is(err, organization.ErrNotExist):
 			return nil, connect.NewError(connect.CodeNotFound, ErrNotFound)
-		case errors.Is(err, user.ErrNotExist):
-			return nil, connect.NewError(connect.CodeNotFound, ErrUserNotExist)
-		case errors.Is(err, user.ErrDisabled):
-			return nil, connect.NewError(connect.CodeFailedPrecondition, err)
 		case errors.Is(err, membership.ErrNotMember):
 			return nil, connect.NewError(connect.CodeFailedPrecondition, ErrNotMember)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case errors.Is(err, user.ErrNotExist):
return nil, connect.NewError(connect.CodeNotFound, ErrUserNotExist)
case errors.Is(err, user.ErrDisabled):
return nil, connect.NewError(connect.CodeFailedPrecondition, err)
case errors.Is(err, organization.ErrNotExist):
return nil, connect.NewError(connect.CodeNotFound, ErrNotFound)
case errors.Is(err, membership.ErrNotMember):
return nil, connect.NewError(connect.CodeFailedPrecondition, ErrNotMember)

Comment on lines +360 to +362
"/raystack.frontier.v1beta1.FrontierService/RemoveOrganizationMember": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
pbreq := req.(*connect.Request[frontierv1beta1.RemoveOrganizationMemberRequest])
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.OrganizationNamespace, ID: pbreq.Msg.GetOrgId()}, schema.UpdatePermission, req)
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

Tighten the permission gate for member removal.

RemoveOrganizationMember now cascades through org/project/group policies, but the interceptor still only requires schema.UpdatePermission. That lets callers who can update an org indirectly delete policies they couldn't manage directly. This should be guarded by the same policy-management permission as the operations it now performs, or the implementation should be split so the lower-privilege path cannot remove scoped policies.

🔐 Proposed fix
 "/raystack.frontier.v1beta1.FrontierService/RemoveOrganizationMember": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
 	pbreq := req.(*connect.Request[frontierv1beta1.RemoveOrganizationMemberRequest])
-	return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.OrganizationNamespace, ID: pbreq.Msg.GetOrgId()}, schema.UpdatePermission, req)
+	return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.OrganizationNamespace, ID: pbreq.Msg.GetOrgId()}, schema.PolicyManagePermission, req)
 },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"/raystack.frontier.v1beta1.FrontierService/RemoveOrganizationMember": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
pbreq := req.(*connect.Request[frontierv1beta1.RemoveOrganizationMemberRequest])
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.OrganizationNamespace, ID: pbreq.Msg.GetOrgId()}, schema.UpdatePermission, req)
"/raystack.frontier.v1beta1.FrontierService/RemoveOrganizationMember": func(ctx context.Context, handler *v1beta1connect.ConnectHandler, req connect.AnyRequest) error {
pbreq := req.(*connect.Request[frontierv1beta1.RemoveOrganizationMemberRequest])
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.OrganizationNamespace, ID: pbreq.Msg.GetOrgId()}, schema.PolicyManagePermission, req)
},

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