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