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 1 commit 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:
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.
| // 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. |
| 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 | ||
| )` |
| 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) |
| 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 | ||
| )` |
| 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) |
| 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 { |
Coverage Report for CI Build 25166893403Coverage decreased (-0.07%) to 41.889%Details
Uncovered Changes
Coverage Regressions2 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?
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 embeds the owner count check directly in the SQL DELETE statement, making the check-and-delete atomicvalidateMinOwnerConstraintremains as a fast-path optimizationTest plan