Skip to content

fix(auth): bound the per-user lock wait and make closure evidence attributable - #879

Merged
remyluslosius merged 6 commits into
mainfrom
fix/ow-069-073-closure-evidence
Sep 25, 2026
Merged

remyluslosius merged 6 commits into
mainfrom
fix/ow-069-073-closure-evidence

Conversation

@remyluslosius

@remyluslosius remyluslosius commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Partial. This PR lands part of the closure scope for bugs/OW-069 and bugs/doing/OW-073 and closes neither. Both close only against the single matrix in bugs/doing/OW-062 section 14.7, run as integrated acceptance on main. #878 merged as 6284496e; this PR is reconciled onto it and now targets main.

Changes

Credential operations are limited (C-43, AC-73, AC-81, AC-82). Two limits, and neither proves why a wait was long.

Limit What it covers What it does not cover
lock_timeout 5 s (LockWaitBound), set transaction-local by LockUser Each lock acquisition in the transaction, separately, later implicit row locks included Pool acquisition, the transaction as a whole, the request
Context deadline 15 s (OperationDeadline) Starts at the identity binder. Covers cookie verification, and on the credential and account-mutation routes is handed to the handler, so its reads and its transaction (pool acquisition, statements, commit, retries) spend one budget. RunSerialized and the users service apply it again only as a ceiling. Never extends an earlier caller deadline Handlers on other routes, which keep their own context (reports, the event stream)
Outcome Answer
Per-user lock not acquired, any path but logout 503 server.error, retryable. Nothing changed
Per-user lock not acquired, logout 503 server.error, not retryable, cookies cleared, message names the account lock and says nothing was revoked
A later lock times out Rolled back. Logout 500 auth.logout_incomplete; others 503 retryable. No message claims the account lock was not acquired
Deadline expires during a commit Unknown outcome (ErrCommitUnknown), in RunSerialized and in the users service
Admin commit outcome unknown 503 server.error, not retryable, "may or may not have been applied", even when a deadline interrupted the commit
Admin transaction never began (no connection free before the deadline) 503 server.error, retryable, "could not start"
Admin deadline after the transaction began, before its commit Rolled back; 503 server.error, retryable, "did not complete in time"

Admin failures are classified by the stage the transaction reached, with the commit stage tested first.

Cleanup (AC-91). The rollback after a failure runs on a context detached from the expired operation, keeping its values, with its own 2 s limit (RollbackCleanupLimit). Cleanup can therefore add up to 2 s beyond the 15 s operation deadline; it is not contained within it. Its result never changes the reported outcome: a transaction that never reached COMMIT cannot have committed, so an unconfirmed rollback leaves only lock release uncertain. A real rollback failure in the driver (backend terminated, SQLSTATE 57P01) closes the connection, shown against pgx v5.9.2 itself rather than a mock.

The admin mapping covers disable, enable, reset and soft delete. Soft delete now uses the shared mapping.

SSO audit reasons (system-sso C-05, AC-12, AC-13). A refused federated sign-in records sso_account_disabled or sso_account_deleted on both refusal paths. The sign-in page stays /login?sso_error=signin.

Audit contract (C-11, AC-80). auth.login.failure declared five reasons while the code records 36, and it sent three undeclared keys. The event now declares all of them. AC-80's source scan proves declared coverage only. Whether each request emits the right reason is asserted separately, by the request-correlated tests (AC-34, AC-36, AC-44, AC-54, AC-57, system-sso AC-12, AC-13).

Correlated audit reads (bugs/OW-075) and promoted regressions (AC-73 to AC-79) with writes attributed by row ID.

OpenAPI declares the 403, 500 and 503 responses these endpoints return.

OW-077: cookie verification is bounded (C-44, AC-83 to AC-88)

Found while testing this PR: the binder's idle slide waited without limit on a locked session row. GET /auth/me waited 12 s; logout about 21 s. Founder decision: bound the wait, do not skip the slide.

  • The slide runs in its own short transaction with the per-lock limit, and its UPDATE re-checks revocation and both expiry deadlines. A session revoked or expired during the wait is refused with the reason the row now shows.
  • A verification that cannot complete answers 503 server.error: no handler, no cookie change, no refresh.
  • Logout verifies without sliding, then reaches its hash lookup, CSRF check and bounded revocation. Background requests stay non-sliding.

Committed admin changes and role lookups (1f08433)

A committed change is reported as committed (api-users C-08, AC-21). Disable and Enable used to commit, audit, then re-read the user; a failure of that read became "not applied", or 404 after a concurrent delete. DisableUser and EnableUser now read the user inside the locked transaction and return it only after the commit is confirmed. No read follows the commit.

A failed role lookup answers 503 (C-45, AC-92). The cookie binder mapped every role-lookup error to a refused credential (401). Only the confirmed identity.ErrNoRoles still refuses; any other error answers 503 role_lookup_unavailable, with no handler, no cookie change and no refresh. RolesForUser now checks rows.Err(), so an error that ends the rows is not an empty role list.

AC-91 asserts the driver's real rollback error, SQLSTATE 57P01.

Hosted failure on b0c3f09, kept as evidence

Go CI run 36081673305 failed four AC-73 cases. They answered at 15.0 s, and the logout case's fixture login got a 503. Two separate causes:

  1. Fixture assumption. The eight cases ran in parallel on the test pool, whose default size follows the CPU count. On a small pool the lock holders starved the requests of connections, so they hit the operation deadline instead of the lock limit. I did not verify the runner's CPU count. Instead I reproduced the same signature locally by running the old parallel test on an explicit 4-connection pool. AC-73 and AC-81 now run one case at a time on a pool explicitly sized to 8, and each case confirms its connections are back before the next.
  2. Production defect it exposed. When the deadline expired before an admin transaction began, the handler answered 500 "user operation failed", although nothing was applied. Fixed by the stage mapping above, covered by AC-90.

Also filed, not changed here

bugs/OW-076. authz.permission.denied sends undeclared actor_role and never sends its declared route.

Evidence

  • Local, PostgreSQL 16 test database: go test -race -p 1 ./... passed (65 packages). make spec-check passed: 121 specs, structural coverage 100%. tsc --noEmit passed; Vitest passed 408 tests in 60 files.
  • Mutation checks, each applied alone, restored and verified by file hash:
Mutation Tests that went red
No lock limit AC-73, every path
RunSerialized sets no deadline AC-82 establishes a deadline
The deadline extends the caller's AC-82 preserves the caller's deadline (identity), and the users-service AC-82
Admin lock-timeout mapping removed AC-73 disable, enable, reset, soft delete; AC-81 disable
Soft delete back to its own 500 AC-73 soft delete
Logout timeout marked retryable AC-73 logout
Logout treats any lock timeout as the account lock AC-81 logout
55P03 not mapped to the typed error AC-42 lock-timeout case
Login skips account state / password recheck / OTP AC-77, AC-78; AC-78 reset; AC-76
Refresh skips the account-state check None of the new tests (Disable also revokes the token); existing AC-41 catches it on both paths
SSO skips the state check / reasons collapsed system-sso AC-10, AC-13 / AC-12, AC-13
Commit error swallowed AC-79, AC-77
Bearer lookup failure answered 401 AC-36
Undeclared reason added AC-80
Each asserted reason renamed AC-44, AC-54, AC-57
Idle slide not revalidated AC-85
Idle slide without the lock limit AC-83
Logout slides AC-86
Binder deadline not handed to the handler AC-87 one budget
Binder verification with no deadline AC-87 one budget
Background requests slide AC-88
Users-service commit error unclassified AC-89 durable and non-durable
Users-service transaction without a deadline AC-89 default deadline
"Never began" not mapped AC-90 pool exhausted, AC-90 mapping
Deadline tested before the uncertain commit AC-90 mapping, AC-90 deadline during the commit
Begin failure not tagged as never began AC-90 pool exhausted
Cleanup not detached from the expired context AC-91 cleanup context
Cleanup drops the caller's values AC-91 cleanup context
Cleanup with no deadline AC-91 cleanup context
Cleanup limit above 2 s AC-91 cleanup context
A failed cleanup downgrades an uncertain commit to "not applied" AC-91 uncertain commit with failed cleanup
A failed cleanup upgrades a pre-commit failure to uncertain AC-91 pre-commit with failed cleanup
Disable re-reads the user after the commit api-users AC-21 disable
Enable re-reads the user after the commit api-users AC-21 enable
Every role-lookup error treated alike AC-92 lookup fails (the mutation authenticated the request with an empty role: 200)
rows.Err() not checked AC-92 rows end with an error (a real canceled query surfaces at Query, so this case uses a querier seam)
The rollback error hidden AC-91 real driver failure

Test gaps from the previous review, now closed: the users service's default deadline (AC-89) and HTTP handling of durable and non-durable unknown commits on an administrative mutation (AC-89).

Manual, reported separately: the structural review behind OW-062 AC-77. Every reachable write to sessions or refresh_tokens sits in the locked protocol, except the idle slide, which is now bounded and revalidating but still outside the per-user lock. This review is not enforcement of lock ordering.

make lint cannot run locally (pinned golangci-lint 1.64.8 against Go 1.26).

Refs: bugs/OW-069, bugs/doing/OW-073, bugs/OW-075, bugs/OW-076, bugs/OW-077, bugs/doing/OW-062 sections 14.7 and 14.8 and AC-54.

@remyluslosius
remyluslosius force-pushed the fix/ow-069-073-closure-evidence branch from 5274704 to e64db4c Compare September 25, 2026 00:34
Base automatically changed from fix/ow-069-enable-revokes-interactive-credentials to main September 25, 2026 01:00
…ributable

Follow-up to the first integrated closure run for OW-069 and OW-073 on
a22c8b2, authorized 2026-09-24. Stacked on the I10 change.

Lock-wait bound. Nothing bounded the wait for the per-user lock: the
http.Server WriteTimeout does not cancel a handler, so a request blocked
on the lock waited until its client disconnected. LockUser now sets a
transaction-local lock_timeout of 5 s (identity.LockWaitBound), so every
path that takes the lock inherits it, the administrative account
mutations included. A timeout is a determinate failure reported as 503
server.error, retryable, and is distinct from a logout that failed and
rolled back (500 auth.logout_incomplete) and from an unknown commit (503,
not retryable). Logout still clears both cookies on a timeout, because
the web client treats every logout response as signed out.

SSO audit reasons. A refused federated sign-in now records
sso_account_disabled or sso_account_deleted, on both refusal paths. The
sign-in page stays /login?sso_error=signin.

Audit contract. auth.login.failure declared five reasons, of which the
code recorded one; it records 36. The event now declares the full
vocabulary and the username, remote_addr and user_agent keys the
emitters send, which the key check had been logging as violations on
every binder refusal. A source scan (AC-80) keeps the two in step.

Evidence. The three assertions that read the newest audit row in the
database now read only the row their own request produced, by
correlation id (bugs/OW-075). The scratch closure cases are promoted to
committed tests that attribute writes by row ID, with the coordinating
mutation's own changes asserted separately.

The OpenAPI document now declares the error responses login, both
refresh paths, logout and the admin account mutations already returned.

Specs: system-auth-identity 1.9.0 (C-11 amended, C-43 added, AC-73 to
AC-80), system-sso 1.2.0 (C-05 amended, AC-12, AC-13).

Refs: bugs/OW-069, bugs/doing/OW-073, bugs/OW-075,
bugs/doing/OW-062 sections 14.7 and 14.8
…limits claim

Review corrections to the lock-wait change, 2026-09-24.

The transaction-local lock_timeout limits each lock acquisition
separately, later implicit row locks included. It does not limit pool
acquisition, the transaction or the request, and exceeding it shows only
that a wait exceeded the limit. The comments and C-43 now say that, and
no longer claim it proves a stuck holder or leaves 55 s of the
WriteTimeout.

An operation deadline (identity.OperationDeadline, 15 s) now covers
RunSerialized as a whole, pool acquisition through the commit and any
retries, and the users service's account-state, reset and enable
transactions. It never extends an earlier caller deadline. A deadline
that expires during a commit stays an unknown outcome, including in the
users service, whose commit errors now go through the same classifier.

A lock that times out after the per-user lock was held rolls back and is
not reported as the account lock: logout answers 500
auth.logout_incomplete, the others a retryable 503. The admin mapping
covers disable, enable, reset and soft delete, which now uses the shared
mapping, and maps an unknown commit to a non-retryable 503.

Logout's lock-timeout answer is no longer retryable: it clears the
cookies, so a repeated request may name no family or a different one.
Its message names the account lock rather than saying the request did
not reach the server.

Specs: system-auth-identity 1.9.0, C-43 rewritten, AC-73 extended to all
eight paths, AC-81 and AC-82 added, and the added criteria given
explicit response, cookie and audit expectations. The manual structural
note is removed from AC-78.

Found while testing, filed and not changed here: bugs/OW-077, the
cookie binder's idle slide waits without limit on a locked session row.

Refs: bugs/OW-069, bugs/doing/OW-073, bugs/doing/OW-062 AC-54,
bugs/OW-077
…oked during the wait

The cookie binder slid a session's idle window with an unconditional
UPDATE on the pool, with no lock limit and no deadline. With the session
row held by another transaction, every cookie request for that user
waited until the lock was released: 12 s for GET /auth/me and about 21 s
for logout in the measurement that found it (bugs/OW-077).

Founder decision 2026-09-24: bound the wait, do not skip the slide.
Skipping would change the idle policy under contention.

- The binder runs verification under identity.OperationDeadline, never
  beyond an earlier deadline the request carries, and hands that
  deadline to the credential and account-mutation handlers, so the
  handler's transaction spends the remaining budget instead of starting
  a new one. Other routes keep their own context.
- The slide runs in its own short transaction with the per-lock limit.
  Its UPDATE re-checks revocation and both expiry deadlines, so a session
  revoked or expired while the request waited is refused with the
  reason the row now shows, not authenticated from the earlier read.
- A verification that cannot complete answers 503 server.error without
  running the handler, clearing a cookie or triggering a refresh.
- Logout verifies without sliding, then reaches its hash lookup, CSRF
  check and bounded revocation. Background requests stay non-sliding.

The users service gains a transaction seam so the remaining #879 test
gaps are covered: its default deadline, and durable and non-durable
unknown commits over HTTP.

Specs: system-auth-identity 1.9.0, C-43 amended, C-44 added, AC-83 to
AC-89.

Refs: bugs/OW-077, bugs/OW-069, bugs/doing/OW-062 AC-54
@remyluslosius
remyluslosius force-pushed the fix/ow-069-073-closure-evidence branch from e64db4c to b0c3f09 Compare September 25, 2026 01:21
…rolled back

Go CI run 36081673305 failed AC-73 on b0c3f09. Two separate causes.

The fixture. AC-73 ran eight lock-holding cases in parallel on the test
pool, whose default size follows the CPU count. On a small pool the
holders starved the requests of connections, so they hit the 15 s
operation deadline instead of the 5 s lock limit. Reproduced locally with
an explicit 4-connection pool. AC-73 and AC-81 now run one case at a
time on a pool explicitly sized to 8, and each case confirms every
connection is back before the next.

The production defect the failure exposed. When the operation deadline
expired before an admin transaction began, the handler answered 500
"user operation failed". Nothing had been applied. Failures are now
classified by stage, with the commit stage tested first:

- uncertain commit: 503, not retryable, even when a deadline
  interrupted it (ClassifyCommitError keeps both errors visible);
- never began (identity.ErrNotBegun): 503, retryable, "could not start";
- lock timeout, or a deadline before the commit: rolled back, 503,
  retryable, "not applied".

Rollbacks run on a context detached from the expired one.

AC-90 covers pool exhaustion on all four admin mutations with a
deliberately small pool, a deadline after the transaction began, the
mapping order, and a deadline during the commit.

Specs: system-auth-identity 1.9.0, C-43 amended, AC-73 and AC-81
inputs amended, AC-90 added.

Refs: bugs/OW-069, bugs/doing/OW-062 AC-54
…the reported outcome

Review follow-up to 4a04697.

RollbackDetached's limit is now a named constant, RollbackCleanupLimit
(2 s), and a rollback that fails for any reason other than an already
closed transaction is logged. Its result still never changes how an
attempt is reported.

AC-91 covers four cases:

- The cleanup context is not done while the rollback runs, keeps the
  caller's values, and carries a positive deadline at most 2 s away.
- A real rollback failure in the driver (backend terminated, SQLSTATE
  57P01) closes the connection; an untouched control stays open. This
  exercises pgx v5.9.2 itself, not a mock.
- Through the users service's transaction seam, a pre-commit failure
  whose cleanup also fails still answers the retryable 503 "not
  applied", and an uncertain commit whose cleanup fails still answers
  the non-retryable 503. Each uses a user created for the case, so its
  unchanged-state assertions describe that attempt alone.

C-43 now states that cleanup can add up to 2 s beyond the 15 s operation
deadline and is not contained within it.

Specs: system-auth-identity 1.9.0, C-43 amended, AC-90 input narrowed,
AC-91 added.

Refs: bugs/OW-069, bugs/doing/OW-062 AC-54
…when the role lookup fails

Review corrections to 49ff8d4, from a source review of the head.

A committed disable or enable could be reported as not applied. The
handler committed, emitted its success audit, then re-read the user; a
failure of that read went through the admin error mapping, so a deadline
read as "not applied" and a concurrent delete as 404. DisableUser and
EnableUser now read the user inside the locked transaction and return it
only after the commit is confirmed. No read follows the commit.

A failed role lookup answered 401. The cookie binder mapped every role
lookup error to session_user_lookup_failed, a refused credential, which
can trigger a refresh during an infrastructure failure. Only
identity.ErrNoRoles, the confirmed answer, still refuses; any other
error answers 503 as role_lookup_unavailable, declared in the audit
contract. RolesForUser now checks rows.Err(), so an error that ends the
rows can no longer read as an empty role list.

AC-91 now asserts the driver's real rollback error, SQLSTATE 57P01,
through RollbackDetachedErr; RollbackDetached still discards it.

Specs: system-auth-identity 1.9.0, C-45 and AC-92 added, AC-91 amended;
api-users 1.4.0, C-08 amended, AC-21 added.

Refs: bugs/OW-069, bugs/doing/OW-062
@remyluslosius
remyluslosius merged commit 18eb9e4 into main Sep 25, 2026
20 checks passed
@remyluslosius
remyluslosius deleted the fix/ow-069-073-closure-evidence branch September 25, 2026 18:12
remyluslosius added a commit that referenced this pull request Sep 27, 2026
… guidance

CHANGELOG [Unreleased] gains the ten PRs merged after v0.8.0-rc.5 (#870
to #879; #874 is CI-only and is not listed). Upgrade notes lead: the
0065 migration signs everyone out, cookie logout requires the CSRF
token, and the audit export refuses an unknown parameter. Each entry was
checked against the merged code: the 0065 migration body, the binder's
sid check and EvaluateBearerBinding, the logout CSRF branch, the
LockWaitBound, OperationDeadline and RollbackCleanupLimit constants, and
the auth.login.failure and admin.user.enabled declarations in
audit/events.yaml. Known limitations name CP bugs/OW-072 and OW-062.

QUICKSTART's incident step said active sessions end "via logout" and
told operators to rotate passwords. Logout ends one login, and a user's
own password change signs out nothing else (OW-072). It now names
disable and the administrator reset, which end every interactive
credential since #875 and #876.

SECURITY_INCIDENT said an access token is ended only by rotating the
signing key. Since #876 it names its session and is refused once that
session is revoked, so revoking the rows ends it with no restart. Key
rotation is kept, scoped to a key that may itself be exposed.
remyluslosius added a commit that referenced this pull request Sep 27, 2026
… guidance (#883)

* docs(release): record the changes since rc.5 and correct the incident guidance

CHANGELOG [Unreleased] gains the ten PRs merged after v0.8.0-rc.5 (#870
to #879; #874 is CI-only and is not listed). Upgrade notes lead: the
0065 migration signs everyone out, cookie logout requires the CSRF
token, and the audit export refuses an unknown parameter. Each entry was
checked against the merged code: the 0065 migration body, the binder's
sid check and EvaluateBearerBinding, the logout CSRF branch, the
LockWaitBound, OperationDeadline and RollbackCleanupLimit constants, and
the auth.login.failure and admin.user.enabled declarations in
audit/events.yaml. Known limitations name CP bugs/OW-072 and OW-062.

QUICKSTART's incident step said active sessions end "via logout" and
told operators to rotate passwords. Logout ends one login, and a user's
own password change signs out nothing else (OW-072). It now names
disable and the administrator reset, which end every interactive
credential since #875 and #876.

SECURITY_INCIDENT said an access token is ended only by rotating the
signing key. Since #876 it names its session and is refused once that
session is revoked, so revoking the rows ends it with no restart. Key
rotation is kept, scoped to a key that may itself be exposed.

* docs(runbook): disable a compromised account instead of deleting it

Three defects in SECURITY_INCIDENT, all present in v0.8.0-rc.5 (CP
bugs/OW-082), kept in their own commit so they can be dropped
independently.

- "There is no is_active flag; disabling an account means
  soft-deleting it." POST /api/v1/users/{id}:disable has existed since
  #601 and, since #875, ends every interactive credential. The section
  now leads with disable, which :enable reverses, and keeps delete and
  the SQL fallback with what each does and does not do.
- The delete was said to be audited as account.user.deleted, which is
  the host-side /etc/passwd event. DeleteUserByID emits
  admin.user.deleted.
- Recovery verification step 3, headed "No live sessions for disabled
  accounts", checked only deleted_at. It now checks disabled_at too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant