Repository navigation
fix(auth): close last-admin TOCTOU with deferred DB trigger - #921
Conversation
|
@coderabbitai review |
|
Warning Review limit reached
More reviews will be available in 40 minutes and 14 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThis PR closes a time-of-check/time-of-use race in last-admin protection by adding a PostgreSQL deferred constraint trigger that prevents zero-admin states at commit time. The service layer detects and translates trigger violations into sentinel errors. New tests validate soft checks and exercise concurrent removal of the last two admins. ChangesLast-admin constraint enforcement and error mapping
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
internal/auth/service_user_test.go (1)
388-456: 🏗️ Heavy liftConcurrency test is deterministic and does not validate the DB-level race closure.
The mock is pre-wired so
admin-b's delete always returns the sentinel error andadmin-aalways succeeds; the outcome is independent of goroutine scheduling, so "exactly one fails" holds trivially regardless of whether the real deferred trigger works. This guards the service-layer error mapping under concurrency, but the headline claim ("proving the race is closed") rests on the DB trigger, which is never exercised here. Given the critical concern raised on migration 000058 (an unlockedCOUNT(*)may not serialize concurrent commits), an integration test against real PostgreSQL spawning two real transactions is what would actually prove the invariant. Consider renaming/reframing this as a "concurrent mapping" test and adding a DB-backed regression test.Want me to draft a PostgreSQL-backed integration test (two concurrent transactions deleting the last two admins) to validate the trigger end-to-end?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/auth/service_user_test.go` around lines 388 - 456, The test currently claims to prove the DB-level race closure but deterministically wires mockStore so admin-b always fails; rename TestService_DeleteUser_ConcurrentLastTwoAdmins to TestService_DeleteUser_ConcurrentErrorMapping (or similar), update the function comment and assertion message to state it only verifies service-layer error mapping under concurrent DeleteUser calls (not the DB trigger), and add a TODO comment that an actual PostgreSQL-backed integration test should be added to validate migration 000058 end-to-end; keep the existing mockStore setup and assertions unchanged otherwise.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/database/postgres/migrations/000058_enforce_min_one_admin.up.sql`:
- Around line 29-35: The current migration's admin_count check ignores user
active state and thus allows deactivating the last active admin; update the
SELECT that sets admin_count (used in this IF/RAISE block) to only count users
who are active (i.e., add "AND active = true") so the predicate mirrors the
AdminExists/CountGroupMembers semantics and prevents deactivation-driven lockout
for the Administrators group UUID '00000000-0000-5000-8000-000000000001'.
- Around line 42-47: The header comment and combined constraint trigger
trg_min_one_admin are misleading and cause the expensive COUNT(*) check to run
on any UPDATE to an admin row; change the implementation so UPDATE and DELETE
are handled by separate constraint triggers on table users: create an
UPDATE-only trigger that uses a WHEN clause referencing OLD and NEW (e.g., WHEN
(OLD.group_ids @> ARRAY['00000000-0000-5000-8000-000000000001']::UUID[] AND NOT
(NEW.group_ids @> ARRAY['00000000-0000-5000-8000-000000000001']::UUID[]))) to
only fire when admin membership is removed, keep a DELETE-only trigger that
fires when OLD.group_ids contains the admin id, both executing
check_min_one_admin(), and update the header comment to accurately describe this
behavior.
- Around line 24-47: The deferred trigger function check_min_one_admin() is
vulnerable to concurrent updates because it does an unlocked SELECT COUNT(*)—fix
by serializing access inside the function: instead of COUNT(*) use a SELECT FOR
UPDATE to lock the rows matching the Administrators group (or lock a dedicated
sentinel row) before counting, ensuring concurrent transactions cannot both see
>=1; update check_min_one_admin() to acquire that FOR UPDATE lock on users where
group_ids @> ARRAY['00000000-0000-5000-8000-000000000001']::UUID[] (or use a
sentinel lock table/row) and then re-evaluate the count, keeping the trigger
trg_min_one_admin as DEFERRABLE INITIALLY DEFERRED.
---
Nitpick comments:
In `@internal/auth/service_user_test.go`:
- Around line 388-456: The test currently claims to prove the DB-level race
closure but deterministically wires mockStore so admin-b always fails; rename
TestService_DeleteUser_ConcurrentLastTwoAdmins to
TestService_DeleteUser_ConcurrentErrorMapping (or similar), update the function
comment and assertion message to state it only verifies service-layer error
mapping under concurrent DeleteUser calls (not the DB trigger), and add a TODO
comment that an actual PostgreSQL-backed integration test should be added to
validate migration 000058 end-to-end; keep the existing mockStore setup and
assertions unchanged otherwise.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f5736e9c-83ec-46ad-ba70-f46f5d78ddfb
📒 Files selected for processing (5)
internal/auth/service_user.gointernal/auth/service_user_test.gointernal/auth/store_postgres.gointernal/database/postgres/migrations/000058_enforce_min_one_admin.down.sqlinternal/database/postgres/migrations/000058_enforce_min_one_admin.up.sql
…wly scoped CodeRabbit review on #921 found the deferred constraint trigger added for the #919 TOCTOU fix was not actually race-safe, ignored the active column, and fired on every admin-row UPDATE including the login hot path. Address all three: - Concurrency (critical): a deferred trigger with an unlocked COUNT(*) runs against each transaction's own MVCC snapshot, so two concurrent deletes / deactivations can both pass and leave zero admins. Take a transaction-scoped pg_advisory_xact_lock at the start of check_min_one_admin() so the checks serialize: the second waiter blocks until the first commits, then re-counts the post-commit state. The lock auto-releases at COMMIT/ROLLBACK. - Active semantics (minor): count only active members (AND active = true) so the trigger mirrors AdminExists. Deactivating the last admin (active=false with group_ids unchanged) is now blocked, not just deletes and group removal. - Trigger scope (major): split the single AFTER UPDATE OR DELETE trigger into a DELETE-only and an UPDATE-only constraint trigger. The UPDATE trigger's WHEN clause fires only when an active admin loses admin standing (drops the group or is deactivated), so login-path writes (last_login_at, failed_login_attempts, lockout) on an admin that stays active never pay the COUNT(*) cost. Comment rewritten to match. Add an application-level deactivation soft guard in UpdateUser mirroring the existing group-removal guard, so deactivating the last admin rejects early with ErrLastAdmin (friendly 409) instead of relying solely on the trigger. Tests: deactivation soft-block, allow-when-others-exist, trigger-violation mapping, and a concurrent-deactivation race regression (WaitGroup + ready channel, require only on the test goroutine). go test -race green. Migration stays 000058 (base tops out at 000057, no collision). down migration drops both new triggers plus the legacy single-trigger name.
CR follow-up (commit 913e783)All three findings on migration Critical (concurrency): Major (WHEN clause / heavy lift): split into separate Minor (active): Migration stays Verification: @coderabbitai review |
|
Triggering a review of the updated commit now. (⠀◕ᴗ◕⠀)🐇 ✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
) Add migration 000058 (deferred constraint trigger trg_min_one_admin) that enforces at least one Administrators-group member at commit time, making the count-and-mutate sequence atomic at the DB level. The existing application-level soft check in checkLastAdminConstraint and DeleteUser is retained as a fast early-exit that returns a user-friendly 409; the trigger is the hard race-free backstop that catches the narrow window where two concurrent requests both pass the soft check. isLastAdminConstraintViolation in store_postgres.go maps the P0001 trigger exception back to ErrLastAdmin so DeleteUser and UpdateUser surface the same sentinel regardless of which guard fires. Regression tests added: soft-check unit tests, trigger-simulation tests (plain error with sentinel prefix), and a concurrent goroutine test that runs both deletes simultaneously and asserts exactly one returns ErrLastAdmin.
…wly scoped CodeRabbit review on #921 found the deferred constraint trigger added for the #919 TOCTOU fix was not actually race-safe, ignored the active column, and fired on every admin-row UPDATE including the login hot path. Address all three: - Concurrency (critical): a deferred trigger with an unlocked COUNT(*) runs against each transaction's own MVCC snapshot, so two concurrent deletes / deactivations can both pass and leave zero admins. Take a transaction-scoped pg_advisory_xact_lock at the start of check_min_one_admin() so the checks serialize: the second waiter blocks until the first commits, then re-counts the post-commit state. The lock auto-releases at COMMIT/ROLLBACK. - Active semantics (minor): count only active members (AND active = true) so the trigger mirrors AdminExists. Deactivating the last admin (active=false with group_ids unchanged) is now blocked, not just deletes and group removal. - Trigger scope (major): split the single AFTER UPDATE OR DELETE trigger into a DELETE-only and an UPDATE-only constraint trigger. The UPDATE trigger's WHEN clause fires only when an active admin loses admin standing (drops the group or is deactivated), so login-path writes (last_login_at, failed_login_attempts, lockout) on an admin that stays active never pay the COUNT(*) cost. Comment rewritten to match. Add an application-level deactivation soft guard in UpdateUser mirroring the existing group-removal guard, so deactivating the last admin rejects early with ErrLastAdmin (friendly 409) instead of relying solely on the trigger. Tests: deactivation soft-block, allow-when-others-exist, trigger-violation mapping, and a concurrent-deactivation race regression (WaitGroup + ready channel, require only on the test goroutine). go test -race green. Migration stays 000058 (base tops out at 000057, no collision). down migration drops both new triggers plus the legacy single-trigger name.
…l last-admin race test (refs #919) Adversarial review of PR #921 surfaced three blockers; all three are addressed here on top of the rebased branch. 1. Migration number collision. The branch forked before the base gained migrations 000058..000064, so its 000058_enforce_min_one_admin pair collided with the base's 000058_purchase_executions_direct_execute_audit and would sort below already-applied migrations on merge, breaking golang-migrate. Rebased onto feat/multicloud-web-frontend and renumbered the migration to 000065 (1 + the highest existing number) via git mv for both the up and down SQL. Updated the migration-number references in the comments across service_user.go, store_postgres.go, the down SQL, and the service tests to match. 2. gocyclo on UpdateUser. UpdateUser was at cyclomatic complexity 16 (limit 10) after the deactivation guard landed. Extracted the deactivation guard into guardDeactivation and the shared lookup-or-not-found block into loadUser (also reused by DeleteUser), mirroring the existing checkLastAdminConstraint extraction style. UpdateUser is now 9. No behaviour change. 3. The fix was untested at the DB level. The existing tests are pure mocks that hardcode the trigger error and would pass even if the SQL were wrong. Added a testcontainers-backed integration test (000065_enforce_min_one_admin_test.go) that applies the real migrations and, with a commit barrier, runs two concurrent transactions each stripping admin standing from a different one of two active admins (deactivate / delete / group-removal). It asserts the invariant issue #919 actually requires: the deferred trigger never lets both commit and at least one active admin always remains. Verified the concurrent test FAILS when the trigger SQL is stubbed out and PASSES with it in place. A second test pins the single-transaction guarantee (rejecting removal of the sole admin, allowing removal of one of two).
913e783 to
435320d
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
Base branch landed 000065_enforce_min_one_admin via PR #921 while this PR was in review. Without a renumber, `golang-migrate` rejects two files sharing the same version number and CI fails on the merge ref. Same collision class as the project_migration_number_collisions memory entry. Updates the body comments and the matching reference in internal/config/types.go.
Summary
DEFERRABLE INITIALLY DEFERREDconstraint trigger (trg_min_one_admin) that enforces at least one Administrators-group member at transaction commit time, making the count-and-mutate sequence atomic at the DB levelisLastAdminConstraintViolationinstore_postgres.goto map theP0001trigger exception back toErrLastAdmin, soDeleteUserandUpdateUsersurface the same sentinel regardless of which guard firescheckLastAdminConstraint/DeleteUseras a fast early-exit returning a user-friendly 409; the trigger is the hard backstopRace condition fixed
Two concurrent privileged requests each removing a different one of the last two admin-group members can both observe
count == 2, both pass the soft check, and leave zero admins. The deferred trigger fires at commit time for each transaction, seeing the post-commit state (including any concurrently committed deletes), and rejects the transaction that would leave zero admins.Test plan
TestService_DeleteUser/blocks_delete_of_last_admin_member_(soft_check): soft check returnsErrLastAdminwhen count == 1TestService_DeleteUser/maps_DB_trigger_violation_to_ErrLastAdmin: trigger sentinel error is mapped toErrLastAdminTestService_DeleteUser_ConcurrentLastTwoAdmins: two goroutines fire simultaneously; exactly one getsErrLastAdminTestService_UpdateUser/blocks_removing_admin_group_from_last_admin_member_(soft_check): soft check in guardGroupChangeTestService_UpdateUser/maps_DB_trigger_violation_to_ErrLastAdmin_on_UpdateUser: UpdateUser trigger pathgo test ./internal/auth/... ./internal/api/...all pass (513 + 1385 tests)go vetclean;gofmt -lno outputgo test -race ./internal/auth/... -run TestService_DeleteUserpassescloses #919
Summary by CodeRabbit
Bug Fixes
Tests