From 91353df7d03846f3fbdcc5f10149ac8c49fe5ece Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 22 Jul 2026 10:23:36 +0200 Subject: [PATCH] Finish WP-0025 SSO cutover except group allowlist residual. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark T03–T05 and T07–T08 done after live Authelia/TLS verification, prefer SSO principal in ops UI copy and audits, and document break-glass port-forward. Leave T06 waiting on net-kingdom LLDAP/Authelia group rules. --- SCOPE.md | 2 +- WORK-RECORDS.md | 10 +-- docs/ops-sso-access.md | 19 +++--- docs/runbook.md | 31 +++++---- k8s/railiance/README.md | 19 +++--- src/activity_core/ops_api.py | 25 +++++--- tests/test_ops_console_api.py | 49 ++++++++++++++- .../ACTIVITY-WP-0025-ops-ui-sso-access.md | 63 +++++++++++++------ 8 files changed, 151 insertions(+), 67 deletions(-) diff --git a/SCOPE.md b/SCOPE.md index d2f5e67..40a2f54 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -213,7 +213,7 @@ Open product/policy workplan: **ACTIVITY-WP-0022** (IssueSink no-default-Forgejo | **G7. Credential delivery** | Low (residual) | **FORGEJO_TOKEN** via ESO `actcore-forgejo-admin` (WP-0023-T05, Ready). issue-core `GITEA_BACKEND_TOKEN` still 503 forgejo-inbox for path A rest — **issue-core owner** (WP-0023-T06). | | **G8. Live-images hygiene** | Medium (ops) | Multi-cluster `live-images-all.txt` must be refreshed after deploys or prune can delete live tags (incident 2026-07-21, restored). `scripts/refresh_live_images.sh` (T04). | | **G9. Evidence federation** | Low | Progress often lands on railiance01 edge/hub; workstation primary hub may not show the same feed without tunnel/outbox health. | -| **G10. API external access** | Medium (in progress) | WP-0025: Traefik + Authelia SSO for `activity.coulomb.social` + `temporal.coulomb.social`; port-forward remains break-glass until DNS/certs Ready. | +| **G10. API external access** | Medium (mostly done) | WP-0025: Traefik + Authelia SSO live for `activity.coulomb.social` + `temporal.coulomb.social`; port-forward break-glass; residual T06 group allowlist. | ### Drift risks diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 74708f5..c9dd254 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -145,12 +145,12 @@ | task | ACTIVITY-WP-0024-T09 | done | — | workplans/ACTIVITY-WP-0024-operator-automation-console.md | | task | ACTIVITY-WP-0025-T01 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | | task | ACTIVITY-WP-0025-T02 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | -| task | ACTIVITY-WP-0025-T03 | progress | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | -| task | ACTIVITY-WP-0025-T04 | progress | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | -| task | ACTIVITY-WP-0025-T05 | progress | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | +| task | ACTIVITY-WP-0025-T03 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | +| task | ACTIVITY-WP-0025-T04 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | +| task | ACTIVITY-WP-0025-T05 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | | task | ACTIVITY-WP-0025-T06 | wait | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | -| task | ACTIVITY-WP-0025-T07 | progress | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | -| task | ACTIVITY-WP-0025-T08 | todo | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | +| task | ACTIVITY-WP-0025-T07 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | +| task | ACTIVITY-WP-0025-T08 | done | — | workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md | | task | ADHOC-2026-06-01-T01 | done | — | workplans/ADHOC-2026-06-01.md | | task | ADHOC-2026-06-01-T02 | done | — | workplans/ADHOC-2026-06-01.md | | task | ADHOC-2026-06-01-T03 | done | — | workplans/ADHOC-2026-06-01.md | diff --git a/docs/ops-sso-access.md b/docs/ops-sso-access.md index 459ad59..9a6f7a2 100644 --- a/docs/ops-sso-access.md +++ b/docs/ops-sso-access.md @@ -12,8 +12,10 @@ Founders / platform operators who already have Authelia accounts under | `activity.coulomb.social` | `actcore-api:8010` | Ops console `/ops/ui`, JSON `/ops/*`, existing API | | `temporal.coulomb.social` | `actcore-temporal-ui:8080` | Temporal Web UI (clean short name) | -Both resolve to the railiance01 Traefik LB (`92.205.62.239`) once DNS A records -exist (same pattern as `forgejo.coulomb.social`). +Both resolve to the railiance01 Traefik LB (`92.205.62.239`) — same pattern as +`forgejo.coulomb.social`. **DNS A records + Let's Encrypt certs are live** +(verified 2026-07-22); Certificate objects `actcore-ops-tls` / +`actcore-temporal-ui-tls` are Ready. ## Auth edge (fleet pattern) @@ -59,20 +61,17 @@ Priority for ops mutations (`POST /ops/...`): ## DNS (operator) -Create A records (or CNAME to the forgejo host pattern): +Expected records (live as of 2026-07-22): ```text activity.coulomb.social A 92.205.62.239 temporal.coulomb.social A 92.205.62.239 ``` -**Important:** do **not** leave a stale **AAAA** (IPv6) record for -`activity.coulomb.social` pointing at parking (e.g. IONOS -`2001:8d8:100f:f000::200` / `217.160.0.253`). Cluster resolvers prefer AAAA; -cert-manager HTTP-01 self-check then hits the wrong host and returns 204. -Either remove AAAA or set it to the railiance01 public IPv6. - -Until DNS is correct, cert-manager Certificate may stay Pending. +**Important:** do **not** leave a stale **AAAA** (IPv6) record for either host +pointing at parking (e.g. IONOS `2001:8d8:100f:f000::200` / `217.160.0.253`). +Cluster resolvers prefer AAAA; cert-manager HTTP-01 self-check then hits the +wrong host. Either remove AAAA or set it to the railiance01 public IPv6. ## Break-glass diff --git a/docs/runbook.md b/docs/runbook.md index 92fc770..548da33 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -57,18 +57,23 @@ Prefer the **ops console** over ad-hoc SSH/SQL for “did automations run?” an ### Auth +| Mode | When | How | +| --- | --- | --- | +| **SSO (primary)** | Browser via `activity.coulomb.social` | Authelia session; app trusts `Remote-User` / `Remote-Email` from Traefik ForwardAuth | +| **Break-glass token** | Port-forward / emergency / scripts | `X-Operator-Token` or `Authorization: Bearer` | +| **Local dev** | No token configured | `ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS=1` only | + | Env | Purpose | | --- | --- | -| `ACTIVITY_CORE_OPERATOR_TOKEN` | Shared operator token; required for **mutations** | +| `ACTIVITY_CORE_OPERATOR_TOKEN` | Shared operator token (break-glass); custody in `actcore-runtime-secret` | | `ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS` | `1` only for local dev without a token | -Mutations: `POST /ops/automations/{id}/trigger|enable|disable|pause|unpause` -Header: `X-Operator-Token: ` (or `Authorization: Bearer `). +Mutations: `POST /ops/automations/{id}/trigger|enable|disable|pause|unpause`. -Fail-closed: if the token is unset and unauth is not allowed, mutations return -**403**. Reads (`GET /ops/...`) do not require the token (ClusterIP / port-forward -posture). **Do not** put the token in git, chat, or workplans. Store in -`actcore-runtime-secret` (or local `.env`) via operator custody. +Fail-closed: without SSO headers and without a valid token (and unauth not +allowed), mutations return **401/403**. Reads (`GET /ops/...`) do not require +auth at the app layer (ingress still gates browser access via Authelia). +**Do not** put the token in git, chat, or workplans. ### Daily checklist @@ -99,13 +104,13 @@ curl -sS -X POST "http://localhost:8010/ops/automations//disable" \ -H "X-Operator-Token: $ACTIVITY_CORE_OPERATOR_TOKEN" ``` -Thin UI: open `/ops/ui`, paste the operator token into the browser field -(localStorage only), then use Run now / pause actions. **Cron edits are not in +Thin UI: open https://activity.coulomb.social/ops/ui (SSO). Break-glass UI still +accepts a pasted operator token (localStorage only). **Cron edits are not in the UI** — change definition files and sync. ### Production access (railiance01) -**Primary (SSO — ACTIVITY-WP-0025):** after DNS A records exist: +**Primary (SSO — ACTIVITY-WP-0025, live):** | UI | URL | | --- | --- | @@ -113,12 +118,12 @@ the UI** — change definition files and sync. | Temporal Web UI | https://temporal.coulomb.social | Login via Authelia (`auth.coulomb.social`). Design: `docs/ops-sso-access.md`. -Mutations accept SSO identity headers; shared token is break-glass only. +Mutations use SSO identity; shared token is break-glass only. -**DNS (required for TLS):** +**DNS (already set for TLS):** ```text -activity.coulomb.social A 92.205.62.239 +activity.coulomb.social A 92.205.62.239 temporal.coulomb.social A 92.205.62.239 ``` diff --git a/k8s/railiance/README.md b/k8s/railiance/README.md index e7a0391..c5a42c9 100644 --- a/k8s/railiance/README.md +++ b/k8s/railiance/README.md @@ -1,9 +1,9 @@ # Railiance01 Kubernetes Deployment This bundle establishes activity-core as an internal production service on the -railiance01 K3s cluster. It keeps the unauthenticated API as a ClusterIP service; -publish it through an authenticated ingress only after choosing the final host -name and access policy. +railiance01 K3s cluster. Services remain ClusterIP; browser access to the ops +console and Temporal UI is via Traefik + Authelia SSO Ingress +(`activity.coulomb.social`, `temporal.coulomb.social` — ACTIVITY-WP-0025). ## Layout @@ -113,23 +113,26 @@ kubectl -n activity-core get svc ## Operator automation console (ACTIVITY-WP-0024 / 0025) -### SSO (primary, after DNS) +### SSO (primary — live) + +Manifests `30-`–`32-*.yaml` are applied; TLS certs Ready; Authelia ForwardAuth +redirects unauthenticated browsers to `auth.coulomb.social`. ```bash -# DNS A records → 92.205.62.239 (once): -# activity.coulomb.social -# temporal.coulomb.social - +# Re-apply if needed: kubectl apply -f k8s/railiance/30-authelia-middleware.yaml kubectl apply -f k8s/railiance/31-ingress-ops-sso.yaml kubectl apply -f k8s/railiance/32-ingress-temporal-sso.yaml kubectl -n activity-core set env deploy/actcore-api \ ACTIVITY_CORE_TEMPORAL_UI_URL=https://temporal.coulomb.social +kubectl -n activity-core set env deploy/actcore-temporal-ui \ + TEMPORAL_CORS_ORIGINS=https://temporal.coulomb.social,http://localhost:8080,http://127.0.0.1:8080 ``` - Ops: https://activity.coulomb.social/ops/ui (Authelia SSO) - Temporal: https://temporal.coulomb.social - Design: `docs/ops-sso-access.md` +- Follow-up: LLDAP group `activity-core-operators` + Authelia domain rules (T06) ### Break-glass port-forward diff --git a/src/activity_core/ops_api.py b/src/activity_core/ops_api.py index 9d2aab6..8e21a02 100644 --- a/src/activity_core/ops_api.py +++ b/src/activity_core/ops_api.py @@ -248,15 +248,20 @@ async def auth_status() -> dict[str, Any]: "yes", "on", } + temporal = temporal_ui_url() return { "operator_token_configured": operator_token_configured(), "mutation_header": HEADER_NAME, + # True when a shared token is required for *break-glass* / non-SSO clients. + # Browser SSO (Authelia Remote-User / Remote-Email) does not need the token. "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", + "sso_preferred": True, + "sso_headers": ["Remote-User", "Remote-Email", "Remote-Groups"], + "temporal_ui_url": temporal, + "sso_docs": "docs/ops-sso-access.md", "public_hosts": { "ops": "https://activity.coulomb.social", - "temporal_ui": "https://temporal.coulomb.social", + "temporal_ui": temporal, }, } @@ -309,7 +314,7 @@ def _page(title: str, body: str) -> HTMLResponse: Inventory Status Temporal UI + title="Temporal Web UI (SSO: temporal.coulomb.social)">Temporal UI Auth JSON
@@ -351,12 +356,15 @@ def _token_form() -> str: configured = "yes" if operator_token_configured() else "no" return f"""
-
""" @@ -405,8 +413,7 @@ async def ui_index() -> HTMLResponse:

Temporal Web UI (workflow debugger): {html.escape(temporal_ui_url())} - — requires port-forward of svc/actcore-temporal-ui 8080:8080 - until SSO ingress (ACTIVITY-WP-0025).

+ — primary SSO URL; port-forward of svc/actcore-temporal-ui is break-glass only.

""" return _page("Inventory", body) diff --git a/tests/test_ops_console_api.py b/tests/test_ops_console_api.py index 85aef52..fb5c8d2 100644 --- a/tests/test_ops_console_api.py +++ b/tests/test_ops_console_api.py @@ -67,6 +67,53 @@ async def test_trigger_requires_token(ops_app: FastAPI, monkeypatch: pytest.Monk assert res.status_code == 401 +@pytest.mark.asyncio +async def test_trigger_with_sso_principal( + ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch +) -> None: + """SSO Remote-User is preferred; audit must record sso:, never the token.""" + def_id = uuid.uuid4() + row = MagicMock() + row.name = "Weekly SBOM" + row.context_sources = [] + row.task_templates = [] + row.trigger_config = {"trigger_type": "cron", "cron_expression": "0 9 * * 1"} + + session = AsyncMock() + session.get = AsyncMock(return_value=row) + session.__aenter__ = AsyncMock(return_value=session) + session.__aexit__ = AsyncMock(return_value=None) + ops_app.state.session_factory.return_value = session + + handle = MagicMock() + handle.id = f"activity-{def_id}:manual-sso" + ops_app.state.temporal.start_workflow = AsyncMock(return_value=handle) + + captured: dict[str, Any] = {} + + async def capture_audit(**kwargs: Any) -> dict[str, Any]: + captured.update(kwargs) + return { + "action": kwargs["action"], + "audit_id": "sso-a1", + "principal": kwargs["principal"], + } + + monkeypatch.setattr("activity_core.ops_api.record_ops_audit", capture_audit) + + transport = ASGITransport(app=ops_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + res = await client.post( + f"/ops/automations/{def_id}/trigger", + headers={"Remote-User": "alice.operator"}, + json={}, + ) + assert res.status_code == 200 + assert res.json()["audit"]["principal"] == "sso:alice.operator" + assert captured["principal"] == "sso:alice.operator" + assert "token" not in str(res.json()).lower() or "test-token" not in str(res.json()) + + @pytest.mark.asyncio async def test_trigger_with_token(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatch) -> None: def_id = uuid.uuid4() @@ -216,7 +263,7 @@ async def test_ui_index_renders(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatc res = await client.get("/ops/ui/") assert res.status_code == 200 assert "Daily Triage" in res.text - assert "Operator token" in res.text + assert "SSO" in res.text or "Break-glass" in res.text assert "Temporal UI" in res.text assert "temporal.coulomb.social" in res.text diff --git a/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md b/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md index 026eacf..1657eca 100644 --- a/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md +++ b/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md @@ -116,7 +116,7 @@ state_hub_task_id: "650db102-ad4e-4d8e-86c1-eee50f5bafcc" ```task id: ACTIVITY-WP-0025-T03 -status: progress +status: done priority: high state_hub_task_id: "7084f7d5-4181-4dfa-b239-0dad15efbae9" ``` @@ -134,7 +134,7 @@ port-forward; unauthorized gets login or 403. ```task id: ACTIVITY-WP-0025-T04 -status: progress +status: done priority: high state_hub_task_id: "99b36f31-eb19-4f96-b3f1-9ac1ab02d78f" ``` @@ -151,7 +151,7 @@ port-forward, under SSO. ```task id: ACTIVITY-WP-0025-T05 -status: progress +status: done priority: high state_hub_task_id: "77fff202-bfb6-49eb-b8e5-3760abacc8bd" ``` @@ -186,7 +186,7 @@ account. ```task id: ACTIVITY-WP-0025-T07 -status: progress +status: done priority: medium state_hub_task_id: "c5945e64-c460-4830-a19e-a944bb44a67e" ``` @@ -201,7 +201,7 @@ state_hub_task_id: "c5945e64-c460-4830-a19e-a944bb44a67e" ```task id: ACTIVITY-WP-0025-T08 -status: todo +status: done priority: medium state_hub_task_id: "f73eafbf-ad11-4d7d-b89e-e910c662ce86" ``` @@ -215,12 +215,12 @@ state_hub_task_id: "f73eafbf-ad11-4d7d-b89e-e910c662ce86" ## Success criteria -- [ ] HTTPS SSO URLs for ops console and Temporal UI (no port-forward required) -- [ ] Only authorized IdP group can access -- [ ] Ops mutations attribute to SSO identity; audits show principal -- [ ] Ops nav Temporal link points at SSO Temporal URL -- [ ] Runbook documents SSO primary + break-glass port-forward -- [ ] SCOPE G10 updated to reflect UI SSO posture +- [x] HTTPS SSO URLs for ops console and Temporal UI (no port-forward required) +- [ ] Only authorized IdP group can access — **MVP: any Authelia user; T06 residual** +- [x] Ops mutations attribute to SSO identity; audits show principal (`sso:`) +- [x] Ops nav Temporal link points at SSO Temporal URL +- [x] Runbook documents SSO primary + break-glass port-forward +- [x] SCOPE G10 updated to reflect UI SSO posture ## Dependencies / coordination @@ -250,13 +250,36 @@ state_hub_task_id: "f73eafbf-ad11-4d7d-b89e-e910c662ce86" | --- | --- | --- | | T01 | done | Hosts: activity.coulomb.social + temporal.coulomb.social; docs/ops-sso-access.md | | T02 | done | Reuse Authelia + Traefik ForwardAuth (sso ns); not oauth2-proxy | -| T03 | progress | Ingress + middleware manifests landed; **blocked on DNS A records** for cert | -| T04 | progress | Temporal Ingress manifest + CORS env; same DNS gate | -| T05 | progress | SSO headers preferred over shared token in ops_auth | -| T06 | wait | LLDAP group activity-core-operators + Authelia rules (net-kingdom) | -| T07 | progress | Runbook + design doc updated | -| T08 | todo | After DNS/cert Ready: smoke HTTPS + SSO login | +| T03 | done | Ingress + cert Ready; unauth → 302 auth.coulomb.social | +| T04 | done | Temporal Ingress + cert Ready; `ACTIVITY_CORE_TEMPORAL_UI_URL` + CORS set | +| T05 | done | SSO `Remote-User` preferred; token break-glass; tests + redeploy | +| T06 | wait | LLDAP group activity-core-operators + Authelia domain rules (net-kingdom) | +| T07 | done | Runbook + k8s README + design doc: SSO primary, port-forward break-glass | +| T08 | done | See verification checklist below | -**Operator action required:** create DNS A records for both hosts → 92.205.62.239, -then apply `k8s/railiance/30-*.yaml`–`32-*.yaml` and set -`ACTIVITY_CORE_TEMPORAL_UI_URL=https://temporal.coulomb.social` on actcore-api. +### Verification checklist (T08) — 2026-07-22 + +| Check | Result | +| --- | --- | +| DNS A → 92.205.62.239 for both hosts | ok | +| Certificate Ready (`actcore-ops-tls`, `actcore-temporal-ui-tls`) | ok | +| Unauth HTTPS `/ops/ui` → Authelia login redirect | ok | +| Unauth HTTPS Temporal host → Authelia login redirect | ok | +| Services remain ClusterIP (no public LB) | ok | +| Mutation without SSO/token → 401 fail-closed | ok | +| Mutation with `Remote-User` → auth passes (404 only if unknown def) | ok | +| `ACTIVITY_CORE_TEMPORAL_UI_URL=https://temporal.coulomb.social` | ok | +| `TEMPORAL_CORS_ORIGINS` includes public Temporal host | ok | +| Unit tests `test_ops_auth` + `test_ops_console_api` | 18 passed | +| Image `activity-core:railiance01-prod` rebuilt, imported, api/worker/router rolled | ok | +| NetworkPolicy ingress→services | **skipped** (optional; ClusterIP + Authelia sufficient for MVP) | +| Full browser login + Run now under real MFA session | **operator smoke** (credentials not in agent) | + +### Residual (keeps WP active) + +**T06** — restrict Authelia access to LLDAP group `activity-core-operators` +(net-kingdom Authelia access_control rules). Until then any authenticated +Authelia user can reach the UIs (org-wide SSO, not least-privilege). + +**Operator:** open https://activity.coulomb.social/ops/ui once, confirm inventory +loads and a safe mutation (or dry path) shows `sso:` in `/ops/audits`.