Skip to content

Fix lost-update race in access-policy upsertPolicy - #490

Merged
TheGreatAxios merged 2 commits into
mainfrom
cl-7232-lost-update-access-policy
Aug 30, 2026
Merged

TheGreatAxios merged 2 commits into
mainfrom
cl-7232-lost-update-access-policy

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

upsertPolicy in packages/access-policy/src/store.ts read the current tenant policy row and wrote a merged result back as two separate round trips, with no transaction, row lock, or optimistic-concurrency guard. Two concurrent PATCHes touching different fields of the same tenant's policy (e.g. one admin toggles selfSignup, another sets allowedDomains) could both read the same baseline, so whichever write landed second silently reverted the other's field.

Fixes CL-7232.

Concurrency mechanism

Wraps upsertPolicy in a transaction with SELECT ... FOR UPDATE row locking rather than an optimistic version-stamp check. This is the same choice a sibling in-flight lane made for the identical lost-update shape elsewhere in this codebase (a workbench-settings mutate path): an optimistic check based on a wall-clock version stamp was rejected there because two transactions starting in the same tick could still both pass it, which is exactly the bug this PR closes.

Because access_policy.policy's row may not exist yet (this is an upsert, not always an update), SELECT ... FOR UPDATE alone has nothing to lock for a brand-new tenant. An INSERT ... ON CONFLICT DO NOTHING guarantees a row exists first: two concurrent first-writes for the same tenant serialize on that insert's unique-index conflict — the loser blocks until the winner's transaction commits, then no-ops and its own subsequent SELECT ... FOR UPDATE sees the winner's committed row.

Tests

Two new regression tests in store.drizzle.test.ts against real Postgres:

  • Existing-row race: a third connection takes the row's lock first and holds it open while two real upsertPolicy calls start and queue up behind it, forcing the exact worst-case interleaving (both reads before either write commits) deterministically rather than hoping a fast local round trip races that way.
  • Brand-new-tenant race: the same two-call concurrency via Promise.all, which reliably reproduces the bug for the insert path on a real database.

Both were verified to fail against the pre-fix implementation and pass against the fix.

Stack

This is the base of a 3-PR stack: CL-7233 (onboarding map eviction) branches off this, and CL-7234 (onboarding reportError) branches off that. Do not merge out of order.

Review process note

Greybeard reviewed the approach before implementation; Critique reviewed the committed diff and found the original existing-row test did not actually discriminate buggy from fixed code (a Promise.all was too fast to force the interleaving) plus two commit-message/comment hygiene issues (an external ticket reference, and citing an uncommitted sibling as settled fact). Both commits were rewritten to address every finding, verified empirically (the fixed test now reliably fails pre-fix and passes post-fix).

Reconstructs the real-Postgres finding that two concurrent
upsertPolicy calls patching different fields of the same tenant's
policy can silently revert each other, for both an existing row and a
brand-new tenant's first write. A plain Promise.all of two real calls
does not reliably force the interleaving on a fast local connection,
so the existing-row case uses a third connection that takes the row's
lock first and holds it open while both real calls start and queue up
behind it, guaranteeing the overlap. Mirrors the existing
consumePendingInvite concurrency test in the same file.
upsertPolicy read the current policy row and wrote a merged result
back as two separate round trips, with no transaction, row lock, or
optimistic-concurrency guard. Two concurrent PATCHes touching
different fields of the same tenant's policy could both read the same
baseline, so whichever write landed second silently reverted the
other's field.

Wraps upsertPolicy in a transaction with SELECT ... FOR UPDATE rather
than an optimistic version-stamp check: two transactions starting in
the same wall-clock tick could still both pass a version check, which
is exactly the bug this closes. Because the policy row may not exist
yet, an INSERT ... ON CONFLICT DO NOTHING guarantees a row exists
before the row lock is taken, serializing concurrent first-writes for
a brand-new tenant on the insert's unique index instead of racing
them.
@TheGreatAxios
TheGreatAxios merged commit d408d99 into main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant