Skip to content

fix: atomic last-owner guard prevents TOCTOU race on role demotion#1590

Draft
rohilsurana wants to merge 3 commits intomainfrom
fix/toctou-last-owner-guard
Draft

fix: atomic last-owner guard prevents TOCTOU race on role demotion#1590
rohilsurana wants to merge 3 commits intomainfrom
fix/toctou-last-owner-guard

Conversation

@rohilsurana
Copy link
Copy Markdown
Member

@rohilsurana rohilsurana commented Apr 30, 2026

Summary

  • Two concurrent SetOrganizationMemberRole requests to demote the last two owners can both pass the application-level validateMinOwnerConstraint check (each sees 2 owners) and both proceed, leaving the org with zero owners
  • Added DeleteWithMinRoleGuard to the policy repository that uses SELECT FOR UPDATE in a CTE to serialize concurrent deletions, then conditionally deletes only if at least one other owner remains
  • Resource ID and type are derived from the existing policy inside the repository, not from caller input
  • Owner role is fetched once and passed through validateMinOwnerConstraintreplacePolicyWithOwnerGuard, eliminating duplicate role lookups
  • DB delete runs before SpiceDB relation removal to prevent inconsistent state if the guard rejects
  • Existing validateMinOwnerConstraint remains as a fast-path optimization

Test plan

  • Existing membership and policy unit tests pass (updated mocks)
  • Verified concurrent demotions via pg_sleep forced race: without FOR UPDATE both delete (0 owners), with FOR UPDATE second transaction blocks and is rejected (1 owner)

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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

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

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment May 4, 2026 3:47pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 30, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3db84381-69a9-470e-9e4a-ea1d5a334f38

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 DeleteWithMinRoleGuard to the policy repository/service layer and wired it into SetOrganizationMemberRole.
  • 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.

Comment thread internal/store/postgres/policy_repository.go Outdated
Comment thread internal/store/postgres/policy_repository.go Outdated
Comment thread internal/store/postgres/policy_repository.go Outdated
Comment thread internal/store/postgres/policy_repository.go Outdated
Comment thread core/policy/service.go Outdated
Comment thread core/membership/service.go Outdated
@coveralls
Copy link
Copy Markdown

coveralls commented Apr 30, 2026

Coverage Report for CI Build 25328592784

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.08%) to 41.887%

Details

  • Coverage decreased (-0.08%) from the base build.
  • Patch coverage: 93 uncovered changes across 3 files (28 of 121 lines covered, 23.14%).
  • 3 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
internal/store/postgres/policy_repository.go 55 0 0.0%
core/membership/service.go 56 28 50.0%
core/policy/service.go 10 0 0.0%

Coverage Regressions

3 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/membership/service.go 3 77.73%

Coverage Stats

Coverage Status
Relevant Lines: 37360
Covered Lines: 15649
Line Coverage: 41.89%
Coverage Strength: 11.85 hits per line

💛 - Coveralls

Comment thread core/policy/service.go Outdated
Comment on lines +93 to +100
if err := s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: id,
Namespace: schema.RoleBindingNamespace,
},
}); err != nil {
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.

This is still unguarded, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yeah you are right, fixing that

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

fixed this

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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread core/policy/service.go
Comment thread internal/store/postgres/policy_repository.go
Comment thread core/membership/service.go
Comment thread core/membership/service.go
Comment thread core/membership/service_test.go
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +380 to +384
// 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) {
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.

4 participants