Adds the user and session data layer, the dependency gates that enforce
permission levels, and the coverage check that proves no route was left
ungated. Nothing is wired to a route yet -- that is the next commit.
database/auth_user.py, database/auth_session.py
Follow the vendor.py template, with one deliberate departure: no websocket
notifier. websocket_manager.send fans out to every subscriber, so
broadcasting account or session changes would hand every connected client
the details of every account. Both module docstrings say so, because the
omission otherwise looks like an oversight worth "fixing".
create() and set_password() take an already-derived hash rather than a
plaintext password. Deriving one blocks for ~50 ms, and these are called
from async handlers where that would stall the single process; callers
pick the sync or async hashing entry point according to where they run.
Sessions slide their expiry forward on use, but only once last_seen is
older than a minute. Without that throttle every authenticated request
becomes a database write, which on SQLite serialises the whole application
behind session bookkeeping.
auth/dependencies.py
require_level() short-circuits to UNRESTRICTED before any database access
when auth is off, which is what keeps the disabled path identical to
before. It does not declare a get_db_session dependency: route-level
dependencies are solved ahead of handler parameters, so doing so would
open a session on every request including /health and /info, which need no
database today and would start failing when one is unavailable. It opens
its own short-lived session only when a credential is actually present.
CSRF failure is 403, not 401. The credential is valid, so reporting
"unauthenticated" would sign the user out over what is usually a stale tab.
ws_authenticated() accepts the handshake before closing on rejection,
which looks wrong and is load-bearing. uvicorn turns a close sent before
the accept into an HTTP 403 and discards the code, and Starlette routes
WebSocketException from a dependency through that same path, so neither
can deliver 4401/4403. Accepting first is the only way the browser sees
the real code, which is what lets the client tell "signed out, stop
retrying" from "network blipped, retry". The existing close(4040) in
api/v1/setting.py has never reached a client for this reason.
auth/coverage.py
Gating routes individually is fail-open. This inverts it: walk the route
table at import and refuse to start if anything lacks a gate. It descends
through FastAPI's include_router wrappers, which are not flattened into
the parent route list -- iterating app.routes alone sees 4 of the 49
routes and would call the rest covered.
Also registers the auth_anonymous_read setting, with a comment forbidding
secrets in the settings table: settings are readable via GET /setting/ and
every write is broadcast to all websocket subscribers.
Verified: coverage check catches forgotten HTTP and websocket routes,
recognises gates declared either as dependencies= or as a handler
parameter, and honours the allow-list. Websocket matrix end to end -- auth
off passes through, no credential gives 4401, a forged cookie gives 4401, a
read-level session connects to a read route and gets 4403 on a manage
route, and path parameters still inject through functools.wraps. The
anonymous-read lookup fails closed on any error.