Files
Spoolman/spoolman/auth/coverage.py
T
Donkie 95f3acea6b auth: data layer and permission gates
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.
2026-07-28 09:01:34 +02:00

139 lines
4.7 KiB
Python

"""Proof that every API route carries a permission gate.
Gating routes one at a time is fail-open: forget one and it silently serves
unauthenticated traffic, and nothing tells you until someone notices. This module
inverts that. It walks the route table and raises if any route lacks a gate, so a
forgotten one stops the process at import rather than becoming a hole.
It runs at import of :mod:`spoolman.api.v1.router`, which is the earliest possible
moment -- nothing registers routes after that -- and means the failure happens before
uvicorn binds a port, before migrations run, and equally when the module is imported by
the docs generator or a test.
"""
import logging
from collections.abc import Iterator
from typing import Final
from fastapi import FastAPI
from fastapi.dependencies.models import Dependant
from fastapi.routing import APIRoute, APIWebSocketRoute
from starlette.routing import BaseRoute
from spoolman.auth.dependencies import AUTH_LEVEL_ATTR
from spoolman.auth.levels import Level
logger = logging.getLogger(__name__)
WEBSOCKET_METHOD: Final = "WEBSOCKET"
def _walk(routes: list[BaseRoute]) -> Iterator[BaseRoute]:
"""Yield every route, descending into included routers.
FastAPI does not flatten ``include_router`` into the parent's route list; it stores
a wrapper object holding the original router. Iterating ``app.routes`` alone
therefore sees only the handful of routes declared directly on the app and would
declare everything else covered.
Args:
routes: The route list to walk.
Yields:
BaseRoute: Each concrete route.
"""
for route in routes:
nested = getattr(route, "original_router", None)
if nested is not None:
yield from _walk(nested.routes)
else:
yield route
def _gate_level(dependant: Dependant) -> Level | None:
"""Find the permission level a route's dependencies enforce.
Reads ``dependant.dependencies`` rather than the route's raw ``dependencies``
argument, so that a gate declared as a handler parameter counts too.
Args:
dependant: The route's resolved dependency tree.
Returns:
Optional[Level]: The level enforced, or None if no gate is present.
"""
for sub in dependant.dependencies:
level = getattr(sub.call, AUTH_LEVEL_ATTR, None)
if level is not None:
return level
nested = _gate_level(sub)
if nested is not None:
return nested
return None
def find_uncovered(app: FastAPI, allow: frozenset[tuple[str, str]]) -> tuple[list[str], dict[str, Level]]:
"""Sort an app's routes into gated and ungated.
Args:
app: The application to inspect.
allow: Method and path pairs that are intentionally public.
Returns:
tuple: The ungated route descriptions, and the level of each gated route.
"""
missing: list[str] = []
covered: dict[str, Level] = {}
for route in _walk(app.routes):
if isinstance(route, APIWebSocketRoute):
methods = (WEBSOCKET_METHOD,)
level = getattr(route.endpoint, AUTH_LEVEL_ATTR, None)
elif isinstance(route, APIRoute):
# HEAD is added implicitly alongside GET and shares its gate.
methods = tuple(sorted(method for method in route.methods if method != "HEAD"))
level = _gate_level(route.dependant)
else:
# Starlette's own routes: /openapi.json, /docs, /docs/oauth2-redirect and
# /redoc. They serve the schema, not data.
continue
for method in methods:
if (method, route.path) in allow:
continue
if level is None:
missing.append(f"{method} {route.path}")
else:
covered[f"{method} {route.path}"] = level
return missing, covered
def assert_routes_covered(app: FastAPI, allow: frozenset[tuple[str, str]]) -> dict[str, Level]:
"""Raise unless every route on an app carries a permission gate.
Args:
app: The application to inspect.
allow: Method and path pairs that are intentionally public.
Raises:
RuntimeError: If any route lacks a gate.
Returns:
dict: The level enforced on each gated route.
"""
missing, covered = find_uncovered(app, allow)
if missing:
listed = "\n ".join(sorted(missing))
raise RuntimeError(
"Some API routes have no authentication gate, which would serve them "
"unauthenticated whenever authentication is enabled. Add a "
"require_level dependency, or a ws_authenticated decorator for websockets, "
f"or list the route as intentionally public:\n {listed}",
)
return covered