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

@ -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** | Low | ClusterIP-only; ops mutations use operator token (WP-0024). Public Ingress / OIDC still deferred. |
| **G10. API external access** | Medium (in progress) | WP-0025: Traefik + Authelia SSO for `activity.coulomb.social` + `activity-temporal.coulomb.social`; port-forward remains break-glass until DNS/certs Ready. |
### Drift risks

94
docs/ops-sso-access.md Normal file
View file

@ -0,0 +1,94 @@
# Ops console + Temporal UI — SSO access design (ACTIVITY-WP-0025)
## Audience
Founders / platform operators who already have Authelia accounts under
`coulomb.social` SSO (LLDAP → Authelia → KeyCape MFA path).
## Hostnames (locked)
| Host | Backend | Purpose |
| --- | --- | --- |
| `activity.coulomb.social` | `actcore-api:8010` | Ops console `/ops/ui`, JSON `/ops/*`, existing API |
| `activity-temporal.coulomb.social` | `actcore-temporal-ui:8080` | Temporal Web UI |
Both resolve to the railiance01 Traefik LB (`92.205.62.239`) once DNS A records
exist (same pattern as `forgejo.coulomb.social`).
## Auth edge (fleet pattern)
| Component | Location |
| --- | --- |
| Ingress controller | Traefik (`ingressClassName: traefik`) |
| TLS | cert-manager `ClusterIssuer/letsencrypt-prod` |
| Login portal | Authelia `https://auth.coulomb.social` |
| IAM / MFA | KeyCape `https://kc.coulomb.social` (post-password) |
| Forward auth | Traefik Middleware `forwardAuth` → Authelia `/api/verify` |
**Not** oauth2-proxy — the live railiance01 stack already runs Authelia in
namespace `sso`. Reuse it.
### Middleware
`Middleware/authelia-forwardauth` in `activity-core` calls:
```text
http://authelia.sso.svc.cluster.local:9091/api/verify?rd=https://auth.coulomb.social/
```
Response headers trusted into the app:
- `Remote-User`
- `Remote-Groups`
- `Remote-Name`
- `Remote-Email`
### Access control policy
Authelia global `default_policy: one_factor` currently applies. MVP accepts any
authenticated Authelia user. Follow-up (T06): LLDAP group
`activity-core-operators` + Authelia domain rules (net-kingdom config change).
## Mutation identity
Priority for ops mutations (`POST /ops/...`):
1. SSO principal from Authelia headers (preferred)
2. Shared `ACTIVITY_CORE_OPERATOR_TOKEN` (break-glass)
3. Else 401/403 fail-closed
## DNS (operator)
Create A records (or CNAME to the forgejo host pattern):
```text
activity.coulomb.social A 92.205.62.239
activity-temporal.coulomb.social A 92.205.62.239
```
Until DNS exists, cert-manager Certificate will stay Pending and HTTPS will not
serve. Manifests can still be applied.
## Break-glass
Port-forward remains documented in `docs/runbook.md` if Ingress/SSO is down.
## Manifests
- `k8s/railiance/30-authelia-middleware.yaml`
- `k8s/railiance/31-ingress-ops-sso.yaml`
- `k8s/railiance/32-ingress-temporal-sso.yaml`
Apply after DNS:
```bash
export KUBECONFIG=~/.kube/config-hosteurope
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
# set public Temporal URL on API after cert Ready:
kubectl -n activity-core set env deploy/actcore-api \
ACTIVITY_CORE_TEMPORAL_UI_URL=https://activity-temporal.coulomb.social
kubectl -n activity-core set env deploy/actcore-temporal-ui \
TEMPORAL_CORS_ORIGINS=https://activity-temporal.coulomb.social
```

View file

@ -105,31 +105,39 @@ the UI** — change definition files and sync.
### Production access (railiance01)
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.
**Primary (SSO — ACTIVITY-WP-0025):** after DNS A records exist:
| UI | URL |
| --- | --- |
| Ops console | https://activity.coulomb.social/ops/ui |
| Temporal Web UI | https://activity-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.
**DNS (required for TLS):**
```text
activity.coulomb.social A 92.205.62.239
activity-temporal.coulomb.social A 92.205.62.239
```
**Break-glass port-forward** (hosteurope kubeconfig):
```bash
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
# 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
# http://127.0.0.1:8010/ops/ui and http://127.0.0.1:8080
```
Override the Temporal link target if needed:
Env overrides:
```bash
# on actcore-api
ACTIVITY_CORE_TEMPORAL_UI_URL=http://127.0.0.1:8080
ACTIVITY_CORE_TEMPORAL_UI_URL=https://activity-temporal.coulomb.social
ACTIVITY_CORE_OPERATOR_TOKEN=… # break-glass; in actcore-runtime-secret
```
Ensure `ACTIVITY_CORE_OPERATOR_TOKEN` is set on actcore-api (runtime secret key).
Bootstrap token (operator workstation; never commit the value):
```bash

View file

@ -384,4 +384,5 @@ spec:
- name: TEMPORAL_ADDRESS
value: actcore-temporal:7233
- name: TEMPORAL_CORS_ORIGINS
value: http://localhost:8080
# Include SSO host (ACTIVITY-WP-0025) and local port-forward.
value: https://activity-temporal.coulomb.social,http://localhost:8080,http://127.0.0.1:8080

View file

@ -0,0 +1,22 @@
# Traefik ForwardAuth → Authelia (fleet SSO).
# Pattern: net-kingdom sso-mfa Authelia at auth.coulomb.social
# ACTIVITY-WP-0025
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: authelia-forwardauth
namespace: activity-core
labels:
app.kubernetes.io/name: activity-core
app.kubernetes.io/part-of: activity-core
app.kubernetes.io/component: sso
spec:
forwardAuth:
# rd= sends the browser to the Authelia login portal when unauthenticated.
address: http://authelia.sso.svc.cluster.local:9091/api/verify?rd=https://auth.coulomb.social/
trustForwardHeader: true
authResponseHeaders:
- Remote-User
- Remote-Groups
- Remote-Name
- Remote-Email

View file

@ -0,0 +1,34 @@
# HTTPS SSO front door for activity-core ops console + API.
# Requires DNS: activity.coulomb.social A → 92.205.62.239
# ACTIVITY-WP-0025
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: actcore-ops
namespace: activity-core
labels:
app.kubernetes.io/name: actcore-api
app.kubernetes.io/part-of: activity-core
app.kubernetes.io/component: sso-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: activity-core-authelia-forwardauth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- activity.coulomb.social
secretName: actcore-ops-tls
rules:
- host: activity.coulomb.social
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: actcore-api
port:
number: 8010

View file

@ -0,0 +1,34 @@
# HTTPS SSO front door for Temporal Web UI.
# Requires DNS: activity-temporal.coulomb.social A → 92.205.62.239
# ACTIVITY-WP-0025
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: actcore-temporal-ui
namespace: activity-core
labels:
app.kubernetes.io/name: actcore-temporal-ui
app.kubernetes.io/part-of: activity-core
app.kubernetes.io/component: sso-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.middlewares: activity-core-authelia-forwardauth@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- activity-temporal.coulomb.social
secretName: actcore-temporal-ui-tls
rules:
- host: activity-temporal.coulomb.social
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: actcore-temporal-ui
port:
number: 8080

View file

@ -111,20 +111,33 @@ kubectl -n activity-core get pods
kubectl -n activity-core get svc
```
## Operator automation console (ACTIVITY-WP-0024)
## Operator automation console (ACTIVITY-WP-0024 / 0025)
API Service is ClusterIP-only. From a shell with cluster access:
### SSO (primary, after DNS)
```bash
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
# UI: http://127.0.0.1:8010/ops/ui
# JSON: http://127.0.0.1:8010/ops/automations/status?since=sunday
# DNS A records → 92.205.62.239 (once):
# activity.coulomb.social
# activity-temporal.coulomb.social
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://activity-temporal.coulomb.social
```
Mutations require `ACTIVITY_CORE_OPERATOR_TOKEN` in `actcore-runtime-secret`
(injected via `envFrom` on `actcore-api`). Merge the key into the existing
secret (do not replace DB URL keys). Header: `X-Operator-Token`. See
`docs/runbook.md` § Operator automation console.
- Ops: https://activity.coulomb.social/ops/ui (Authelia SSO)
- Temporal: https://activity-temporal.coulomb.social
- Design: `docs/ops-sso-access.md`
Cron/schedule expression changes remain git-owned (definition files + sync);
the console supports Run now, enable/disable, and Temporal pause/unpause only.
### Break-glass port-forward
```bash
export KUBECONFIG=~/.kube/config-hosteurope
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
# UI: http://127.0.0.1:8010/ops/ui
```
Mutations: SSO headers when behind Authelia, else `X-Operator-Token` from
`actcore-runtime-secret`. Cron edits remain git-owned.

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,

View file

@ -1,13 +1,17 @@
"""Unit tests for operator token auth (ACTIVITY-WP-0024-T02/T06)."""
"""Unit tests for operator token + SSO auth (ACTIVITY-WP-0024/0025)."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from activity_core.ops_auth import (
extract_operator_token,
extract_sso_principal,
operator_token_configured,
require_operator,
verify_operator_token,
)
@ -42,7 +46,7 @@ def test_verify_allows_anonymous_dev(monkeypatch: pytest.MonkeyPatch) -> None:
def test_verify_token_match(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "correct-horse")
assert verify_operator_token("correct-horse") == "operator"
assert verify_operator_token("correct-horse") == "operator-token"
with pytest.raises(HTTPException) as exc:
verify_operator_token("wrong")
assert exc.value.status_code == 401
@ -56,3 +60,37 @@ def test_operator_token_configured(monkeypatch: pytest.MonkeyPatch) -> None:
assert operator_token_configured() is False
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "x")
assert operator_token_configured() is True
def test_extract_sso_principal_remote_user() -> None:
req = MagicMock()
req.headers = {"Remote-User": "bernd", "Remote-Email": "bernd@example.com"}
# MagicMock headers.get needs side_effect
headers = {"Remote-User": "bernd", "Remote-Email": "bernd@example.com"}
class H(dict):
def get(self, key, default=None): # type: ignore[no-untyped-def]
for k, v in self.items():
if k.lower() == str(key).lower():
return v
return default
req.headers = H(headers)
assert extract_sso_principal(req) == "bernd"
@pytest.mark.asyncio
async def test_require_operator_prefers_sso(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "tok")
class H(dict):
def get(self, key, default=None): # type: ignore[no-untyped-def]
for k, v in self.items():
if k.lower() == str(key).lower():
return v
return default
req = MagicMock()
req.headers = H({"Remote-User": "alice"})
principal = await require_operator(req, x_operator_token=None, authorization=None)
assert principal == "sso:alice"

View file

@ -218,7 +218,7 @@ async def test_ui_index_renders(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatc
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
assert "activity-temporal.coulomb.social" in res.text
@pytest.mark.asyncio

View file

@ -4,7 +4,7 @@ type: workplan
title: "SSO access control for ops console and Temporal UI"
domain: infotech
repo: activity-core
status: proposed
status: active
owner: codex
topic_slug: activity-core
created: "2026-07-22"
@ -81,7 +81,7 @@ git or State Hub.
```task
id: ACTIVITY-WP-0025-T01
status: todo
status: done
priority: high
state_hub_task_id: "de28ac08-cae5-476b-b093-82fcf8e3fa5e"
```
@ -100,7 +100,7 @@ hostnames and IdP group named.
```task
id: ACTIVITY-WP-0025-T02
status: todo
status: done
priority: high
state_hub_task_id: "650db102-ad4e-4d8e-86c1-eee50f5bafcc"
```
@ -116,7 +116,7 @@ state_hub_task_id: "650db102-ad4e-4d8e-86c1-eee50f5bafcc"
```task
id: ACTIVITY-WP-0025-T03
status: todo
status: progress
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: todo
status: progress
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: todo
status: progress
priority: high
state_hub_task_id: "77fff202-bfb6-49eb-b8e5-3760abacc8bd"
```
@ -170,7 +170,7 @@ token not required for normal operator use.
```task
id: ACTIVITY-WP-0025-T06
status: todo
status: wait
priority: medium
state_hub_task_id: "b14e1f2c-1320-4218-8bd3-2a309295a69c"
```
@ -186,7 +186,7 @@ account.
```task
id: ACTIVITY-WP-0025-T07
status: todo
status: progress
priority: medium
state_hub_task_id: "c5945e64-c460-4830-a19e-a944bb44a67e"
```
@ -243,3 +243,20 @@ state_hub_task_id: "f73eafbf-ad11-4d7d-b89e-e910c662ce86"
- `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
## Progress 2026-07-22
| Task | Status | Notes |
| --- | --- | --- |
| T01 | done | Hosts: activity.coulomb.social + activity-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 |
**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://activity-temporal.coulomb.social` on actcore-api.