Skip to content

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

Draft
rohilsurana wants to merge 1 commit intomainfrom
fix/toctou-last-owner-guard
Draft

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

Conversation

@rohilsurana
Copy link
Copy Markdown
Member

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 embeds the owner count check directly in the SQL DELETE statement, making the check-and-delete atomic
  • The role name, resource ID, and resource type are all parameterized — no hardcoded values in the query
  • Existing validateMinOwnerConstraint remains as a fast-path optimization

Test plan

  • Existing membership and policy unit tests pass (updated mocks)
  • Integration test: two concurrent owner demotions on a 2-owner org — one should succeed, one should fail with ErrLastOwnerRole

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 Apr 30, 2026 1:03pm

@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: e4e5492b-896c-4ba6-a3da-d4e1ca78cb81

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

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 on lines +366 to +368
// DeleteWithMinRoleGuard atomically deletes a policy only if at least one other
// policy with the same guarded role remains for the given resource. This prevents
// the TOCTOU race where concurrent requests both pass a count check then both delete.
Comment on lines +377 to +385
query := `DELETE FROM policies WHERE id = $1 AND (
role_id != $2
OR (SELECT COUNT(*) FROM policies
WHERE resource_id = $3
AND resource_type = $4
AND role_id = $2
AND id != $1
) > 0
)`
Comment on lines +377 to +386
query := `DELETE FROM policies WHERE id = $1 AND (
role_id != $2
OR (SELECT COUNT(*) FROM policies
WHERE resource_id = $3
AND resource_type = $4
AND role_id = $2
AND id != $1
) > 0
)`
result, err := tx.ExecContext(ctx, query, id, guardRoleID, resourceID, resourceType)
Comment on lines +377 to +385
query := `DELETE FROM policies WHERE id = $1 AND (
role_id != $2
OR (SELECT COUNT(*) FROM policies
WHERE resource_id = $3
AND resource_type = $4
AND role_id = $2
AND id != $1
) > 0
)`
Comment thread core/policy/service.go
Comment on lines +93 to +101
if err := s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: id,
Namespace: schema.RoleBindingNamespace,
},
}); err != nil {
return err
}
return s.repository.DeleteWithMinRoleGuard(ctx, id, guardRoleID, resourceID, resourceType)
Comment on lines +225 to +229
ownerRole, err := s.roleService.Get(ctx, schema.RoleOrganizationOwner)
if err != nil {
return fmt.Errorf("get owner role for guard: %w", err)
}
if err := s.replacePolicyWithOwnerGuard(ctx, orgID, schema.OrganizationNamespace, principalID, principalType, resolvedRoleID, existing, ownerRole.ID); err != nil {
@coveralls
Copy link
Copy Markdown

Coverage Report for CI Build 25166893403

Coverage decreased (-0.07%) to 41.889%

Details

  • Coverage decreased (-0.07%) from the base build.
  • Patch coverage: 74 uncovered changes across 3 files (10 of 84 lines covered, 11.9%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
internal/store/postgres/policy_repository.go 48 0 0.0%
core/membership/service.go 26 10 38.46%
core/policy/service.go 10 0 0.0%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/membership/service.go 2 78.25%

Coverage Stats

Coverage Status
Relevant Lines: 37337
Covered Lines: 15640
Line Coverage: 41.89%
Coverage Strength: 11.85 hits per line

💛 - Coveralls

Comment thread core/policy/service.go
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?

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