diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 1c83cd9..d702755 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/intakes/activity-core-ops-sso-operators.md b/docs/intakes/activity-core-ops-sso-operators.md index 84d31b5..b3db1a5 100644 --- a/docs/intakes/activity-core-ops-sso-operators.md +++ b/docs/intakes/activity-core-ops-sso-operators.md @@ -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 \ - --to workplan \ - --repo-path ~/net-kingdom \ - --repo-slug net-kingdom \ - --domain communication -# or --to task --workplan-file workplans/.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 (T01–T02). 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 (T03–T04). 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/30–32). 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" ``` diff --git a/sso-mfa/k8s/authelia/README.md b/sso-mfa/k8s/authelia/README.md index 317bad1..83f154a 100644 --- a/sso-mfa/k8s/authelia/README.md +++ b/sso-mfa/k8s/authelia/README.md @@ -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 Authelia’s 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 diff --git a/sso-mfa/k8s/authelia/configmap.yaml b/sso-mfa/k8s/authelia/configmap.yaml index 25949d2..f0fe4a4 100644 --- a/sso-mfa/k8s/authelia/configmap.yaml +++ b/sso-mfa/k8s/authelia/configmap.yaml @@ -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. diff --git a/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md b/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md new file mode 100644 index 0000000..68f6c5f --- /dev/null +++ b/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md @@ -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 activity-core-operators + +# Remove +./manage-group-members.sh remove 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. diff --git a/sso-mfa/k8s/lldap/README.md b/sso-mfa/k8s/lldap/README.md index 10b18f6..d734b02 100644 --- a/sso-mfa/k8s/lldap/README.md +++ b/sso-mfa/k8s/lldap/README.md @@ -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 diff --git a/sso-mfa/k8s/lldap/bootstrap-users.sh b/sso-mfa/k8s/lldap/bootstrap-users.sh index 97a2dcb..d09e9ae 100755 --- a/sso-mfa/k8s/lldap/bootstrap-users.sh +++ b/sso-mfa/k8s/lldap/bootstrap-users.sh @@ -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 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" diff --git a/sso-mfa/k8s/lldap/create-secrets.sh b/sso-mfa/k8s/lldap/create-secrets.sh index 46d0b71..132a5e0 100644 --- a/sso-mfa/k8s/lldap/create-secrets.sh +++ b/sso-mfa/k8s/lldap/create-secrets.sh @@ -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" diff --git a/sso-mfa/k8s/lldap/manage-group-members.sh b/sso-mfa/k8s/lldap/manage-group-members.sh new file mode 100755 index 0000000..7abf5bd --- /dev/null +++ b/sso-mfa/k8s/lldap/manage-group-members.sh @@ -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 [lldap-url] [secrets-dir] +# ./manage-group-members.sh remove [lldap-url] [secrets-dir] +# ./manage-group-members.sh list [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 diff --git a/workplans/NK-WP-0021-activity-core-ops-sso-operators.md b/workplans/NK-WP-0021-activity-core-ops-sso-operators.md new file mode 100644 index 0000000..e066d10 --- /dev/null +++ b/workplans/NK-WP-0021-activity-core-ops-sso-operators.md @@ -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** → T01–T02 | +| NK-IN-0002 | `019f88ff-cc9e-76b4-8ce3-eb354aabefe7` | Authelia domain rules | **Accept** → T03–T04 | + +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 30–32). +- 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 Authelia’s 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 +workplan’s 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 version’s 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`, …). +)