diff --git a/docs/runbook.md b/docs/runbook.md
index 124a75a..775f285 100644
--- a/docs/runbook.md
+++ b/docs/runbook.md
@@ -105,15 +105,31 @@ the UI** — change definition files and sync.
### Production access (railiance01)
-API remains **ClusterIP** (no public Ingress in WP-0024).
+API remains **ClusterIP** (no public Ingress in WP-0024; SSO planned in
+ACTIVITY-WP-0025). Use the **hosteurope / railiance01** kubeconfig, not a local
+empty cluster.
```bash
-# From a machine with kubectl to railiance01:
+export KUBECONFIG=~/.kube/config-hosteurope
+
+# Ops console (activity-core API)
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
# Browser: http://127.0.0.1:8010/ops/ui
-# Ensure ACTIVITY_CORE_OPERATOR_TOKEN is set on actcore-api (runtime secret key).
+
+# Temporal Web UI (second terminal — linked from ops nav as "Temporal UI")
+kubectl -n activity-core port-forward svc/actcore-temporal-ui 8080:8080
+# Browser: http://127.0.0.1:8080
```
+Override the Temporal link target if needed:
+
+```bash
+# on actcore-api
+ACTIVITY_CORE_TEMPORAL_UI_URL=http://127.0.0.1:8080
+```
+
+Ensure `ACTIVITY_CORE_OPERATOR_TOKEN` is set on actcore-api (runtime secret key).
+
Bootstrap token (operator workstation; never commit the value):
```bash
diff --git a/src/activity_core/ops_api.py b/src/activity_core/ops_api.py
index 8e7c765..eeaf203 100644
--- a/src/activity_core/ops_api.py
+++ b/src/activity_core/ops_api.py
@@ -247,6 +247,7 @@ async def auth_status() -> dict[str, Any]:
"operator_token_configured": operator_token_configured(),
"mutation_header": HEADER_NAME,
"mutations_require_token": operator_token_configured() or not allow,
+ "temporal_ui_url": temporal_ui_url(),
}
@@ -256,6 +257,7 @@ _CSS = """
:root { font-family: system-ui, sans-serif; color: #1a1a1a; }
body { margin: 1.5rem; max-width: 1100px; }
nav a { margin-right: 1rem; }
+nav a.external::after { content: " ↗"; font-size: 0.75em; opacity: 0.7; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; font-size: 0.9rem; }
th { background: #f4f4f4; }
@@ -268,7 +270,22 @@ 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).
+
+ 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).
+ """
+ raw = (
+ os.environ.get("ACTIVITY_CORE_TEMPORAL_UI_URL")
+ or os.environ.get("TEMPORAL_UI_URL")
+ or "http://127.0.0.1:8080"
+ ).strip()
+ return raw.rstrip("/") or "http://127.0.0.1:8080"
+
+
def _page(title: str, body: str) -> HTMLResponse:
+ temporal_href = html.escape(temporal_ui_url(), quote=True)
doc = f"""
@@ -281,6 +298,8 @@ def _page(title: str, body: str) -> HTMLResponse:
activity-core ops
Inventory
Status
+ Temporal UI
Auth JSON
@@ -373,6 +392,11 @@ async def ui_index() -> HTMLResponse:
{''.join(rows) or '| No automations found |
'}
Schedule cron is read-only in MVP — edit definition files + sync.
+ 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).
"""
return _page("Inventory", body)
diff --git a/tests/test_ops_console_api.py b/tests/test_ops_console_api.py
index d78b78b..30e7e48 100644
--- a/tests/test_ops_console_api.py
+++ b/tests/test_ops_console_api.py
@@ -48,6 +48,8 @@ async def test_auth_status(ops_app: FastAPI) -> None:
body = res.json()
assert body["operator_token_configured"] is True
assert body["mutation_header"] == "X-Operator-Token"
+ assert "temporal_ui_url" in body
+ assert body["temporal_ui_url"].startswith("http")
@pytest.mark.asyncio
@@ -215,6 +217,8 @@ async def test_ui_index_renders(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatc
assert res.status_code == 200
assert "Daily Triage" in res.text
assert "Operator token" in res.text
+ assert "Temporal UI" in res.text
+ assert "127.0.0.1:8080" in res.text
@pytest.mark.asyncio
diff --git a/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md b/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md
new file mode 100644
index 0000000..03ee24e
--- /dev/null
+++ b/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md
@@ -0,0 +1,236 @@
+---
+id: ACTIVITY-WP-0025
+type: workplan
+title: "SSO access control for ops console and Temporal UI"
+domain: infotech
+repo: activity-core
+status: proposed
+owner: codex
+topic_slug: activity-core
+created: "2026-07-22"
+updated: "2026-07-22"
+---
+
+# SSO access control for ops console and Temporal UI
+
+## Origin
+
+ACTIVITY-WP-0024 shipped the operator automation console (`/ops/ui`) with
+ClusterIP + port-forward access and a shared operator token for mutations.
+Operators also use Temporal Web UI (`actcore-temporal-ui:8080`) for workflow
+debugging; the ops UI now deep-links to it.
+
+**Pain:** port-forward + kubeconfig switching is fragile; shared token is not
+identity-aware. **Goal:** proper browser access via SSO without port-forward.
+
+Closes SCOPE **G10** (API external access) for the **ops and Temporal UIs**
+(not necessarily for unauthenticated programmatic API from the public internet).
+
+## Goal
+
+Expose **activity-core ops UI** and **Temporal Web UI** behind authenticated
+ingress with **Keycloak / key-cape OIDC SSO**, so an authorized operator can:
+
+1. Open HTTPS URLs in a browser (no `kubectl port-forward`)
+2. Authenticate with org SSO (MFA per IAM profile where required)
+3. Use ops console mutations under **their identity** (not only a shared token)
+4. Open Temporal UI under the same access policy
+
+## Non-goals
+
+- Public anonymous access to `/ops` or Temporal UI
+- Replacing Temporal’s own RBAC model with fine-grained multi-tenant Temporal
+ namespaces for all of Coulomb (single activity-core Temporal is fine for MVP)
+- Full API productization for third-party clients (focus is **human UI access**)
+- Moving Temporal server hosting ownership out of the activity-core namespace
+ (unless railiance-platform already owns that path)
+
+## Architecture sketch (proposed)
+
+```
+Browser
+ │ HTTPS
+ ▼
+Ingress (TLS) ──► oauth2-proxy / key-cape edge ── OIDC ──► Keycloak (key-cape)
+ │ │
+ │ allowlisted groups │ identity headers / session
+ ▼ ▼
+actcore-api (/ops, /ops/ui) actcore-temporal-ui
+ mutations: require SSO subject
+ (retire shared token as primary; keep break-glass optional)
+```
+
+Credential routing: **key-cape** owns OIDC/login (`warden route show key-cape-oidc-login`);
+**flex-auth** for authorization decisions if used; **do not** invent secrets in
+git or State Hub.
+
+## Design decisions to lock in T01
+
+| Decision | Options | Lean |
+| --- | --- | --- |
+| Hostnames | e.g. `activity.ops.…` / `temporal.ops.…` vs path-based single host | Prefer **two hostnames** or one host with path prefixes |
+| Auth edge | oauth2-proxy vs nginx auth_request vs key-cape native | Prefer **oauth2-proxy** or fleet-standard edge if one already exists |
+| Mutation identity | SSO email/sub as principal; drop shared token for normal use | Shared token = break-glass only |
+| Temporal UI auth | Same edge in front of `actcore-temporal-ui` | Yes for MVP |
+| Who may access | Explicit Keycloak group (e.g. `activity-core-operators`) | Least privilege |
+
+## Tasks
+
+## Task: Access policy brief and hostnames
+
+```task
+id: ACTIVITY-WP-0025-T01
+status: todo
+priority: high
+```
+
+1. Document intended audience (founders / operators), MFA expectation, and
+ allowed IdP groups.
+2. Propose hostnames + TLS cert path (cluster issuer / railiance DNS).
+3. Align with railiance-platform / key-cape conventions; open coordination
+ messages if host or client registration is external.
+4. Update SCOPE G10 with the chosen posture (UI SSO vs full public API).
+
+**Done when:** short design note in `docs/` or this workplan is approved;
+hostnames and IdP group named.
+
+## Task: Inventory fleet SSO ingress patterns
+
+```task
+id: ACTIVITY-WP-0025-T02
+status: todo
+priority: high
+```
+
+1. Find an existing oauth2-proxy / OIDC ingress pattern on railiance01 or
+ coulombcore (railiance-platform, state-hub dashboard, etc.).
+2. Prefer **reuse** over inventing a new edge stack.
+3. Record the pattern (manifest paths, secrets ownership, callback URLs).
+
+**Done when:** “pattern to copy” is written with repo/file pointers.
+
+## Task: Ingress + TLS for ops API (read path)
+
+```task
+id: ACTIVITY-WP-0025-T03
+status: todo
+priority: high
+```
+
+1. Add Ingress (or Gateway) for `actcore-api` serving at least `/ops` and
+ `/ops/ui` (and health for probes as needed).
+2. Terminate TLS with the platform cert strategy.
+3. Gate with SSO edge; unauthenticated browser hits redirect to login.
+4. Keep ClusterIP service; no LoadBalancer unless required.
+
+**Done when:** authorized browser reaches `/ops/ui` over HTTPS without
+port-forward; unauthorized gets login or 403.
+
+## Task: Ingress + SSO for Temporal Web UI
+
+```task
+id: ACTIVITY-WP-0025-T04
+status: todo
+priority: high
+```
+
+1. Expose `actcore-temporal-ui` behind the same auth edge (hostname or path).
+2. Ensure Temporal UI env (`TEMPORAL_ADDRESS`, public URL / CORS if required)
+ works behind the external hostname.
+3. Ops console `ACTIVITY_CORE_TEMPORAL_UI_URL` points at the **public SSO URL**.
+
+**Done when:** Temporal UI opens from the ops nav link without a second
+port-forward, under SSO.
+
+## Task: Map SSO identity into ops mutations
+
+```task
+id: ACTIVITY-WP-0025-T05
+status: todo
+priority: high
+```
+
+1. Accept trusted identity from the auth edge (e.g. `X-Forwarded-User` /
+ `X-Auth-Request-Email` — exact headers depend on T02 pattern).
+2. Prefer SSO principal over shared `ACTIVITY_CORE_OPERATOR_TOKEN` for
+ mutations when present.
+3. Audit log records SSO subject (never tokens).
+4. Optional break-glass: keep operator token for emergency; document custody.
+
+**Done when:** Run now / pause audit shows human identity from SSO; shared
+token not required for normal operator use.
+
+## Task: Keycloak client and group membership
+
+```task
+id: ACTIVITY-WP-0025-T06
+status: todo
+priority: medium
+```
+
+1. Register OIDC client(s) via key-cape / netkingdom process (not ad-hoc).
+2. Create/bind operator group; grant only named operators.
+3. Document how to add/remove operators.
+
+**Done when:** login works for a test operator and fails for an unprivileged
+account.
+
+## Task: Docs and runbook cutover
+
+```task
+id: ACTIVITY-WP-0025-T07
+status: todo
+priority: medium
+```
+
+1. Runbook: primary access = SSO URLs; port-forward = break-glass only.
+2. k8s README: Ingress manifests and secret ownership.
+3. Credential routing notes: key-cape for login; no secrets in git.
+
+**Done when:** an operator can follow runbook without kubectl for daily use.
+
+## Task: Verify and harden
+
+```task
+id: ACTIVITY-WP-0025-T08
+status: todo
+priority: medium
+```
+
+1. Confirm ClusterIP APIs without Ingress path remain not publicly reachable.
+2. Confirm mutations without SSO (and without break-glass token) fail closed.
+3. Smoke: inventory, status, Run now dry path, Temporal UI namespaces list.
+4. Optional: network policies locking ingress → services.
+
+**Done when:** checklist signed off in workplan closeout notes.
+
+## 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
+
+## Dependencies / coordination
+
+- **key-cape** / Keycloak: OIDC clients, groups (`warden route show key-cape-oidc-login`)
+- **railiance-platform**: DNS, cert-manager, ingress controller norms
+- **ACTIVITY-WP-0024**: ops console exists; this WP unlocks production access
+
+## Implementation order
+
+1. T01–T02 design + pattern reuse
+2. T06 IdP client (can parallel with T03)
+3. T03 ops Ingress + SSO
+4. T04 Temporal UI Ingress + env URL
+5. T05 mutation identity
+6. T07–T08 docs and verify
+
+## References
+
+- `docs/runbook.md` — operator console + dual port-forward (pre-SSO)
+- `src/activity_core/ops_api.py` — `ACTIVITY_CORE_TEMPORAL_UI_URL`
+- `k8s/railiance/` — `actcore-api`, `actcore-temporal-ui` ClusterIP services
+- ACTIVITY-WP-0024 operator automation console