#!/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] # # default: ../../bootstrap/secrets # default: https://pink.coulomb.social # 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 [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/ 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