ishaq101's picture
/fix planner count and report (#19)
f282b15
Raw
History Blame Contribute Delete
3.86 kB
"""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.",
)