fix(auth): bound the per-user lock wait and make closure evidence attributable - #879
Merged
Merged
Conversation
remyluslosius
force-pushed
the
fix/ow-069-073-closure-evidence
branch
from
September 25, 2026 00:34
5274704 to
e64db4c
Compare
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
force-pushed
the
fix/ow-069-073-closure-evidence
branch
from
September 25, 2026 01:21
e64db4c to
b0c3f09
Compare
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Partial. This PR lands part of the closure scope for
bugs/OW-069andbugs/doing/OW-073and closes neither. Both close only against the single matrix inbugs/doing/OW-062section 14.7, run as integrated acceptance onmain. #878 merged as6284496e; this PR is reconciled onto it and now targetsmain.Changes
Credential operations are limited (C-43, AC-73, AC-81, AC-82). Two limits, and neither proves why a wait was long.
lock_timeout5 s (LockWaitBound), set transaction-local byLockUserOperationDeadline)RunSerializedand the users service apply it again only as a ceiling. Never extends an earlier caller deadlineserver.error, retryable. Nothing changedserver.error, not retryable, cookies cleared, message names the account lock and says nothing was revokedauth.logout_incomplete; others 503 retryable. No message claims the account lock was not acquiredErrCommitUnknown), inRunSerializedand in the users serviceserver.error, not retryable, "may or may not have been applied", even when a deadline interrupted the commitserver.error, retryable, "could not start"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_disabledorsso_account_deletedon both refusal paths. The sign-in page stays/login?sso_error=signin.Audit contract (C-11, AC-80).
auth.login.failuredeclared 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/mewaited 12 s; logout about 21 s. Founder decision: bound the wait, do not skip the slide.server.error: no handler, no cookie change, no refresh.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.
DisableUserandEnableUsernow 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.ErrNoRolesstill refuses; any other error answers 503role_lookup_unavailable, with no handler, no cookie change and no refresh.RolesForUsernow checksrows.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:
Also filed, not changed here
bugs/OW-076.authz.permission.deniedsends undeclaredactor_roleand never sends its declaredroute.Evidence
go test -race -p 1 ./...passed (65 packages).make spec-checkpassed: 121 specs, structural coverage 100%.tsc --noEmitpassed; Vitest passed 408 tests in 60 files.RunSerializedsets no deadlineBeginfailure not tagged as never beganrows.Err()not checkedQuery, so this case uses a querier seam)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
sessionsorrefresh_tokenssits 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 lintcannot 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-062sections 14.7 and 14.8 and AC-54.