Skip to content

refactor(group): move group-delete cleanup into membership package#1615

Merged
whoAbhishekSah merged 6 commits into
mainfrom
refactor/group-delete-via-membership
May 15, 2026
Merged

refactor(group): move group-delete cleanup into membership package#1615
whoAbhishekSah merged 6 commits into
mainfrom
refactor/group-delete-via-membership

Conversation

@whoAbhishekSah
Copy link
Copy Markdown
Member

@whoAbhishekSah whoAbhishekSah commented May 14, 2026

Mirrors project's deletion pattern (handler → deleter orchestrator → DeleteModel + membership cleanup) and centralises every SpiceDB write performed during a group's lifetime in one package.

What we create vs. what we delete (service-level audit)

A group's lifetime touches SpiceDB through exactly two service primitives. From that abstraction, every write must be matched by an explicit delete on the same primitive.

relation.Service writes (outside policy.AssignRole)

Created by Tuple Deleted by
linkGroupToOrg group#org@organization unlinkGroupFromOrg
linkGroupToOrg organization#member@group#member unlinkGroupFromOrg
SetGroupMemberRole (add path) group#owner@user / group#member@user RemoveAllGroupMembersremoveRelations

policy.Service writes

Created by Policy shape Deleted by
SetGroupMemberRole resource=group, principal=user RemoveAllGroupMemberspolicyService.Delete
External callers principal=group, resource=<other> (group granted role elsewhere) removeGroupAsPrincipalPoliciespolicyService.Delete

Everything created via the group/membership code path is matched 1:1 with an explicit delete via the same primitive. No wildcard sweeps, no defensive cleanup.

Orchestration

core/deleter.DeleteGroup(id) is the new orchestrator:

  1. membership.OnGroupDeleted(id) — tears down all SpiceDB state above. Symmetric counterpart to OnGroupCreated.
  2. group.Service.DeleteModel(id) — drops the entity row + emits audit. Pure entity cleanup, no SpiceDB writes.

RemoveAllGroupMembers uses a two-pass approach: delete every policy first, track which principals had a failure, then clean up direct relations only for principals whose policies were all deleted. Prevents stripping a principal's relations while a surviving policy still references them; a retry can finish the job.

Known leak (separate, broader)

policy.AssignRole writes three SpiceDB tuples but policy.Delete only cleans two of them — the resource→rolebinding grant tuple leaks on every policy removal across the codebase (org / project / group member removal all hit it). The historical wildcard Delete(Object: <resource>) sweep in group.DeleteModel / project.DeleteModel was a workaround that only covered the same-resource case. Tracked separately in #1617 — not in scope here.

Other pieces

  • DeleteGroup handler routes through deleterService instead of calling groupService directly.
  • group.RemoveUsers / removeUsers stay — they serve the single-user cascade in deleter.DeleteUser (user is being destroyed system-wide), same dual-track as org.

Test plan

  • go build ./...
  • go test ./core/membership/... ./core/group/... ./core/deleter/... ./internal/api/v1beta1connect/...
  • New unit tests for RemoveAllGroupMembers (principal-dedupe, partial-failure leaves relations for the failed principal intact) and OnGroupDeleted (full three-way cleanup, group-not-found). Existing deleter cascade test updated.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 14, 2026

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

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment May 15, 2026 8:17am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Review Change Stack

Warning

Rate limit exceeded

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

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e48deca5-3b63-431e-afe5-d9c55658d431

📥 Commits

Reviewing files that changed from the base of the PR and between 8641d84 and 2b30c2a.

📒 Files selected for processing (13)
  • cmd/serve.go
  • core/deleter/mocks/group_service.go
  • core/deleter/mocks/membership_service.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • core/group/service.go
  • core/membership/service.go
  • core/membership/service_test.go
  • internal/api/v1beta1connect/group.go
  • internal/api/v1beta1connect/group_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/cascade_deleter.go
  • internal/api/v1beta1connect/mocks/group_service.go
📝 Walkthrough

Walkthrough

Introduce MembershipService cleanup APIs and call them from the cascade deleter: add RemoveAllGroupMembers, OnGroupDeleted, and removeGroupAsPrincipalPolicies; replace group.Service.Delete with DeleteModel and wire membership cleanup into the cascade deleter.

Changes

Group deletion refactoring

Layer / File(s) Summary
Membership: RemoveAllGroupMembers
core/membership/service.go
RemoveAllGroupMembers(ctx, groupID) deletes group policies, deduplicates principals, removes owner/member SpiceDB relations for principals whose policy deletes succeeded, and aggregates partial errors with errors.Join.
Membership: OnGroupDeleted orchestration
core/membership/service.go, core/membership/service_test.go
OnGroupDeleted(ctx, groupID) runs member cleanup, deletes group-as-principal policies, unlinks the group from its org, and performs a defensive relation sweep; errors are joined. Tests added for success and error paths.
Membership: removeGroupAsPrincipalPolicies helper
core/membership/service.go
Deletes policies where the group is the principal (GroupPrincipal + principalID=groupID) and aggregates deletion errors.
Cascade deleter interface & wiring
core/deleter/service.go, cmd/serve.go, core/deleter/service_test.go
NewCascadeDeleter gains membershipService dependency; Service adds a membershipService field and DeleteGroup method that calls membershipService.OnGroupDeleted then groupService.DeleteModel. DeleteOrganization now calls DeleteGroup for each group; tests and server wiring updated.
Mocks: Group service mock updates
core/deleter/mocks/group_service.go, internal/api/v1beta1connect/mocks/*
Mocks updated to remove Delete and add DeleteModel; new MembershipService mock added; connect-layer mocks/tests updated to use CascadeDeleter.DeleteGroup.
API handler
internal/api/v1beta1connect/group.go, internal/api/v1beta1connect/group_test.go
Handler DeleteGroup now calls deleterService.DeleteGroup; connect tests updated to assert DeleteGroup calls on cascade deleter mock.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • rohilsurana
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

@coveralls
Copy link
Copy Markdown

coveralls commented May 14, 2026

Coverage Report for CI Build 25907737689

Coverage increased (+0.08%) to 42.311%

Details

  • Coverage increased (+0.08%) from the base build.
  • Patch coverage: 20 uncovered changes across 5 files (50 of 70 lines covered, 71.43%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/membership/service.go 58 44 75.86%
core/deleter/service.go 7 5 71.43%
core/group/service.go 2 0 0.0%
cmd/serve.go 1 0 0.0%
internal/api/v1beta1connect/group.go 2 1 50.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 37737
Covered Lines: 15967
Line Coverage: 42.31%
Coverage Strength: 11.87 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: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1aaa74b4-fcef-4791-b54f-6a2ef06dd697

📥 Commits

Reviewing files that changed from the base of the PR and between 2fe010a and 8a646ca.

📒 Files selected for processing (13)
  • cmd/serve.go
  • core/deleter/mocks/group_service.go
  • core/deleter/mocks/membership_service.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • core/group/service.go
  • core/membership/service.go
  • core/membership/service_test.go
  • internal/api/v1beta1connect/group.go
  • internal/api/v1beta1connect/group_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/cascade_deleter.go
  • internal/api/v1beta1connect/mocks/group_service.go
💤 Files with no reviewable changes (1)
  • internal/api/v1beta1connect/mocks/group_service.go

Comment thread core/group/service.go Outdated
Comment thread core/membership/service.go Outdated
whoAbhishekSah added a commit that referenced this pull request May 15, 2026
- core/group: use schema.GroupNamespace (semantic constant for the
  group-as-resource Object) instead of schema.GroupPrincipal in
  DeleteModel's wildcard relation delete. Both values are the same
  string today, but GroupNamespace is the correct constant for this
  call site.

- core/membership.RemoveAllGroupMembers: split into two passes.
  First pass attempts every policy delete and tracks principals with
  any failure. Second pass clears direct relations only for principals
  whose policies were all deleted. This avoids leaving a surviving
  policy without its matching SpiceDB tuples when a per-policy delete
  fails mid-loop — the previous code could strip relations after one
  policy succeeded for a principal even though a later policy delete
  for the same principal failed.

Co-Authored-By: Claude Opus 4.7 (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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e53ac88a-4d76-4836-bd01-b6c8cbf1f29b

📥 Commits

Reviewing files that changed from the base of the PR and between 0f8498f and d5b7ab7.

📒 Files selected for processing (3)
  • core/group/service.go
  • core/membership/service.go
  • core/membership/service_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/membership/service_test.go

Comment thread core/membership/service.go Outdated
@whoAbhishekSah
Copy link
Copy Markdown
Member Author

Manual Testing Results

Tested group deletion scenarios with fresh org pr1615-034ec2:

Test Description Result
1 User loses project access when their only group is deleted ✅ PASS
2 User retains project access if they have direct access ✅ PASS
3 User retains project access via other groups ✅ PASS
4 Org membership preserved after group deletion ✅ PASS
5 No orphaned policies for deleted groups ✅ PASS
6 No stale group refs in project user lists ✅ PASS
7 System functional after deletions ✅ PASS

Setup

  • group-alpha: alice, bob → viewer on project-one & project-two
  • group-beta: charlie, dave, alice → manager on project-two
  • bob: also direct viewer on project-one (edge case)

Verified Behaviors

  1. Deleting group-alpha: alice lost project-one access, bob retained it (direct), bob lost project-two access
  2. Deleting group-beta: alice/charlie/dave lost project-two access
  3. All users retained org membership after both groups deleted
  4. No orphaned policies or stale references found

whoAbhishekSah added a commit that referenced this pull request May 15, 2026
- core/group: use schema.GroupNamespace (semantic constant for the
  group-as-resource Object) instead of schema.GroupPrincipal in
  DeleteModel's wildcard relation delete. Both values are the same
  string today, but GroupNamespace is the correct constant for this
  call site.

- core/membership.RemoveAllGroupMembers: split into two passes.
  First pass attempts every policy delete and tracks principals with
  any failure. Second pass clears direct relations only for principals
  whose policies were all deleted. This avoids leaving a surviving
  policy without its matching SpiceDB tuples when a per-policy delete
  fails mid-loop — the previous code could strip relations after one
  policy succeeded for a principal even though a later policy delete
  for the same principal failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@whoAbhishekSah whoAbhishekSah force-pushed the refactor/group-delete-via-membership branch from 245e293 to 8641d84 Compare May 15, 2026 06:06
whoAbhishekSah and others added 6 commits May 15, 2026 13:46
Mirrors the project deletion pattern (handler → deleter → DeleteModel +
membership cleanup):

- core/membership.RemoveAllGroupMembers: deletes every policy on the
  group and every per-principal owner/member relation. No min-owner
  check; the group itself is being destroyed.
- core/group.Service.DeleteModel: minimal entity teardown — Object-side
  relations on the group + repo delete + audit. Replaces the previous
  combined Delete method that mixed membership cleanup into the entity
  service.
- core/deleter.DeleteGroup: orchestrator that calls
  RemoveAllGroupMembers then DeleteModel. Wired into the
  DeleteOrganization cascade and exposed via the CascadeDeleter
  interface.
- DeleteGroup handler routes through deleterService instead of
  groupService directly.

group.RemoveUsers / removeUsers stay — they serve the single-user
remove path in deleter.DeleteUser (cascade where the user is being
destroyed system-wide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DeleteModel only clears Object-side relations on the group, which misses
the inverse hierarchy tuple created in OnGroupCreated:
  organization#member@group#member
(Object is the org, Subject is the group — pre-existing leak in the old
group.Service.Delete; surfaced during this refactor's audit.)

Adds membership.OnGroupDeleted as the symmetric counterpart to
OnGroupCreated: bundles RemoveAllGroupMembers + unlinkGroupFromOrg so
both hierarchy directions plus member policies/relations are cleaned up
in one call. deleter.DeleteGroup now invokes it before DeleteModel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OnGroupDeleted previously only deleted policies where the group was the
*resource* (memberships into the group). Policies where the group is
the *principal* on some other resource — e.g. CreatePolicy granting a
group access to a project — were leaked when the group was destroyed,
leaving orphaned bindings.

Adds removeGroupAsPrincipalPolicies which queries policies by principal
and deletes them, called from OnGroupDeleted alongside the existing
member + hierarchy cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- core/group: use schema.GroupNamespace (semantic constant for the
  group-as-resource Object) instead of schema.GroupPrincipal in
  DeleteModel's wildcard relation delete. Both values are the same
  string today, but GroupNamespace is the correct constant for this
  call site.

- core/membership.RemoveAllGroupMembers: split into two passes.
  First pass attempts every policy delete and tracks principals with
  any failure. Second pass clears direct relations only for principals
  whose policies were all deleted. This avoids leaving a surviving
  policy without its matching SpiceDB tuples when a per-policy delete
  fails mid-loop — the previous code could strip relations after one
  policy succeeded for a principal even though a later policy delete
  for the same principal failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wildcard Object-side relation sweep now lives in membership.OnGroupDeleted
alongside the policy/member/hierarchy cleanup. group.DeleteModel is reduced
to entity-only cleanup (repo delete + audit log) so all SpiceDB writes are
owned by a single package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sweep was a workaround for a leak in policy.Delete (resource-side
grant tuple not cleaned up — tracked in #1617). It doesn't belong in
the membership package: every relation and policy created during a
group's lifetime is now matched 1:1 with an explicit delete via the
same service primitive. The leak will be addressed at policy.Delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@whoAbhishekSah whoAbhishekSah force-pushed the refactor/group-delete-via-membership branch from 8641d84 to 2b30c2a Compare May 15, 2026 08:17
@whoAbhishekSah whoAbhishekSah merged commit c922ba4 into main May 15, 2026
8 checks passed
@whoAbhishekSah whoAbhishekSah deleted the refactor/group-delete-via-membership branch May 15, 2026 10:11
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.

3 participants