2026-03-19 09:04:07 +00:00
#!/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
2026-05-25 00:16:05 +02:00
# 3. Realm "coulomb" exists in privacyIDEA
# 4. LDAP resolver "lldap-coulomb" exists
2026-03-19 09:04:07 +00:00
# 5. LDAP resolver resolves users (LLDAP connectivity)
# 6. KeyCape→privacyIDEA token: valid admin token in keycape-pi-token
2026-05-25 00:16:05 +02:00
# 7. KeyCape can list tokens in the coulomb realm
2026-03-19 09:04:07 +00:00
# 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"
2026-05-25 00:16:05 +02:00
REALM_NAME = "coulomb"
RESOLVER_NAME = "lldap-coulomb"
2026-03-19 09:04:07 +00:00
PASS = 0
FAIL = 0
WARN = 0
2026-05-29 03:07:17 +02:00
pass( ) { echo " [PASS] $1 " ; PASS = $(( PASS + 1 )) ; }
fail( ) { echo " [FAIL] $1 " ; FAIL = $(( FAIL + 1 )) ; }
warn( ) { echo " [WARN] $1 " ; WARN = $(( WARN + 1 )) ; }
2026-03-19 09:04:07 +00:00
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