Files
net-kingdom/sso-mfa/k8s/verify-t06.sh
Bernd Worsch 69e900ddb1 feat(sso-mfa): T06 realm config & MFA flow manifests (NK-WP-0001-T06)
- k8s/privacyidea/bootstrap-realm.sh: creates LLDAP resolver
  "lldap-netkingdom", the "netkingdom" default realm, TOTP self-enrollment
  policy, and passthru authentication policy (phase-1 rollout).
- k8s/verify-t06.sh: verifies realm, resolver, LDAP user resolution,
  KeyCape→privacyIDEA admin token, API connectivity, and policies.
- WORKPLAN.md: mark T05 done, add T06 section with done-criteria.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:04:07 +00:00

286 lines
14 KiB
Bash
Executable File

#!/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]
#
# <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