Skip to content

feat: resolve an Actor from the JWT without a database read - #11

Merged
lesnik512 merged 2 commits into
mainfrom
auth-actor
Aug 23, 2026
Merged

feat: resolve an Actor from the JWT without a database read#11
lesnik512 merged 2 commits into
mainfrom
auth-actor

Conversation

@lesnik512

@lesnik512 lesnik512 commented Aug 23, 2026

Copy link
Copy Markdown
Member

Why

retrieve_user_handler loaded a full UsersTable row on every authenticated
request. Three costs, and nothing that survives inspection on the other side:

  • All ten reads of actor across app/use_cases/ are actor.id. No use case
    touches username, display_name or password_hash.
  • It is a second DB session per authenticated request against db_pool_size=5
    / db_max_overflow=0 — filed as
    planning/deferred/2026-08-21-two-sessions-per-authenticated-request.md,
    which this closes.
  • The row comes from a session the handler then closes, so request.user is a
    detached 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.py holds Actor, a frozen/slots/kw_only dataclass
carrying id: int and nothing else. It sits beside app/security.py and
app/exceptions.py rather than under app/api/ so app/use_cases/ never
imports that package — nothing outside app/api/ imports it today, and this is
not the change that should be the first.

retrieve_user_handler becomes total on the token alone:

async def retrieve_user_handler(token: Token, _connection: ASGIConnection) -> Actor | None:
    try:
        return Actor(id=int(token.sub))
    except ValueError:
        return None

The int(token.sub) parse and the None on ValueError stay — they are what
keep a garbage subject a 401 rather than a 500. AuthedRequest and
JWTCookieAuth are parameterised on Actor. Nine use-case modules change
actor: tables.UsersTableactor: Actor; every body is unchanged, because
every read was already actor.id.

GET /api/auth/me/ gains a FetchUserUseCase and an ioc.UseCases provider
(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 | None and
the handler raises NotAuthorizedException — the same split login already
uses, and 401 rather than the 404 a NotFoundError would map to: the caller's
token is what stopped being good, and this keeps caller-visible behaviour
identical to today's. docs/adr/0005 claimed login was "the one place
litestar.exceptions is used deliberately"; that sentence is now corrected to
name both, and nothing else in it changes. docs/adr/0002 loses its stale
JWTCookieAuth[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/carol still insert a user row but yield
Actor(id=user.id). Every use of them was .id or actor=, so all 63 call
sites 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_id FK. Nothing can reach that state today (there is no
delete-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 be
solved once for both. That claim is carried by the invariant docstring below.

Non-goals

  • UUID / uuid7 user ids and an opaque public id. Both rejected, with
    the reasoning recorded in
    docs/adr/0014-auth-carries-an-actor-id.md
    rather than repeated here. In short: one writer means no id-coordination
    problem, direct_key no longer fits two ids, three FK columns widen 8→16
    bytes, and the opaque-id shape is strictly additive on top of this decision
    if it is ever wanted.
  • A deferred item for the lost existence check. Declined; the invariant
    test's docstring carries the claim, and ADR 0014's Consequence section
    states it.
  • User deletion / deactivation. The revisit trigger on ADR 0014.

Verification

  • New invariant:
    test_retrieve_user_handler_resolves_an_actor_without_reading_the_database.
    It passes None as 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.
  • The existing invariant at tests/api/test_auth_api.py (non-integer sub
    yields 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, ty check): all checks passed.
  • just check-planning, just check-adrs, just check-links: all OK.

`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.
@lesnik512
lesnik512 merged commit 59432c4 into main Aug 23, 2026
3 checks passed
@lesnik512
lesnik512 deleted the auth-actor branch August 23, 2026 18:17
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