Files
Holzleitner---Backend--aktu…/tool/keycloak_bootstrap.sh
Dennis Nemec c65e13485d keycloak_bootstrap: parametrisierbar für baremetal-Prod
Das Bootstrap-Skript funktioniert jetzt gegen jede Keycloak-Instanz (Dev-Docker
wie baremetal-Prod) und nimmt Realm + Server-Adresse als Parameter:
- --url <basis-inkl-port>, --realm <name> (überschreibt den Namen in der
  JSON-Payload), --admin/--admin-password.
- --provisioner-secret <s>: ersetzt das Dev-Secret in der Payload (Prod).
- --no-test-user: entfernt "testfahrer" aus der Payload (Prod).
- Docker wird per Default NICHT mehr angefasst (Prod=baremetal); nur via --up.
- Alle Optionen auch via Env; CLI hat Vorrang.

README: parametrisierte Nutzung (Dev + Prod-Beispiel) + Abschnitt "Wie die
Authentifizierung funktioniert" (Skript→Keycloak Admin-Grant; Laufzeit
App↔Backend↔Keycloak: PKCE-Login, JWKS-JWT-Validierung mit aud/iss, Provisioner
client-credentials, X-Admin-Api-Key für /admin).

Verifiziert gegen Dev-Keycloak: custom Realm + überschriebenes Secret +
--no-test-user erzeugt korrekten Realm; Test-Realm danach wieder entfernt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:24:55 +02:00

222 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Keycloak-Bootstrap: importiert den Holzleitner-Realm über die Admin-REST-API
# und konfiguriert ihn passend zum Backend (Client `holzleitner-app` mit PKCE +
# Audience-Mapper `holzleitner-api` + `personalnummer`-Claim, Rolle `driver`,
# Service-Account-Client `holzleitner-provisioner`, optional ein Test-User).
#
# Funktioniert gegen JEDE Keycloak-Instanz (Dev-Docker ODER baremetal-Prod) —
# es spricht nur die REST-API an. Quelle der Realm-Definition ist
# `keycloak/import/realm-holzleitner.json`; Realm-Name, Keycloak-Adresse,
# Provisioner-Secret und Test-User lassen sich beim Aufruf überschreiben.
#
# ── Aufruf ────────────────────────────────────────────────────────────────
# ./tool/keycloak_bootstrap.sh \
# --url https://auth.holzleitner.de \
# --realm holzleitner \
# --admin admin --admin-password '****'
#
# Production-Beispiel (eigener Realm, neues Secret, ohne Test-User):
# ./tool/keycloak_bootstrap.sh \
# --url https://auth.holzleitner.de \
# --realm holzleitner-prod \
# --admin kc-admin --admin-password "$KC_PW" \
# --provisioner-secret "$(openssl rand -hex 24)" \
# --no-test-user
#
# Dev (lokales Docker mit hochfahren):
# ./tool/keycloak_bootstrap.sh --up
#
# ── Optionen ────────────────────────────────────────────────────────────────
# --url <url> Keycloak-Basis-URL inkl. Port (Default http://localhost:8080)
# --realm <name> Realm-Name (Default: aus der JSON, "holzleitner")
# --admin <user> Bootstrap-/Admin-User im master-Realm (Default admin)
# --admin-password <pw> Admin-Passwort (Default admin) [besser via Env KC_ADMIN_PASSWORD]
# --realm-file <pfad> Realm-JSON (Default keycloak/import/realm-holzleitner.json)
# --provisioner-secret <s> Überschreibt das Client-Secret von holzleitner-provisioner
# --no-test-user Entfernt den Test-User "testfahrer" aus der Payload (Prod)
# --reset Vorhandenen Realm LÖSCHEN + neu importieren (destruktiv!)
# --up Vor dem Import `docker compose up -d` (nur Dev)
#
# Alles auch via Env: KC_URL, REALM, KC_ADMIN, KC_ADMIN_PASSWORD, REALM_FILE,
# PROVISIONER_SECRET. CLI-Flags haben Vorrang.
#
# Deps: bash, curl, python3 (kein jq nötig).
#
# ACHTUNG: `--reset` löscht den kompletten Realm inkl. aller per ERP-Sync
# provisionierten Fahrer-Konten.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# ── Defaults (Env-Overrides) ───────────────────────────────────────────────
KC_URL="${KC_URL:-http://localhost:8080}"
REALM="${REALM:-}" # leer ⇒ aus JSON lesen
KC_ADMIN="${KC_ADMIN:-admin}"
KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-admin}"
REALM_FILE="${REALM_FILE:-${PROJECT_ROOT}/keycloak/import/realm-holzleitner.json}"
PROVISIONER_SECRET="${PROVISIONER_SECRET:-}" # leer ⇒ Wert aus JSON behalten
NO_TEST_USER=0
RESET=0
DO_UP=0
# ── Arg-Parsing (Flags haben Vorrang vor Env) ──────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--url) KC_URL="$2"; shift 2 ;;
--realm) REALM="$2"; shift 2 ;;
--admin) KC_ADMIN="$2"; shift 2 ;;
--admin-password) KC_ADMIN_PASSWORD="$2"; shift 2 ;;
--realm-file) REALM_FILE="$2"; shift 2 ;;
--provisioner-secret) PROVISIONER_SECRET="$2"; shift 2 ;;
--no-test-user) NO_TEST_USER=1; shift ;;
--reset) RESET=1; shift ;;
--up) DO_UP=1; shift ;;
-h|--help) sed -n '2,60p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
done
KC_URL="${KC_URL%/}" # evtl. abschließenden Slash entfernen
# ── Vorbedingungen ──────────────────────────────────────────────────────────
command -v curl >/dev/null 2>&1 || { echo "✗ curl fehlt." >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "✗ python3 fehlt (für JSON)." >&2; exit 1; }
[ -f "${REALM_FILE}" ] || { echo "✗ Realm-Datei nicht gefunden: ${REALM_FILE}" >&2; exit 1; }
# Realm-Name: CLI/Env hat Vorrang, sonst aus der JSON.
if [ -z "${REALM}" ]; then
REALM="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["realm"])' "${REALM_FILE}")"
fi
echo "→ Keycloak : ${KC_URL}"
echo "→ Realm : ${REALM}"
echo "→ Quelle : ${REALM_FILE}"
# ── 1. (Dev) Keycloak via docker compose starten ────────────────────────────
if [ "${DO_UP}" -eq 1 ]; then
if command -v docker >/dev/null 2>&1; then
echo "→ docker compose up -d keycloak postgres (Dev) …"
(cd "${PROJECT_ROOT}" && docker compose up -d keycloak postgres >/dev/null)
else
echo " (docker nicht gefunden — überspringe --up)"
fi
fi
# ── 2. Payload aufbauen (Realm-Name/Secret/Test-User anwenden) ──────────────
PAYLOAD="$(mktemp -t kc-realm.XXXXXX.json)"
trap 'rm -f "${PAYLOAD}" /tmp/kc_import_resp.$$' EXIT
python3 - "${REALM_FILE}" "${PAYLOAD}" "${REALM}" "${PROVISIONER_SECRET}" "${NO_TEST_USER}" <<'PY'
import json, sys
src, dst, realm, secret, no_test_user = sys.argv[1:6]
d = json.load(open(src))
d["realm"] = realm
if secret:
for c in d.get("clients", []):
if c.get("clientId") == "holzleitner-provisioner":
c["secret"] = secret
if no_test_user == "1":
d["users"] = [u for u in d.get("users", []) if u.get("username") != "testfahrer"]
json.dump(d, open(dst, "w"))
PY
# ── 3. Auf Keycloak warten + Admin-Token holen ──────────────────────────────
# Authentifizierung des Skripts gegen die Admin-REST-API: Resource-Owner-
# Password-Grant im master-Realm über den eingebauten Client `admin-cli`.
echo -n "→ Admin-Login (${KC_ADMIN} @ master) "
TOKEN=""
for _ in $(seq 1 60); do
TOKEN="$(curl -s --fail \
-d "client_id=admin-cli" \
-d "username=${KC_ADMIN}" \
--data-urlencode "password=${KC_ADMIN_PASSWORD}" \
-d "grant_type=password" \
"${KC_URL}/realms/master/protocol/openid-connect/token" 2>/dev/null \
| python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("access_token",""))
except Exception: print("")' 2>/dev/null || true)"
[ -n "${TOKEN}" ] && { echo "— ok."; break; }
echo -n "."
sleep 2
done
if [ -z "${TOKEN}" ]; then
echo
echo "✗ Kein Admin-Token von ${KC_URL}. Läuft Keycloak? Stimmen --admin/--admin-password?" >&2
exit 1
fi
AUTH="Authorization: Bearer ${TOKEN}"
# ── 4. Realm vorhanden? ─────────────────────────────────────────────────────
CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "${AUTH}" "${KC_URL}/admin/realms/${REALM}")"
if [ "${CODE}" = "200" ]; then
if [ "${RESET}" -eq 1 ]; then
echo "→ Realm '${REALM}' existiert — wird wegen --reset GELÖSCHT (inkl. provisionierter Konten) …"
curl -s --fail -X DELETE -H "${AUTH}" "${KC_URL}/admin/realms/${REALM}" >/dev/null
echo " gelöscht."
else
echo
echo "✓ Realm '${REALM}' existiert bereits — keine Änderung."
echo " Sauberer Neu-Import (löscht provisionierte Konten!): … --reset"
exit 0
fi
elif [ "${CODE}" != "404" ]; then
echo "✗ Unerwartete Antwort beim Realm-Check: HTTP ${CODE}" >&2
exit 1
fi
# ── 5. Importieren ──────────────────────────────────────────────────────────
echo "→ Importiere Realm '${REALM}' …"
RESP="/tmp/kc_import_resp.$$"
IMP="$(curl -s -o "${RESP}" -w '%{http_code}' \
-X POST -H "${AUTH}" -H "Content-Type: application/json" \
--data-binary "@${PAYLOAD}" "${KC_URL}/admin/realms")"
if [ "${IMP}" != "201" ]; then
echo "✗ Import fehlgeschlagen: HTTP ${IMP}" >&2
echo " Antwort: $(cat "${RESP}" 2>/dev/null)" >&2
exit 1
fi
echo " importiert (HTTP 201)."
# ── 6. Verifizieren ─────────────────────────────────────────────────────────
echo "→ Verifiziere Clients …"
curl -s --fail -H "${AUTH}" "${KC_URL}/admin/realms/${REALM}/clients" | python3 -c '
import json, sys
ids = {c["clientId"] for c in json.load(sys.stdin)}
ok = True
for cid in ("holzleitner-app", "holzleitner-provisioner"):
present = cid in ids
print(" client " + cid + ": " + ("OK" if present else "FEHLT"))
ok = ok and present
sys.exit(0 if ok else 1)
'
# ── 7. Summary ──────────────────────────────────────────────────────────────
ISSUER="${KC_URL}/realms/${REALM}"
if [ -n "${PROVISIONER_SECRET}" ]; then
SECRET_LINE="provisioner_client_secret = \"${PROVISIONER_SECRET}\" (gesetzt)"
else
SECRET_LINE="provisioner_client_secret = \"provisioner-dev-secret\" (Dev-Wert aus der JSON — in Prod via --provisioner-secret setzen!)"
fi
echo
echo "✓ Fertig. Realm '${REALM}' ist eingerichtet und passt zum Backend."
echo " ───────────────────────────────────────────────────────────────"
echo " config.toml [keycloak]:"
echo " issuer_url = \"${ISSUER}\""
echo " audience = \"holzleitner-api\""
echo " realm = \"${REALM}\""
echo " admin_url = \"${KC_URL}\""
echo " provisioner_client_id = \"holzleitner-provisioner\""
echo " ${SECRET_LINE}"
echo " ───────────────────────────────────────────────────────────────"
echo " Admin-Console : ${KC_URL}/admin/"
if [ "${NO_TEST_USER}" -eq 0 ]; then
echo " Test-Fahrer : testfahrer / test (Personalnummer 1001, Rolle driver)"
fi
echo
echo " WICHTIG: issuer_url muss EXAKT dem 'iss'-Claim entsprechen, das Keycloak"
echo " ausstellt. In Prod dazu Keycloaks Frontend-/Hostname-URL passend setzen"
echo " (KC_HOSTNAME bzw. --hostname), sonst lehnt das Backend Tokens mit"
echo " 'invalid issuer' ab."