fix(auth): enforce the last-administrator invariant inside the write transaction - #9479
Closed
lstein wants to merge 2 commits into
Closed
fix(auth): enforce the last-administrator invariant inside the write transaction#9479lstein wants to merge 2 commits into
lstein wants to merge 2 commits into
Conversation
…transaction
Removing the last active administrator is irreversible from inside the app: authorization
is derived from the database on every request, so no authenticated path back exists. It
also drops `has_admin()` to zero, which makes `GET /auth/status` report `setup_required:
true` and re-opens the *unauthenticated* `POST /auth/setup` to any caller.
The invariant was enforced in one place only — the `delete_user` route — and enforced
there by reading `count_admins()` in its own transaction before writing in another. That
left three gaps:
- `update_user` had no guard at all, so `PATCH /auth/users/{id}` with `{"is_admin": false}`
or `{"is_active": false}` against the sole administrator succeeded, single-threaded.
- The read and the write were separate transactions, so two callers could each observe two
administrators and each remove one. Route handlers now run in a threadpool, which makes
that reachable from two concurrent requests rather than only across processes.
- `invoke-usermod` / `invoke-userdel` construct `UserService` directly and never reach the
route guard, so the CLI could take the instance to zero on its own.
Moves the check into `UserService.update()` / `UserService.delete()`, evaluated on the
cursor of the transaction that performs the write. Those transactions now open with
`BEGIN IMMEDIATE` so the count is read under the write lock: without it the SELECT runs in
autocommit and a second process can still interleave. In-process callers are additionally
serialized by the database's shared RLock.
The guard keys on the *requested* values, so renaming or changing the password of the last
administrator stays allowed, as does removing an administrator who is already inactive —
they are not counted, so removing them cannot reach zero.
`LastAdministratorError` subclasses `ValueError`, which both the routes and the CLIs
already map to a friendly 400 / error message, so no call site needed changing. The
existing route-level check in `delete_user` stays as the friendly-message path; the
service is the backstop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein
requested review from
JPPhoto,
Pfannkuchensack,
blessedcoolant and
dunkeroni
as code owners
August 8, 2026 13:42
5 tasks
Collaborator
Author
|
Folded into #9360 rather than landed separately. Both PRs were reworking the same invariant from opposite ends — #9360 added the Merging them surfaced things neither PR could see alone:
Everything from here is preserved in #9360, including the concurrency tests. Continuing |
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.
Summary
Kind: fix (backend, auth)
Removing the last active administrator is irreversible from inside the app. Authorization is derived from the database on every request, so the caller loses admin access immediately and no authenticated path back exists. It also drops
has_admin()to zero, which makesGET /auth/statusreportsetup_required: trueand re-opens the unauthenticatedPOST /auth/setupto whoever reaches it first.That invariant was enforced in exactly one place — the
delete_userroute — and enforced there by readingcount_admins()in its own transaction and then writing in another. Three gaps followed:update_userhad no guard at all.PATCH /api/v1/auth/users/{id}with{"is_admin": false}or{"is_active": false}against the sole administrator succeeded, single-threaded, no race required.count_admins()had one call site in the whole codebase.invoke-usermod/invoke-userdelconstructUserServicedirectly and never reach the route guard, so the CLIs could take the instance to zero on their own.The fix
The check moves into
UserService.update()andUserService.delete(), evaluated on the cursor of the transaction that performs the write. Those transactions now open withBEGIN IMMEDIATEso the count is read under the write lock — without it theSELECTruns in autocommit and a second process can still interleave between the read and the write. In-process callers are additionally serialized by the database's sharedRLock.The guard keys on the requested values rather than on the target being an administrator, so these stay allowed:
count_admins(), so removing them cannot reach zero.LastAdministratorErrorsubclassesValueError, which the routes (except ValueError -> 400) and both CLIs already handle, so no call site needed changing. The existing route-level check indelete_userstays as the friendly-message path; the service is the backstop.Why now
Found while reviewing #9436, which converts 167 route handlers from
async deftodef. Anasync defhandler with noawaitin its body cannot yield, so its check-then-act ran atomically against other requests; dispatched to the threadpool it does not. That turns gap 2 from a cross-process race into one reachable from two concurrent HTTP requests:This PR is independent of #9436 — the invariant is wrong on
maintoday, gap 1 needs no concurrency at all, and the fix is confined to the user service.Relationship to #9360
#9360 adds a route-level
count_admins()guard toupdate_user, which closes gap 1 at the API boundary. It does not move either guard into the write transaction, so gaps 2 and 3 survive it. The two changes are complementary and touch different layers: #9360 keeps the friendly 400 in the route, this PR makes the invariant hold underneath it. Expect a small textual conflict inusers_default.pyonly if both land; neither depends on the other.QA Instructions
Automated. 17 new tests, all verified to fail before the fix and pass after — 9 of the 17 fail without the guard, including every concurrency case:
tests/app/services/users/test_last_admin_invariant.py— the guard itself. Rejects delete / demote / deactivate of the last admin; allows rename, password change, demoting one of two, and removing an already-inactive admin. Three tests spawn two threads through athreading.Barrierand assert exactly one succeeds: concurrent deletes, concurrent demotions, and one of each.tests/app/routers/test_last_admin_routes.py— the HTTP contract: 400 rather than a 500 escaping from the service, and the last admin's record is not blanket-locked.Full backend run on this branch: 2197 passed, 8 skipped, 6 xfailed, 0 failures.
ruff check/ruff format --checkclean.Manual. In multiuser mode with a single administrator:
PATCH /api/v1/auth/users/{admin_id}with{"is_admin": false}→ 400Cannot remove the last administrator(before: 200, and the instance falls back to unauthenticated setup).{"is_active": false}→ 400.{"display_name": "..."}→ 200, still works.invoke-usermod --no-admin <email>on the sole administrator → refused with the same message instead of succeeding.Checklist
What's Newcopy (if doing a release after this PR)