"""Operator auth for activity-core ops console (ACTIVITY-WP-0024 / 0025). Mutations under ``/ops`` are fail-closed. Accepted principals (in order): 1. **SSO** — Authelia ForwardAuth response headers (``Remote-User``, ``Remote-Email``, etc.) when the request came through Traefik SSO. 2. **Break-glass token** — ``ACTIVITY_CORE_OPERATOR_TOKEN`` via ``X-Operator-Token`` or ``Authorization: Bearer``. 3. **Local dev only** — ``ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS`` truthy when no token is configured. Token values are never logged or returned. """ 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" # Authelia forwardAuth + common proxies (case-insensitive lookup via Starlette) SSO_USER_HEADERS = ( "Remote-User", "Remote-Email", "X-Forwarded-User", "X-Auth-Request-User", "X-Auth-Request-Email", ) 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 extract_sso_principal(request: Request) -> str | None: """Return authenticated SSO subject from Authelia/proxy headers, if any.""" for name in SSO_USER_HEADERS: value = request.headers.get(name) if value and value.strip(): return value.strip() # Starlette lowercases; also try explicit lower keys headers = request.headers for name in SSO_USER_HEADERS: value = headers.get(name.lower()) if value and value.strip(): return value.strip() return None def extract_sso_groups(request: Request) -> list[str]: raw = request.headers.get("Remote-Groups") or request.headers.get("remote-groups") or "" if not raw.strip(): return [] return [part.strip() for part in raw.replace(";", ",").split(",") if part.strip()] def verify_operator_token(provided: str | None) -> str: """Return operator principal label from shared token, or raise.""" 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}, use SSO (Authelia), " f"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) or SSO session", ) if not hmac.compare_digest(provided, expected): raise HTTPException(status_code=401, detail="invalid operator token") return "operator-token" 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: SSO principal or valid operator token.""" sso = extract_sso_principal(request) if sso: return f"sso:{sso}" 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)