diff --git a/sso-mfa/WORKPLAN.md b/sso-mfa/WORKPLAN.md index e070d66..cd3f90e 100644 --- a/sso-mfa/WORKPLAN.md +++ b/sso-mfa/WORKPLAN.md @@ -1,7 +1,7 @@ # SSO-MFA Platform — Stack Migration Workplan # NK-WP-0001 — Keycloak → Authelia + LLDAP + KeyCape -**Updated:** 2026-03-19 +**Updated:** 2026-03-19 (T06 in progress) **Workstream:** sso-mfa-platform (39263c4b-ef70-4053-b782-350834b7e1be) ## Stack Decision @@ -22,8 +22,8 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co | T02 — K8s foundations | 721ca6b2 | done | Manifests authored; pending live cluster | | T03 — PostgreSQL | 7fa60004 | done | Manifests authored; pending live cluster | | T04 — privacyIDEA | 6ad1296a | **todo** | Manifests exist in k8s/privacyidea/; pending cluster | -| T05 — SSO core (new stack) | b9f73aa6 | **in-progress** | See below | -| T06 — Realm config & MFA flow | 3b6379a4 | todo | | +| T05 — SSO core (new stack) | b9f73aa6 | done | commit 0754dc3 | +| T06 — Realm config & MFA flow | 3b6379a4 | **in-progress** | See below | | T07 — User mgmt & self-service | c7cf902a | todo | | | T08 — Backups, DR, break-glass | 9cbd1d89 | todo | | @@ -53,3 +53,26 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co - gen-secrets.sh generates correct secrets for new stack - verify-t05.sh checks all three components - Committed to main + +## T06 — Realm config & MFA flow (KeyCape → privacyIDEA) + +### Deliverables +- [x] `k8s/privacyidea/bootstrap-realm.sh` — creates LLDAP resolver, "netkingdom" realm, enrollment + passthru policies +- [x] `k8s/verify-t06.sh` — verifies realm, resolver, KeyCape→PI token, connectivity + +### In Progress (this session) +- [ ] Run `bootstrap-realm.sh` on live cluster (requires T04 applied) +- [ ] Run `keycape/create-pi-token.sh` then `keycape/create-secrets.sh` (inject real PI token) +- [ ] Restart KeyCape with updated keycape-config +- [ ] Enroll a TOTP token for pi-admin via pink-account.coulomb.social +- [ ] Test end-to-end login via kc.coulomb.social +- [ ] Run `verify-t06.sh` — all checks pass +- [ ] Commit and mark T06 done + +### Done-criteria for T06 +- privacyIDEA "netkingdom" realm exists with LLDAP resolver +- LDAP resolver resolves users from LLDAP +- keycape-pi-token contains a real (non-placeholder) JWT +- KeyCape→privacyIDEA token list API returns status=True +- At least one user has enrolled a TOTP token +- verify-t06.sh: 0 FAILs diff --git a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh new file mode 100755 index 0000000..820acd0 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# bootstrap-realm.sh — configure the "netkingdom" realm in privacyIDEA +# +# Run AFTER bootstrap-admin.sh (pi-admin must exist and have MFA enrolled). +# +# What it does: +# 1. Authenticates as pi-admin to get a short-lived JWT. +# 2. Creates the LDAP resolver "lldap-netkingdom" pointing to the in-cluster LLDAP. +# 3. Creates (or updates) the "netkingdom" realm using that resolver. +# 4. Creates a self-enrollment policy: any authenticated user may enroll TOTP. +# 5. Prints a checklist of manual steps to complete via the WebUI. +# +# This script is idempotent: re-running it will update existing objects. +# +# Usage: +# ./bootstrap-realm.sh [secrets-dir] [pi-url] [lldap-bind-pass] +# +# default: ../../bootstrap/secrets +# default: https://pink.coulomb.social +# default: read from secrets-dir/lldap/secrets.env +# +# The script exits 0 if all steps succeed, non-zero otherwise. +# Partial failures are reported but do not abort subsequent steps. + +set -euo pipefail + +NAMESPACE="mfa" +SECRETS_DIR="${1:-../../bootstrap/secrets}" +PI_URL="${2:-https://pink.coulomb.social}" +PI_ENV="$SECRETS_DIR/privacyidea/secrets.env" +LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" + +RESOLVER_NAME="lldap-netkingdom" +REALM_NAME="netkingdom" +LLDAP_URL="ldap://lldap.sso.svc.cluster.local:3890" +LLDAP_BASE_DN="dc=netkingdom,dc=local" +LLDAP_BIND_DN="uid=admin,ou=people,dc=netkingdom,dc=local" + +PASS_COUNT=0 +FAIL_COUNT=0 + +ok() { echo " [OK] $1"; ((PASS_COUNT++)); } +fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); } +info() { echo " [INFO] $1"; } + +# ── Validate secrets ────────────────────────────────────────────────────────── +for f in "$PI_ENV" "$LLDAP_ENV"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: $f not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2 + exit 1 + fi +done + +read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } + +PI_ADMIN_PASS=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD) +LLDAP_BIND_PW="${3:-$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS)}" + +if [[ -z "$PI_ADMIN_PASS" ]]; then + echo "ERROR: PI_ADMIN_PASSWORD not found in $PI_ENV" >&2 + exit 1 +fi +if [[ -z "$LLDAP_BIND_PW" ]]; then + echo "ERROR: LLDAP_LDAP_USER_PASS not found in $LLDAP_ENV" >&2 + exit 1 +fi + +# ── Authenticate ────────────────────────────────────────────────────────────── +echo "" +echo "Authenticating to privacyIDEA at $PI_URL ..." +AUTH_RESPONSE=$(curl -sf -X POST "$PI_URL/auth" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" 2>/dev/null || echo "CURL_FAILED") + +if [[ "$AUTH_RESPONSE" == "CURL_FAILED" ]]; then + echo "ERROR: Could not reach $PI_URL — is the cluster up and privacyIDEA running?" >&2 + echo " Run verify-t04.sh to diagnose." >&2 + exit 1 +fi + +PI_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c \ + "import sys,json; print(json.load(sys.stdin)['result']['value']['token'])" 2>/dev/null || echo "") + +if [[ -z "$PI_TOKEN" ]]; then + echo "ERROR: Authentication failed — check pi-admin credentials and MFA enrollment." >&2 + echo " Response: $AUTH_RESPONSE" >&2 + exit 1 +fi +info "Authenticated as pi-admin (token obtained)" + +pi_api() { + # pi_api [json-body] + local method="$1"; local path="$2"; local body="${3:-}" + if [[ -n "$body" ]]; then + curl -sf -X "$method" "$PI_URL$path" \ + -H "Authorization: $PI_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$body" 2>/dev/null || echo "CURL_FAILED" + else + curl -sf -X "$method" "$PI_URL$path" \ + -H "Authorization: $PI_TOKEN" \ + -H "Content-Type: application/json" \ + 2>/dev/null || echo "CURL_FAILED" + fi +} + +check_result() { + local step="$1"; local resp="$2" + if [[ "$resp" == "CURL_FAILED" ]]; then + fail "$step — curl request failed" + return 1 + fi + local status + status=$(echo "$resp" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('status',''))" \ + 2>/dev/null || echo "") + if [[ "$status" == "True" || "$status" == "true" ]]; then + ok "$step" + return 0 + else + local err + err=$(echo "$resp" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('error',{}).get('message','unknown'))" \ + 2>/dev/null || echo "unknown") + fail "$step — $err" + return 1 + fi +} + +# ── 1. Create LDAP resolver ─────────────────────────────────────────────────── +echo "" +echo "Step 1: Creating LDAP resolver '$RESOLVER_NAME' ..." + +# USERINFO maps privacyIDEA fields to LLDAP/LDAP attributes. +# LLDAP uses standard inetOrgPerson attributes. +USERINFO='{"username": "uid", "phone": "telephoneNumber", "mobile": "mobile", "email": "mail", "surname": "sn", "givenname": "givenName"}' + +RESOLVER_BODY=$(python3 -c " +import json, sys +body = { + 'type': 'ldapresolver', + 'LDAPURI': '$(echo "$LLDAP_URL" | sed "s/'/'\\''/g")', + 'BINDDN': '$(echo "$LLDAP_BIND_DN" | sed "s/'/'\\''/g")', + 'BINDPW': sys.argv[1], + 'LDAPBASE': '$LLDAP_BASE_DN', + 'LOGINNAMEATTRIBUTE': 'uid', + 'LDAPSEARCHFILTER': '(objectClass=inetOrgPerson)', + 'LDAPFILTER': '(&(objectClass=inetOrgPerson)(uid=%s))', + 'USERINFO': json.dumps({\"username\": \"uid\", \"phone\": \"telephoneNumber\", \"mobile\": \"mobile\", \"email\": \"mail\", \"surname\": \"sn\", \"givenname\": \"givenName\"}), + 'UIDTYPE': 'uid', + 'NOREFERRALS': True, + 'NOSCHEMAS': True +} +print(json.dumps(body)) +" "$LLDAP_BIND_PW") + +RESP=$(pi_api POST "/resolver/$RESOLVER_NAME" "$RESOLVER_BODY") +check_result "LDAP resolver '$RESOLVER_NAME' created/updated" "$RESP" || true + +# ── 2. Test resolver ────────────────────────────────────────────────────────── +echo "" +echo "Step 2: Testing resolver '$RESOLVER_NAME' ..." +TEST_RESP=$(pi_api GET "/resolver/$RESOLVER_NAME") +if [[ "$TEST_RESP" != "CURL_FAILED" ]]; then + RESOLVER_TYPE=$(echo "$TEST_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); r=d.get('result',{}).get('value',{}).get('data',{}); print(list(r.values())[0].get('type','') if r else '')" \ + 2>/dev/null || echo "") + if [[ "$RESOLVER_TYPE" == "ldapresolver" ]]; then + ok "Resolver '$RESOLVER_NAME' exists and is type ldapresolver" + else + fail "Resolver '$RESOLVER_NAME' type unexpected: '$RESOLVER_TYPE'" + fi +else + fail "Could not retrieve resolver '$RESOLVER_NAME'" +fi + +# ── 3. Create realm ─────────────────────────────────────────────────────────── +echo "" +echo "Step 3: Creating realm '$REALM_NAME' ..." + +# privacyIDEA realm creation: POST /realm/ with resolvers list +REALM_BODY=$(python3 -c " +import json +body = { + 'resolvers': ['$RESOLVER_NAME'], + 'priority.$RESOLVER_NAME': 1 +} +print(json.dumps(body)) +") + +RESP=$(pi_api POST "/realm/$REALM_NAME" "$REALM_BODY") +check_result "Realm '$REALM_NAME' created/updated" "$RESP" || true + +# ── 4. Verify realm ─────────────────────────────────────────────────────────── +echo "" +echo "Step 4: Verifying realm '$REALM_NAME' ..." +REALM_RESP=$(pi_api GET "/realm/") +if [[ "$REALM_RESP" != "CURL_FAILED" ]]; then + REALM_EXISTS=$(echo "$REALM_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); realms=d.get('result',{}).get('value',{}); print('yes' if '$REALM_NAME' in realms else 'no')" \ + 2>/dev/null || echo "no") + if [[ "$REALM_EXISTS" == "yes" ]]; then + ok "Realm '$REALM_NAME' confirmed in realm list" + else + fail "Realm '$REALM_NAME' not found in realm list after creation" + fi +else + fail "Could not retrieve realm list" +fi + +# ── 5. Set default realm ────────────────────────────────────────────────────── +echo "" +echo "Step 5: Setting '$REALM_NAME' as the default realm ..." +RESP=$(pi_api POST "/defaultrealm/$REALM_NAME") +check_result "Default realm set to '$REALM_NAME'" "$RESP" || true + +# ── 6. Create self-enrollment policy ───────────────────────────────────────── +echo "" +echo "Step 6: Creating self-enrollment policy ..." +# Allows users in the netkingdom realm to self-enroll TOTP tokens. +# The WebUI self-service portal is at pink-account.coulomb.social. +ENROLL_POLICY=$(python3 -c " +import json +body = { + 'scope': 'enrollment', + 'action': 'enrollTOTP, delete, disable', + 'realm': '$REALM_NAME', + 'user': '*', + 'client': '', + 'adminrealm': '', + 'priority': 1, + 'active': True +} +print(json.dumps(body)) +") + +RESP=$(pi_api POST "/policy/totp-self-enrollment" "$ENROLL_POLICY") +check_result "Policy 'totp-self-enrollment' created" "$RESP" || true + +# ── 7. Create authentication policy (fail open for token-less users) ────────── +echo "" +echo "Step 7: Creating authentication policy ..." +# passthru: users without an enrolled token are allowed through (password only). +# This enables a phased MFA rollout: enroll first, enforce later. +# To enforce MFA for all users, remove 'passthru' from the action list and +# add 'otppin=tokenpin' instead — but ONLY after all users have enrolled a token. +AUTH_POLICY=$(python3 -c " +import json +body = { + 'scope': 'authentication', + 'action': 'passthru', + 'realm': '$REALM_NAME', + 'user': '*', + 'client': '', + 'adminrealm': '', + 'priority': 1, + 'active': True +} +print(json.dumps(body)) +") + +RESP=$(pi_api POST "/policy/mfa-passthru-phase1" "$AUTH_POLICY") +check_result "Policy 'mfa-passthru-phase1' created (passthru for token-less users)" "$RESP" || true + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════" +echo " Realm bootstrap: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" +echo "════════════════════════════════════════════════════════════" +echo "" + +if [[ "$FAIL_COUNT" -gt 0 ]]; then + echo "INCOMPLETE — resolve FAIL items above before proceeding." + echo "" +fi + +echo "Manual steps required after this script:" +echo "" +echo " 1. Verify the LDAP resolver resolves users:" +echo " WebUI → Config → Resolver → $RESOLVER_NAME → [test] → enter a username" +echo " A user should resolve from LLDAP. If not, check the LLDAP bind password." +echo "" +echo " 2. Log in to the self-service portal as a regular user:" +echo " https://pink-account.coulomb.social" +echo " Enroll a TOTP token (use Google Authenticator or a hardware key)." +echo "" +echo " 3. Test MFA via KeyCape:" +echo " Visit an application protected by kc.coulomb.social." +echo " After password auth (Authelia), you should see a TOTP challenge." +echo " Enter the OTP. If it passes, T06 is complete." +echo "" +echo " 4. Phase 2 — enforce MFA (after all users have enrolled):" +echo " WebUI → Config → Policies → mfa-passthru-phase1 → set active=False" +echo " Create a new policy: scope=authentication, action=otppin=tokenpin, realm=$REALM_NAME" +echo " This blocks login for users without an enrolled token." +echo "" +echo "Next step: ./verify-t06.sh" + +if [[ "$FAIL_COUNT" -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/sso-mfa/k8s/verify-t06.sh b/sso-mfa/k8s/verify-t06.sh new file mode 100755 index 0000000..5530636 --- /dev/null +++ b/sso-mfa/k8s/verify-t06.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# verify-t06.sh — verify NK-WP-0001-T06 done-criteria +# +# Checks the MFA flow integration between KeyCape and privacyIDEA. +# +# Sections: +# 1. privacyIDEA pod Running+Ready (namespace: mfa) +# 2. privacyIDEA API reachable +# 3. Realm "netkingdom" exists in privacyIDEA +# 4. LDAP resolver "lldap-netkingdom" exists +# 5. LDAP resolver resolves users (LLDAP connectivity) +# 6. KeyCape→privacyIDEA token: valid admin token in keycape-pi-token +# 7. KeyCape can list tokens in the netkingdom realm +# 8. Self-enrollment policy exists +# 9. Authentication policy exists +# 10. Self-service portal reachable (pink-account.coulomb.social) +# +# Usage: +# chmod +x verify-t06.sh +# ./verify-t06.sh [secrets-dir] +# +# default: ../bootstrap/secrets + +set -euo pipefail + +SECRETS_DIR="${1:-../bootstrap/secrets}" +PI_ENV="$SECRETS_DIR/privacyidea/secrets.env" + +PI_HOST="pink.coulomb.social" +PI_URL="https://$PI_HOST" +PI_NAMESPACE="mfa" +SSO_NAMESPACE="sso" +REALM_NAME="netkingdom" +RESOLVER_NAME="lldap-netkingdom" + +PASS=0 +FAIL=0 +WARN=0 + +pass() { echo " [PASS] $1"; ((PASS++)); } +fail() { echo " [FAIL] $1"; ((FAIL++)); } +warn() { echo " [WARN] $1"; ((WARN++)); } + +section() { echo ""; echo "── $1 ──────────────────────────────────────"; } + +# ── 1. privacyIDEA pod ──────────────────────────────────────────────────────── +section "1. privacyIDEA pod (namespace: $PI_NAMESPACE)" +PI_POD=$(kubectl get pod -n "$PI_NAMESPACE" \ + -l app.kubernetes.io/name=privacyidea \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") + +if [[ -n "$PI_POD" ]]; then + pass "Pod Running: $PI_POD" + READY=$(kubectl get pod -n "$PI_NAMESPACE" "$PI_POD" \ + -o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || echo "false") + if [[ "$READY" == "true" ]]; then + pass "Pod readiness probe passing" + else + fail "Pod is Running but not Ready — check logs: kubectl logs -n $PI_NAMESPACE $PI_POD" + fi +else + fail "No Running privacyIDEA pod in namespace '$PI_NAMESPACE' — run verify-t04.sh" +fi + +# ── 2. privacyIDEA API reachable ────────────────────────────────────────────── +section "2. privacyIDEA API reachable" + +# Authenticate as pi-admin to get a token for subsequent checks. +PI_TOKEN="" +if [[ -f "$PI_ENV" ]]; then + read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } + PI_ADMIN_PASS=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD) + + if [[ -n "$PI_ADMIN_PASS" ]]; then + AUTH_RESP=$(curl -sf -X POST "$PI_URL/auth" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" \ + 2>/dev/null || echo "CURL_FAILED") + if [[ "$AUTH_RESP" != "CURL_FAILED" ]]; then + PI_TOKEN=$(echo "$AUTH_RESP" | python3 -c \ + "import sys,json; print(json.load(sys.stdin)['result']['value']['token'])" \ + 2>/dev/null || echo "") + fi + fi +fi + +if [[ -n "$PI_TOKEN" ]]; then + pass "privacyIDEA API reachable and pi-admin authenticated" +else + warn "Could not authenticate to $PI_URL as pi-admin" + warn " Ensure $PI_ENV exists and pink.coulomb.social is reachable." + warn " Remaining checks that require API access will be skipped." +fi + +pi_get() { + local path="$1" + if [[ -z "$PI_TOKEN" ]]; then echo "NO_TOKEN"; return; fi + curl -sf -X GET "$PI_URL$path" \ + -H "Authorization: $PI_TOKEN" \ + 2>/dev/null || echo "CURL_FAILED" +} + +# ── 3. Realm "netkingdom" exists ────────────────────────────────────────────── +section "3. Realm '$REALM_NAME' in privacyIDEA" +REALM_RESP=$(pi_get "/realm/") +if [[ "$REALM_RESP" == "NO_TOKEN" ]]; then + warn "Skipping realm check — no API token" +elif [[ "$REALM_RESP" == "CURL_FAILED" ]]; then + fail "Could not retrieve realm list from $PI_URL" +else + REALM_EXISTS=$(echo "$REALM_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print('yes' if '$REALM_NAME' in d.get('result',{}).get('value',{}) else 'no')" \ + 2>/dev/null || echo "no") + if [[ "$REALM_EXISTS" == "yes" ]]; then + pass "Realm '$REALM_NAME' exists" + # Check if it is the default realm + IS_DEFAULT=$(echo "$REALM_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); r=d.get('result',{}).get('value',{}).get('$REALM_NAME',{}); print('yes' if r.get('default') else 'no')" \ + 2>/dev/null || echo "no") + if [[ "$IS_DEFAULT" == "yes" ]]; then + pass "Realm '$REALM_NAME' is the default realm" + else + warn "Realm '$REALM_NAME' exists but is not the default realm" + warn " Run: POST $PI_URL/defaultrealm/$REALM_NAME" + fi + else + fail "Realm '$REALM_NAME' not found — run bootstrap-realm.sh" + fi +fi + +# ── 4. LDAP resolver exists ─────────────────────────────────────────────────── +section "4. LDAP resolver '$RESOLVER_NAME'" +RESOLVER_RESP=$(pi_get "/resolver/$RESOLVER_NAME") +if [[ "$RESOLVER_RESP" == "NO_TOKEN" ]]; then + warn "Skipping resolver check — no API token" +elif [[ "$RESOLVER_RESP" == "CURL_FAILED" ]]; then + fail "Could not retrieve resolver '$RESOLVER_NAME' from $PI_URL" +else + RESOLVER_TYPE=$(echo "$RESOLVER_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); v=d.get('result',{}).get('value',{}).get('data',{}); print(list(v.values())[0].get('type','') if v else '')" \ + 2>/dev/null || echo "") + if [[ "$RESOLVER_TYPE" == "ldapresolver" ]]; then + pass "Resolver '$RESOLVER_NAME' exists (type: ldapresolver)" + elif [[ -z "$RESOLVER_TYPE" ]]; then + fail "Resolver '$RESOLVER_NAME' not found — run bootstrap-realm.sh" + else + warn "Resolver '$RESOLVER_NAME' has unexpected type: '$RESOLVER_TYPE'" + fi +fi + +# ── 5. LDAP resolver connectivity (user resolution) ────────────────────────── +section "5. LDAP resolver user resolution" +# Test resolver by listing users in the netkingdom realm. +USERS_RESP=$(pi_get "/user/?realm=$REALM_NAME&pagesize=1") +if [[ "$USERS_RESP" == "NO_TOKEN" ]]; then + warn "Skipping user resolution check — no API token" +elif [[ "$USERS_RESP" == "CURL_FAILED" ]]; then + fail "Could not query users in realm '$REALM_NAME' — LDAP resolver may be broken" +else + USER_COUNT=$(echo "$USERS_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(len(d.get('result',{}).get('value',{}).get('users',[])))" \ + 2>/dev/null || echo "0") + if [[ "$USER_COUNT" -gt 0 ]]; then + pass "LDAP resolver resolves users from LLDAP ($USER_COUNT returned in page)" + else + warn "LDAP resolver returned 0 users — LLDAP may have no users yet, or the resolver may be misconfigured" + warn " Check: WebUI → Config → Resolver → $RESOLVER_NAME → [Test]" + fi +fi + +# ── 6. KeyCape privacyIDEA token ────────────────────────────────────────────── +section "6. KeyCape→privacyIDEA admin token" +# The token lives in the keycape-pi-token Secret in the sso namespace. +# It should have been created by keycape/create-pi-token.sh after T04 bootstrap. +if kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" &>/dev/null; then + pass "Secret keycape-pi-token exists in namespace $SSO_NAMESPACE" + TOKEN_VALUE=$(kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" \ + -o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || echo "") + if [[ -n "$TOKEN_VALUE" && "$TOKEN_VALUE" != "PENDING_create-pi-token.sh" ]]; then + pass "keycape-pi-token contains a non-placeholder token" + else + fail "keycape-pi-token is a placeholder — run keycape/create-pi-token.sh after T04 bootstrap" + fi +else + fail "Secret keycape-pi-token not found in namespace $SSO_NAMESPACE" + fail " Run: cd sso-mfa/k8s/keycape && ./create-pi-token.sh" +fi + +# ── 7. KeyCape can list tokens via privacyIDEA API ─────────────────────────── +section "7. KeyCape→privacyIDEA API connectivity" +# Use the keycape-pi-token to call the token list endpoint. +KC_PI_TOKEN=$(kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" \ + -o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || echo "") + +if [[ -z "$KC_PI_TOKEN" || "$KC_PI_TOKEN" == "PENDING_create-pi-token.sh" ]]; then + warn "Skipping connectivity check — keycape-pi-token not populated" +else + TOKEN_RESP=$(curl -sf -X GET "$PI_URL/token/?realm=$REALM_NAME&pagesize=1" \ + -H "Authorization: Bearer $KC_PI_TOKEN" \ + 2>/dev/null || echo "CURL_FAILED") + if [[ "$TOKEN_RESP" == "CURL_FAILED" ]]; then + fail "KeyCape→privacyIDEA: token list request failed (network or auth error)" + else + STATUS=$(echo "$TOKEN_RESP" | python3 -c \ + "import sys,json; print(json.load(sys.stdin).get('result',{}).get('status',''))" \ + 2>/dev/null || echo "") + if [[ "$STATUS" == "True" || "$STATUS" == "true" ]]; then + pass "KeyCape→privacyIDEA: token list API returns status=True" + else + fail "KeyCape→privacyIDEA: token list API returned unexpected status: '$STATUS'" + fi + fi +fi + +# ── 8. Self-enrollment policy ───────────────────────────────────────────────── +section "8. Self-enrollment policy" +POLICY_RESP=$(pi_get "/policy/totp-self-enrollment") +if [[ "$POLICY_RESP" == "NO_TOKEN" ]]; then + warn "Skipping policy check — no API token" +elif [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then + warn "Could not retrieve policy 'totp-self-enrollment'" +else + POLICY_EXISTS=$(echo "$POLICY_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('result',{}).get('value',{}).get('totp-self-enrollment') else 'no')" \ + 2>/dev/null || echo "no") + if [[ "$POLICY_EXISTS" == "yes" ]]; then + pass "Policy 'totp-self-enrollment' exists" + else + warn "Policy 'totp-self-enrollment' not found — run bootstrap-realm.sh" + fi +fi + +# ── 9. Authentication policy ────────────────────────────────────────────────── +section "9. Authentication policy (passthru phase 1)" +POLICY_RESP=$(pi_get "/policy/mfa-passthru-phase1") +if [[ "$POLICY_RESP" == "NO_TOKEN" ]]; then + warn "Skipping policy check — no API token" +elif [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then + warn "Could not retrieve policy 'mfa-passthru-phase1'" +else + POLICY_EXISTS=$(echo "$POLICY_RESP" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('result',{}).get('value',{}).get('mfa-passthru-phase1') else 'no')" \ + 2>/dev/null || echo "no") + if [[ "$POLICY_EXISTS" == "yes" ]]; then + pass "Policy 'mfa-passthru-phase1' exists (passthru for token-less users)" + else + warn "Policy 'mfa-passthru-phase1' not found — run bootstrap-realm.sh" + fi +fi + +# ── 10. Self-service portal reachable ──────────────────────────────────────── +section "10. Self-service portal (pink-account.coulomb.social)" +PORTAL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ + "https://pink-account.coulomb.social" 2>/dev/null || echo "000") +if [[ "$PORTAL_STATUS" == "200" || "$PORTAL_STATUS" == "302" ]]; then + pass "Self-service portal reachable (HTTP $PORTAL_STATUS)" +elif [[ "$PORTAL_STATUS" == "000" ]]; then + warn "Self-service portal not reachable — DNS/TLS/ingress may not be configured yet" +else + warn "Self-service portal returned HTTP $PORTAL_STATUS (expected 200 or 302)" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════" +echo " T06 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL" +echo "════════════════════════════════════════════════════════════" + +if [[ "$FAIL" -gt 0 ]]; then + echo " Result: INCOMPLETE — resolve FAIL items before marking T06 done" + echo "" + echo " Common next steps:" + echo " - Run: sso-mfa/k8s/privacyidea/bootstrap-realm.sh" + echo " - Run: sso-mfa/k8s/keycape/create-pi-token.sh (then restart keycape)" + echo " - Run: sso-mfa/k8s/keycape/create-secrets.sh (to update keycape-config)" + exit 1 +elif [[ "$WARN" -gt 0 ]]; then + echo " Result: PARTIAL — T06 core checks pass; review WARN items" + echo " Enroll a TOTP token and test the end-to-end login flow." + exit 0 +else + echo " Result: COMPLETE — T06 done-criteria met; proceed to T07 (User mgmt & self-service)" + exit 0 +fi