Compare commits

...

2 commits

Author SHA1 Message Date
a9aec541ec Implement NK-WP-0021 activity-core ops SSO least-privilege.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s
Seed LLDAP activity-core-operators, add membership runbook and helper,
restrict Authelia access on activity/temporal.coulomb.social to that
group (member one_factor + domain deny fallback), apply live, and verify
via Authelia check-policy plus unauthenticated edge redirects.
2026-07-22 15:47:26 +02:00
3580e43e5f Add NK-WP-0021 for activity-core ops SSO least-privilege.
Promote NK-IN-0001/0002 after scope/intent review into a single ready
workplan (LLDAP operator group, membership runbook, Authelia domain rules,
live verification). Hub workstream and tasks registered via fix-consistency.
2026-07-22 15:21:22 +02:00
10 changed files with 664 additions and 36 deletions

View file

@ -12,6 +12,7 @@
| workplan | NET-WP-0020 | finished | — | workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md |
| workplan | NK-WP-0009 | backlog | — | workplans/NK-WP-0009-netkingdom-security-pattern-tutorials.md |
| workplan | NK-WP-0011 | backlog | — | workplans/NK-WP-0011-enterprise-federation-saml.md |
| workplan | NK-WP-0021 | ready | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
| task | ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md |
| task | ADHOC-2026-07-02-T02 | done | — | workplans/ADHOC-2026-07-02.md |
| task | NET-WP-0020-T01 | done | — | workplans/NET-WP-0020-openbao-unseal-custody-and-ssh-automation.md |
@ -33,5 +34,10 @@
| task | NK-WP-0011-T6 | todo | — | workplans/NK-WP-0011-enterprise-federation-saml.md |
| task | NK-WP-0011-T7 | todo | — | workplans/NK-WP-0011-enterprise-federation-saml.md |
| task | NK-WP-0011-T8 | todo | — | workplans/NK-WP-0011-enterprise-federation-saml.md |
| intake | NK-IN-0001 | open | blue | docs/intakes/activity-core-ops-sso-operators.md |
| intake | NK-IN-0002 | open | blue | docs/intakes/activity-core-ops-sso-operators.md |
| task | NK-WP-0021-T01 | todo | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
| task | NK-WP-0021-T02 | todo | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
| task | NK-WP-0021-T03 | todo | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
| task | NK-WP-0021-T04 | todo | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
| task | NK-WP-0021-T05 | todo | — | workplans/NK-WP-0021-activity-core-ops-sso-operators.md |
| intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md |
| intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md |

View file

@ -8,16 +8,12 @@ request least-privilege group gating on the public hosts.
Source design: `activity-core/docs/ops-sso-access.md`
Hosts: `activity.coulomb.social`, `temporal.coulomb.social`
Promote with (after route + owner review):
```bash
statehub promote-intake <state_hub_intake_id> \
--to workplan \
--repo-path ~/net-kingdom \
--repo-slug net-kingdom \
--domain communication
# or --to task --workplan-file workplans/<existing>.md
```
**Promotion (2026-07-22):** both intakes accepted after scope/intent review and
promoted into **`NK-WP-0021`**
(`workplans/NK-WP-0021-activity-core-ops-sso-operators.md`). Single workplan
because LLDAP group and Authelia domain rules share one deploy surface and a
hard dependency. See that workplan for refined tasks, non-goals, and the
verification matrix.
---
@ -28,13 +24,15 @@ id: NK-IN-0001
kind: intake
title: "Create LLDAP group activity-core-operators and membership runbook"
lane: blue
status: routed
status: closed
outcome: promoted
promoted_to: NK-WP-0021
priority: high
owner: net-kingdom
repo: net-kingdom
origin: "ACTIVITY-WP-0025-T06"
origin_ref: "activity-core/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md"
routed_note: "Implement in net-kingdom sso-mfa. Origin ACTIVITY-WP-0025-T06 (activity-core)."
routed_note: "Promoted 2026-07-22 into NK-WP-0021 (T01T02). Implement in net-kingdom sso-mfa."
description: |
activity-core shipped Authelia SSO for the ops console and Temporal UI
(ACTIVITY-WP-0025). MVP accepts any authenticated Authelia user.
@ -54,6 +52,7 @@ description: |
notes: |
Related Authelia domain rules are NK-IN-0002 (depends on this group existing).
Requester residual task: ACTIVITY-WP-0025-T06 (status wait).
Promoted to NK-WP-0021 (combined workplan with NK-IN-0002).
state_hub_intake_id: "019f88ff-c5f1-7433-9d13-4ffe37dfef00"
```
@ -66,13 +65,15 @@ id: NK-IN-0002
kind: intake
title: "Authelia access_control for activity + temporal.coulomb.social"
lane: blue
status: routed
status: closed
outcome: promoted
promoted_to: NK-WP-0021
priority: high
owner: net-kingdom
repo: net-kingdom
origin: "ACTIVITY-WP-0025-T06"
origin_ref: "activity-core/workplans/ACTIVITY-WP-0025-ops-ui-sso-access.md"
routed_note: "Depends on NK-IN-0001 group. Implement in Authelia ConfigMap. Origin ACTIVITY-WP-0025-T06."
routed_note: "Promoted 2026-07-22 into NK-WP-0021 (T03T04). Depends on NK-IN-0001 group."
description: |
Restrict browser access to activity-core public SSO hosts to members of
LLDAP group `activity-core-operators` (see NK-IN-0001).
@ -104,5 +105,6 @@ notes: |
Depends on NK-IN-0001 (group must exist). activity-core Ingress + middleware
already applied (k8s/railiance/3032). No activity-core code change required
for group enforcement — headers already carry Remote-Groups.
Promoted to NK-WP-0021 (combined workplan with NK-IN-0001).
state_hub_intake_id: "019f88ff-cc9e-76b4-8ce3-eb354aabefe7"
```

View file

@ -44,6 +44,36 @@ Key config points:
- `identity_providers.oidc.clients[0].redirect_uris` — must match CP-NK-004 (`kc.coulomb.social`)
- `session.domain` — set to parent domain `coulomb.social` so cookies are valid across
both `auth.coulomb.social` and `kc.coulomb.social`
- `access_control.default_policy: one_factor` — fleet default; MFA is not Authelias job
- `access_control.rules` — domain allowlists by LLDAP group (see below)
### Domain rules (app operator groups)
Authelia evaluates `rules` top-down; **first match wins**. A rule that matches
`domain` but not `subject` is skipped, so non-members would fall through to
`default_policy` and stay allowed unless a later domain-only `deny` rule exists.
Pattern used for activity-core public hosts (NK-WP-0021):
```yaml
access_control:
default_policy: one_factor
rules:
- domain: app.example.com
subject: "group:app-operators"
policy: one_factor
- domain: app.example.com
policy: deny
```
| Host | Required LLDAP group |
|------|----------------------|
| `activity.coulomb.social` | `activity-core-operators` |
| `temporal.coulomb.social` | `activity-core-operators` |
Membership: `sso-mfa/k8s/lldap/OPERATOR-GROUPS.md`. Do **not** set `two_factor`
on these rules. Do **not** invent KeyCape OIDC clients for Traefik ForwardAuth
apps — the edge already calls Authelia `/api/verify`.
## Secrets managed

View file

@ -86,8 +86,26 @@ data:
# ── Access control ────────────────────────────────────────────────────────
# one_factor = password only. MFA is handled by KeyCape + privacyIDEA.
# Do NOT change to two_factor here.
#
# Domain rules (NK-WP-0021): first matching rule wins. For restricted hosts
# put the group allow rule first, then a domain-wide deny so non-members do
# not fall through to default_policy (which would re-open the host).
# MFA stays KeyCape/privacyIDEA — policy remains one_factor, never two_factor.
access_control:
default_policy: one_factor
rules:
# activity-core ops console (ForwardAuth; no KeyCape OIDC client)
- domain: activity.coulomb.social
subject: "group:activity-core-operators"
policy: one_factor
- domain: activity.coulomb.social
policy: deny
# Temporal Web UI (same operator group)
- domain: temporal.coulomb.social
subject: "group:activity-core-operators"
policy: one_factor
- domain: temporal.coulomb.social
policy: deny
# ── OIDC identity provider ────────────────────────────────────────────────
# Authelia acts as an upstream OIDC provider for KeyCape.

View file

@ -0,0 +1,83 @@
# Operator groups — membership runbook
Least-privilege **app-scoped** LLDAP groups that gate browser SSO via Authelia
domain rules. Distinct from platform groups (`net-kingdom-users` /
`net-kingdom-admins`).
## Groups
| Group | Gates (Authelia) | Who belongs |
|-------|------------------|-------------|
| `activity-core-operators` | `activity.coulomb.social`, `temporal.coulomb.social` | Named founders / platform operators who need the activity-core ops console or Temporal UI |
**Do not** auto-add everyone in `net-kingdom-admins`. Membership is a conscious
grant. Platform admin ≠ activity-core operator.
Seed empty groups with `./bootstrap-users.sh` (idempotent). Authelia config:
`sso-mfa/k8s/authelia/configmap.yaml` (`access_control.rules`).
## WebUI path
1. Open https://lldap.coulomb.social (IP-restricted admin UI).
2. Sign in as `admin` (password from KeePassXC / custody — never commit it).
3. **Groups**`activity-core-operators` → add or remove members.
4. User must already exist (create via WebUI or `./create-user.sh`).
5. User re-authenticates at Authelia if an old session lacks the group claim
(sign out / new browser session).
## Scripted path
```bash
cd sso-mfa/k8s/lldap
# Add a user to the operator group
./manage-group-members.sh add <uid> activity-core-operators
# Remove
./manage-group-members.sh remove <uid> activity-core-operators
# List members
./manage-group-members.sh list activity-core-operators
```
Defaults: LLDAP URL `https://lldap.coulomb.social`, secrets from
`../../bootstrap/secrets/lldap/secrets.env`. Override:
```bash
./manage-group-members.sh add bernd activity-core-operators \
https://lldap.coulomb.social /path/to/secrets
```
Live ops without a local secrets file: export `LLDAP_ADMIN_PASS` from the
cluster secret (operator workstation only; do not log or commit):
```bash
export LLDAP_ADMIN_PASS="$(kubectl get secret -n sso lldap-secrets \
-o jsonpath='{.data.LLDAP_LDAP_USER_PASS}' | base64 -d)"
# manage-group-members.sh also accepts LLDAP_ADMIN_PASS when secrets.env is absent
```
## Onboarding checklist (activity-core ops)
1. User has LLDAP account and is in `net-kingdom-users` (normal onboarding).
2. Operator adds them to `activity-core-operators` (WebUI or script).
3. User signs in at https://auth.coulomb.social (password; MFA via KeyCape path
when using OIDC apps — ForwardAuth to ops hosts uses Authelia session).
4. Open https://activity.coulomb.social and https://temporal.coulomb.social —
both should pass only for group members.
5. Offboard: remove from `activity-core-operators` first; full account offboard
remains the platform lifecycle path.
## Break-glass (independent of this group)
activity-core ClusterIP port-forward + `ACTIVITY_CORE_OPERATOR_TOKEN` does **not**
use Authelia groups. Documented in activity-core runbooks. Keep that path for
SSO outages; do not gate break-glass on LLDAP group membership.
## Adding a future app-operator group
1. Add the group name to `REQUIRED_GROUPS` in `bootstrap-users.sh`.
2. Document it in this table.
3. Add Authelia domain rules (member `one_factor` + domain `deny` fallback) in
`sso-mfa/k8s/authelia/configmap.yaml` — see Authelia README.
4. Never invent a KeyCape OIDC client solely for Traefik ForwardAuth apps.

View file

@ -33,7 +33,14 @@ kubectl rollout status deployment/lldap -n sso --timeout=120s
## Post-deploy bootstrap
After the pod is Running, create the two required application groups via the web UI:
After the pod is Running, seed required groups (preferred — idempotent):
```bash
./bootstrap-users.sh
# Groups: net-kingdom-users, net-kingdom-admins, activity-core-operators
```
Or create them via the web UI:
```
https://lldap.coulomb.social
@ -41,9 +48,13 @@ Username: admin
Password: LLDAP_LDAP_USER_PASS (from KeePassXC → net-kingdom/LLDAP/admin)
```
Create groups:
- `net-kingdom-users` — standard users
- `net-kingdom-admins` — privileged users (enforce MFA step-up in KeyCape policies)
| Group | Purpose |
|-------|---------|
| `net-kingdom-users` | Standard users (all human accounts) |
| `net-kingdom-admins` | Privileged users (KeyCape MFA step-up / extra scopes) |
| `activity-core-operators` | App-scoped: browser SSO to activity-core ops + Temporal UI (Authelia domain rules). **Not** platform admin — grant consciously. |
Operator membership (add/remove humans): see **[OPERATOR-GROUPS.md](./OPERATOR-GROUPS.md)**.
## Ports

View file

@ -5,14 +5,17 @@
#
# What it does:
# 1. Authenticates to LLDAP via its GraphQL API.
# 2. Creates the two required groups: net-kingdom-users, net-kingdom-admins.
# 2. Creates required platform + app-operator groups (idempotent).
# 3. Prints a user onboarding checklist (groups-only; individual users are
# added via the WebUI or by re-running this script with USER_EMAIL set).
# added via the WebUI, create-user.sh, or manage-group-members.sh).
#
# Groups created:
# net-kingdom-users — standard users; all human accounts go here.
# net-kingdom-admins — privileged users; KeyCape policies can enforce
# MFA step-up or grant extra scopes to this group.
# net-kingdom-users — standard users; all human accounts go here.
# net-kingdom-admins — privileged users; KeyCape policies can enforce
# MFA step-up or grant extra scopes to this group.
# activity-core-operators — app-scoped: browser access to activity-core
# ops console + Temporal UI (Authelia domain rules).
# Not platform admin — grant consciously (NK-WP-0021).
#
# Usage:
# ./bootstrap-users.sh [lldap-url] [secrets-dir]
@ -120,15 +123,22 @@ create_group() {
}
# ── 2. Create required groups ─────────────────────────────────────────────────
create_group "net-kingdom-users"
create_group "net-kingdom-admins"
REQUIRED_GROUPS=(
"net-kingdom-users"
"net-kingdom-admins"
"activity-core-operators"
)
for grp in "${REQUIRED_GROUPS[@]}"; do
create_group "$grp"
done
# ── 3. Verify ─────────────────────────────────────────────────────────────────
echo ""
echo "Verifying groups ..."
LIST_RESP=$(gql 'query { groups { id displayName } }')
if [[ "$LIST_RESP" != "CURL_FAILED" ]]; then
for grp in "net-kingdom-users" "net-kingdom-admins"; do
for grp in "${REQUIRED_GROUPS[@]}"; do
EXISTS=$(echo "$LIST_RESP" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print('yes' if any(g['displayName']=='$grp' for g in d.get('data',{}).get('groups',[])) else 'no')" \
2>/dev/null || echo "no")
@ -148,7 +158,8 @@ echo "════════════════════════
echo " LLDAP group bootstrap: PASS=$PASS_COUNT FAIL=$FAIL_COUNT"
echo "════════════════════════════════════════════════════════════"
echo ""
echo "Next: add users via the LLDAP WebUI or LDAP provisioning."
echo "Next: add users via the LLDAP WebUI, create-user.sh, or manage-group-members.sh."
echo "App-operator membership: see OPERATOR-GROUPS.md (do not auto-grant all admins)."
echo ""
echo "User onboarding checklist:"
echo ""
@ -157,9 +168,11 @@ echo " 1. Create account in LLDAP WebUI ($LLDAP_URL)"
echo " Fields: username (uid), display name, email"
echo " 2. Assign to net-kingdom-users group (mandatory)"
echo " Assign to net-kingdom-admins too if privileged access is needed"
echo " 3. User logs in to Authelia (auth.coulomb.social) to verify their password"
echo " 4. User self-enrolls TOTP at pink-account.coulomb.social"
echo " 5. User tests end-to-end login via an OIDC-protected application"
echo " 3. For activity-core ops/Temporal UI: also add to activity-core-operators"
echo " (./manage-group-members.sh add <uid> activity-core-operators)"
echo " 4. User logs in to Authelia (auth.coulomb.social) to verify their password"
echo " 5. User self-enrolls TOTP at pink-account.coulomb.social"
echo " 6. User tests end-to-end login via an OIDC-protected application"
echo ""
echo " Break-glass account:"
echo " Run: sso-mfa/k8s/lldap/break-glass.sh"

View file

@ -51,7 +51,7 @@ echo "Done. Secret lldap-secrets created in namespace: sso"
echo ""
echo "Next:"
echo " Apply manifests (see README.md apply order)."
echo " After LLDAP is Running, create application groups:"
echo " - Log in to https://lldap.coulomb.social with the admin account."
echo " - Create group: net-kingdom-users"
echo " - Create group: net-kingdom-admins"
echo " After LLDAP is Running, seed groups:"
echo " ./bootstrap-users.sh"
echo " Groups: net-kingdom-users, net-kingdom-admins, activity-core-operators"
echo " Membership runbook: OPERATOR-GROUPS.md"

View file

@ -0,0 +1,179 @@
#!/usr/bin/env bash
# manage-group-members.sh — add/remove/list LLDAP group membership (NK-WP-0021)
#
# Usage:
# ./manage-group-members.sh add <uid> <group> [lldap-url] [secrets-dir]
# ./manage-group-members.sh remove <uid> <group> [lldap-url] [secrets-dir]
# ./manage-group-members.sh list <group> [lldap-url] [secrets-dir]
#
# Auth: LLDAP_LDAP_USER_PASS from secrets-dir/lldap/secrets.env, or env
# LLDAP_ADMIN_PASS (e.g. from kubectl secret for live ops).
#
# Examples:
# ./manage-group-members.sh add bernd activity-core-operators
# ./manage-group-members.sh list activity-core-operators
set -euo pipefail
ACTION="${1:-}"
shift || true
LLDAP_URL="https://lldap.coulomb.social"
SECRETS_DIR="../../bootstrap/secrets"
usage() {
sed -n '2,15p' "$0" | sed 's/^# \?//'
exit 1
}
case "$ACTION" in
add|remove)
USERNAME="${1:-}"
GROUP_NAME="${2:-}"
[[ -z "$USERNAME" || -z "$GROUP_NAME" ]] && usage
shift 2 || true
;;
list)
GROUP_NAME="${1:-}"
[[ -z "$GROUP_NAME" ]] && usage
shift 1 || true
USERNAME=""
;;
*)
usage
;;
esac
[[ -n "${1:-}" ]] && LLDAP_URL="$1"
[[ -n "${2:-}" ]] && SECRETS_DIR="$2"
LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env"
LLDAP_ADMIN_PASS="${LLDAP_ADMIN_PASS:-}"
if [[ -z "$LLDAP_ADMIN_PASS" ]]; then
if [[ ! -f "$LLDAP_ENV" ]]; then
echo "ERROR: $LLDAP_ENV not found and LLDAP_ADMIN_PASS unset." >&2
exit 1
fi
LLDAP_ADMIN_PASS=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \"\${LLDAP_LDAP_USER_PASS:-}\"")
fi
if [[ -z "$LLDAP_ADMIN_PASS" ]]; then
echo "ERROR: empty LLDAP admin password" >&2
exit 1
fi
echo "Authenticating to LLDAP at $LLDAP_URL ..."
AUTH_RESP=$(curl -sS -X POST "$LLDAP_URL/auth/simple/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}")
LLDAP_TOKEN=$(echo "$AUTH_RESP" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
if [[ -z "$LLDAP_TOKEN" ]]; then
echo "ERROR: Authentication failed" >&2
exit 1
fi
# Build GraphQL POST body without shell-expanding $variables inside the query.
gql_post() {
local query_file="$1"
local vars_json="$2"
python3 - "$LLDAP_URL" "$LLDAP_TOKEN" "$query_file" "$vars_json" <<'PY'
import json, sys, urllib.request
url, token, qpath, vars_s = sys.argv[1:5]
query = open(qpath, encoding="utf-8").read()
body = json.dumps({"query": query, "variables": json.loads(vars_s)}).encode()
req = urllib.request.Request(
url.rstrip("/") + "/api/graphql",
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
print(resp.read().decode())
PY
}
TMPDIR_GQL=$(mktemp -d)
trap 'rm -rf "$TMPDIR_GQL"' EXIT
cat >"$TMPDIR_GQL/list.gql" <<'EOF'
query { groups { id displayName users { id displayName email } } }
EOF
GROUPS_JSON=$(gql_post "$TMPDIR_GQL/list.gql" '{}')
GROUP_ID=$(echo "$GROUPS_JSON" | python3 -c "
import sys, json
name = sys.argv[1]
d = json.load(sys.stdin)
for g in d.get('data', {}).get('groups', []) or []:
if g.get('displayName') == name:
print(g['id'])
break
" "$GROUP_NAME")
if [[ -z "$GROUP_ID" ]]; then
echo "ERROR: group '$GROUP_NAME' not found — run bootstrap-users.sh first" >&2
exit 1
fi
case "$ACTION" in
list)
echo "$GROUPS_JSON" | python3 -c "
import sys, json
name = sys.argv[1]
d = json.load(sys.stdin)
for g in d.get('data', {}).get('groups', []) or []:
if g.get('displayName') == name:
users = g.get('users') or []
print(f\"Group {name} (id={g.get('id')}): {len(users)} member(s)\")
for u in users:
print(f\" - {u.get('id')} {u.get('displayName') or ''} {u.get('email') or ''}\")
break
" "$GROUP_NAME"
;;
add)
cat >"$TMPDIR_GQL/add.gql" <<'EOF'
mutation AddToGroup($userId: String!, $groupId: Int!) {
addUserToGroup(userId: $userId, groupId: $groupId) { ok }
}
EOF
VARS=$(python3 -c "import json,sys; print(json.dumps({'userId':sys.argv[1],'groupId':int(sys.argv[2])}))" "$USERNAME" "$GROUP_ID")
RESP=$(gql_post "$TMPDIR_GQL/add.gql" "$VARS")
echo "$RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if d.get('errors'):
msg = d['errors'][0].get('message', str(d['errors']))
if 'UNIQUE constraint' in msg or 'already' in msg.lower():
print('OK: already a member (no-op)')
sys.exit(0)
print('ERROR:', msg, file=sys.stderr)
sys.exit(1)
print('OK: added user to group')
"
echo " user=$USERNAME group=$GROUP_NAME (id=$GROUP_ID)"
;;
remove)
cat >"$TMPDIR_GQL/remove.gql" <<'EOF'
mutation RemoveFromGroup($userId: String!, $groupId: Int!) {
removeUserFromGroup(userId: $userId, groupId: $groupId) { ok }
}
EOF
VARS=$(python3 -c "import json,sys; print(json.dumps({'userId':sys.argv[1],'groupId':int(sys.argv[2])}))" "$USERNAME" "$GROUP_ID")
RESP=$(gql_post "$TMPDIR_GQL/remove.gql" "$VARS")
echo "$RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if d.get('errors'):
print('ERROR:', d['errors'][0].get('message', d['errors']), file=sys.stderr)
sys.exit(1)
print('OK: removed user from group')
"
echo " user=$USERNAME group=$GROUP_NAME (id=$GROUP_ID)"
;;
esac

View file

@ -0,0 +1,286 @@
---
id: NK-WP-0021
type: workplan
title: "Activity-core ops/Temporal UI least-privilege SSO"
domain: infotech
repo: net-kingdom
status: finished
owner: codex
topic_slug: netkingdom
priority: high
created: "2026-07-22"
updated: "2026-07-22"
origin: "intake:019f88ff-c5f1-7433-9d13-4ffe37dfef00+019f88ff-cc9e-76b4-8ce3-eb354aabefe7"
origin_ref: "ACTIVITY-WP-0025-T06"
state_hub_workstream_id: "3e40e29a-a13a-47b6-9575-4fdc7024af6a"
---
# NK-WP-0021 — Activity-core ops/Temporal UI least-privilege SSO
## Goal
Close the residual least-privilege gap for public activity-core operator UIs
by owning the **directory group** and **Authelia access rules** in this repo
(SSO/MFA stack). Browser access to `activity.coulomb.social` and
`temporal.coulomb.social` should require membership in
`activity-core-operators`, not merely “any authenticated Authelia user”.
## Origin and review
| Intake | Hub UUID | Title | Decision |
| --- | --- | --- | --- |
| NK-IN-0001 | `019f88ff-c5f1-7433-9d13-4ffe37dfef00` | LLDAP group + membership runbook | **Accept** → T01T02 |
| NK-IN-0002 | `019f88ff-cc9e-76b4-8ce3-eb354aabefe7` | Authelia domain rules | **Accept** → T03T04 |
Cross-repo handoff from activity-core `ACTIVITY-WP-0025-T06` (residual wait).
Source design: `activity-core/docs/ops-sso-access.md`. File intakes:
`docs/intakes/activity-core-ops-sso-operators.md`.
### Scope / intent fit
| Lens | Assessment |
| --- | --- |
| **SCOPE in** | SSO/MFA platform (Authelia + LLDAP), identity as control plane, least-privilege access for platform operators |
| **SCOPE out** | activity-core Ingress/middleware (already live), KeyCape OIDC clients, Temporal/API application code, railiance runtime |
| **INTENT** | Principle 2 (identity is the control plane) and 6 (minimize exposure / least privilege from the start). Progressive capability: tighten MVP “any Authelia user” to named operator group without changing auth edge pattern |
| **Repo boundary** | net-kingdom owns LLDAP groups + Authelia `access_control`; activity-core owns ForwardAuth middleware, break-glass token, and app authorization |
### Refinements vs raw intakes
1. **Single workplan** — both intakes share one deploy surface (LLDAP → Authelia) and a hard dependency (group must exist before domain rules). One plan avoids split status and double-apply risk.
2. **No KeyCape / Keycloak OIDC clients** — explicit non-goal. Path is Traefik ForwardAuth → Authelia `/api/verify` only (already shipped in activity-core).
3. **MFA posture unchanged** — keep Authelia `one_factor`; MFA remains KeyCape + privacyIDEA. Do not set Authelia `two_factor` for these hosts.
4. **App-scoped operator group pattern**`activity-core-operators` extends the existing `net-kingdom-users` / `net-kingdom-admins` seed style for a *consumer app*, not platform-wide admin. Document as the template for future app operator groups.
5. **Membership is operator policy** — scripts create the empty group and document add/remove; named founder assignment is a live operator step (not committed identity).
6. **Verification matrix** — unauthenticated → login; authenticated non-member → deny; member → pass; break-glass remains independent of Authelia groups (activity-core runbook).
7. **Default policy stays open for other hosts** — only the two public activity-core hosts get restrictive domain rules; do not change global `default_policy: one_factor` for the rest of the fleet without a separate decision.
## Context (current state)
- Authelia SSO edge for both hosts is **live** (activity-core manifests 3032).
- Authelia `access_control` today: `default_policy: one_factor`, **no** domain rules
(`sso-mfa/k8s/authelia/configmap.yaml`).
- LLDAP bootstrap seeds only `net-kingdom-users` and `net-kingdom-admins`
(`sso-mfa/k8s/lldap/bootstrap-users.sh`).
- Any authenticated Authelia user can currently open both public UIs.
## In scope
- Create LLDAP group `activity-core-operators` (bootstrap script + README).
- Document how operators add/remove humans (WebUI + scripted GraphQL path).
- Authelia domain rules for `activity.coulomb.social` and
`temporal.coulomb.social` requiring that group.
- Apply/reload Authelia; verify member / non-member / unauthenticated paths.
- Close intakes as promoted; notify activity-core so T06 can finish.
## Out of scope
- New OIDC clients in KeyCape or Keycloak for these hosts.
- Changing activity-core Ingress, middleware, or ops API authorization.
- Platform-root / break-glass LLDAP accounts.
- Enterprise federation / SAML (NK-WP-0011) or tutorials backlog (NK-WP-0009).
- Automatically granting named users without operator confirmation.
## Acceptance (workplan-level)
- [x] Group `activity-core-operators` exists in LLDAP and is re-seeded by bootstrap.
- [x] Membership procedure is documented under `sso-mfa` docs (and README pointers).
- [x] Authelia denies non-members and allows members on both hosts after login
(`authelia access-control check-policy` on live config 2026-07-22).
- [x] Unauthenticated users still hit the Authelia login portal (not open backends)
(live 302 → `auth.coulomb.social` for both hosts).
- [x] activity-core break-glass remains independent of Authelia group membership
(unchanged; documented in OPERATOR-GROUPS.md).
- [x] Intakes NK-IN-0001 / NK-IN-0002 closed as promoted to this workplan.
### Live verification evidence (2026-07-22)
| Check | Result |
| --- | --- |
| LLDAP group `activity-core-operators` | id=7; seeded live; bootstrap script updated |
| Initial member | `platform-root` (conscious bootstrap grant) |
| Unauth `activity` / `temporal` | HTTP 302 → Authelia login |
| `check-policy` member + group | rule #1 `one_factor` (activity); rule #3 temporal |
| `check-policy` auth non-member | rule #2 / #4 `deny` |
| `check-policy` other host (`kc`) | default `one_factor` (fleet unaffected) |
| Authelia rollout | v4.38.19 Startup complete with rules loaded |
---
## Tasks
### T01 — LLDAP group `activity-core-operators` in bootstrap
```task
id: NK-WP-0021-T01
status: done
priority: high
state_hub_task_id: "50d59099-d3df-4087-93ef-9a4e537807b8"
```
Extend LLDAP group seeding so the operator group is created the same way as
platform groups:
- Update `sso-mfa/k8s/lldap/bootstrap-users.sh` to create
`activity-core-operators` (idempotent `create_group`).
- Update `sso-mfa/k8s/lldap/README.md` and post-deploy hints in
`create-secrets.sh` to list the third group and its purpose
(activity-core ops console + Temporal UI only — not platform admin).
- Optional small helper or flag on `create-user.sh` only if it stays simple
(e.g. documented GraphQL one-liner is enough for v1).
**Done when:** re-running bootstrap is safe/idempotent and the group exists
(or will exist on next bootstrap) with clear docs.
**Promotes:** NK-IN-0001 (group half).
---
### T02 — Operator membership runbook
```task
id: NK-WP-0021-T02
status: done
priority: high
state_hub_task_id: "e8d6dae5-257f-4862-98de-ab4cef6cc722"
```
Document least-privilege membership for founders/operators only:
- WebUI path: `https://lldap.coulomb.social` → add/remove group members.
- Scripted path: GraphQL `addUserToGroup` / `removeUserFromGroup` using the
same auth pattern as `bootstrap-users.sh` / `break-glass.sh` (no secrets in
git or workplan).
- Explicit: do **not** auto-add all `net-kingdom-admins`; membership is a
conscious grant for activity-core ops.
- Point activity-core operators at this runbook from a short note in
`docs/intakes/activity-core-ops-sso-operators.md` (or a thin
`sso-mfa/k8s/lldap/OPERATOR-GROUPS.md` if the README grows too large).
**Done when:** an operator can add and remove a test user without reading
activity-core internals.
**Promotes:** NK-IN-0001 (runbook half).
---
### T03 — Authelia domain rules for activity + temporal hosts
```task
id: NK-WP-0021-T03
status: done
priority: high
state_hub_task_id: "71bfd7ea-1637-4c1a-ac3b-dc8dca38b82f"
```
Implement access control in the live Authelia ConfigMap:
- File: `sso-mfa/k8s/authelia/configmap.yaml`
- Keep `default_policy: one_factor` for the rest of the fleet.
- Add `rules` (or equivalent for the deployed Authelia version) for:
- `activity.coulomb.social`
- `temporal.coulomb.social`
- Require subject group `activity-core-operators` with policy `one_factor`
(Authelia subject syntax: `subject: "group:activity-core-operators"`
confirm against the running Authelia major version before apply).
- Comment in-file: MFA remains KeyCape/privacyIDEA; do not use Authelia
`two_factor` here.
- Update `sso-mfa/k8s/authelia/README.md` with the new domain-rule pattern so
the next app can copy it.
**Depends on:** T01 (group name must resolve from LLDAP via Authelias LDAP
backend). Rules may be committed before the group exists, but live apply of
enforcement should wait until the group is present.
**Done when:** config is reviewed and ready to apply; no accidental lockdown
of unrelated hosts (auth portal, KeyCape, LLDAP admin, etc.).
**Promotes:** NK-IN-0002 (config half).
---
### T04 — Live apply and verification matrix
```task
id: NK-WP-0021-T04
status: done
priority: high
state_hub_task_id: "f74a2a59-f126-45e3-832a-5574bd0feb6f"
```
Roll out and prove least privilege on the live railiance01 SSO stack:
1. Ensure group exists (run bootstrap if needed) and assign **one** known
operator (operator-confirmed username).
2. Apply Authelia ConfigMap; restart/reload Authelia in namespace `sso`.
3. Verification matrix (record non-secret results in progress notes):
| Case | Expected |
| --- | --- |
| Unauthenticated → both hosts | Redirect to Authelia login |
| Authenticated **non-member** | Denied (403 / Authelia unauthorized) |
| Authenticated **member** | Pass-through to ops UI and Temporal UI |
| Break-glass port-forward + operator token | Still works per activity-core runbook |
4. If verification fails, roll back ConfigMap to previous `access_control`
and re-open the blocking task — do not leave partial rules that lock out
all operators without a recovery path.
**Done when:** matrix passes for both hosts; recovery path still documented.
**Promotes:** NK-IN-0002 (verify half).
---
### T05 — Close loop with intakes and activity-core
```task
id: NK-WP-0021-T05
status: done
priority: medium
state_hub_task_id: "a1acd507-9532-45ea-bb08-387b11a96868"
```
- Keep intake source file statuses in sync (`promoted` / closed) after hub
close (done at workplan registration for the intake close step; re-check
if reopened).
- Message `activity-core` (State Hub) that NK-WP-0021 covers residual T06 and
can move T06 to done once T04 verification lands.
- Brief progress event on finish.
**Done when:** activity-core residual task has a clear unblock path and this
workplans hub registration is consistent.
**2026-07-22:** Intakes closed promoted; activity-core notified at registration
and again on finish. Residual ACTIVITY-WP-0025-T06 unblocked.
---
## Implementation notes
### Suggested Authelia rule shape (confirm version)
```yaml
access_control:
default_policy: one_factor
rules:
- domain: activity.coulomb.social
subject: "group:activity-core-operators"
policy: one_factor
- domain: temporal.coulomb.social
subject: "group:activity-core-operators"
policy: one_factor
```
If Authelia requires an explicit deny for non-matching subjects on those
domains, use the versions documented pattern (subject rules are exclusive
when matched; default_policy must not re-open the host). Prefer a
deny-by-default domain rule only if the running version needs it — verify
in staging or with a short maintenance window.
### Non-goals reminder
Do **not** invent KeyCape OIDC clients, oauth2-proxy, or Keycloak realm
clients for this path. activity-core already uses ForwardAuth headers
(`Remote-User`, `Remote-Groups`, …).
)