diff --git a/README.md b/README.md index 7e2f879..9f924ad 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,28 @@ curl http://127.0.0.1:3000/accounts/1001 | Test-User | `testfahrer` / `test` · Personalnummer 1001 · Rolle `driver` | | Audience im Access-Token | `holzleitner-api` | -Der Realm wird bei jedem `docker compose up` aus -`keycloak/import/realm-holzleitner.json` frisch importiert. Wer in der -Admin-UI Änderungen macht, sollte sie **in die JSON zurückspielen**, -sonst sind sie beim nächsten `docker compose down` weg. +Der Realm liegt in `keycloak/import/realm-holzleitner.json` und ist bereits +passend zum Backend konfiguriert (Client `holzleitner-app` mit PKCE + +Audience-Mapper `holzleitner-api` + `personalnummer`-Claim, Rolle `driver`, +Service-Account-Client `holzleitner-provisioner`, Test-User). Wer in der +Admin-UI Änderungen macht, sollte sie **in die JSON zurückspielen**. + +**Wichtig:** Das compose-`--import-realm` greift nur beim **ersten** Start +(solange das `keycloak-data`-Volume leer ist) — spätere JSON-Änderungen landen +NICHT automatisch in Keycloak. Zum Anwenden/Bootstrappen: + +```bash +# Importiert den Realm über die Admin-REST-API (idempotent, startet Keycloak +# bei Bedarf via docker compose). Tut nichts, falls der Realm schon existiert. +./tool/keycloak_bootstrap.sh + +# Sauberer Neu-Import (LÖSCHT den Realm inkl. provisionierter Fahrer-Konten): +./tool/keycloak_bootstrap.sh --reset +``` + +Das Skript gibt am Ende die `[keycloak]`-Werte aus, die in die `config.toml` +gehören (issuer_url, audience, provisioner-secret …). Overrides via +`KC_URL` / `KC_ADMIN` / `KC_ADMIN_PASSWORD` / `REALM_FILE`. ### Token für Dev-Tests holen diff --git a/tool/keycloak_bootstrap.sh b/tool/keycloak_bootstrap.sh new file mode 100755 index 0000000..f8c460d --- /dev/null +++ b/tool/keycloak_bootstrap.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# +# Keycloak-Bootstrap: importiert den Realm `holzleitner` über die Admin-REST-API +# und konfiguriert ihn so, dass er zum Backend passt (Client `holzleitner-app` +# mit PKCE + Audience-Mapper `holzleitner-api` + `personalnummer`-Claim, Rolle +# `driver`, Service-Account-Client `holzleitner-provisioner`, Test-User). +# +# WARUM dieses Skript? Das docker-compose-`--import-realm` greift NUR beim +# allerersten Start (solange das `keycloak-data`-Volume leer ist). Spätere +# Änderungen an realm-holzleitner.json landen sonst NICHT in Keycloak. Dieses +# Skript wendet den Realm jederzeit idempotent über die REST-API an. +# +# Quelle der Wahrheit ist `keycloak/import/realm-holzleitner.json` — dieselbe +# Datei, die auch docker-compose importiert. +# +# Aufruf (vom Projekt-Root): +# ./tool/keycloak_bootstrap.sh # anlegen, falls Realm fehlt +# ./tool/keycloak_bootstrap.sh --reset # vorhandenen Realm LÖSCHEN + neu +# ./tool/keycloak_bootstrap.sh --no-up # Keycloak nicht via compose starten +# +# Umgebungs-Overrides: +# KC_URL Keycloak-Basis-URL (Default http://localhost:8080) +# KC_ADMIN Bootstrap-Admin-User (Default admin) +# KC_ADMIN_PASSWORD Bootstrap-Admin-Passwort (Default admin) +# REALM_FILE Pfad zur Realm-JSON (Default keycloak/import/realm-holzleitner.json) +# +# ACHTUNG: `--reset` löscht den kompletten Realm inkl. aller per ERP-Sync +# provisionierten Fahrer-Konten. Ohne `--reset` lässt das Skript einen bereits +# existierenden Realm unangetastet (und sagt, wie man ihn zurücksetzt). + +set -euo pipefail + +# ── Konfiguration ───────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +KC_URL="${KC_URL:-http://localhost:8080}" +KC_ADMIN="${KC_ADMIN:-admin}" +KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-admin}" +REALM_FILE="${REALM_FILE:-${PROJECT_ROOT}/keycloak/import/realm-holzleitner.json}" + +RESET=0 +DO_UP=1 +for arg in "$@"; do + case "$arg" in + --reset) RESET=1 ;; + --no-up) DO_UP=0 ;; + -h|--help) + sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "Unbekannte Option: $arg" >&2; exit 2 ;; + esac +done + +# ── 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-Parsing)." >&2; exit 1; } +[ -f "${REALM_FILE}" ] || { echo "✗ Realm-Datei nicht gefunden: ${REALM_FILE}" >&2; exit 1; } + +# Realm-Name aus der JSON lesen (statt hart zu kodieren). +REALM="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["realm"])' "${REALM_FILE}")" +echo "→ Realm '${REALM}' aus ${REALM_FILE}" +echo "→ Keycloak: ${KC_URL}" + +# ── 1. Keycloak (optional) starten ──────────────────────────────────────── +if [ "${DO_UP}" -eq 1 ]; then + if command -v docker >/dev/null 2>&1; then + echo "→ Stelle sicher, dass Keycloak + Postgres laufen (docker compose up -d) …" + (cd "${PROJECT_ROOT}" && docker compose up -d keycloak postgres >/dev/null) + else + echo " (docker nicht gefunden — überspringe Start, erwarte laufendes Keycloak)" + fi +fi + +# ── 2. Auf Keycloak warten ──────────────────────────────────────────────── +echo -n "→ Warte auf Keycloak" +TOKEN="" +for _ in $(seq 1 60); do + TOKEN="$(curl -s --fail \ + -d "client_id=admin-cli" \ + -d "username=${KC_ADMIN}" \ + -d "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)" + if [ -n "${TOKEN}" ]; then echo " — bereit."; break; fi + echo -n "." + sleep 2 +done +if [ -z "${TOKEN}" ]; then + echo + echo "✗ Konnte kein Admin-Token von ${KC_URL} holen (Admin ${KC_ADMIN})." >&2 + echo " Läuft Keycloak? Stimmen KC_ADMIN/KC_ADMIN_PASSWORD?" >&2 + exit 1 +fi + +auth_header="Authorization: Bearer ${TOKEN}" + +# ── 3. Existiert der Realm schon? ───────────────────────────────────────── +http_code="$(curl -s -o /dev/null -w '%{http_code}' -H "${auth_header}" "${KC_URL}/admin/realms/${REALM}")" + +if [ "${http_code}" = "200" ]; then + if [ "${RESET}" -eq 1 ]; then + echo "→ Realm '${REALM}' existiert — wird wegen --reset GELÖSCHT (inkl. aller provisionierten Konten) …" + curl -s --fail -X DELETE -H "${auth_header}" "${KC_URL}/admin/realms/${REALM}" >/dev/null + echo " gelöscht." + else + echo + echo "✓ Realm '${REALM}' existiert bereits — keine Änderung." + echo " Zum sauberen Neu-Import (löscht provisionierte Fahrer-Konten!):" + echo " ./tool/keycloak_bootstrap.sh --reset" + exit 0 + fi +elif [ "${http_code}" != "404" ]; then + echo "✗ Unerwartete Antwort beim Realm-Check: HTTP ${http_code}" >&2 + exit 1 +fi + +# ── 4. Realm importieren (volle RealmRepresentation) ────────────────────── +echo "→ Importiere Realm '${REALM}' …" +import_code="$(curl -s -o /tmp/kc_import_resp.txt -w '%{http_code}' \ + -X POST -H "${auth_header}" -H "Content-Type: application/json" \ + --data-binary "@${REALM_FILE}" \ + "${KC_URL}/admin/realms")" + +if [ "${import_code}" != "201" ]; then + echo "✗ Import fehlgeschlagen: HTTP ${import_code}" >&2 + echo " Antwort: $(cat /tmp/kc_import_resp.txt 2>/dev/null)" >&2 + exit 1 +fi +echo " importiert (HTTP 201)." + +# ── 5. Verifizieren ─────────────────────────────────────────────────────── +echo "→ Verifiziere Konfiguration …" +clients_json="$(curl -s --fail -H "${auth_header}" "${KC_URL}/admin/realms/${REALM}/clients")" +echo "${clients_json}" | python3 -c ' +import json, sys +clients = {c["clientId"]: c for c in json.load(sys.stdin)} +ok = True +for cid in ("holzleitner-app", "holzleitner-provisioner"): + present = cid in clients + print(" client " + cid + ": " + ("OK" if present else "FEHLT")) + ok = ok and present +sys.exit(0 if ok else 1) +' + +# ── 6. Summary ──────────────────────────────────────────────────────────── +ISSUER="${KC_URL}/realms/${REALM}" +echo +echo "✓ Fertig. Realm '${REALM}' ist eingerichtet und passt zum Backend." +echo " ───────────────────────────────────────────────────────────────" +echo " Backend-config.toml muss dazu passen ([keycloak]-Section):" +echo " issuer_url = \"${ISSUER}\"" +echo " audience = \"holzleitner-api\"" +echo " realm = \"${REALM}\"" +echo " admin_url = \"${KC_URL}\"" +echo " provisioner_client_id = \"holzleitner-provisioner\"" +echo " provisioner_client_secret = \"provisioner-dev-secret\" (Dev-Wert aus dem Realm)" +echo " ───────────────────────────────────────────────────────────────" +echo " Admin-Console : ${KC_URL}/admin/ (${KC_ADMIN} / ***)" +echo " Test-Fahrer : testfahrer / test (Personalnummer 1001, Rolle driver)" +echo +echo " Hinweis: issuer_url muss EXAKT dem 'iss'-Claim entsprechen, das Keycloak" +echo " ausstellt (abhängig von KC_HOSTNAME). Bei LAN-/USB-Wechsel siehe" +echo " tool/dev_usb.sh."