Repository navigation
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>
After LeanerCloud#581 nothing in production called PostgresStore.UpdateUser(ctx, *User): every service path writes through a column-scoped, compare-and-set writer. Keeping a 19-column writer in the store interface lets a future caller bring back the stale whole-row save that overwrote concurrent MFA, password and membership changes. Remove it from StoreInterface, PostgresStore, MockStore, MockAuthStore and the server health-test fake. The LeanerCloud#63 lock-order tests drop their UpdateUser cases; UpdateUserAdminFields and DeleteUser are now the only store writes that can lower the active-admin count, and both are still covered by the pgxmock order tests, the NOWAIT lock-before-row test, the last-two-admins races and the mixed no-deadlock workload. Fixtures that used UpdateUser as the concurrent writer now go through UpdateUserAdminFields (the credential race tests), or through a test-only statement that writes the same columns under the admin invariant lock (the login test, whose competing change spans more columns than any one store writer). AssertNotCalled("UpdateUser") checks, which could no longer fail, now name the narrow writer each path would use, or are dropped where the mock already panics on any unstubbed write. Comments that named the store UpdateUser now name the real writer. TestStore_HasNoWholeRowUserWriter pins the removal. Closes LeanerCloud#583 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
|
Independent review 2 (claude-fleet, cold): APPROVE c1d39b0 Context: this PR is stacked on #1202. The parent of c1d39b0 is cad1a4b, #1202's head, so the 25-file diff against 7e6ec71 includes all of #1202. This PR's own commit is 23 files, +98/-146. Findings:
Attacked:
Verified locally on the tree merged with origin/main, with GOTOOLCHAIN=go1.26.10 and no cloud credentials, under the heavy lock:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: REQUEST_CHANGES c1d39b0 Reviewed only the PR's own delta (cad1a4b..c1d39b0, commit c1d39b0) since #1202 merged to main as d53eb3f. The content is correct; the only blocker is mergeability. Findings:
Issue #583 check: no production caller of the store-level UpdateUser(ctx, *User) remains on the replayed tree (git grep over *.go: only the service-level Service.UpdateUser and comments). The interface, PostgresStore and all three mocks lose the method, and TestStore_HasNoWholeRowUserWriter pins it. The remaining admin-count-lowering store writes (UpdateUserAdminFields, DeleteUser) keep their lock-order cases. Fixes the issue as stated. Attacked:
|
Root cause
After #581, every service path writes users through a column-scoped compare-and-set writer (
UpdateUserAdminFields,UpdateUserCredentials,SetPasswordResetToken, the MFA writers and the login bookkeeping writers). The whole-rowPostgresStore.UpdateUser(ctx, *User), which rewrites 19 columns from whatever the caller read, had no production caller left. It was still onStoreInterfaceand every mock, so a future caller could bring back the stale whole-row save that #581 removed. #1202 had also wrapped it inwithAdminInvariantLockand given it its own lock-order test cases.Before changing anything I confirmed there is no production caller, on both
mainandcad1a4b6:git grep -nw UpdateUser <ref> -- '*.go' | grep -v _test.go: the only hits are the definition, the interface method, the two mocks, andService.UpdateUser(ctx, actor, target, req). That last one is the unrelated service-level method, and it writes throughUpdateUserAdminFields.gh pr list --state open --search UpdateUser, plus a scan of every open PR touchinginterfaces.go,store_postgres.go,test_helpers.go,internal/mocks/stores.goorinternal/server/health_test.go: no open PR adds a store-levelUpdateUsercall.Fix (commit after
cad1a4b6)UpdateUser(ctx, *User)fromStoreInterface, fromPostgresStore, and fromMockStore(internal/auth/test_helpers.go),MockAuthStore(internal/mocks/stores.go) andmockAuthStoreForHealth(internal/server/health_test.go).UpdateUsercases. These are theUpdateUserentry inadminLockedWrites()(pgxmock), itsupdateUserSQLRegex, and the"UpdateUser deactivate"entry inadminDemotingWrites()(real Postgres). TheUpdateUserAdminFields(deactivate and drop-group) andDeleteUsercases are unchanged. They are now the only store writes that can lower the active-admin count, and they still run through:TestIntegration_AdminDemotingWrites_LockBeforeRowLock(NOWAIT)TestIntegration_ConcurrentDemotionOfLastTwoAdminsandTestIntegration_ConcurrentBootstrapVersusDemotion(service-level, unchanged)TestIntegration_AdminWrites_NoDeadlock. Its mixed workload still sends two writes per path and spreads them over all three admins.UpdateUseras the concurrent writer:service_credentials_db_test.go(4 sites: two email changes, two deactivations): these now go through a newcredentialRaceFixture.adminEdithelper, which callsUpdateUserAdminFields, the writer an admin's email or active change really uses. The fields set are the same as before.service_login_db_test.go: the competing change here covers 14 columns (credentials, MFA, reset token, history, groups, active), more than any one store writer touches. It now goes through a test-onlyoverwriteSecurityColumnsstatement that writes exactly those columns. Because it changesgroup_idsandactive, it runs underwithAdminInvariantLock. The function is in a_test.gofile with the integration tag, so production code can't call it.AssertNotCalled(t, "UpdateUser", ...): these could no longer fail once the method was gone, and the repo's vacuous-assertion guard would flag them.SetPendingMFASecret,DisableMFAorReplaceMFARecoveryCodes, with the matching arity.UpdateUsernow name the real writer: two in production code (service_mfa.go,service_password.go) and several in tests. Comments about the service-levelUpdateUserwere left as they are.TestStore_HasNoWholeRowUserWriterasserts by reflection that neitherStoreInterfacenor*PostgresStorehas anUpdateUsermethod.No behavior change.
Closes #583
Verification
All commands run from the repo root at
c1d39b0c, withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. The local~/.aws/configdefault profile is invalid, which breaks some unrelated tests.Fail before / pass after (unit):
go test ./internal/auth/ -run TestStore_HasNoWholeRowUserWriter -count=1auth.StoreInterface still has the whole-row UpdateUserand*auth.PostgresStore still has the whole-row UpdateUser.Builds, vet and tests:
go build ./...passes.go vet ./...andgo vet -tags integration ./...are clean.go test ./internal/auth/ -count=1: ok.go test -tags integration ./internal/auth/ -count=1 -timeout 30m(real PostgreSQL via testcontainers): ok, 121s. This includes the four lock-order and race tests named above,TestIntegration_LoginPreservesSecurityChanges,TestIntegration_CredentialWritesRejectStaleCredentialsandTestIntegration_ResetConfirmRejectsStaleRead.go test ./internal/mocks/ -count=1: ok. This includesTestNoUnfailableMockAssertions.go test ./internal/server/ -count=1andgo test ./internal/api/ -count=1: ok. Each package was run on its own.Lint and other CI checks:
golangci-lintv2.10.1, run with--allow-parallel-runnerson./internal/auth/... ./internal/mocks/... ./internal/server/..., with and without--build-tags integration: 0 issues.gocyclo -over 10 -ignore "_test\.go" internal/auth internal/mocks internal/server: silent.bash scripts/gosec-hook.sh <changed .go files>: clean.git diff --check: clean.--scanover all tracked files in a freshgit initrepo with an isolated HOME: clean, with both this branch's andorigin/main'ssetup-git-secrets.shand.gitallowed(mainchanged the setup in fix(scripts): detect bare AWS secret access keys in git-secrets setup #1125).scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.Mutation probes (local only, reverted, not committed):
UpdateUserAdminFieldswrite withoutwithAdminInvariantLock.TestIntegration_AdminDemotingWrites_LockBeforeRowLockthen fails on both of itsUpdateUserAdminFieldssubtests ("the write locked the users row before the admin-invariant advisory lock"), andTestPGXMock_AdminWrites_TakeLockBeforeWriteAndCommit/UpdateUserAdminFieldsfails too. So removing theUpdateUsercases did not weaken lock-order coverage of the paths that remain.overwriteSecurityColumnslands all 14 changed values (the 11 scalar or slice fields plus the three timestamps) in all four login scenarios, the same fields the oldUpdateUsercall changed.Evidence type and gaps: this is unit, pgxmock and real-PostgreSQL (testcontainers) evidence. The change is internal to the store, has no cloud surface, and needs no real-account verification.
Out of scope
CompletePasswordResetwritesactive = $7from the caller's struct without the admin-invariant lock. Every caller only ever raisesactive(an invite activates, and theWHEREclause excludes deactivated rows), so it cannot lower the admin count. I left it as fix(auth): take the admin invariant lock before the row on demote, deactivate and delete #1202 left it.main, so the stack stays reviewable.main's three newer commits don't touch these files.Labels
A maintainer please mirror #583's triage labels:
impact/internal,type/chore,priority/p3,severity/low,urgency/eventually,effort/s.🤖 Generated with Claude Code