fix: lock parent user row in user soft-delete guards - #28546
Conversation
|
/coder-agents-review |
|
Chat: Review in progress (15/15 reviewers complete) | View chat Review history
deep-review v0.9.0 | Round 7 | Last posted: Round 7, 76 findings (1 P0, 4 P1, 16 P2, 37 P3, 17 Nit, 1 Note), REQUEST_CHANGES. Review Finding inventoryFinding inventory - PR #28546Findings
Law analysisRound 4 (first assessment). Head 59f5ebe. Effective LOC +1397 -8 (14 files; 569 production, 828 test, 148 generated). Verdict: SPLIT (vertical, three slices). Enforcement: MANDATORY, scoped to slice 3 (C6, the GetAuthorizationUserRoles deleted filter, 5 production lines + test) which must be extracted and reviewed on its own; slice 1 (C2, cap triggers onto advisory locks, a pre-existing-hazard fix) recommended not required; slice 2 (the write-side guard core: guards + contract + 4 call sites + backfill + error mapping + dbcrypt test adaptation) is atomic and must not be cut further. Churn guard treats this mandatory verdict like a P0 on later rounds: author silence = BLOCKED. Round 5. Head 2f95dc4. Effective LOC +1954 -13 (22 files; 718 production, 1236 test, 206 generated), +557 since R4. R4 mandatory split HONORED (read-side extracted to #28634). NEW verdict: SPLIT (vertical, two slices, slice A first). Enforcement: MANDATORY, scoped to slice A = the cap changes (C3 advisory-lock rewrite + C4 require_read_committed + C5 skills-cap UPDATE leg), which must be extracted and land first. Grounds: C4/C5 are new this round; require_read_committed rejects previously-succeeding writes under RR/Serializable (broad blast radius, a snapshot-isolation review, not soft-delete); the same-owner exemption is caller-enumeration-dependent (dbcrypt); C5 has no production caller. Slice B (the guard core) is atomic, not to be cut further. Contested and acknowledgedCRF-3 (Note, up.sql:29) - SQLSTATE change to 23514 with stable constraint names
Round logRound 1Netero-only gate. 1 P2, 1 P3, 1 Note. P2 gates the panel: first-pass review posted, panel deferred until Netero findings are addressed. Reviewed against 64d2d8a..bcd0d53. Round 2Churn guard PROCEED: CRF-1 and CRF-2 author-fixed (39171c5), CRF-3 author-accepted. Netero re-ran and found a new P1 (CRF-4) and P2 (CRF-5). Pre-panel gate: P1 keeps the round Netero-only; panel deferred to round 3. Reviewed against bcd0d53..39171c5. Round 3Churn guard PROCEED: CRF-4 (P1) and CRF-5 (P2) author-fixed in 5bb95f2 (branch rebased onto bd4af31, migration renumbered 000585 -> 000587). All prior findings closed or accepted. Consecutive-Netero cap reached, so the panel runs for the first time. Reviewed against bd4af31..e87dccb. Round 3 panel result17-reviewer panel. CRF-5 fix verified real; CRF-2 fix verified. But CRF-4 fix is INCOMPLETE: 2 new P1 deadlocks reproduced on unfixed callers (CRF-6 provisionerdserver, CRF-7 login org-sync), root cause an unenforced lock-ordering contract (CRF-8). CRF-1 fix traded the deadlock for a cap bypass (CRF-9) and a wrong INSERT-path lock mode (CRF-10). New: read-side authz gap (CRF-11). Plus 12 P3s and 3 Nits. Event REQUEST_CHANGES. Round 4Churn guard PROCEED: all 21 R3 findings addressed in one commit (59f5ebe, +926/-480, 19 files). Substantial rework: AcquireUserSoftDeleteGuardLock (now :one, ActionUpdate) at all four transactions with per-path deadlock tests (TestSoftDeleteGuardLockOrderPaths); advisory-lock cap triggers (pg_advisory_xact_lock, zz_ prefix + order test); shared fail_if_user_deleted() via TG_ARGV; read-side GetAuthorizationUserRoles deleted filter; 400 error mapping; +2 backfill tables. Panel re-runs to verify the new mechanisms (significant restructure). Law runs (effective additions 1397 > 1000, never assessed). Reviewed against bd4af31..59f5ebe. Round 4 resultLaw MANDATORY split (extract C6 read-side filter) -> panel skipped per the Law gate. Netero (advisory, post-panel) found a P0: the CRF-15 ActionUpdate fix breaks both OAuth2 token grants for ordinary users because they run under the user's own actor (CRF-27, reproduced), plus a P1 test-coverage gap (CRF-28, the lock-order test is SQL-only and cannot catch CRF-27) and P3s. Netero verified CRF-4/6/7/8/9/10/11/14/17/19/22/23/26 fixes hold at the SQL/state level. Event REQUEST_CHANGES. Reviewed against bd4af31..59f5ebe. Round 4 panelThe post command requires panel reviewers in a post-panel round, so the panel ran (16 reviewers) despite the Law mandatory split, and folded Netero+Law in. Convergence: CRF-27 P0 confirmed by 7 reviewers (Bisky/Netero empirically show it is CAUGHT by an existing coderd test TestOAuth2ProviderTokenExchange/OK running as a member, so CI goes red, not silent; the genuine gap CRF-28 is the reverse direction). New: CRF-34 (read-side breaks provisioner delete -> undeletable workspace, the concrete bug behind Law's CRF-33), CRF-35 (ActionUpdate gate is role-dependent, gate as ResourceSystem, don't widen member role), CRF-37 (advisory-lock new deadlock edge, latent/#28423), plus CRF-38..50. Strategic convergence (Law/Pariston/Ryosuke/Meruem, echoing Knov R3): the write-side lock-ordering contract has produced 5 failures in 4 rounds (CRF-6/7/14/16/27); the read-side C6 is the actual security fix; land C6 first, reconsider the lock via a DB-level BEFORE DELETE/UPDATE lock or an idempotent reaper. All R3 fixes verified holding. Event REQUEST_CHANGES. Round 5Churn guard PROCEED: all 23 R4 findings addressed in 2f95dc4 (+965/-355). Law mandatory split honored: read-side hardening extracted to #28634. New mechanism: require_read_committed() gates the caps against RR/Serializable overshoot; skills cap gains an UPDATE leg. Effective additions grew to 1954 (+557 since Law's R4 analysis), so Law re-runs. Panel re-runs to verify the system-primitive authz fix, the new isolation guard, and the cap UPDATE leg. Reviewed against bd4af31..2f95dc4. Round 5 panel + LawLaw: another MANDATORY split (extract the cap changes as slice A, land first). Panel (11) ran per the CLI post-panel constraint; folded Law+Netero in. No P0/P1: all R4 fixes verified holding (Netero/Bisky/Komugi/Mafu-san mutation-tested CRF-5/27/28/39/33/34). New P2 cluster on the NEW mechanisms: CRF-52 (require_read_committed over-broad: rejects all non-RC secret/skill writes, feature outage under config/ReadModifyUpdate), CRF-53 (same-owner exemption reopens the cap overshoot, reproduced), CRF-54 (migration DROP TRIGGER ACCESS EXCLUSIVE on api_keys + unbounded backfill = upgrade auth outage window), CRF-55 (TestOAuth2ProviderTokenExchangeLockOrder pins lock-taken not lock-first; deadlock class still reintroducible). Plus P3s: CRF-56 (UPDATE SET user_id re-parents onto deleted user), CRF-57 ("safe under any isolation level" false for guard INSERT path), CRF-58 (dead TG_ARGV[2] branch, 10 reviewers), CRF-59 (7th guard 500), CRF-60 (api_keys 409 gap), CRF-61 (group_members false justification: GetAuthorizationUserRoles reads it unfiltered into the RBAC subject), CRF-62/63/64 (test/comment accuracy). Nits CRF-65..69. Strategic (Pariston/Law): after the #28634 split this PR is defense-in-depth + metric hygiene; land #28634 first so protection is source-agnostic. Event REQUEST_CHANGES. Reviewed against bd4af31..2f95dc4. Round 6 updateChurn guard BLOCKED. Base changed to e968694 (stacked on #28870 fix-user-cap-advisory-locks): Law's R5 mandatory cap split is HONORED. Migration renumbered 000587->000591; backfill DELETEs replaced by an idempotent dbpurge reaper (PurgeSoftDeletedUserResources); DROP TRIGGER replaced by CREATE OR REPLACE FUNCTION (CRF-54); 8 guards incl group_members; api_keys BEFORE UPDATE OF user_id trigger. 17 of 19 R5 findings addressed (caps-side CRF-51/52/53/65/66/67/69 in the base; guard-side CRF-54/55/56/57/58/61/62/63/68 in f3bd60a; CRF-64 partial). BLOCKED because CRF-59 (7th guard user_ai_budget_overrides still 500s, no 409 mapping) and CRF-60 (api_keys token/key creation still 500s; description dropped the false claim but does not state api_keys is deliberately unmapped) are SILENT: no code change, no reply (both were R5 body-level non-diff findings), omitted from the author's round-6 summary. No reviewers or Netero spawned per the churn-guard BLOCKED rule. COMMENT posted naming the silent findings; review blocked until the author fixes them or states why not. Also flagged: CRF-64's comment-falsity half (server_dbcrypt_test.go:263 "Deleted users cannot have user_links or user_secrets") is untouched. Reviewed against e968694..c5e2ac3. Round 7Churn guard PROCEED: R6 blockers CRF-59 and CRF-60 addressed in 8c32ed7 (409 mappings + tests). Base rebased onto main (1d5631f), migration renumbered 000592. Law does not run (effective 1805 < R5's 1954). First panel on the guard-only PR + the new dbpurge reaper. CI is broadly red (20 jobs incl gen/fmt/lint/sqlc-vet/build/test-js/storybook); this diff builds clean and is gofmt-clean, so the breadth points to a base/rebase issue (unconfirmed, gh 401). Reviewed against 1d5631f..dd747c8. Round 7 panel13-reviewer panel + Netero. No P0/P1 in the code: the guard core is solid, authz correct (Kurapika: no security findings), tests mutation-verified (Bisky), CRF-59/60/64 fixes real. Findings concentrate on the never-panel-reviewed dbpurge reaper and the handler/description surface. New: CRF-70 (P2, reaper unbatched + shares the purge tx: a failure rolls back all retention purges; Pariston/Zoro/Killua P2, Knuckle/Takumi/Mafuuu P3), CRF-71 (P2, reaper unindexed full scans forever + never latches; group_members has no index; Killua P2), CRF-72 (P3, reaper has no metric/log; Chopper/Mafuuu), CRF-73 (P3, group_members guard has no 409 handler mapping in patchGroup - class fix one instance short again; Netero/Hisoka), CRF-74 (P3, reaper CTE-order deadlock comment false twice; 6 reviewers), CRF-75 (P3, reaper test global delete flakes sibling tests under shared DB; Komugi), plus nits CRF-77/78/79. PROCESS: CI red is a P1 merge blocker (Mafu-san), likely base/rebase (this diff clean); PR description names 2 of ~6 409-mapped tables (P2 drift, Mafu-san/Leorio). #28634 (read-side) still needed for the auth half. Event REQUEST_CHANGES. Reviewed against 1d5631f..dd747c8. Round 7 findings
About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
First-pass review only. These are mechanical findings from the pre-panel reviewer (Netero); the full review panel has not yet reviewed this PR and will do so after these findings are addressed.
The change is well-constructed: the FOR NO KEY UPDATE choice is correctly reasoned against FOR KEY SHARE from FK validation, the INSERT-only gate is deliberate and documented, the isolation-level analysis is sound, and the tests are genuine (the concurrent-insert test blocks on the real row lock via pg_stat_activity rather than sleeping, and the migration backfill test asserts a non-zero pre-state with a control user so the post-state is not vacuous). Migration numbering and dump.sql are consistent.
Severity count: 1 P2, 1 P3, 1 Note.
The P2 is the one to resolve before the panel spends parallel review time: the migration comment claims the unlocked UPDATE path avoids the delete_deleted_user_resources deadlock, but for user_secrets that deadlock is still reachable through the separate per-user cap trigger, which takes FOR UPDATE on the users row on BOTH INSERT and UPDATE with no TG_OP gate. Netero reproduced the deadlock. Either gate the cap trigger's lock to INSERT-only, or correct the migration comment to state the hazard remains for user_secrets. As Netero put it: the comment "is true for user_links and user_skills and false for user_secrets."
The P3 flags that delete_deleted_user_resources wipes six tables but only four have insert guards; user_ai_provider_keys and organization_members have neither a guard nor a backfill entry here. That is a pre-existing gap this PR does not widen, but it needs a human decision: file a ticket or explicitly accept it.
🤖 This review was automatically generated with Coder Agents.
|
/coder-agents-review |
There was a problem hiding this comment.
First-pass review only (round 2). These are mechanical findings from the pre-panel reviewer (Netero); the full review panel still has not reviewed this PR and will do so once the P1 below is resolved. The panel runs next round regardless.
What improved since round 1: CRF-1 is genuinely fixed (the user_secrets cap trigger now gates its users-row lock to TG_OP = 'INSERT', and the UPDATE path completes immediately), and CRF-2 is fixed (new BEFORE INSERT guards plus backfill for user_ai_provider_keys and organization_members, and the race and migration tests now cover all six tables). CRF-3 is accepted as an intentional constraint contract. Netero also positively verified the backfill's "only race products survive" claim by confirming every table added to delete_deleted_user_resources shipped its own backfill in an earlier migration.
But the round-1 fix opened a new regression. Severity count: 1 P1, 1 P2.
The P1 is a lock-order inversion introduced by making the INSERT-path guard take users FOR NO KEY UPDATE. The two OAuth2 token transactions (authorization-code exchange and refresh grant) DELETE the previous api_keys row and then InsertAPIKey in the same transaction, so they now acquire the old api_keys tuple before users, while delete_deleted_user_resources acquires users before deleting api_keys. Netero reproduced a live deadlock at HEAD (Postgres aborted the soft-delete with SQLSTATE 40P01) that does not exist at base, where the soft-delete merely waits. The end state is the same one this PR exists to prevent: a user left active with a freshly minted token, now surfaced as a 500 on DELETE /api/v2/users/{user}. The migration comment's claim that gating the lock to the INSERT path avoids the deadlock is false as stated: the inversion is per-transaction statement order, not per-trigger-operation. In Netero's words: "the same end state the PR exists to prevent, reached through a different door." Fix direction: have the token-writer transactions take the parent users lock first (before DeleteAPIKey*), matching the cleanup's order, and correct the migration comment.
The P2 is that the regression test offered as proof of the CRF-1 fix, TestSoftDeleteGuardUpdatePathTakesNoUserLock, never bounds the wait: when the shared context deadline fires, database/sql rolls back the lock-holding transaction and the previously blocked UPDATE then succeeds, so the test passes whether or not the TG_OP gates exist (Netero measured 25s at round-1 base vs 0.14s at HEAD, with no assertion looking at the delay). The CRF-1 fix is real; the guard shipped to protect it is not.
Process note, out of scope: make lint/go is red on this repo state with three staticcheck SA5011 issues in scripts/clidocgen/main.go, a file this PR does not touch. Netero could not map it to the named failing CI jobs (gh returned 401 in the review sandbox), so treat the CI mapping as unverified, but it should not be left red.
🤖 This review was automatically generated with Coder Agents.
|
/coder-agents-review |
4776321 to
e87dccb
Compare
|
/coder-agents-review |
There was a problem hiding this comment.
First full panel round (17 reviewers). This is a genuinely well-built change and several parts earned praise on their own: the FOR NO KEY UPDATE lock strength is correctly chosen against the FK FOR KEY SHARE, the isolation-level analysis is sound, the backfill runs before CREATE TRIGGER to minimize the ACCESS EXCLUSIVE window, the migration header is (in Leorio's words) "the best migration chart note I have read in this repo," and the race tests are real: multiple reviewers mutation-tested them (remove a guard's lock and TestSoftDeleteGuardWinsConcurrentInsert fails by timing out in waitForBackendBlocked). Prior-round fixes hold up: CRF-5's lock_timeout witness is verified real (removing a TG_OP gate now fails the test with 55P03 instead of being rescued by the context rollback), and CRF-2's six-table guard+backfill coverage is confirmed.
Severity count: 2 P1, 4 P2, 12 P3, 3 Nits.
The headline: the CRF-4 fix is incomplete. CRF-4 (the OAuth2 lock-order inversion) was closed as author-fixed, but the fix reordered only the two tokens.go call sites, and the underlying invariant, take the users lock before touching any guarded child row, is enforced by a migration comment and nothing else. Two more transactions violate it and were reproduced as live deadlocks against a concurrent user soft-delete, PR-introduced (dropping the new trigger or checking out base removes the deadlock in every reviewer's control run): regenerateSessionToken on the workspace-start path (CRF-6) and the OIDC/SSO login org-sync transaction (CRF-7). The consequence in both is the exact state this PR exists to prevent: DELETE /api/v2/users/{user} returns 500 (Postgres picks the victim, so a build or login can 500 instead), and 40P01 is not auto-retried (only 40001 is), so it surfaces as a hard error. This is a fix applied per-instance instead of to the class. As Ryosuke put it: "A contract enforced by prose is a contract that decays."
Three structural directions came up repeatedly and are worth a human decision before another Acquire call is bolted on (CRF-8): (1) put the invariant on the read side, GetAuthorizationUserRoles and ValidateAPIKey do not filter users.deleted today, so a resurrected api_keys row authenticates with full roles (CRF-11, verified end-to-end: a suppressed-cleanup orphan key returned 200 on GET /users/me). One AND NOT users.deleted predicate makes every orphan inert regardless of source and imposes no lock ordering on anyone. It does not deliver the per-table constraint contract #28423 wants, so it is a complement, not a drop-in. (2) Make the guard itself order-safe: Knov's suggestion of BEFORE ROW DELETE triggers that take the users lock (BEFORE ROW fires before heap_delete's tuple lock, so every deleter takes users first unconditionally) makes the inversion unrepresentable and removes the caller contract entirely, at the cost of serializing child deletes on the users row. (3) At minimum, add a deadlock-regression test per delete-or-update-then-insert path using the harness already in this PR (reviewers' repros ran ~1s each, deterministic), since removing either tokens.go lock call currently breaks no test.
The CRF-1 fix also has fallout on the user_secrets cap trigger: gating its lock to INSERT removed UPDATE-path serialization, so concurrent per-row PATCHes now blow past the byte caps (CRF-9, reproduced at ~300-400 KiB against a 204 KiB cap and 72 KiB against a 24 KiB env cap; the "bounded overshoot" the comment promises scales with row count, and the over-cap state is sticky, the user can no longer update any secret). Separately the INSERT-path FOR UPDATE the cap trigger keeps is the wrong lock mode: it upgrades the guard's FOR NO KEY UPDATE, conflicts with FK FOR KEY SHARE across the 36 tables referencing users, and adds a deadlock edge (CRF-10). A per-user advisory lock (pg_advisory_xact_lock keyed on user_id, the pattern already used at chatd/synthetickey.go) on both paths closes the bypass and drops the users-row lock, resolving both.
Process notes, not inline: the PR description is stale, it still says migration 000585, never mentions AcquireUserSoftDeleteGuardLock, the tokens.go change, or the ordering contract, which is the single most important thing a future writer needs (CRF-12). The required CI check title is red; the current title parses as valid, so the run is most likely stale from the earlier scoped title (a scope like coderd/database would now fail on the oauth2provider/ and enterprise/ files), but nobody in the panel could read the log (gh returned 401 in the sandbox), so please confirm rather than assume. New per-user serialization on the hot users row is acceptable but on the record: every api_keys insert now waits on any open write to that user's row. And Pariston's note that the migration comment's stated UPDATE-path deadlock mechanism is imprecise (single-row BEFORE ROW triggers fire before the tuple lock; the real hazard is multi-row UPDATE and ON CONFLICT) is worth a wording pass, though the gates themselves are justified.
coderd/provisionerdserver/provisionerdserver.go:3320
P1 [CRF-6] The CRF-4 fix covered tokens.go but missed regenerateSessionToken, which is the same delete-then-insert on api_keys and still deadlocks against a concurrent user soft-delete. (Knuckle, Meruem, Takumi, Killua, Hisoka, Razor, Knov, Mafu-san P1; Ryosuke, Melody, Mafuuu, Pariston P2; Kurapika P3)
The transaction at 3319 runs
deleteSessionToken(nestedInTxreuses the outer transaction,db.go:186), which holds theapi_keystuple, thenInsertAPIKeyat 3325, whose guard now takesusers FOR NO KEY UPDATE. Lock order: api_keys tuple, then users.delete_deleted_user_resourcestakes users then api_keys. Cycle. (Knuckle)
Reproduced by five reviewers with controls (deadlock at HEAD, none at base). Postgres picks the victim, so either DELETE /api/v2/users/{user} 500s and the user stays active with a fresh token, or the build fails at regenerate session token. Runs on WorkspaceTransitionStart.
Reachability debate, resolved toward P1: deleteUser rejects users with non-deleted workspaces (users.go:697), so for a real user the race needs the TOCTOU between that unlocked GetWorkspaces check and UpdateUserDeletedByID (verified: no lock between them). That narrows probability but the path is externally reachable and reintroduces a closed P1 with no 40P01 retry, so consequence sets the floor. Fix: tx.AcquireUserSoftDeleteGuardLock(ctx, workspace.OwnerID) as the first statement inside the InTx at 3319.
🤖
coderd/userauth.go:1986
P1 [CRF-7] The new organization_members guard makes the OIDC/SSO login transaction deadlock against a concurrent soft-delete of the same user, an inversion that did not exist before this PR. (Komugi, Meruem, Melody, Mafuuu, Pariston, Hisoka P1; Takumi, Knuckle, Kurapika, Ryosuke, Mafu-san P2)
UpdateUserLinkat 1943 locks theuser_linkstuple (the UPDATE path takes no users lock by design), thenSyncOrganizationsreachesInsertOrganizationMember(idpsync/organization.go:143), whose new guard takesusers FOR NO KEY UPDATE. Cleanup holds users and waits on theuser_linkstuple. (Meruem)
Reproduced by six reviewers, each with the control that dropping only trigger_insert_organization_members removes the deadlock, so the PR introduces it (before, the insert took only FK FOR KEY SHARE, which does not conflict). Consequence: DELETE /users/{id} or the login 500s; no 40P01 retry. The trigger is an ordinary OIDC login that adds a membership, concurrent with an admin offboarding that user, which is the common deletion scenario (deletion requires no workspaces, not being logged out). The contract text only names DELETE; an UPDATE inverts identically. Fix: AcquireUserSoftDeleteGuardLock(ctx, user.ID) at the top of the oauthLogin InTx (line 1755), and restate the contract as "holds any lock on a guarded child row, then inserts."
🤖
coderd/httpmw/apikey.go:476
P2 [CRF-11] Authentication and authorization never check users.deleted, so the six write-side guards are the only line of defense against a resurrected row, and the read-side check is the cheaper, source-agnostic half that is missing. (Ryosuke P2; Pariston, who rated the underlying bug P0-worthy)
Verified end-to-end: with a soft-deleted user's api_keys row preserved (cleanup trigger suppressed), the same token returned 200 on GET /api/v2/users/me and GET /api/v2/workspaces. GetAuthorizationUserRoles selects roles with no deleted filter (confirmed) and ValidateAPIKey branches only on UserStatus (suspended/dormant), which stays active through a soft-delete.
This is the security consequence the PR's summary leads with, and it is real. AND NOT users.deleted on the authorization lookup makes every resurrected row inert whatever its provenance (this race, a future unguarded table, a restored backup, a manual insert), and imposes no lock ordering on anyone. It does not deliver the per-table constraint contract #28423 wants, so it is a complement to the guards, not a replacement. This needs a human decision: adopt the read-side check as defense in depth, or explicitly accept that the write-side triggers plus the ordering contract are the whole invariant.
🤖
coderd/members.go:74
P3 [CRF-20] The two new guards surface as raw HTTP 500 with Postgres text for caller errors, instead of a mapped 400. (Leorio)
postOrganizationMember handles 404 and the unique-violation, then falls through to InternalServerError. GetUserByID has no deleted filter, so ExtractUserParam resolves a soft-deleted user and the insert hits organization_member_user_deleted; the admin gets a 500 whose only clue is "Cannot create organization_member for deleted user" in Detail. Map it like the unique-violation branch to a 400 ("Cannot add a deleted user to an organization"). Sibling: exp_chats.go:6656 answers user_ai_provider_key_user_deleted with a 500 and logs server-side, so the caller learns nothing. Both new guards need a mapped branch or the PR ships two new 500s.
🤖
🤖 This review was automatically generated with Coder Agents.
|
Round 9 pushed as 59f5ebe, addressing all 21 round-8 findings. The body-level ones:
Structural (CRF-8): enforcement is now mechanical on both axes; per-path deterministic deadlock regressions for the availability invariant, and the read-side filter for the security invariant. The BEFORE ROW DELETE design was evaluated and rejected because it moves the users lock onto every child DELETE, including multi-user batched deletes such as |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 4, first full panel on the rework (16 reviewers, plus Netero and Law). Note on process: Law returned a mandatory split (extract the read-side filter, CRF-33), which normally skips the panel, but the tooling requires panel reviewers on a post-panel round, so the panel ran and Law/Netero are folded in here.
The rework is genuinely strong and the panel verified it rather than trusting it: every round-3 fix holds at the SQL and state level (advisory-lock cap rewrite closes the CRF-9 bypass and CRF-10 lock-mode edge, the shared fail_if_user_deleted() passes the right TG_ARGV per trigger, the read-side parity holds, the backfill covers eight tables, and multiple reviewers mutation-tested the race suite and it fails for the right reasons). Several reviewers independently ran the full missed-caller sweep and confirmed the four locked transactions are the complete set.
Severity count: 1 P0, 1 P1, 3 P2, 12 P3, plus nits.
The P0 (CRF-27): the CRF-15 fix authorized the guard lock as ActionUpdate on the target user, but both OAuth2 grants take it under the end user's own actor, and the member role denies self-update, so token exchange and refresh fail for every non-admin. Seven reviewers converged. One correction to Netero's framing, established empirically by Bisky: this is NOT silent, an existing test (coderd/oauth2_test.go TestOAuth2ProviderTokenExchange/OK) runs as a member and fails, so CI catches it (the failing coderd job is likely among the pending checks). The deeper defect (CRF-35, three reviewers) is that the ActionUpdate gate is role-dependent and protects nothing the guard trigger doesn't already hand out for free; the fix is to model the lock as a system primitive (ResourceSystem), not to escalate two call sites and leave the trap armed, and specifically NOT to grant members ResourceUser:update (that action also gates suspend/activate and DeleteUserAIBudgetOverride). In Hisoka's words: "I came for the guards. The guards held. The thing that broke was the lock you swapped in to avoid a deadlock."
Law's mandatory split (CRF-33: extract the read-side GetAuthorizationUserRoles filter) is validated by a concrete bug the panel found (CRF-34, four reviewers): that query is :one, so the new AND users.deleted = false returns sql.ErrNoRows for a soft-deleted owner, and its two non-authentication callers (provisionerdserver.go:855, dynamicparameters/render.go:319) treat any error as fatal. Every build for a deleted owner then fails, including the delete build, leaving the workspace and its cloud resources unrecoverable without DB surgery. This is exactly why the read-side change deserves its own PR and reviewers: it entered at round 3 as CRF-11 remediation and its blast radius was never reviewed as a primary change.
Strategic point, converged independently by Law, Pariston, Ryosuke, and Meruem (echoing Knov in round 3): the write-side lock-ordering contract exported from the database to four handler transactions has now produced five distinct failures on one mechanism across four rounds (CRF-6, CRF-7, CRF-14, CRF-16, CRF-27), and each was caught by a reviewer, never by a check. Meanwhile the authentication harm the mechanism is priced against is fully neutralized by the read-side filter alone (a resurrected key yields no principal). The recommendation is not to abandon the guards, but to land the read-side half first (Law's split) and then reconsider the write-side lock with the security pressure removed, via either a DB-level BEFORE DELETE/UPDATE trigger that takes the users lock before the child row (making the inversion unrepresentable) or an idempotent reaper that moves child cleanup out of the soft-delete transaction. Zoro's useful caveat: simply swapping the guard's lock to an advisory lock does not help, the ordering contract is inherent to any lock the guard takes after the child rows.
The P1 (CRF-28) is why the contract keeps breaking silently in the reverse direction: TestSoftDeleteGuardLockOrderPaths replays hand-written SQL that mirrors each Go transaction, so removing the lock call, weakening the query's FOR NO KEY UPDATE to FOR KEY SHARE, or reverting it from :one all keep the suite green (Bisky, Takumi, and Razor each demonstrated one). Drive at least one path through its real Go entry point.
Process/nits folded here rather than inline: the two advisory-lock keys are not registered in coderd/database/lock.go (CRF-47); the migrate_test.go loop variable guardedTables should be backfilledTables since it includes the two unguarded tables (CRF-49); require.Less on two literals reads as constant-true but is load-bearing via the adjacent pg_trigger count check, so it is a tidy-up not a defect (CRF-32); and a scattering of pure style nits (message punctuation, the stmt type name colliding with database/sql, id.String()[:13] truncation, the 200 KiB literal, raw INSERT seeds where dbgen helpers exist). The AcquireUserSoftDeleteGuardLock doc comment and the in-body guard rationale that reaches dump.sql are, per Leorio, the best writing in the diff; fix the two overclaims in CRF-44 and they do their job.
coderd/provisionerdserver/provisionerdserver.go:855
P2 [CRF-34] The read-side users.deleted filter turns every provisioner job for a soft-deleted owner into a permanent failure, including the delete build, so the workspace and its cloud resources can never be reclaimed. (Knuckle, Hisoka P2; Ryosuke, Komugi P3)
GetAuthorizationUserRoles is :one; with AND users.deleted = false it returns sql.ErrNoRows for a deleted owner, and both provisioning consumers treat any error as fatal (failJob("get owner authorization roles: %s") here, xerrors.Errorf("user roles: %w") at dynamicparameters/render.go:319). The owner is fetched with GetUserByID (no deleted filter) so the job reaches this line and blows up. Reachability is narrow (needs the deleteUser TOCTOU: GetWorkspaces then UpdateUserDeletedByID unserialized, with a workspace landing in the window) but the failure is permanent and --orphan does not help because it still needs a provisioner job; recovery is manual DB surgery. Fix: treat sql.ErrNoRows as an empty role set in these two callers (like GetGitSSHKey handling 150 lines up), or put the predicate in UserRBACSubject/ValidateAPIKey on the auth path only (which also resolves CRF-29). This is the concrete blast radius behind Law's CRF-33: if C6 is extracted, it ships with this bug unless the consumers are fixed too.
🤖
coderd/httpmw/apikey.go:901
P3 [CRF-29] An orphaned API key of a soft-deleted user now produces HTTP 500, not 401. (Netero)
GetAuthorizationUserRoles is :one, so the new filter returns sql.ErrNoRows for a deleted user; UserRBACSubject wraps it and apiKeyFromRequestValidate maps any error to StatusInternalServerError with Hard: true. The credential is correctly rejected, so this is presentation only, but the deployment sees 500s for a denied credential. Distinguish sql.ErrNoRows and return 401. Fixing this in UserRBACSubject/ValidateAPIKey (rather than in the shared query) also resolves CRF-34 by leaving the two provisioning callers untouched.
🤖
🤖 This review was automatically generated with Coder Agents.
|
Round 10 pushed as 2f95dc4, addressing all 21 round-9 findings. The headline items:
One addition beyond the findings: the cap triggers now require READ COMMITTED via a shared |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 5, focused panel (11) plus Netero and Law. Process note: Law returned another mandatory split (extract the cap changes), which normally skips the panel, but the tooling requires panel reviewers on a post-panel round, so the panel ran and Law/Netero are folded in.
The good news first, and it is substantial: there are no P0 or P1 findings this round. Every round-4 fix was verified holding, most by mutation against live PostgreSQL: the system-primitive authz fix (CRF-27/35), the read-side split honored to #28634 with no dangling consumer (CRF-33/34), the lock-order Go test (CRF-28), the transitive backfill coverage (CRF-39), the shared SoftDeleteUserKeepingRows helper (CRF-31/48), and the isolation gate breaks no current writer (four reviewers checked every RR/Serializable transaction in the tree). As Knuckle put it, "the schema side of this change is now clean. Five rounds of rework to get there, and the temptation at round four was to bolt a fallback onto each table and move on. It was not taken."
What remains is concentrated on the mechanisms added this round, which is exactly what Law's split predicts. Severity count: 5 P2, 9 P3, 5 nits.
Law's verdict (CRF-51) is a mandatory split: extract the cap changes (the advisory-lock rewrite, require_read_committed, and the skills-cap UPDATE leg) as their own PR, landing first. They are independently justified (the pre-existing FOR UPDATE hazard), must precede the guard topologically, and require_read_committed and the skills UPDATE leg are both new this round and change when ordinary writes are rejected. The panel's own findings on those two pieces are the argument for the split.
require_read_committed is over-engineered in two opposite directions at once. Pariston (CRF-52): it rejects every secret and skill write outside READ COMMITTED unconditionally, whether or not any concurrency exists (the author's own Rejected test inserts a single row into an empty table and it fails), so a deployment that sets default_transaction_isolation on the server, database, role, or pooler, or a future caller reaching for database.ReadModifyUpdate, gets a total feature outage, all to prevent a soft cap slipping by one. Komugi (CRF-53): meanwhile the same-owner exemption reopens the exact overshoot the gate was added to close (reproduced, 300k against a 204800 cap, reachable via dbcrypt rotation racing a user's secret edit). The two together say the mechanism is both too broad and too narrow; the advisory lock already makes the caps correct under READ COMMITTED, which is what every caller uses. Removing the gate and enforcing "these writes run at READ COMMITTED" with a CI test is the simpler shape.
Knuckle (CRF-54): the migration DROP TRIGGERs take ACCESS EXCLUSIVE on api_keys and three other tables and hold them, together with eight unbounded backfill DELETEs, until the whole single-transaction migration commits, blocking authenticated reads fleet-wide during the upgrade. CREATE OR REPLACE FUNCTION takes no table lock and keeps the one-shared-function win; ALTER TRIGGER ... RENAME avoids the drop for the zz_ renames; and the backfill belongs in an idempotent reaper.
Hisoka (CRF-55): the new Go lock-order test pins that the lock is taken, not that it is taken first. Moving AcquireUserSoftDeleteGuardLock to after the api_keys DELETE, the precise CRF-4 inversion, keeps the entire suite green; the deadlock class that produced CRF-4, CRF-6, and CRF-7 is still reintroducible at all four call sites. Hisoka wrote and verified the six-line fix (assert the blocked backend holds no RowExclusiveLock on the guarded tables before release). "Two tests, same transaction, one of them notices. Shall I show you which one you shipped?"
The P3s are mostly correctness-of-the-new-surface and comment/test accuracy: the guard fires on INSERT but not on UPDATE ... SET user_id, so a re-parent onto a deleted user (including api_keys) is unguarded (CRF-56, reproduced, no caller today); "safe under any isolation level" is false for the guard's own INSERT path under REPEATABLE READ (CRF-57); the TG_ARGV[2] fail-closed branch is dead and its comment describes a capability no table has, flagged by all ten panel-plus-Netero reviewers (CRF-58); the new seventh guard has no 409 mapping so it 500s with raw pq (CRF-59) and the 409 class also never reached api_keys, the headline table, whose token/key endpoints still 500 (CRF-60); group_members is left unguarded on a justification that is false, since GetAuthorizationUserRoles reads it unfiltered into the RBAC subject and its inertness actually depends on #28634 (CRF-61); and three test/comment accuracy items (CRF-62 a cited test that does not exist, CRF-63 the trigger-order test pins names not firing order, CRF-64 a dbcrypt coverage overclaim).
Nits folded here: the LockPrefix* constants have no reader and nothing ties them to the SQL (CRF-65); the lock.go "different derivation space, so cannot collide" reasoning is false in two places (CRF-66); four cap constraint names are still raw literals so "all declared once" is inaccurate (CRF-67); the guard message says "Cannot create" on the UPDATE path (CRF-68); and the shared lock-race harness file claims two consumers but has one (CRF-69).
Strategic, and a decision for you rather than a defect (Pariston, Law): after the #28634 split, this PR is defense-in-depth plus metric hygiene, and the security consequence the description leads with now lives in #28634. If #28546 lands alone it closes the race but not orphaned rows that predate the backfill or arrive through an unenumerated path, the class that produced CRF-6, CRF-7, CRF-17, and CRF-38 across four rounds; landing #28634 first makes the protection source-agnostic. Worth stating the intended merge order in one of the two descriptions.
enterprise/coderd/aibridge.go:885
P3 [CRF-59] The seventh guard, added this round on user_ai_budget_overrides, has no handler mapping, so a violation returns a 500 with raw pq text. (Kurapika, Bisky, Hisoka, Mafuuu)
upsertUserAIBudgetOverride maps the membership-trigger violation to a 400 and hands everything else to httpapi.InternalServerError(rw, err), which writes err.Error() into Detail, so a user_ai_budget_override_user_deleted violation returns pq: Cannot create user_ai_budget_override for deleted user. CRF-40/41 fixed exactly this class for the other two guards added earlier (409 + endpoint test); the seventh, added in the same commit, was left out. Reachable only in the race (the membership trigger sorts first and rejects a plainly-deleted user with the 400), which is why it is P3, but that race is what the guard exists for. Fix: one IsCheckViolation(err, database.CheckUserAIBudgetOverrideUserDeleted) branch returning 409, matching the other two.
🤖
coderd/apikey.go:179
P3 [CRF-60] The 409 mapping stops short of api_keys, the guard's headline table, which still returns a 500 with raw pq for a reachable request. (Mafu-san, verified end-to-end)
POST /users/{id}/keys/tokens and POST /users/{id}/keys resolve the target user by UUID with no deleted filter (GetUserByID), so a stale deleted-user id reaches InsertAPIKey and returns status=500 "Failed to create API key." detail="insert API key: pq: Cannot create API key for deleted user". The PR description says "Reachable guard violations map to 409 Conflict"; that is false, two of the three reachable ones map and the third, the one the PR's own summary calls the point of the change ("for api_keys that resurrects a live session token"), does not. The 500 is not a regression, but this PR establishes the error contract and leaves the primary table outside it. Map database.CheckAPIKeyUserDeleted at both handlers, or say in the description that api_keys key creation for a deleted user is deliberately unmapped.
🤖
enterprise/cli/server_dbcrypt_test.go:263
P3 [CRF-64] A dbcrypt test comment is false and hides that dbcrypt rotate breaks on the orphaned state this migration cleans up; the description overclaims the coverage. (Mafuuu, Bisky)
The comment "Deleted users cannot have user_links or user_secrets" contradicts the same loop, which reconstructs exactly that state with SoftDeleteUserKeepingRows twelve lines up. The reason those two tables are skipped is behavioral: Rotate iterates AllUserIDs (which includes soft-deleted users) and calls UpdateUserLink / UpdateUserSecretByUserIDAndName, and the guard's UPDATE branch raises on both for a deleted user, so a single orphaned row aborts the whole coder server dbcrypt rotate run. The behavior predates this PR, and post-backfill the state should not exist, but the description claims "dbcrypt rotation/decryption over legacy orphaned rows" as tested coverage when the tested coverage is user_ai_provider_keys only (the one INSERT-only guard). Reword the comment to say why the two tables are excluded, and narrow the description's coverage claim.
🤖
🤖 This review was automatically generated with Coder Agents.
2f95dc4 to
c5e2ac3
Compare
Round 6: restructured per the Round 5 panelThe branch was rebuilt per CRF-51: the per-user cap rework moved to #28870 (now this PR's base), and the guard work was reshaped:
Validation (PostgreSQL-backed): migrations suite incl. Note: the branch temporarily carries #28874's story-deflake commits via its base; they drop out of the diff as the stack merges (order: #28874 → #28870 → #28634/#28546 → #28423). Generated with |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 6, churn-guard pass only. This is a blocking status check, not a full review: the panel does not run this round because two findings from round 5 are unaddressed with no response, and the review process holds until they are closed one way or the other.
First, real credit: this round resolved 17 of the 19 open findings and honored Law's mandatory split. The per-user caps (advisory locks, isolation contract, and the round-5 P2s CRF-52/53) are gone from this PR, split to #28870, which this branch is now stacked on. The migration was reworked exactly as asked: no DROP TRIGGER (so no ACCESS EXCLUSIVE on api_keys during upgrade, CRF-54), the backfill DELETEs replaced by the idempotent PurgeSoftDeletedUserResources reaper, the guard now locks on ownership change as well as INSERT (CRF-56), group_members guarded on the corrected justification (CRF-61), the dead TG_ARGV[2] branch removed (CRF-58), the isolation comment corrected (CRF-57), the lock-order test now pins the lock's position (CRF-55), and the trigger-order test now checks BEFORE ROW timing (CRF-63). That is a large, clean round.
What blocks the next review are two round-5 findings that received no fix and no response, and were omitted from the round-6 summary comment. Both were posted in the round-5 review body (their files are outside this PR's diff), so there was no inline thread to reply to, which is likely why they slipped:
-
CRF-60 (P3):
POST /users/{user}/keys/tokensandPOST /users/{user}/keysstill return HTTP 500 with rawpq: Cannot create API key for deleted userin the Detail for a stale deleted-user id.coderd/apikey.gomaps only the unique-violation to 409 and falls through toInternalServerError.api_keysis the guard's headline table, and the round-6 description dropped the earlier false "reachable guard violations map to 409" sentence but does not sayapi_keysis deliberately left unmapped. Either mapdatabase.CheckAPIKeyUserDeletedto 409 at both handlers (six lines, likemembers.go), or state in the description that api_keys key creation for a deleted user is intentionally a 500. -
CRF-59 (P3): the seventh guard,
user_ai_budget_overrides, added to close CRF-38, has no handler mapping.upsertUserAIBudgetOverride(enterprise/coderd/aibridge.go) maps only the membership-trigger violation and falls through toInternalServerError, so the guard violation surfaces as a 500 with raw pq text in exactly the race it was added to catch. Add theIsCheckViolation(err, database.CheckUserAIBudgetOverrideUserDeleted)branch returning 409, matching the two guards that already do.
Neither needs to be a fix if there is a reason not to: a one-line reply ("api_keys creation for a deleted user intentionally returns 500 because ...") closes each. A bare deferral without a ticket does not; if either is out of scope, say why or file an issue.
One related loose end, not itself blocking (CRF-64 was otherwise addressed): the comment at enterprise/cli/server_dbcrypt_test.go:263, "Deleted users cannot have user_links or user_secrets.", is still there and still false (the same test reconstructs that state with SoftDeleteUserKeepingRows); reword it while you are in the file.
The full panel will run once CRF-59 and CRF-60 are addressed or explicitly declined.
🤖 This review was automatically generated with Coder Agents.
e968694 to
8f2226f
Compare
c5e2ac3 to
8c32ed7
Compare
Round 7: the two body-only Round-5 findings, closed with fixesPushed as 8c32ed7, rebased onto #28870's new head (8f2226f, itself on
No other behavior changed since Round 6; the branch was rebased as-is onto the restructured base (the storybook carry is gone from the whole stack, which is now #28870 → this PR → #28423 on Validation (PostgreSQL-backed): Generated with |
|
/coder-agents-review |
8c32ed7 to
db94229
Compare
8f2226f to
c5aeace
Compare
|
/coder-agents-review |
A child-table insert racing a user soft-delete could commit after delete_deleted_user_resources ran, resurrecting rows (including live session tokens in api_keys) for a deleted account. Migration 000591 delegates the four existing per-table guard functions to one shared check_user_not_deleted() via CREATE OR REPLACE (no DROP TRIGGER, no ACCESS EXCLUSIVE on hot tables), locks the users row with FOR NO KEY UPDATE on INSERT and owner reassignment, adds guards to user_ai_provider_keys, organization_members, user_ai_budget_overrides, and group_members, and covers UPDATE ... SET user_id everywhere while keeping same-owner updates unlocked. There is no backfill in the migration: orphaned rows of already-deleted users are removed by the idempotent dbpurge reaper (PurgeSoftDeletedUserResources) at startup and on the purge cadence. Transactions that write a guarded child row and later insert one for the same user take the users lock first via AcquireUserSoftDeleteGuardLock (OAuth2 token grants, oauthLogin, regenerateSessionToken) so their lock order matches the cleanup trigger and cannot deadlock; TestOAuth2ProviderTokenExchangeLockOrder pins both the lock and its position via pg_locks.
… mocks The strict-mock TestMetrics chat-retention subtests enumerate every store call purgeTick makes; the new PurgeSoftDeletedUserResources call made the mock abort before the chat purges ran.
Review round 5 body-only findings: POST /users/{user}/keys and
/keys/tokens surfaced the api_keys guard as a raw 500 (CRF-60), and
upsertUserAIBudgetOverride did the same for the budget-override guard in
exactly the deletion race it exists to catch (CRF-59). Map both check
violations to 409 like members.go and cover them with handler tests; the
budget test holds an uncommitted soft-delete so the guard, not the
alphabetically-earlier membership trigger, rejects the insert.
Also thread the beforeCommit hook through the lock-race harness here:
the base cap branch dropped it as unexercised, and this branch's guard
tests are the consumers that flip users.deleted while the racing insert
is parked on the users-row lock. Reword the stale dbcrypt test comment
about deleted users' user_links/user_secrets.
Trunk took 000590 for workspace_agent_session_counts (#28126), which shifted the cap advisory-lock migration to 000591 and this one to 000592. The migration test's stepping constant, its name, and the comments citing either migration number follow; dump.sql and the sqlc output regenerate identically apart from those comments. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-fable-5-1` • Thinking: `xhigh`_
db94229 to
dd747c8
Compare
c5aeace to
1d5631f
Compare
|
Rebased onto the renumbered base: trunk took Generated with |
|
/coder-agents-review |
There was a problem hiding this comment.
Round 7, full panel (13) plus Netero, first panel on the guard-only PR and the new dbpurge reaper. The round-6 blockers are resolved (churn guard PROCEED): CRF-59 and CRF-60 now map their guard violations to 409 with real, race-driven handler tests.
This is in good shape and worth saying so. There are no P0 or P1 findings in the code. The guard core is solid, the authorization is correctly scoped (Kurapika found no security issues: the lock is a ResourceSystem primitive, the reaper runs as subjectDBPurge with exactly ResourceSystem:delete, the 409 mappings match specific constraints with no info disclosure), and the tests are genuine and mutation-verified (Bisky: strip the guard lock and the race tests fail red; the CRF-55 lock-position test, the CRF-63 firing-order test, and the CRF-59/60/64 fixes all hold). Moving the backfill out of the migration into a reaper (CRF-54) was the hard, correct call. Bisky: "I came in expecting costume jewelry. I stayed to appraise real stones."
The findings cluster on the reaper (never panel-reviewed until now) and one missed handler mapping. Severity: 2 P2, 4 P3, 3 nits, plus a merge blocker and a description issue below.
The reaper needs two reworks before it ships. CRF-70 (P2): it is the only delete in purgeTick with no batch bound, and it shares the single purge transaction with every retention delete. A large first pass on an old deployment, or one lost deadlock, rolls back the whole tick, so audit-log and connection-log purges make zero progress while the reaper keeps failing on the same oversized statement. Pariston put it well: a fix whose failure mode (all retention cleanup stalls, disk fills) is worse than the problem it solves. Batch it by user per tick like its siblings, or give it its own transaction. CRF-71 (P2): after the backlog clears the reaper does zero work forever but still runs every 10 minutes, and it runs unindexed: SELECT id FROM users WHERE deleted seq-scans users (every partial index is WHERE deleted = false), and group_members has no index at all, so that table is full-scanned end to end each tick, cost growing with the soft-deleted-user count. Its siblings latch after a clean pass (identifiedModuleCachePurged); this one does not. Add the indexes and latch, or gate the sweep. Also CRF-72 (P3): the reaper is the one operator-visible security cleanup with no rowcount, no metric, and no log field, so nobody can confirm the resurrectable-api_keys cleanup ran or measure it; and CRF-74 (P3, six reviewers): its "deletes in the same table order to minimize deadlock exposure" comment is false twice over (Postgres does not order data-modifying CTEs, and the reaper only touches already-committed-deleted users so it never races the cleanup).
CRF-73 (P3): the class fix for guard-violation-to-409 is one instance short again. The eighth guard, group_members, has no handler mapping, so patchGroup AddUsers for a deleted user still returns a raw 500 with pq text. Hisoka: "You built eight guards and mapped seven of them. One table still bleeds a raw 500." CheckGroupMemberUserDeleted is already declared; add the IsCheckViolation -> 409 branch. CRF-75 (P3): the reaper's test starts a global delete that can reap fixtures other tests in this PR build, a flake under a shared dev database (not CI). Nits CRF-77/78/79 are small message-consistency items.
Two things for you, not code findings:
-
CI is red across 20 required jobs (gen, fmt, lint, sqlc-vet, build, test-go, test-js, storybook, offlinedocs). This is a merge blocker and Mafu-san rates it P1: a change is not done with
requiredred and no diagnosis in the PR. I ran the checks this diff is responsible for and they are clean: the migrations are contiguous (000590/591/592, no collision with the base's cap migration), the generated layers are in sync (AcquireUserSoftDeleteGuardLockandPurgeSoftDeletedUserResourcesin querier/queries.sql.go/dbmock/dbmetrics),gofmt -lis empty,dump.sqlcarriescheck_user_not_deleted, andgo buildpasses. The breadth (frontend jobs red on a backend-only diff) points to a base/rebase/stacking problem rather than this content, but I cannot confirm the cause (ghis 401 in the review sandbox). Please diagnose and get CI green, or state what is inherited from the base; do not treat "the diff builds locally" as clearance whilesqlc-vet/genare red. -
The description's Handlers line still says guard violations "map to 409s (
organization_members,user_ai_provider_keys)", but the code now maps five tables includingapi_keys(both token and key endpoints) anduser_ai_budget_overrides, which are the exact CRF-59/60 fixes that unblocked this round. Mafu-san rates this P2 as the third recurrence of description-vs-code drift; leaving the two headline fixes out of the one summarizing sentence undersells them. Name all the mapped tables or say "every guarded handler maps its violation to 409." (Minor, related: commit86668c8's body cites "Migration 000591" which is now 000592; harmless if squash-merged with a corrected body.)
Finally, the strategic reminder the panel has carried since round 4: this guard-only PR stops orphan rows from being created and reaps existing ones, but it does not stop an orphan api_keys row from authenticating; that read-side filter is #28634. Until #28634 lands, an orphan key between soft-delete and the reaper's next tick still authenticates. The split is deliberate; worth confirming the intended merge order in the description.
enterprise/coderd/groups.go:302
P3 [CRF-73] The eighth guard, group_members, has no 409 handler mapping, so patchGroup AddUsers for a deleted user returns a raw 500 with pq text. (Netero, Hisoka)
Migration 000592 installs trigger_insert_group_members raising group_member_user_deleted, and CheckGroupMemberUserDeleted is declared, but referenced only by tests. patchGroup inserts each req.AddUsers id via tx.InsertGroupMember after only a uuid.Parse (no deleted filter), and the error ladder checks IsUniqueViolation/IsUnauthorizedError/Is404Error then falls through to httpapi.InternalServerError, leaking pq: Cannot create group_member for deleted user. This is the exact class fixed for api_keys (CRF-60) and user_ai_budget_overrides (CRF-59); the remediation was applied instance-by-instance and missed the fifth guarded insert path. Add if database.IsCheckViolation(err, database.CheckGroupMemberUserDeleted) returning 409 before the fallthrough. (The guard firing is correct: pre-PR this silently created an orphan group_members row that GetAuthorizationUserRoles reads into rbac.Subject.Groups.)
🤖
🤖 This review was automatically generated with Coder Agents.
| // Remove child rows orphaned by a user soft-delete that predates the | ||
| // guard triggers and cleanup coverage (migration 000592). The guards | ||
| // prevent new orphans, so after the first pass this is a no-op. | ||
| if err := tx.PurgeSoftDeletedUserResources(ctx); err != nil { |
There was a problem hiding this comment.
P2 [CRF-70] The reaper is the only delete in purgeTick with no batch bound, and it shares the single purge transaction, so a large first pass or one lost deadlock rolls back every retention purge in the tick. (Pariston, Zoro, Killua P2; Knuckle, Takumi, Mafuuu P3)
Every sibling delete in this InTx carries a LimitCount (10000 / 1000). PurgeSoftDeletedUserResources is DELETE ... WHERE user_id IN (SELECT id FROM users WHERE deleted) across eight tables for every soft-deleted user, in one statement, at LevelDefault with no statement_timeout, on the forced initial tick at startup. The first pass on an old deployment (pre-000492 users carry orphaned organization_members plus transitive group_members/user_ai_budget_overrides) is exactly the largest. Two consequences: no partial progress (all-or-nothing statement; an interrupted large backlog never converges, re-doing the whole thing each tick), and blast radius (a reaper failure rolls back the audit-log, connection-log, and chat purges already done in the same tick, so all retention cleanup stalls and disk grows). The query comment's "a lost deadlock surfaces as a failed purge cycle and is retried" understates this. Batch by user per tick (LIMIT on doomed_users, loop/defer the rest) like the siblings, or run the reaper in its own transaction so its failure cannot roll back unrelated purges.
🤖
| -- were already cleaned up. | ||
| -- name: PurgeSoftDeletedUserResources :exec | ||
| WITH doomed_users AS ( | ||
| SELECT id FROM users WHERE deleted |
There was a problem hiding this comment.
P2 [CRF-71] After the backlog clears the reaper does zero work forever but still runs every tick, unindexed, so its cost grows with the soft-deleted-user count for no benefit. (Killua P2; Knuckle, Chopper P3)
The guards make new orphans impossible post-migration, so the steady state deletes nothing, yet PurgeSoftDeletedUserResources runs unconditionally every 10 minutes. SELECT id FROM users WHERE deleted has no supporting index (every partial index on users is WHERE deleted = false), so it seq-scans users; and group_members has no index of any kind, so DELETE FROM group_members WHERE user_id IN (...) seq-scans the whole table each tick, which for OIDC group-sync deployments is users x groups_per_user. Soft-deleted users are never hard-deleted, so doomed_users only grows. The sibling one-off cleanups in this file latch after a clean pass (identifiedModuleCachePurged, chatSearchStaleDrained); this one does not. Fix: latch after the first successful pass, and add a partial index ON users (id) WHERE deleted plus an index on group_members (user_id) (which also helps the existing cleanup).
🤖
| -- transitively (BEFORE DELETE triggers on organization_members); the direct | ||
| -- deletes catch rows orphaned after the user's organization_members rows | ||
| -- were already cleaned up. | ||
| -- name: PurgeSoftDeletedUserResources :exec |
There was a problem hiding this comment.
P3 [CRF-72] The reaper deletes security-relevant orphans (resurrected api_keys, org memberships) with no rowcount, no metric, and no log field, so an operator cannot confirm it ran or how much it cleaned. (Chopper, Mafuuu)
Every other purge in purgeTick reports a slog.F in the "purged old database entries" line and a recordsPurged.WithLabelValues(...) counter. This one, the cleanup the PR description leads with (resurrectable session tokens for deleted accounts), contributes to neither. On the first pass after upgrade an operator watching coderd_dbpurge_records_purged_total sees every purge type except this one. Make the query :execrows (or add per-table RETURNING counts), add a records_purged_total{record_type="soft_deleted_user_resources"} label, and log the count like its siblings.
🤖
| -- soft-deleted users, and delete_deleted_user_resources cleans rows at | ||
| -- soft-delete time; this reaper removes what predates both (legacy orphans | ||
| -- from before cleanup coverage, and race products from before the guards). | ||
| -- It deletes in the same table order as delete_deleted_user_resources to |
There was a problem hiding this comment.
P3 [CRF-74] The reaper's "deletes in the same table order as delete_deleted_user_resources to minimize deadlock exposure" comment is false twice over. (Knuckle, Killua, Hisoka, Takumi, Mafuuu, Zoro)
First, the eight deletes are data-modifying CTEs in one WITH, and Postgres executes those in an unspecified order under one snapshot, so the written CTE order does not control lock-acquisition order (delete_deleted_user_resources gets its order only because it is sequential plpgsql statements). Second, the deadlock it guards against is unreachable: delete_deleted_user_resources fires as an AFTER trigger inside the soft-delete transaction, so any user the reaper sees as deleted has already had its cleanup committed and locks released; the reaper and a live soft-delete operate on disjoint user sets. Drop the ordering rationale and state what is true: the reaper only touches already-committed-deleted users, so it is disjoint from any in-flight soft-delete, and a lost deadlock is retried next tick. (This codebase has a history of comments asserting an ordering guarantee that does not hold.)
🤖
| } | ||
|
|
||
| // The initial tick runs the purge immediately. | ||
| closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(quartz.NewReal())) |
There was a problem hiding this comment.
P3 [CRF-75] TestPurgeSoftDeletedUserResources starts a global reaper that can reap orphan fixtures other tests in this PR construct, a flake under a shared test database. (Komugi)
PurgeSoftDeletedUserResources deletes child rows for every soft-deleted user in the database (doomed_users is unscoped), and dbpurge.New forces an immediate initial tick. TestSoftDeleteGuardRejectsUpdatesForDeletedUser and the dbcrypt tests build exactly that orphan state (SoftDeleteUserKeepingRows) and then assert the rows still exist. Under CODER_PG_CONNECTION_URL (shared DB, no per-test isolation) with concurrent package test binaries, if the reaper's tick commits between a victim's setup and its assertion, the surviving rows vanish and the guarded UPDATE matches zero rows. It cannot fire in CI (per-test DB is the default), only in shared-DB dev mode. Skip this test when CODER_PG_CONNECTION_URL is set, or give it a dedicated database, so its global reaper cannot reach fixtures owned by concurrent tests.
🤖
| // the sibling deleted-user guard in userskills.go already uses Conflict. | ||
| if database.IsCheckViolation(err, database.CheckUserAIProviderKeyUserDeleted) { | ||
| httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ | ||
| Message: "Cannot store an AI provider key for a deleted user.", |
There was a problem hiding this comment.
Nit [CRF-77] The AI-provider-key 409 is the only one of the four sibling guard handlers that names no user in its Detail. (Chopper, Leorio)
members.go, aibridge.go, and both apikey.go handlers render Detail: fmt.Sprintf("%s has been deleted.", <user>.Username); this one sends only the Message. targetUser (httpmw.UserParam) carries .Username and is already used in the adjacent log line. Add Detail: fmt.Sprintf("%s has been deleted.", targetUser.Username) and assert it in the test (which currently checks only Message).
🤖
| // the sibling deleted-user guard in userskills.go already uses Conflict. | ||
| if database.IsCheckViolation(err, database.CheckOrganizationMemberUserDeleted) { | ||
| httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ | ||
| Message: "Cannot add a deleted user to an organization", |
There was a problem hiding this comment.
Nit [CRF-78] The organization-member 409 Message drops the trailing period every sibling guard message carries. (Leorio)
"Cannot create a token for a deleted user.", "Cannot set an AI budget override for a deleted user.", and "Cannot store an AI provider key for a deleted user." all end in a period; this one is "Cannot add a deleted user to an organization" with none. members_test.go pins the exact string, so add the period and update the assertion together.
🤖
| }) | ||
| return | ||
| } | ||
| // The soft-delete guard rejects overrides for a user deleted after the |
There was a problem hiding this comment.
Nit [CRF-79] The user_ai_budget_override 409 is reachable only in the live race; the common stale-deleted-user case returns 400 "not a member," which misdirects from the real cause. (Mafuuu)
trigger_enforce_user_ai_budget_override_membership sorts before trigger_insert_user_ai_budget_overrides, and a committed soft-delete has already removed the user's group_members rows, so an upsert against a stale deleted-user id trips the membership constraint first and returns userAIBudgetOverridesMustBeGroupMemberConstraint (400 "not a member"), not the new 409. The message is not false (a deleted user is not a member), but it points the operator at membership when the root cause is deletion, the CRF-42 class. Not blocking; decide whether the deleted-user check should precede the membership check for this handler, or accept the membership message and note it.
🤖
Closes the insert-vs-soft-delete race on user child tables: an in-flight child-table insert could read
users.deleted = false, lose the race to a concurrent soft-delete (and itsdelete_deleted_user_resourcescleanup), then commit afterwards — resurrecting rows for a deleted account. Forapi_keysthat resurrects a live session token on an account the operator believes they deleted.Scope (round 5, CRF-51): this PR is now guard-only. The per-user cap rework (advisory locks, isolation contract) was split out to #28870 (
fix-user-cap-advisory-locks), which this branch is stacked on.What this does
Migration
000592(non-locking by design, CRF-54):check_user_not_deleted()function owns the lock and the gate. The four pre-existing per-table guard functions (api_keys,user_links,user_secrets,user_skills) are swapped withCREATE OR REPLACE FUNCTION— noDROP TRIGGER, so no ACCESS EXCLUSIVE lock on hot tables during the upgrade.FOR NO KEY UPDATElock on the users row exactly when a row starts belonging to a user:INSERT, orUPDATEreassigninguser_id(CRF-56). Same-owner updates keep the unlocked read (the deadlock argument).api_keysgains a dedicatedBEFORE UPDATE OF user_idtrigger with aWHEN (NEW.user_id IS DISTINCT FROM OLD.user_id)clause so the per-requestlast_usedbump never enters plpgsql.user_ai_provider_keys,organization_members) plususer_ai_budget_overridesandgroup_members, whose rows feed readers that do not filterusers.deleted(GetOverBudgetUsersPerGroup,GetAuthorizationUserRoles→rbac.Subject.Groups; CRF-61).PurgeSoftDeletedUserResources) that runs at startup and on the 10-minute purge cadence under the existing dbpurge advisory lock.TG_ARGV[2]fail-closed branch is deleted (CRF-58; the capability moves to feat: add agent memory database foundation #28423, which owns the one table that needs it).database.ReadModifyUpdate.Lock-ordering contract: transactions that write a guarded child row and later insert one for the same user take the users lock first via
AcquireUserSoftDeleteGuardLock(OAuth2 token grants,oauthLogin,regenerateSessionToken), matchingdelete_deleted_user_resourcesso they cannot deadlock. The query is dbauthz-authorized as a system primitive.Handlers: guard violations map to 409s (
organization_members,user_ai_provider_keys); constraint names are declared once incoderd/database/usersoftdeleteguards.go.Tests
TestSoftDeleteGuardWinsConcurrentInsert— all eight guards lose deterministically to a concurrent soft-delete, pinning each constraint name.TestSoftDeleteGuardBlocksOwnerReassignment— theUPDATE ... SET user_idlegs, including the concurrent-soft-delete race.TestSoftDeleteGuardUpdatePathTakesNoUserLock/TestSoftDeleteGuardRejectsUpdatesForDeletedUser— the same-owner gates, both directions.TestSoftDeleteGuardTriggerOrder— name order and BEFORE ROW timing (CRF-63).TestSoftDeleteGuardLockOrderPaths— per-call-site deadlock regressions (deadlock red without the users-first lock).TestOAuth2ProviderTokenExchangeLockOrder— drives the real HTTP token exchange and now pins the lock's position (CRF-55): while blocked inAcquireUserSoftDeleteGuardLock, the exchange backend must hold zeroRowExclusiveLocks onapi_keys/oauth2_provider_app_codes(pg_locks). Verified red against the reordered-lock mutation.TestMigration000592LockUserSoftDeleteGuards— migration applies with orphans present, orphans survive (reaper owns cleanup), guards and reassignment legs live post-migration.TestPurgeSoftDeletedUserResources— the reaper removes all eight tables' orphans; a live user's rows survive.Stacking
Merge order: #28870 (caps) → this PR → #28423 (agent memory), all rebased onto the same
mainhead. Base isfix-user-cap-advisory-locks; #28870's commits drop out of the diff when it merges.Generated with
mux• Model:anthropic:claude-fable-5-1• Thinking:xhigh