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).
This commit is contained in:
parent
7e71c0c837
commit
f885697e96
13 changed files with 366 additions and 57 deletions
|
|
@ -1,12 +1,15 @@
|
|||
"""Operator token auth for activity-core ops console (ACTIVITY-WP-0024).
|
||||
"""Operator auth for activity-core ops console (ACTIVITY-WP-0024 / 0025).
|
||||
|
||||
Mutations under ``/ops`` are fail-closed:
|
||||
- If ``ACTIVITY_CORE_OPERATOR_TOKEN`` is set, requests must send matching
|
||||
``X-Operator-Token`` (or ``Authorization: Bearer <token>``).
|
||||
- If the token is **unset**, mutations are refused unless
|
||||
``ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS`` is truthy (local dev only).
|
||||
Mutations under ``/ops`` are fail-closed. Accepted principals (in order):
|
||||
|
||||
Read endpoints do not require the token (ClusterIP / port-forward posture).
|
||||
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
|
||||
|
|
@ -21,6 +24,15 @@ 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())
|
||||
|
|
@ -47,8 +59,30 @@ def extract_operator_token(
|
|||
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 or raise HTTPException."""
|
||||
"""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():
|
||||
|
|
@ -57,17 +91,18 @@ def verify_operator_token(provided: str | None) -> str:
|
|||
status_code=403,
|
||||
detail=(
|
||||
"operator auth not configured; set "
|
||||
f"{OPERATOR_TOKEN_ENV} or enable {ALLOW_UNAUTH_ENV} for local dev"
|
||||
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)",
|
||||
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"
|
||||
return "operator-token"
|
||||
|
||||
|
||||
async def require_operator(
|
||||
|
|
@ -75,8 +110,11 @@ async def require_operator(
|
|||
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).
|
||||
"""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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue