"""Service-secret gate for the live Python surface (F-2, 2026-07-23). ⚠️ PARKED / UNWIRED 2026-07-27 (lead decision, DEV_PLAN #37). This module is no longer mounted — `main.py` dropped the `Depends(require_service_secret)` dependency from every router. It stays in-tree (comment-out-don't-delete) so it can be restored in one edit, but it currently does NOTHING regardless of whether `dataeyond__service__secret` is set. Reason: the sole caller is the browser SPA (E2E-Frontend), which we don't own and can't change to send the header, so arming the gate would 401 the whole app. The durable fix is a verified per-user identity forwarded by Go (DEV_PLAN #43). Restore instructions are in `main.py` beside the router mounts. Python has no authentication of its own. The comments in `api/v1/traceability.py` and `api/v1/charts.py` say "No auth — Go fronts Python", but Go does not: a repo-wide search of the Orchestrator source finds no HTTP client pointed at this service and no config key for one, and the FE calls `POST /api/v2/chat/stream` directly. So every live endpoint is reachable by anyone who knows the URL, with `user_id` and `analysis_id` supplied as ordinary request fields. This is the interim control: a shared secret, carried in a header, checked before the route runs. It deliberately does NOT identify *which* user is calling — it only stops the open internet. The per-user authorization story is the tenant predicates in the stores (see `CatalogStore.get_by_analysis`) plus, eventually, a real per-request identity forwarded by Go. Design: - **Off unless configured.** With `dataeyond__service__secret` unset the dependency is a no-op, so local dev, tests, and the current FE keep working unchanged until the secret is deployed on both sides. Setting the env var is what arms it — a single, reversible switch. - **Constant-time compare** so the check can't be narrowed by timing. - **Applied at router mount** (`main.py`), not per route, so a new endpoint cannot be added without it. - `/` and `/health` stay open — the HF Space health probe has no secret. Replace with JWT verification once Go forwards a real identity; at that point `user_id` should come from the verified claims rather than the request body, which is what makes the store predicates authoritative instead of merely defensive. """ from __future__ import annotations import hmac from fastapi import Header, HTTPException, status from src.config.settings import settings from src.middlewares.logging import get_logger logger = get_logger("service_auth") # Header name kept vendor-neutral; Go/FE send the same value. SERVICE_SECRET_HEADER = "X-Dataeyond-Service-Secret" def _configured_secret() -> str: return (getattr(settings, "dataeyond_service_secret", "") or "").strip() def is_enforced() -> bool: """True when a secret is configured — i.e. the gate actually rejects.""" return bool(_configured_secret()) async def require_service_secret( x_dataeyond_service_secret: str | None = Header(default=None), ) -> None: """FastAPI dependency: 401 unless the caller presents the configured secret. No-op when no secret is configured, so enabling this is a deployment decision rather than a code change. """ expected = _configured_secret() if not expected: return presented = (x_dataeyond_service_secret or "").strip() if not presented or not hmac.compare_digest(presented, expected): # Deliberately terse: never echo the presented value, never distinguish # "missing" from "wrong" to the caller. logger.warning( "service secret rejected", presented=bool(presented) ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid service credentials.", )