Repository navigation
fix(auth): take the admin invariant lock before the row on demote, deactivate and delete - #1202
Conversation
…activate and delete UpdateUser, UpdateUserAdminFields and DeleteUser now run in a transaction whose first statement takes adminInvariantAdvisoryLockKey, the same point CreateAdminIfNone takes it, so every write that can lower the active-admin count orders the advisory lock before the users row lock. Before, these writes locked the row first and only reached the lock in the deferred min-one-admin trigger at commit. The trigger already kept the floor, so this is defense in depth: a mix of the two orders would deadlock, and the new real-Postgres deadlock test catches a partial conversion (40P01 in round 0). Closes LeanerCloud#63 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 5 billable files and costs up to $1.25. Or wait 59 minutes for your next included review. View limit details
Comment |
|
Independent review 2 (claude-fleet, cold): APPROVE cad1a4b Findings (none blocking):
Completeness: every
Error mapping:
Attacked:
Verified locally, with no cloud credentials, under the heavy lock, using the repo's testcontainers Postgres:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE cad1a4b PR #1202 (Closes #63), head cad1a4b, base 7e6ec71, single commit. Findings (none blocking)
Lock-order analysis (deadlock risk)
Results (merged tree, macOS)
Mutation probes (scratch copy, reverted)
CI (gh pr checks, on base 7e6ec71): all green (CI Success, Unit, Integration, E2E, pre-commit, lint, gosec, Trivy, TF validate x3, CodeRabbit); Snyk skipped. No red checks. |
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
|
Local verification at
CI on this head: CI Success and pre-commit green. cristim's independent review approved this head. Migrations in this batch (000136-000139) are applied in numeric order. Nothing was run against any cloud. |
|
Marked ready and merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), after cristim's independent review approving |
Brings the branch up to date with main so the PR is mergeable again. The branch sat on the pre-squash LeanerCloud#1202 commit cad1a4b, so a plain merge conflicted in store_postgres.go and the two admin-lock test files. Those three files take the result of replaying only c1d39b0 onto main (git merge-tree --merge-base cad1a4b), which is conflict-free. service_mfa.go takes main's version: LeanerCloud#1284 already reworded the consumeRecoveryCode comment so it no longer names the store UpdateUser, which is all this PR's change to that file did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Every store write that can lower the active-admin count now takes
adminInvariantAdvisoryLockKeyas the first statement of its transaction. That is the same pointCreateAdminIfNonetakes it: before anyusersrow lock.Main was already safe. The deferred
check_min_one_admin()trigger (migration 000065) already serializes the final count, and the new race tests pass on unmodified main. This PR is defense in depth and makes the lock order consistent, as the issue asks. The one test that fails before the fix is the lock-order test.Root cause
BEGIN → pg_advisory_xact_lock(key) → INSERT → COMMITUPDATE/DELETE (row lock) → COMMIT → trigger takes pg_advisory_xact_lock(key)So admin-demoting writes held the row lock before the advisory lock, which is the opposite order from the bootstrap. Today nothing deadlocks. The bootstrap only inserts and never waits on an existing admin row, and every demoting path uses the same row→lock order. But a fix that converted only some paths would deadlock. The mutation probe below shows this (40P01 in round 0).
Fix
PostgresStore.withAdminInvariantLock(ctx, op, fn):BEGIN → pg_advisory_xact_lock(adminInvariantAdvisoryLockKey) → fn(tx) → COMMIT, with a deferred rollback. It reuses the existing constant. The trigger takes the key again at commit, which is a no-op re-acquire.UpdateUserAdminFields: the fix(auth): write only the admin-editable columns from UpdateUser #581 narrow replacement, used byService.UpdateUserfor both deactivate and group removal.UpdateUser: the whole-row update. It has no production caller left, but it is still onStoreInterfaceand can flipactive/group_ids.DeleteUser.adminInvariantAdvisoryLockKeynow states the convention: any transaction that may demote, deactivate or delete an Administrators-group member, or create the first admin, must take this lock as its first statement. It also explains the deadlock rule.COMMIT, wrapped with%w.isLastAdminConstraintViolationstill finds thePgError, and a unit test pins this, so the service still maps it toErrLastAdmin/ 409.Paths checked and deliberately not locked
These never take admin standing away, so the trigger never fires and they only ever hold the row lock. They cannot complete a cycle with a lock holder:
RecordSuccessfulLoginandRecordFailedLogin.SetPendingMFASecret,EnableMFA,DisableMFA,ReplaceMFARecoveryCodes,ConsumeMFARecoveryCode,ClaimTOTPCounter,ClearPendingMFASecret).UpdateUserCredentials,SetPasswordResetTokenandConsumePasswordResetToken.CompletePasswordReset. Itsactive = $7is alwaystruefrom its only caller (ConfirmPasswordResetactivates invites and returns early for deactivated accounts).CreateUser. A plain INSERT is not watched by the trigger.DeleteGroup. It refuses system-managed groups, Administrators included, and does not touchusers.group_ids.migrate.go. It only adds the group.Verification
Run locally on macOS. Integration tests use a real PostgreSQL 16 testcontainer. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. No cloud APIs are involved.New real-Postgres tests (
internal/auth/store_postgres_admin_lock_db_test.go,integrationtag):TestIntegration_AdminDemotingWrites_LockBeforeRowLockUpdateUserAdminFieldsdeactivate,UpdateUserAdminFieldsdrop the group,UpdateUserdeactivate,DeleteUser), a test tx holds the advisory lock the way a bootstrap would. The write must queue on that lock (checked inpg_locks) before it locks the row, so the holder'sSELECT … FOR UPDATE NOWAITon the target row succeedscould not obtain lock on row in relation "users" (SQLSTATE 55P03)TestIntegration_ConcurrentDemotionOfLastTwoAdminsService, on the last two admins, 15 barrier-synced rounds per pair. Exactly one succeeds, the other getsErrLastAdmin, and exactly one active admin remainsTestIntegration_ConcurrentBootstrapVersusDemotionCreateAdminIfNoneracing each demotion, with 1 or 2 existing admins, 15 rounds each. The bootstrap never inserts. The demotion getsErrLastAdminwith 1 admin and succeeds with 2. Exactly one active admin remainsTestIntegration_AdminWrites_NoDeadlocklock_timeout=10s. The test asserts no40P01and no55P03, only expected outcomes (trigger refusal,ErrUserChanged, user not found), and ≥1 active admin after every roundMutation probe (partial fix: the lock removed from
DeleteUseronly, so it uses the old row→lock order while the other paths use lock→row):TestIntegration_AdminWrites_NoDeadlockfails in round 0 withfailed to update user: ERROR: deadlock detected (SQLSTATE 40P01). The lock-order test fails on itsDeleteUsersubtest. The fix has to cover all paths, and the tests catch one that is missed.New pgxmock unit tests (
internal/auth/store_postgres_admin_lock_test.go) replace the three testifyMockDBConnectiontests forUpdateUser/DeleteUser, which mockeddb.Execdirectly and cannot express a transaction. For each of the 3 methods they check:BEGIN→ lock with the exact key → the write →COMMIT, in that order.user not foundorErrUserChanged.PgErrorraised atCOMMITstill satisfiesisLastAdminConstraintViolation.UpdateUserAdminFieldsstill returns the storedupdated_at.All 7 fail against main's store code and pass with this PR. Unit/mock evidence.
Commands run:
Behavior notes / out of scope
UpdateUserAdminFields, any target) and user deletes now serialize on one global advisory lock for the length of a single-row write. These are rare admin actions. Login, MFA and password writes are not affected.checkLastAdminConstraint/CountGroupMembers) still runs before and outside the lock, so it can still miss a concurrent demotion. The deferred trigger remains the hard backstop and maps to the sameErrLastAdmin. Moving the soft check under the lock would mean restructuring the service, and the issue does not ask for that.CreateAdminIfNonekeeps its own inline begin/lock/commit, which is already in the right order. I left it unchanged to keep its existing error messages and tests.internal/auth/store_postgres.go, in different functions (sessions). Expect at most a trivial merge.Maintainer: please mirror the issue's triage labels on this PR:
impact/internal,type/chore,triaged,priority/p3,severity/low,urgency/eventually,effort/s.Closes #63
🤖 Generated with Claude Code