generated from coulomb/repo-seed
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>
204 lines
8.5 KiB
Bash
Executable File
204 lines
8.5 KiB
Bash
Executable File
#!/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
|
|
|
|
ok() { echo " [OK] $1"; ((PASS_COUNT++)); }
|
|
fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); }
|
|
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
|