"""Dependency injection: how a handler gets a pool, a repo, a catalog, and a team. Everything expensive — the pool, the JWKS client, the parsed catalog — is built once in `lifespan` and parked on `app.state`. These functions only hand it out. A `Depends` that does I/O per request is a `Depends` that does that I/O on every request forever. """ from __future__ import annotations import asyncio from typing import Annotated, Any import jwt from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jwt import PyJWKClient from svcforge_core.domain.models import CatalogEntry from svcforge_core.repo.db import DictPool from svcforge_core.repo.instances import InstanceRepo from svcforge_core.repo.tasks import TaskRepo from svcforge_core.settings import Settings # The algorithm allow-list is the whole point of naming algorithms explicitly. # `jwt.decode(..., algorithms=...)` without it accepts whatever the *token* claims in its # own header — including `none`, and including HS256 verified with the RSA public key as # an HMAC secret. Both are forgery. The list is not configuration. ALLOWED_ALGORITHMS = ["RS256"] # What `auth_disabled` returns. Settings.check_production() refuses that flag in prod. DEV_TEAM = "platform" TEAM_CLAIM = "team" # auto_error=False is load-bearing. HTTPBearer(auto_error=True) answers a *missing* # Authorization header with 403, not 401 — an old FastAPI wart. The spec (and every # client that knows what to do about it) wants 401, so the error is raised here. _bearer = HTTPBearer(auto_error=False) def _unauthorized() -> HTTPException: """One shape for every auth failure. Expired, wrong issuer, wrong audience, bad signature, malformed, no header: all the same 401 with the same body. Telling a caller *which* one turns the endpoint into an oracle they can tune a forgery against. """ return HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "unauthorized", "message": "invalid or missing credentials"}, headers={"WWW-Authenticate": "Bearer"}, ) def get_settings(request: Request) -> Settings: """The Settings that create_app() was handed.""" settings: Settings = request.app.state.settings return settings async def get_pool(request: Request) -> DictPool: """Return the pool that lifespan put on app.state.""" pool: DictPool = request.app.state.pool return pool def get_catalog(request: Request) -> dict[str, CatalogEntry]: """The catalog, parsed once at startup. Read from disk per request and a mid-flight edit to catalog.yaml changes the answer between two requests of the same deploy. Load it at startup; a change is a restart. """ catalog: dict[str, CatalogEntry] = request.app.state.catalog return catalog def get_instance_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> InstanceRepo: """An InstanceRepo bound to the app's pool. Cheap: it is a handle, not a connection.""" return InstanceRepo(pool) def get_task_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> TaskRepo: """A TaskRepo bound to the app's pool.""" return TaskRepo(pool) async def get_current_team( request: Request, creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], settings: Annotated[Settings, Depends(get_settings)], ) -> str: """Verify the JWT against the cached JWKS. Check aud/iss/exp and the alg allow-list. Returns the team claim. Raises HTTPException(401) on any failure — never leaks why. """ if settings.auth_disabled: return DEV_TEAM if creds is None or not creds.credentials: raise _unauthorized() jwks_client: PyJWKClient | None = getattr(request.app.state, "jwks_client", None) if jwks_client is None: # Auth is on but there is no key source. Fail closed. Answering 500 here would be # honest about the cause and would also let a misconfigured deploy be told apart # from a bad token; 401 is the same answer a forger gets. raise _unauthorized() try: # PyJWKClient keeps its own TTL cache, so this is a dict lookup on the hot path. # It is only blocking on a cache MISS (key rotation) — hence to_thread, which # costs a thread hop we take a handful of times a day rather than an event loop # stalled on someone else's HTTP call once per rotation. signing_key = await _signing_key(jwks_client, creds.credentials) claims: dict[str, Any] = jwt.decode( creds.credentials, signing_key.key, algorithms=ALLOWED_ALGORITHMS, audience=settings.jwt_audience, issuer=settings.jwt_issuer, options={ "require": ["exp", "aud", "iss"], "verify_exp": True, "verify_aud": True, "verify_iss": settings.jwt_issuer is not None, "verify_signature": True, }, ) except Exception as exc: # deliberate catch-all: every failure becomes one opaque 401 raise _unauthorized() from exc team = claims.get(TEAM_CLAIM) if not isinstance(team, str) or not team: raise _unauthorized() return team async def _signing_key(client: PyJWKClient, token: str) -> jwt.PyJWK: """Fetch the signing key off the event loop. PyJWKClient.get_signing_key_from_jwt() does a synchronous urlopen on a cache miss. Called directly from `async def`, that blocks the loop — every other in-flight request on this worker stops until the identity provider answers, and if it hangs, so does the pod, and /readyz keeps saying it is fine. """ return await asyncio.to_thread(client.get_signing_key_from_jwt, token) async def rate_limit(team: Annotated[str, Depends(get_current_team)]) -> None: """Per-team rate limiting. Seam only — Module 10 fills this in (Redis, Lua, token bucket). It exists now, wired into the routes, so that turning it on is an edit to one function body rather than a change to every handler signature. """ return None async def idempotency_key(request: Request) -> str | None: """`Idempotency-Key` handling. Seam only — Module 10 fills this in (Redis store). Until then the real idempotency anchor is `instances.release_name`, which is unique in the schema and deterministic from (team, service_type, id). """ return request.headers.get("Idempotency-Key") PoolDep = Annotated[DictPool, Depends(get_pool)] TeamDep = Annotated[str, Depends(get_current_team)] InstanceRepoDep = Annotated[InstanceRepo, Depends(get_instance_repo)] TaskRepoDep = Annotated[TaskRepo, Depends(get_task_repo)] CatalogDep = Annotated[dict[str, CatalogEntry], Depends(get_catalog)]