| """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") |
|
|
| |
| 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): |
| |
| |
| logger.warning( |
| "service secret rejected", presented=bool(presented) |
| ) |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Missing or invalid service credentials.", |
| ) |
|
|