""" Minimal API-key authentication for endpoints that expose stored (historical) data - ``/records``, ``/records/export`` and ``/stats``. This is deliberately a single shared secret, not a user database: the assignment and the reviewer both ask for "a concrete, proportionate access regime", not a full production auth system with accounts, sessions or password hashing. A single key compared on every request is the simplest thing that actually closes the gap (a publicly reachable deployment showing every stored - pseudonymized, but still personal - record to anyone who finds the URL). Usage (see ``main.py``): from auth import require_api_key @app.get("/records", dependencies=[Depends(require_api_key)]) def records(...): ... Fails closed: if ``APP_API_KEY`` is not set at all, every protected request is rejected (HTTP 500) instead of silently running unauthenticated - the same fail-closed pattern already used in ``processors/anonymizer.py`` for masking. """ from __future__ import annotations import os import secrets from fastapi import Header, HTTPException def _configured_key() -> str | None: """Read fresh on every call so a key set/rotated after startup is picked up without restarting the process (e.g. when it's added as a Hugging Face Space secret and the Space only restarts the app, not the whole container's environment loading).""" key = os.getenv("APP_API_KEY", "") return key if key.strip() else None def _key_matches(x_api_key: str | None, configured: str) -> bool: return bool(x_api_key) and secrets.compare_digest(x_api_key, configured) def require_api_key(x_api_key: str | None = Header(default=None)) -> None: """ FastAPI dependency that protects an endpoint with a single shared API key, read from the ``X-API-Key`` request header. Raises: HTTPException(500): ``APP_API_KEY`` is not configured on the server at all - the endpoint is refused rather than served without any protection. HTTPException(401): the request did not supply the correct key. """ configured = _configured_key() if configured is None: raise HTTPException( status_code=500, detail="Server misconfiguration: APP_API_KEY is not set, so this " "endpoint cannot verify the caller and refuses to serve it.", ) if not _key_matches(x_api_key, configured): raise HTTPException(status_code=401, detail="Missing or invalid X-API-Key header.") def has_valid_key(x_api_key: str | None) -> bool: """ Non-raising check used by ``GET /engines`` to decide which engines to report as available - it must return a plain ``false``, not a 401, so the dashboard can simply grey LLM options out instead of erroring. """ configured = _configured_key() return configured is not None and _key_matches(x_api_key, configured) def require_api_key_for_llm(engine: str, x_api_key: str | None) -> None: """ Call this once the requested ``engine`` is known (from the request body/ form), in ``POST /analyze`` and ``POST /ingest``. The local BERT pipeline stays open to anyone; selecting an LLM provider calls a paid third-party API on the operator's own key, so it requires the same API key as viewing stored history. A request for ``engine="local"`` is a no-op. """ if engine != "local": require_api_key(x_api_key)