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).
127 lines
4.1 KiB
Python
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)
|