fix: atomic last-owner guard prevents TOCTOU race on role demotion#1590
fix: atomic last-owner guard prevents TOCTOU race on role demotion#1590rohilsurana wants to merge 3 commits intomainfrom
Conversation
Add DeleteWithMinRoleGuard to the policy repository that atomically checks the owner count within the DELETE statement itself. This eliminates the race where two concurrent demotion requests both pass the application-level owner count check then both proceed. The SQL condition ensures the DELETE only executes if the policy being removed is either not the guarded role, or at least one other policy with that role exists for the same resource. If the condition fails (last owner), zero rows are affected and ErrLastRoleGuard is returned. The existing validateMinOwnerConstraint remains as a fast-path optimization to avoid unnecessary DB round-trips.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent a TOCTOU race in organization owner demotions by moving the “must keep at least 1 owner” check into an (intended) atomic delete path in the policy repository, while keeping the existing application-level validation as a fast-path.
Changes:
- Added
DeleteWithMinRoleGuardto the policy repository/service layer and wired it intoSetOrganizationMemberRole. - Introduced a new policy-layer error (
ErrLastRoleGuard) surfaced when a guarded delete would violate the minimum-role constraint. - Updated membership unit tests/mocks to use the new guarded delete method.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/store/postgres/policy_repository.go | Adds guarded delete SQL intended to atomically prevent deleting the last policy of a role for a resource. |
| core/policy/service.go | Exposes DeleteWithMinRoleGuard at service level and calls into repository. |
| core/policy/policy.go | Extends repository interface with DeleteWithMinRoleGuard. |
| core/policy/errors.go | Adds ErrLastRoleGuard. |
| core/policy/mocks/repository.go | Updates mocks for the new repository method. |
| core/membership/service.go | Switches org role replacement to guarded policy deletion for owner demotions. |
| core/membership/mocks/policy_service.go | Updates mocks for the new policy service method. |
| core/membership/service_test.go | Updates expectations to use guarded deletion and accounts for extra owner-role lookups. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Coverage Report for CI Build 25328592784Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage decreased (-0.08%) to 41.887%Details
Uncovered Changes
Coverage Regressions3 previously-covered lines in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
| if err := s.relationService.Delete(ctx, relation.Relation{ | ||
| Object: relation.Object{ | ||
| ID: id, | ||
| Namespace: schema.RoleBindingNamespace, | ||
| }, | ||
| }); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
This is still unguarded, right?
There was a problem hiding this comment.
yeah you are right, fixing that
1. Flip SpiceDB/DB ordering — DB delete first, then SpiceDB relation removal. Prevents inconsistent state if the guard rejects the delete. 2. Use SELECT FOR UPDATE in CTE to serialize concurrent deletions under READ COMMITTED isolation. Two concurrent DELETEs of different owner rows now block on the row lock instead of both proceeding. 3. Use TABLE_POLICIES constant instead of hardcoded "policies" string. 4. Derive resource_id/resource_type from existingPolicy inside the repository instead of trusting caller-supplied values. 5. Fetch owner role once — validateMinOwnerConstraint now returns the resolved ownerRoleID for reuse, eliminating the duplicate Get call.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Only use guarded delete for owner policies, plain Delete for non-owner (avoids unnecessary locking/serialization) - Apply guarded delete in RemoveOrganizationMember flow too (same TOCTOU) - Add unit test for ErrLastRoleGuard → ErrLastOwnerRole mapping - Fix test expectations for non-owner policy deletions
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // delete org-level policies last, using guarded delete for owner policies | ||
| for _, pol := range orgPoliciesToDelete { | ||
| if pol.RoleID == ownerRoleID { | ||
| if err := s.policyService.DeleteWithMinRoleGuard(ctx, pol.ID, ownerRoleID); err != nil { | ||
| if errors.Is(err, policy.ErrLastRoleGuard) { |
Summary
SetOrganizationMemberRolerequests to demote the last two owners can both pass the application-levelvalidateMinOwnerConstraintcheck (each sees 2 owners) and both proceed, leaving the org with zero ownersDeleteWithMinRoleGuardto the policy repository that usesSELECT FOR UPDATEin a CTE to serialize concurrent deletions, then conditionally deletes only if at least one other owner remainsvalidateMinOwnerConstraint→replacePolicyWithOwnerGuard, eliminating duplicate role lookupsvalidateMinOwnerConstraintremains as a fast-path optimizationTest plan
pg_sleepforced race: withoutFOR UPDATEboth delete (0 owners), withFOR UPDATEsecond transaction blocks and is rejected (1 owner)