mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 10:02:17 -05:00
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.
139 lines
4.7 KiB
Python
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
|