Files
net-kingdom/sso-mfa/k8s/privacyidea/bootstrap-realm.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

303 lines
12 KiB
Bash
Executable File

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