Activate ACTIVITY-WP-0025: Authelia SSO ingress for ops and Temporal UI
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

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:
tegwick 2026-07-22 00:47:29 +02:00
parent 7e71c0c837
commit f885697e96
13 changed files with 366 additions and 57 deletions

View file

@ -15,7 +15,12 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from temporalio.client import Client
from activity_core.ops_auth import HEADER_NAME, operator_token_configured, require_operator
from activity_core.ops_auth import (
HEADER_NAME,
extract_sso_principal,
operator_token_configured,
require_operator,
)
from activity_core.ops_console import (
is_side_effect_definition,
ops_definition_detail,
@ -248,6 +253,11 @@ async def auth_status() -> dict[str, Any]:
"mutation_header": HEADER_NAME,
"mutations_require_token": operator_token_configured() or not allow,
"temporal_ui_url": temporal_ui_url(),
"sso_docs": "/docs not required — see docs/ops-sso-access.md",
"public_hosts": {
"ops": "https://activity.coulomb.social",
"temporal_ui": "https://activity-temporal.coulomb.social",
},
}
@ -271,17 +281,17 @@ pre { background: #f8f8f8; padding: 0.75rem; overflow: auto; font-size: 0.8rem;
def temporal_ui_url() -> str:
"""Browser URL for Temporal Web UI (port-forward or future SSO ingress).
"""Browser URL for Temporal Web UI (SSO ingress or local port-forward).
Override with ACTIVITY_CORE_TEMPORAL_UI_URL. Default assumes a local
port-forward of actcore-temporal-ui to 127.0.0.1:8080 (see runbook).
Override with ACTIVITY_CORE_TEMPORAL_UI_URL. Default prefers the public SSO
hostname when set via env; otherwise local port-forward on :8080.
"""
raw = (
os.environ.get("ACTIVITY_CORE_TEMPORAL_UI_URL")
or os.environ.get("TEMPORAL_UI_URL")
or "http://127.0.0.1:8080"
or "https://activity-temporal.coulomb.social"
).strip()
return raw.rstrip("/") or "http://127.0.0.1:8080"
return raw.rstrip("/") or "https://activity-temporal.coulomb.social"
def _page(title: str, body: str) -> HTMLResponse:

View file

@ -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,