#!/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 Keycloak-Basis-URL inkl. Port (Default http://localhost:8080) # --realm Realm-Name (Default: aus der JSON, "holzleitner") # --admin Bootstrap-/Admin-User im master-Realm (Default admin) # --admin-password Admin-Passwort (Default admin) [besser via Env KC_ADMIN_PASSWORD] # --realm-file Realm-JSON (Default keycloak/import/realm-holzleitner.json) # --provisioner-secret Ü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."