feat(sso-mfa): T07/T08 user mgmt, backups, DR & break-glass (NK-WP-0001-T07/T08)
T07 — User management & self-service:
- k8s/lldap/bootstrap-users.sh: creates net-kingdom-users and net-kingdom-admins
groups in LLDAP via GraphQL API; idempotent.
- k8s/lldap/break-glass.sh: creates break-glass bypass account in LLDAP,
sets BREAKGLASS_PASSWORD, assigns to net-kingdom-admins.
- k8s/verify-t07.sh: 6 checks — groups, break-glass, self-service portal,
KeyCape OIDC client registrations.
T08 — Backups, DR, break-glass:
- k8s/backup/cronjob-sqlite-backups.yaml: daily CronJobs for LLDAP SQLite,
Authelia SQLite (with scale-down/up RBAC), and privacyIDEA enckey backup.
7-day retention, 03:00/03:15/03:30 UTC staggered schedule.
- k8s/backup/DR-RUNBOOK.md: full restore runbook — scenarios, restore order,
LLDAP/Authelia/PI SQLite restore procedure, full node rebuild sequence,
offsite age-encrypted export.
- k8s/verify-t08.sh: 9 checks — CronJobs, RBAC, run history, backup files
on PVCs, DR runbook presence, offsite backup (manual confirmation).
- WORKPLAN.md: T07/T08 sections with done-criteria added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:17:03 +00:00
#!/usr/bin/env bash
# break-glass.sh — create the break-glass bypass account in LLDAP
#
# The break-glass account is a last-resort local user for when the SSO stack
# itself is broken (Authelia down, KeyCape misconfigured, etc.). It is:
# - Created in LLDAP with BREAKGLASS_PASSWORD from gen-secrets.sh
# - Assigned to net-kingdom-admins
# - NOT enrolled in privacyIDEA MFA (so it can log in even if privacyIDEA is down)
# - Its password is stored ONLY in KeePassXC (never in the cluster)
#
# IMPORTANT: After creating this account, immediately store the password in
# KeePassXC → net-kingdom/Break-glass/break-glass. Then test it by logging
# in to the LLDAP WebUI directly with this account.
#
# Usage:
# ./break-glass.sh [lldap-url] [secrets-dir]
#
# <lldap-url> default: https://lldap.coulomb.social
# <secrets-dir> default: ../../bootstrap/secrets
set -euo pipefail
LLDAP_URL = " ${ 1 :- https : //lldap.coulomb.social } "
SECRETS_DIR = " ${ 2 :- ../../bootstrap/secrets } "
LLDAP_ENV = " $SECRETS_DIR /lldap/secrets.env "
BG_ENV = " $SECRETS_DIR /breakglass/secrets.env "
BG_USERNAME = "break-glass"
BG_EMAIL = "break-glass@netkingdom.local"
BG_DISPLAY = "Break-glass Account"
PASS_COUNT = 0
FAIL_COUNT = 0
2026-03-25 23:56:40 +00:00
ok( ) { echo " [OK] $1 " ; PASS_COUNT = $(( PASS_COUNT + 1 )) ; }
fail( ) { echo " [FAIL] $1 " ; FAIL_COUNT = $(( FAIL_COUNT + 1 )) ; }
feat(sso-mfa): T07/T08 user mgmt, backups, DR & break-glass (NK-WP-0001-T07/T08)
T07 — User management & self-service:
- k8s/lldap/bootstrap-users.sh: creates net-kingdom-users and net-kingdom-admins
groups in LLDAP via GraphQL API; idempotent.
- k8s/lldap/break-glass.sh: creates break-glass bypass account in LLDAP,
sets BREAKGLASS_PASSWORD, assigns to net-kingdom-admins.
- k8s/verify-t07.sh: 6 checks — groups, break-glass, self-service portal,
KeyCape OIDC client registrations.
T08 — Backups, DR, break-glass:
- k8s/backup/cronjob-sqlite-backups.yaml: daily CronJobs for LLDAP SQLite,
Authelia SQLite (with scale-down/up RBAC), and privacyIDEA enckey backup.
7-day retention, 03:00/03:15/03:30 UTC staggered schedule.
- k8s/backup/DR-RUNBOOK.md: full restore runbook — scenarios, restore order,
LLDAP/Authelia/PI SQLite restore procedure, full node rebuild sequence,
offsite age-encrypted export.
- k8s/verify-t08.sh: 9 checks — CronJobs, RBAC, run history, backup files
on PVCs, DR runbook presence, offsite backup (manual confirmation).
- WORKPLAN.md: T07/T08 sections with done-criteria added.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 09:17:03 +00:00
info( ) { echo " [INFO] $1 " ; }
for f in " $LLDAP_ENV " " $BG_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 } " ; }
LLDAP_ADMIN_PASS = $( read_env " $LLDAP_ENV " LLDAP_LDAP_USER_PASS)
BG_PASSWORD = $( read_env " $BG_ENV " BREAKGLASS_PASSWORD)
if [ [ -z " $LLDAP_ADMIN_PASS " || -z " $BG_PASSWORD " ] ] ; then
echo "ERROR: could not read LLDAP_LDAP_USER_PASS or BREAKGLASS_PASSWORD" >& 2
exit 1
fi
# ── Authenticate ──────────────────────────────────────────────────────────────
echo ""
echo " Authenticating to LLDAP at $LLDAP_URL ... "
AUTH_RESP = $( curl -sf -X POST " $LLDAP_URL /auth/simple/login " \
-H "Content-Type: application/json" \
-d " {\"username\":\"admin\",\"password\":\" $LLDAP_ADMIN_PASS \"} " \
2>/dev/null || echo "CURL_FAILED" )
if [ [ " $AUTH_RESP " = = "CURL_FAILED" ] ] ; then
echo " ERROR: Could not reach $LLDAP_URL " >& 2
exit 1
fi
LLDAP_TOKEN = $( echo " $AUTH_RESP " | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "" )
if [ [ -z " $LLDAP_TOKEN " ] ] ; then
echo "ERROR: LLDAP authentication failed" >& 2
exit 1
fi
info "Authenticated as admin"
gql( ) {
local query = " $1 " ; local vars = " ${ 2 :- { } } "
local body
body = $( python3 -c "
import json, sys
print( json.dumps( { 'query' : sys.argv[ 1] , 'variables' : json.loads( sys.argv[ 2] ) } ) )
" " $query " " $vars " )
curl -sf -X POST " $LLDAP_URL /api/graphql " \
-H " Authorization: Bearer $LLDAP_TOKEN " \
-H "Content-Type: application/json" \
-d " $body " 2>/dev/null || echo "CURL_FAILED"
}
# ── Check if user already exists ──────────────────────────────────────────────
echo ""
echo "Checking if break-glass user exists ..."
LIST_RESP = $( gql 'query { users { id displayName email } }' )
if [ [ " $LIST_RESP " != "CURL_FAILED" ] ] ; then
EXISTS = $( echo " $LIST_RESP " | python3 -c \
" import sys,json; d=json.load(sys.stdin); print('yes' if any(u['id']==' $BG_USERNAME ' for u in d.get('data',{}).get('users',[])) else 'no') " \
2>/dev/null || echo "no" )
if [ [ " $EXISTS " = = "yes" ] ] ; then
info " User ' $BG_USERNAME ' already exists — skipping creation "
ok " User ' $BG_USERNAME ' exists "
else
# ── Create user ───────────────────────────────────────────────────────
echo " Creating user ' $BG_USERNAME ' ... "
CREATE_VARS = $( python3 -c "
import json, sys
v = {
'user' : {
'id' : '$BG_USERNAME' ,
'email' : '$BG_EMAIL' ,
'displayName' : '$BG_DISPLAY' ,
'firstName' : 'Break' ,
'lastName' : 'Glass'
}
}
print( json.dumps( v) )
" )
RESP = $( gql 'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id creationDate } }' \
" $CREATE_VARS " )
ERR = $( echo " $RESP " | python3 -c \
"import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \
2>/dev/null || echo "" )
if [ [ -n " $ERR " ] ] ; then
fail " Create user ' $BG_USERNAME ' — $ERR "
else
ok " User ' $BG_USERNAME ' created "
fi
fi
else
fail "Could not query user list from LLDAP"
fi
# ── Set password ──────────────────────────────────────────────────────────────
# LLDAP requires a separate API call to set the password after user creation.
echo ""
echo " Setting password for ' $BG_USERNAME ' ... "
PW_VARS = $( python3 -c "
import json, sys
print( json.dumps( { 'userId' : '$BG_USERNAME' , 'password' : sys.argv[ 1] } ) )
" " $BG_PASSWORD " )
PW_RESP = $( gql 'mutation SetPassword($userId: String!, $password: String!) { changeUserPassword(userId: $userId, password: $password) }' \
" $PW_VARS " )
PW_ERR = $( echo " $PW_RESP " | python3 -c \
"import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \
2>/dev/null || echo "" )
if [ [ -n " $PW_ERR " ] ] ; then
fail " Set password — $PW_ERR "
else
ok " Password set for ' $BG_USERNAME ' "
fi
# ── Add to net-kingdom-admins ─────────────────────────────────────────────────
echo ""
echo " Adding ' $BG_USERNAME ' to net-kingdom-admins group ... "
# Find the net-kingdom-admins group ID
GROUPS_RESP = $( gql 'query { groups { id displayName } }' )
ADMIN_GID = $( echo " $GROUPS_RESP " | python3 -c \
"import sys,json; d=json.load(sys.stdin); gs=[g for g in d.get('data',{}).get('groups',[]) if g['displayName']=='net-kingdom-admins']; print(gs[0]['id'] if gs else '')" \
2>/dev/null || echo "" )
if [ [ -z " $ADMIN_GID " ] ] ; then
fail "Group 'net-kingdom-admins' not found — run bootstrap-users.sh first"
else
ADD_VARS = " {\"userId\":\" $BG_USERNAME \",\"groupId\": $ADMIN_GID } "
ADD_RESP = $( gql 'mutation AddToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) }' \
" $ADD_VARS " )
ADD_ERR = $( echo " $ADD_RESP " | python3 -c \
"import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \
2>/dev/null || echo "" )
if [ [ -n " $ADD_ERR " && " $ADD_ERR " != *"already" * ] ] ; then
fail " Add to group — $ADD_ERR "
else
ok " ' $BG_USERNAME ' is in net-kingdom-admins "
fi
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════════════"
echo " Break-glass bootstrap: PASS= $PASS_COUNT FAIL= $FAIL_COUNT "
echo "════════════════════════════════════════════════════════════"
echo ""
echo "CRITICAL — do these steps NOW:"
echo ""
echo " 1. Store the break-glass password in KeePassXC:"
echo " Group: net-kingdom/Break-glass"
echo " Entry: break-glass → username=' $BG_USERNAME ' password=<from gen-secrets.sh> "
echo ""
echo " 2. Test the account (LLDAP WebUI login):"
echo " $LLDAP_URL "
echo " Login as ' $BG_USERNAME ' with BREAKGLASS_PASSWORD "
echo " Confirm you can see the admin panel."
echo ""
echo " 3. Do NOT enroll MFA for ' $BG_USERNAME ' in privacyIDEA. "
echo " This account must remain usable when privacyIDEA is unavailable."
echo " Its sole authentication factor is the password stored in KeePassXC."
echo ""
echo " 4. Document the DR restore sequence:"
echo " See sso-mfa/k8s/backup/DR-RUNBOOK.md"
echo ""
if [ [ " $FAIL_COUNT " -gt 0 ] ] ; then
exit 1
fi
exit 0