generated from coulomb/repo-seed
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>
This commit is contained in:
@@ -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
|
||||
|
||||
302
sso-mfa/k8s/privacyidea/bootstrap-realm.sh
Executable file
302
sso-mfa/k8s/privacyidea/bootstrap-realm.sh
Executable file
@@ -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]
|
||||
#
|
||||
# <secrets-dir> default: ../../bootstrap/secrets
|
||||
# <pi-url> default: https://pink.coulomb.social
|
||||
# <lldap-bind-pass> 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 <method> <path> [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/<name> 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
|
||||
285
sso-mfa/k8s/verify-t06.sh
Executable file
285
sso-mfa/k8s/verify-t06.sh
Executable file
@@ -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]
|
||||
#
|
||||
# <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
|
||||
Reference in New Issue
Block a user