activity-core/src/activity_core/ops_auth.py
tegwick f885697e96
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 54s
Build and Publish Container Image / build-and-push (push) Successful in 1m47s
Activate ACTIVITY-WP-0025: Authelia SSO ingress for ops and Temporal UI
Mark workplan active. Add Traefik ForwardAuth middleware and Ingress
manifests for activity.coulomb.social and activity-temporal.coulomb.social.
Prefer Authelia SSO identity for ops mutations; document DNS gate and
fleet pattern (docs/ops-sso-access.md).
2026-07-22 00:47:29 +02:00

127 lines
4.1 KiB
Python

"""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)