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.
This commit is contained in:
tegwick 2026-07-22 15:47:26 +02:00
parent 3580e43e5f
commit a9aec541ec
8 changed files with 384 additions and 33 deletions

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

@ -4,7 +4,7 @@ type: workplan
title: "Activity-core ops/Temporal UI least-privilege SSO"
domain: infotech
repo: net-kingdom
status: ready
status: finished
owner: codex
topic_slug: netkingdom
priority: high
@ -83,13 +83,27 @@ Source design: `activity-core/docs/ops-sso-access.md`. File intakes:
## Acceptance (workplan-level)
- [ ] Group `activity-core-operators` exists in LLDAP and is re-seeded by bootstrap.
- [ ] Membership procedure is documented under `sso-mfa` docs (and README pointers).
- [ ] Authelia denies non-members and allows members on both hosts after login.
- [ ] Unauthenticated users still hit the Authelia login portal (not open backends).
- [ ] activity-core break-glass (port-forward + operator token) remains valid and
independent of Authelia group membership.
- [ ] Intakes NK-IN-0001 / NK-IN-0002 closed as promoted to this workplan.
- [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 |
---
@ -99,7 +113,7 @@ Source design: `activity-core/docs/ops-sso-access.md`. File intakes:
```task
id: NK-WP-0021-T01
status: todo
status: done
priority: high
state_hub_task_id: "50d59099-d3df-4087-93ef-9a4e537807b8"
```
@ -126,7 +140,7 @@ platform groups:
```task
id: NK-WP-0021-T02
status: todo
status: done
priority: high
state_hub_task_id: "e8d6dae5-257f-4862-98de-ab4cef6cc722"
```
@ -154,7 +168,7 @@ activity-core internals.
```task
id: NK-WP-0021-T03
status: todo
status: done
priority: high
state_hub_task_id: "71bfd7ea-1637-4c1a-ac3b-dc8dca38b82f"
```
@ -189,7 +203,7 @@ of unrelated hosts (auth portal, KeyCape, LLDAP admin, etc.).
```task
id: NK-WP-0021-T04
status: todo
status: done
priority: high
state_hub_task_id: "f74a2a59-f126-45e3-832a-5574bd0feb6f"
```
@ -222,7 +236,7 @@ Roll out and prove least privilege on the live railiance01 SSO stack:
```task
id: NK-WP-0021-T05
status: todo
status: done
priority: medium
state_hub_task_id: "a1acd507-9532-45ea-bb08-387b11a96868"
```
@ -237,6 +251,9 @@ state_hub_task_id: "a1acd507-9532-45ea-bb08-387b11a96868"
**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