"""Operator token auth for activity-core ops console (ACTIVITY-WP-0024). Mutations under ``/ops`` are fail-closed: - If ``ACTIVITY_CORE_OPERATOR_TOKEN`` is set, requests must send matching ``X-Operator-Token`` (or ``Authorization: Bearer ``). - If the token is **unset**, mutations are refused unless ``ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS`` is truthy (local dev only). Read endpoints do not require the token (ClusterIP / port-forward posture). """ from __future__ import annotations import hmac import os from typing import Annotated from fastapi import Header, HTTPException, Request OPERATOR_TOKEN_ENV = "ACTIVITY_CORE_OPERATOR_TOKEN" ALLOW_UNAUTH_ENV = "ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS" HEADER_NAME = "X-Operator-Token" def operator_token_configured() -> bool: return bool((os.environ.get(OPERATOR_TOKEN_ENV) or "").strip()) def allow_unauth_mutations() -> bool: return (os.environ.get(ALLOW_UNAUTH_ENV) or "").strip().lower() in { "1", "true", "yes", "on", } def extract_operator_token( *, x_operator_token: str | None = None, authorization: str | None = None, ) -> str | None: if x_operator_token and x_operator_token.strip(): return x_operator_token.strip() if authorization and authorization.lower().startswith("bearer "): return authorization[7:].strip() or None return None def verify_operator_token(provided: str | None) -> str: """Return operator principal label or raise HTTPException.""" expected = (os.environ.get(OPERATOR_TOKEN_ENV) or "").strip() if not expected: if allow_unauth_mutations(): return "anonymous-dev" raise HTTPException( status_code=403, detail=( "operator auth not configured; set " f"{OPERATOR_TOKEN_ENV} or enable {ALLOW_UNAUTH_ENV} for local dev" ), ) if not provided: raise HTTPException( status_code=401, detail=f"missing operator token ({HEADER_NAME} or Authorization Bearer)", ) if not hmac.compare_digest(provided, expected): raise HTTPException(status_code=401, detail="invalid operator token") return "operator" async def require_operator( request: Request, x_operator_token: Annotated[str | None, Header(alias=HEADER_NAME)] = None, authorization: Annotated[str | None, Header()] = None, ) -> str: """FastAPI dependency: require valid operator token for mutations.""" # Prefer dependency headers; fall back to raw request (HTML form headers rare). provided = extract_operator_token( x_operator_token=x_operator_token, authorization=authorization, ) if provided is None: provided = extract_operator_token( x_operator_token=request.headers.get(HEADER_NAME), authorization=request.headers.get("Authorization"), ) return verify_operator_token(provided)