2026-03-19 09:04:07 +00:00
#!/usr/bin/env bash
2026-03-25 02:03:38 +00:00
# bootstrap-realm.sh — configure the "coulomb" realm in privacyIDEA
2026-03-19 09:04:07 +00:00
#
# 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.
2026-03-25 02:03:38 +00:00
# 2. Creates the LDAP resolver "lldap-coulomb" pointing to the in-cluster LLDAP.
# 3. Creates (or updates) the "coulomb" realm using that resolver.
2026-03-19 09:04:07 +00:00
# 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 "
2026-03-25 02:03:38 +00:00
RESOLVER_NAME = "lldap-coulomb"
REALM_NAME = "coulomb"
2026-03-19 09:04:07 +00:00
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 ..."
2026-03-25 02:03:38 +00:00
# Allows users in the coulomb realm to self-enroll TOTP tokens.
2026-03-19 09:04:07 +00:00
# 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