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
2
SCOPE.md
2
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). |
|
| **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). |
|
| **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. |
|
| **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
|
### Drift risks
|
||||||
|
|
||||||
|
|
|
||||||
94
docs/ops-sso-access.md
Normal file
94
docs/ops-sso-access.md
Normal 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
|
||||||
|
```
|
||||||
|
|
@ -105,31 +105,39 @@ the UI** — change definition files and sync.
|
||||||
|
|
||||||
### Production access (railiance01)
|
### Production access (railiance01)
|
||||||
|
|
||||||
API remains **ClusterIP** (no public Ingress in WP-0024; SSO planned in
|
**Primary (SSO — ACTIVITY-WP-0025):** after DNS A records exist:
|
||||||
ACTIVITY-WP-0025). Use the **hosteurope / railiance01** kubeconfig, not a local
|
|
||||||
empty cluster.
|
| 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
|
```bash
|
||||||
export KUBECONFIG=~/.kube/config-hosteurope
|
export KUBECONFIG=~/.kube/config-hosteurope
|
||||||
|
|
||||||
# Ops console (activity-core API)
|
|
||||||
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
|
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
|
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
|
```bash
|
||||||
# on actcore-api
|
ACTIVITY_CORE_TEMPORAL_UI_URL=https://activity-temporal.coulomb.social
|
||||||
ACTIVITY_CORE_TEMPORAL_UI_URL=http://127.0.0.1:8080
|
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):
|
Bootstrap token (operator workstation; never commit the value):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -384,4 +384,5 @@ spec:
|
||||||
- name: TEMPORAL_ADDRESS
|
- name: TEMPORAL_ADDRESS
|
||||||
value: actcore-temporal:7233
|
value: actcore-temporal:7233
|
||||||
- name: TEMPORAL_CORS_ORIGINS
|
- 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
|
||||||
|
|
|
||||||
22
k8s/railiance/30-authelia-middleware.yaml
Normal file
22
k8s/railiance/30-authelia-middleware.yaml
Normal 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
|
||||||
34
k8s/railiance/31-ingress-ops-sso.yaml
Normal file
34
k8s/railiance/31-ingress-ops-sso.yaml
Normal 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
|
||||||
34
k8s/railiance/32-ingress-temporal-sso.yaml
Normal file
34
k8s/railiance/32-ingress-temporal-sso.yaml
Normal 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
|
||||||
|
|
@ -111,20 +111,33 @@ kubectl -n activity-core get pods
|
||||||
kubectl -n activity-core get svc
|
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
|
```bash
|
||||||
kubectl -n activity-core port-forward svc/actcore-api 8010:8010
|
# DNS A records → 92.205.62.239 (once):
|
||||||
# UI: http://127.0.0.1:8010/ops/ui
|
# activity.coulomb.social
|
||||||
# JSON: http://127.0.0.1:8010/ops/automations/status?since=sunday
|
# 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`
|
- Ops: https://activity.coulomb.social/ops/ui (Authelia SSO)
|
||||||
(injected via `envFrom` on `actcore-api`). Merge the key into the existing
|
- Temporal: https://activity-temporal.coulomb.social
|
||||||
secret (do not replace DB URL keys). Header: `X-Operator-Token`. See
|
- Design: `docs/ops-sso-access.md`
|
||||||
`docs/runbook.md` § Operator automation console.
|
|
||||||
|
|
||||||
Cron/schedule expression changes remain git-owned (definition files + sync);
|
### Break-glass port-forward
|
||||||
the console supports Run now, enable/disable, and Temporal pause/unpause only.
|
|
||||||
|
```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.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from temporalio.client import Client
|
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 (
|
from activity_core.ops_console import (
|
||||||
is_side_effect_definition,
|
is_side_effect_definition,
|
||||||
ops_definition_detail,
|
ops_definition_detail,
|
||||||
|
|
@ -248,6 +253,11 @@ async def auth_status() -> dict[str, Any]:
|
||||||
"mutation_header": HEADER_NAME,
|
"mutation_header": HEADER_NAME,
|
||||||
"mutations_require_token": operator_token_configured() or not allow,
|
"mutations_require_token": operator_token_configured() or not allow,
|
||||||
"temporal_ui_url": temporal_ui_url(),
|
"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:
|
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
|
Override with ACTIVITY_CORE_TEMPORAL_UI_URL. Default prefers the public SSO
|
||||||
port-forward of actcore-temporal-ui to 127.0.0.1:8080 (see runbook).
|
hostname when set via env; otherwise local port-forward on :8080.
|
||||||
"""
|
"""
|
||||||
raw = (
|
raw = (
|
||||||
os.environ.get("ACTIVITY_CORE_TEMPORAL_UI_URL")
|
os.environ.get("ACTIVITY_CORE_TEMPORAL_UI_URL")
|
||||||
or os.environ.get("TEMPORAL_UI_URL")
|
or os.environ.get("TEMPORAL_UI_URL")
|
||||||
or "http://127.0.0.1:8080"
|
or "https://activity-temporal.coulomb.social"
|
||||||
).strip()
|
).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:
|
def _page(title: str, body: str) -> HTMLResponse:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
Mutations under ``/ops`` are fail-closed. Accepted principals (in order):
|
||||||
- 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).
|
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
@ -21,6 +24,15 @@ OPERATOR_TOKEN_ENV = "ACTIVITY_CORE_OPERATOR_TOKEN"
|
||||||
ALLOW_UNAUTH_ENV = "ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS"
|
ALLOW_UNAUTH_ENV = "ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS"
|
||||||
HEADER_NAME = "X-Operator-Token"
|
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:
|
def operator_token_configured() -> bool:
|
||||||
return bool((os.environ.get(OPERATOR_TOKEN_ENV) or "").strip())
|
return bool((os.environ.get(OPERATOR_TOKEN_ENV) or "").strip())
|
||||||
|
|
@ -47,8 +59,30 @@ def extract_operator_token(
|
||||||
return None
|
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:
|
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()
|
expected = (os.environ.get(OPERATOR_TOKEN_ENV) or "").strip()
|
||||||
if not expected:
|
if not expected:
|
||||||
if allow_unauth_mutations():
|
if allow_unauth_mutations():
|
||||||
|
|
@ -57,17 +91,18 @@ def verify_operator_token(provided: str | None) -> str:
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail=(
|
detail=(
|
||||||
"operator auth not configured; set "
|
"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:
|
if not provided:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
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):
|
if not hmac.compare_digest(provided, expected):
|
||||||
raise HTTPException(status_code=401, detail="invalid operator token")
|
raise HTTPException(status_code=401, detail="invalid operator token")
|
||||||
return "operator"
|
return "operator-token"
|
||||||
|
|
||||||
|
|
||||||
async def require_operator(
|
async def require_operator(
|
||||||
|
|
@ -75,8 +110,11 @@ async def require_operator(
|
||||||
x_operator_token: Annotated[str | None, Header(alias=HEADER_NAME)] = None,
|
x_operator_token: Annotated[str | None, Header(alias=HEADER_NAME)] = None,
|
||||||
authorization: Annotated[str | None, Header()] = None,
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""FastAPI dependency: require valid operator token for mutations."""
|
"""FastAPI dependency: SSO principal or valid operator token."""
|
||||||
# Prefer dependency headers; fall back to raw request (HTML form headers rare).
|
sso = extract_sso_principal(request)
|
||||||
|
if sso:
|
||||||
|
return f"sso:{sso}"
|
||||||
|
|
||||||
provided = extract_operator_token(
|
provided = extract_operator_token(
|
||||||
x_operator_token=x_operator_token,
|
x_operator_token=x_operator_token,
|
||||||
authorization=authorization,
|
authorization=authorization,
|
||||||
|
|
|
||||||
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from activity_core.ops_auth import (
|
from activity_core.ops_auth import (
|
||||||
extract_operator_token,
|
extract_operator_token,
|
||||||
|
extract_sso_principal,
|
||||||
operator_token_configured,
|
operator_token_configured,
|
||||||
|
require_operator,
|
||||||
verify_operator_token,
|
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:
|
def test_verify_token_match(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "correct-horse")
|
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:
|
with pytest.raises(HTTPException) as exc:
|
||||||
verify_operator_token("wrong")
|
verify_operator_token("wrong")
|
||||||
assert exc.value.status_code == 401
|
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
|
assert operator_token_configured() is False
|
||||||
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "x")
|
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "x")
|
||||||
assert operator_token_configured() is True
|
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"
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ async def test_ui_index_renders(ops_app: FastAPI, monkeypatch: pytest.MonkeyPatc
|
||||||
assert "Daily Triage" in res.text
|
assert "Daily Triage" in res.text
|
||||||
assert "Operator token" in res.text
|
assert "Operator token" in res.text
|
||||||
assert "Temporal UI" 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
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "SSO access control for ops console and Temporal UI"
|
title: "SSO access control for ops console and Temporal UI"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: activity-core
|
repo: activity-core
|
||||||
status: proposed
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: activity-core
|
topic_slug: activity-core
|
||||||
created: "2026-07-22"
|
created: "2026-07-22"
|
||||||
|
|
@ -81,7 +81,7 @@ git or State Hub.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T01
|
id: ACTIVITY-WP-0025-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "de28ac08-cae5-476b-b093-82fcf8e3fa5e"
|
state_hub_task_id: "de28ac08-cae5-476b-b093-82fcf8e3fa5e"
|
||||||
```
|
```
|
||||||
|
|
@ -100,7 +100,7 @@ hostnames and IdP group named.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T02
|
id: ACTIVITY-WP-0025-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "650db102-ad4e-4d8e-86c1-eee50f5bafcc"
|
state_hub_task_id: "650db102-ad4e-4d8e-86c1-eee50f5bafcc"
|
||||||
```
|
```
|
||||||
|
|
@ -116,7 +116,7 @@ state_hub_task_id: "650db102-ad4e-4d8e-86c1-eee50f5bafcc"
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T03
|
id: ACTIVITY-WP-0025-T03
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7084f7d5-4181-4dfa-b239-0dad15efbae9"
|
state_hub_task_id: "7084f7d5-4181-4dfa-b239-0dad15efbae9"
|
||||||
```
|
```
|
||||||
|
|
@ -134,7 +134,7 @@ port-forward; unauthorized gets login or 403.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T04
|
id: ACTIVITY-WP-0025-T04
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "99b36f31-eb19-4f96-b3f1-9ac1ab02d78f"
|
state_hub_task_id: "99b36f31-eb19-4f96-b3f1-9ac1ab02d78f"
|
||||||
```
|
```
|
||||||
|
|
@ -151,7 +151,7 @@ port-forward, under SSO.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T05
|
id: ACTIVITY-WP-0025-T05
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "77fff202-bfb6-49eb-b8e5-3760abacc8bd"
|
state_hub_task_id: "77fff202-bfb6-49eb-b8e5-3760abacc8bd"
|
||||||
```
|
```
|
||||||
|
|
@ -170,7 +170,7 @@ token not required for normal operator use.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T06
|
id: ACTIVITY-WP-0025-T06
|
||||||
status: todo
|
status: wait
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "b14e1f2c-1320-4218-8bd3-2a309295a69c"
|
state_hub_task_id: "b14e1f2c-1320-4218-8bd3-2a309295a69c"
|
||||||
```
|
```
|
||||||
|
|
@ -186,7 +186,7 @@ account.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: ACTIVITY-WP-0025-T07
|
id: ACTIVITY-WP-0025-T07
|
||||||
status: todo
|
status: progress
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "c5945e64-c460-4830-a19e-a944bb44a67e"
|
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`
|
- `src/activity_core/ops_api.py` — `ACTIVITY_CORE_TEMPORAL_UI_URL`
|
||||||
- `k8s/railiance/` — `actcore-api`, `actcore-temporal-ui` ClusterIP services
|
- `k8s/railiance/` — `actcore-api`, `actcore-temporal-ui` ClusterIP services
|
||||||
- ACTIVITY-WP-0024 operator automation console
|
- 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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue