feat: resolve an Actor from the JWT without a database read - #11
Merged
Conversation
`retrieve_user_handler` loaded a full `UsersTable` row on every authenticated request, at the cost of a second DB session against a pool of five and a detached ORM instance handed to every use case — while all ten reads of `actor` across `app/use_cases/` were `actor.id`. Authentication now mints an `Actor` (a frozen dataclass carrying only `id`) from `token.sub` and touches no database. `GET /api/auth/me/`, the one consumer of the full row, gains a `FetchUserUseCase`; a missing row stays a 401. Closes planning/deferred/2026-08-21-two-sessions-per-authenticated-request.md: one session per authenticated request.
The rejected alternatives — loading the user row in middleware, UUID/uuid7 user ids, an opaque public id — get a permanent home so review does not re-litigate them. Also updates 0002's `JWTCookieAuth[UsersTable]` reference, stale as of the previous commit.
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.
Why
retrieve_user_handlerloaded a fullUsersTablerow on every authenticatedrequest. Three costs, and nothing that survives inspection on the other side:
actoracrossapp/use_cases/areactor.id. No use casetouches
username,display_nameorpassword_hash.db_pool_size=5/
db_max_overflow=0— filed asplanning/deferred/2026-08-21-two-sessions-per-authenticated-request.md,which this closes.
request.useris adetached ORM instance by the time a use case sees it. Safe only because every
column happens to be loaded and nobody mutates it.
The only consumer of the full row in the app is
GET /api/auth/me/.Design
A new top-level
app/actor.pyholdsActor, a frozen/slots/kw_only dataclasscarrying
id: intand nothing else. It sits besideapp/security.pyandapp/exceptions.pyrather than underapp/api/soapp/use_cases/neverimports that package — nothing outside
app/api/imports it today, and this isnot the change that should be the first.
retrieve_user_handlerbecomes total on the token alone:The
int(token.sub)parse and theNoneonValueErrorstay — they are whatkeep a garbage subject a 401 rather than a 500.
AuthedRequestandJWTCookieAuthare parameterised onActor. Nine use-case modules changeactor: tables.UsersTable→actor: Actor; every body is unchanged, becauseevery read was already
actor.id.GET /api/auth/me/gains aFetchUserUseCaseand anioc.UseCasesprovider(autowired by name). A route handler may not resolve a repository directly, so
this is a use case, not a repository call. It returns
UsersTable | Noneandthe handler raises
NotAuthorizedException— the same splitloginalreadyuses, and 401 rather than the 404 a
NotFoundErrorwould map to: the caller'stoken is what stopped being good, and this keeps caller-visible behaviour
identical to today's.
docs/adr/0005claimed login was "the one placelitestar.exceptionsis used deliberately"; that sentence is now corrected toname both, and nothing else in it changes.
docs/adr/0002loses its staleJWTCookieAuth[UsersTable]reference for the same reason.The decision and its rejected alternatives are filed as
docs/adr/0014-auth-carries-an-actor-id.md.Test fixtures follow:
alice/bob/carolstill insert a user row but yieldActor(id=user.id). Every use of them was.idoractor=, so all 63 callsites are untouched. The trade-off is that no test can reach the user row
through those fixtures any more.
The cost this accepts: authentication no longer proves the user exists. A
token whose row is gone proceeds — reads return empty, writes hit the
messages.user_idFK. Nothing can reach that state today (there is nodelete-user or disable-user path), and when one is added it meets the same
problem as
planning/deferred/2026-08-21-logout-does-not-revoke-jwt.md, to besolved once for both. That claim is carried by the invariant docstring below.
Non-goals
the reasoning recorded in
docs/adr/0014-auth-carries-an-actor-id.mdrather than repeated here. In short: one writer means no id-coordination
problem,
direct_keyno longer fits two ids, three FK columns widen 8→16bytes, and the opaque-id shape is strictly additive on top of this decision
if it is ever wanted.
test's docstring carries the claim, and ADR 0014's Consequence section
states it.
Verification
test_retrieve_user_handler_resolves_an_actor_without_reading_the_database.It passes
Noneas the connection, so any reintroduced lookup fails there —API responses are identical either way, so nothing else would catch it. Its
docstring also records the accepted cost above. Written first and confirmed
red (
ModuleNotFoundError: No module named 'app.actor') before the code.tests/api/test_auth_api.py(non-integersubyields 401, not 500) and
test_me_rejects_token_for_a_user_that_no_longer_exists(401 on the new path) both stay green.
just test: 110 passed, total coverage 100.00% (gate met).just test-migrations: 4 passed.just lint(ruff format, ruff check,tycheck): all checks passed.just check-planning,just check-adrs,just check-links: all OK.