Hallo zusammen,
Vorwort
seit ca. 2010 arbeite ich bei der Deutschen Telekom (mit kurzen Unterbrechungen zur Weiterbildung als Programmierer) an einer Identity & Access Management (IAM) Lösung. Derzeit soll ich einen Proof of Concept (POC) bei einem Kunden aufbauen.
Wir haben damals bei der Telekom über Jahre hinweg mit bis zu 100 Leuten an dem Thema gearbeitet (Architektur, Entwicklung, Test, Betrieb vieler Server, Anbindung von Zielsystemen, Beratung, Koordination,...). Das ganze Gebilde gleicht einer Kathedrale.
Eine mit Abstrichen funktionierende Minimalversion davon habe ich letzten Freitag innerhalb von 20 Minuten (o.k. die Vorarbeit war schon deutlich länger) auf meinem Raspberry Pi hinzaubern lassen. Mein mittlerweile einigermaßen trainierter Openclaw Agent sollte die mit ihm zusammen erstellte Dokumentation Realität werden lassen.
Ich bin immer noch baff. Der Kram läuft!
Da es mir um das Verständnis geht, brauchte ich noch eine Installationsanleitung und Erklärung, was da eigentlich geschieht. Das ist dieses Dokument hier.
Als ich am Abend dieses Erlebnis in mein Handy sprach, mit der Bitte an Openclaw daraus einen Tagebucheintrag zu erstellen (ich spazierte gerade und machte noch ein Foto von den tollen Wolken), kam, nachdem ich meinen Text zuende gesprochen hatte, eine Antwort zurück, die sinngemäß so aussah:
"Lieber Achim, es tut mir leid, dass du deine Arbeit durch die KI entwertet siehst. Aber sieh es doch mal positiv: Du warst es doch, der die KI orchestriert hat. Und außerdem hast du jetzt mehr Zeit für andere Sachen. Übrigens passen die dunklen Wolken gut zu deiner Stimmung"
Ein zweites Mal an diesem Tag blieb mir der Mund offen stehen.
Und nun, für alle Menschen und Nichtmenschen die es interessiert, hier meine/unsere Doku über ein komplettes IAM + PAM (Privileged Access Management) System:
Umgebung: Raspberry Pi 4 (8 GB RAM), Podman 5.4.2, Python 3.12 (Alpine)
Repos: /home/achim/iam/webapp1/, /home/achim/iam/webapp2/, /home/achim/iam/wiki-api/
Auftraggeber: IAM/PAM Proof-of-Concept für Arbeitgeber
(Der IGA-Server und der Keycloak Server laufen bereits auf meinem Windows PC und sind hier nicht Gegenstand der Untersuchung)
Inhaltsverzeichnis
- Vorbedingungen
- WebApp 1 – LDAP-Login mit Wiki-Oberfläche
- WebApp 2 – API-Key Gateway
- Wiki-API-Server
- Authentifizierungs-Flows
- Manuelle Befehle nachstellen
- Auto-Start (systemd)
- Netzwerkübersicht
- Troubleshooting
- Zugangsdaten
- Offene Punkte
- Quellen
Übersicht
Fünf Container demonstrieren die Authentifizierungs- und Autorisierungs-Flows im IAM PoC:
| Container | Port | Auth-Methode | Backend |
|---|---|---|---|
| ldap — OpenLDAP | 3389 → 389 | LDAP-Bind (Benutzername + Passwort) | LDAP-Datenbank |
| openbao — Secrets | :8200 | X-Vault-Token | Storage Backend |
| webapp1 — LDAP-Login+Wiki | 5001 → 5000 | Benutzername/Passwort via LDAP-Bind | OpenLDAP + Wiki-API |
| webapp2 — API-Gateway | 5002 → 5000 | X-API-Key → Live-Validation via OpenBao, /v1/authenticate | Wiki-API |
| wiki-api — Wiki-Backend | 5003 → 5000 | X-User-Role-Header (intern) | SQLite (Volume wiki-data) |
Alle Container laufen im Podman-Netzwerk iam-net und sind über den Host (Raspberry Pi) erreichbar.
Aktuelle Container
| Container | Port | Netzwerk |
|---|---|---|
| openbao | 8200 | iam-net |
| ldap | 3389 | iam-net |
| webapp1 | 5001 | iam-net |
| webapp2 | 5002 | iam-net |
| wiki-api | 5003 | iam-net |
1. Vorbedingungen
Vorhanden aus der LDAP-Installation:
# Podman-Netzwerk (bereits vorhanden)
podman network ls | grep iam-net
# LDAP-Server läuft
podman ps -a | grep ldap
Quellcode-Verzeichnisse:
ls -la /home/achim/iam/webapp1/
ls -la /home/achim/iam/webapp2/
ls -la /home/achim/iam/wiki-api/
2. WebApp 1 — LDAP-Login mit Wiki-Oberfläche
WebApp1 authentifiziert Benutzer gegen OpenLDAP. Nach erfolgreichem Login wird die User-Rolle aus LDAP-Gruppen ermittelt (admin > editor > viewer) und eine Wiki-Oberfläche angezeigt. Die Wiki-Artikel werden live vom internen Wiki-API-Server geladen.
2.1 Quellcode
(Kompletter Code siehe unten)
/home/achim/iam/webapp1/app.py — Flask-App mit:
- Login-Formular (Benutzername + Passwort)
- LDAP-Bind gegen OpenLDAP (
ldap3-Bibliothek) - LDAP-Gruppen-Abfrage nach Login (Admin-Bind)
- Rollen-Mapping:
admin/admins→admin,editor/developers→editor,helpdesk/viewer→viewer - Session-basierte geschützte Wiki-Seite
- Einfaches Markdown-Rendering für Artikel
- Logout
/home/achim/iam/webapp1/Dockerfile — Python 3.12 Alpine + flask + ldap3 + requests.
2.2 Image bauen
cd /home/achim/iam/webapp1
podman build -t webapp1-ldap .
2.3 Container starten
podman run -d --name webapp1 --network iam-net \
-p 5001:5000 \
-e LDAP_HOST=ldap \
-e LDAP_PORT=389 \
-e LDAP_BASE_DN="dc=example,dc=com" \
localhost/webapp1-ldap:latest
Parameter-Erklärung:
| Parameter | Zweck |
|---|---|
--network iam-net | Verbindet Container mit LDAP (DNS-Name ldap) |
-p 5001:5000 | Host-Port 5001 → internen Flask-Port 5000 |
-e LDAP_HOST=ldap | LDAP-Hostname (Container-Name im iam-net) |
-e LDAP_PORT=389 | LDAP-Port (intern, kein Port-Mapping nötig) |
2.4 Testen
# Webseite im Browser öffnen
curl -s http://localhost:5001/ | head -5
# → Gibt das Login-Formular zurück
# Health-Check
curl -s http://localhost:5001/health
# → {"app":"webapp1-ldap","status":"ok"}
3. WebApp 2 — API-Key Gateway (Proxy zum Wiki-API)
WebApp2 validiert API-Keys (live gegen OpenBao),
mapped sie auf Rollen und leitet Requests an den internen Wiki-API-Server weiter.
WebApp2 verfügt über einen eigenen LDAP-Login:
Clients können sich per POST /v1/authenticate anmelden und erhalten einen
temporären API-Key aus OpenBao (UUID, 60 Min TTL).
Features:
- LDAP-Login (
POST /v1/authenticate): Benutzername/Passwort → temporärer Key - Live-Validation: Jeder Request wird live gegen OpenBao validiert
(nicht mehr nur beim Start geladen) – inkl. Ablaufzeit-Prüfung - Fallback-Keys für Testzwecke (
poc-api-key-secret-2026etc.)
OpenBao läuft im selben Netzwerk (iam-net) unter http://openbao:8200.
3.1 Quellcode
(Kompletter Code siehe unten)
/home/achim/iam/webapp2/app.py — Flask-API-Gateway:
X-API-Key-Authentifizierung (vor jedem Request außer/health,/v1/authenticateund/v1/validate)- Live-Validation jedes Requests via OpenBao (
secret/data/wiki-api-keys/temp/<uuid>) - LDAP-Authentifizierung (
ldap3) für/v1/authenticate - Proxy an
wiki-api:5000für alle/v1/wiki/*-Endpunkte - Eigene Endpunkte:
/health,/v1/authenticate,/v1/validate
/home/achim/iam/webapp2/Dockerfile — Python 3.12 Alpine + flask + requests + ldap3.
3.2 Image bauen
cd /home/achim/iam/webapp2
podman build -t webapp2-api .
3.3 Container starten
podman run -d --name webapp2 --network iam-net \
-p 5002:5000 \
-e OPENBAO_TOKEN="s.5P16Xr4iAP3Pdwz7ipg3gzBw" \
-e LDAP_HOST="ldap" \
-e LDAP_PORT="389" \
localhost/webapp2-api:latest
# ⏳ Warten, bis der Container bereit ist (2-3 Sekunden)
sleep 3 && curl -s http://localhost:5002/health | python3 -m json.tool
Wichtig: Nach podman run ist der Container nicht sofort bereit.
Warte mit den Tests, bis der Health-Check "status": "ok" zurückgibt.
3.4 Testen (Wiki-API via WebApp2)
⏳ Wichtig: Nach Container-Neustart kurz warten, bis der
Health-Check positiv ist:
curl -s http://localhost:5002/health | python3 -m json.tool
# → {"status": "ok", "auth_mode": "openbao-live+ldap", ...}
Erst dann die folgenden Tests ausführen:
# 1. LDAP-Login → temporären API-Key holen
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"achim","password":"achim123"}' \
http://localhost:5002/v1/authenticate | python3 -m json.tool
# → {"api_key": "a4bcb1...", "role": "admin", "expires_at": "2026-..."}
# 2. Mit temporärem Key die geschützte API aufrufen
KEY=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"achim","password":"achim123"}' \
http://localhost:5002/v1/authenticate | python3 -c \
"import sys,json; print(json.load(sys.stdin)['api_key'])")
curl -s -H "X-API-Key: $KEY" \
http://localhost:5002/v1/wiki/articles | python3 -m json.tool
# 3. Ohne Key → 401
curl -s http://localhost:5002/v1/wiki/articles
# → {"error":"Unauthorized","message":"Gültiger X-API-Key erforderlich"}
# 4. Falsche Credentials → 401
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"achim","password":"falsch"}' \
http://localhost:5002/v1/authenticate
# → {"error":"Unauthorized","message":"Ungültiger Benutzername oder Passwort"}
# 5. API-Key validieren (auth-frei)
curl -s -X POST -H "Content-Type: application/json" \
-d '{"api_key":"poc-viewer-key-2026"}' \
http://localhost:5002/v1/validate
# → {"service":"webapp2-api","valid":true,"role":"viewer"}
# 6. Statischer Fallback-Key (funktioniert weiterhin)
curl -s -H "X-API-Key: poc-api-key-secret-2026" \
http://localhost:5002/v1/wiki/articles | python3 -m json.tool
# 7. Admin-Artikel als viewer → 403
curl -s -H "X-API-Key: poc-viewer-key-2026" \
http://localhost:5002/v1/wiki/articles/3
# → {"detail":"Role 'viewer' insufficient. Required: 'admin'"}
4. Wiki-API-Server
Der Wiki-API-Server ist ein separater FastAPI-Container mit SQLite-Datenbank, der die eigentlichen Wiki-Artikel mit rollenbasiertem Zugriff bereitstellt. Er wird ausschließlich intern über WebApp1 oder WebApp2 angefragt – direkter externer Zugriff ist nicht vorgesehen.
SQLite ist embedded: Die Datenbank läuft als Datei innerhalb des wiki-api-Containers (/data/wiki.db), nicht als separater Server. WebApp1 und WebApp2 greifen nie direkt auf SQLite zu, sondern ausschließlich über die HTTP-REST-Endpunkte (GET/POST/PUT/DELETE /v1/wiki/articles). Das Volume wiki-data stellt lediglich sicher, dass die Datenbankdatei bei Container-Neustarts erhalten bleibt.
webapp1 ──[HTTP / X-User-Role]──▶ wiki-api ──[embedded SQLite]──▶ /data/wiki.db
webapp2 ──[HTTP / X-User-Role]──▶ wiki-api ──[embedded SQLite]──▶ /data/wiki.db
4.1 Container-Konfiguration
cd /home/achim/iam/wiki-api
podman build -t wiki-api .
podman run -d --name wiki-api --network iam-net \
-p 5003:5000 \
-v wiki-data:/data \
localhost/wiki-api:latest
4.2 systemd-Autostart (bereits aktiv)
podman generate systemd --restart-policy=on-failure --name wiki-api \
> ~/.config/systemd/user/podman-wiki-api.service
systemctl --user daemon-reload
systemctl --user enable podman-wiki-api.service
4.3 API-Endpunkte
Alle Endpunkte (außer /health) benötigen einen gültigen API-Key im X-API-Key-Header (bzw. X-User-Role für interne Requests von WebApp1).
| Methode | Pfad | Beschreibung | Benötigte Rolle |
|---|---|---|---|
GET | /v1/wiki/articles | Alle sichtbaren Artikel | beliebig |
GET | /v1/wiki/articles/{id} | Einzelner Artikel | abhängig vom Artikel |
POST | /v1/wiki/articles | Neuen Artikel anlegen | editor / admin |
PUT | /v1/wiki/articles/{id} | Artikel aktualisieren | editor / admin |
DELETE | /v1/wiki/articles/{id} | Artikel löschen | admin |
GET | /health | Health-Check | keiner |
4.4 Seed-Daten
Beim ersten Start werden automatisch 5 Beispiel-Artikel angelegt:
| ID | Titel | Benötigte Rolle |
|---|---|---|
| 1 | Willkommen im Wiki | viewer |
| 2 | VPN-Einrichtung (Helpdesk) | viewer |
| 3 | Kunden-Datenbank – Admin-Handbuch | admin |
| 4 | Anleitung: Passwort-Reset für Kunden | editor |
| 5 | Server-Monitoring – Übersicht | viewer |
4.5 Datenbank
- Typ: SQLite
- Pfad:
/data/wiki.db(im Container, via Volumewiki-datapersistent) - Volume:
podman volume inspect wiki-data
5. Authentifizierungs-Flows (Was passiert im Hintergrund)
5.1 Mensch-Login (WebApp 1 → LDAP → Wiki-API)
Browser ──[POST user/pass]──▶ webapp1 ──[LDAP-Bind]──▶ LDAP
│ │
│ ◄──[erfolg/fehler]──────┘
│
│ [erfolg] → LDAP-Gruppen abfragen
│ → höchste Rolle ermitteln
│ → Session setzen
│ → Wiki-UI laden
│
│ ──[X-User-Role: admin]──▶ wiki-api
│ ◄──[JSON: Artikel]───────┘
│
Browser ◄──[HTML: Wiki-Seite]── webapp1
Im Detail:
- Benutzer öffnet
http://raspi:5001/→ Login-Formular wird geladen - Benutzer gibt
achim/achim123ein und sendet ab (POST) - WebApp1 konstruiert den LDAP-Bind-DN:
uid=achim,ou=people,dc=example,dc=com - WebApp1 verbindet sich via
ldap3zum LDAP-Server (DNS:ldap, Port 389, Container-intern) - LDAP prüft das Passwort mit einem LDAP-Bind:
- Erfolg: WebApp1 fragt LDAP-Gruppen ab (
ou=groups) und ermittelt die höchste Rolle - Rollen-Mapping:
admin/admins→admin,editor/developers→editor,helpdesk/viewer→viewer - Fehler: Login-Seite wird mit Fehlermeldung neu geladen
- Erfolg: WebApp1 fragt LDAP-Gruppen ab (
- Auf der geschützten Wiki-Seite zeigt WebApp1 eine Artikel-Liste an, die live vom internen Wiki-API-Server geladen wird (mit
X-User-Role-Header) - Session lebt serverseitig (Flask-Session) – kein JWT im Cookie, nur eine signierte Session-ID
- Logout: Session wird gelöscht → zurück zum Login
Wichtig: Die Flask-Session ist serverseitig (kein JWT). Das Cookie enthält nur eine signierte Session-ID. Bei Container-Neustart sind alle Sessions weg.
Nach dem Anmelden:
Dies ist der Inhalt des Json Web Token (JWT), mit dem Inhalt, den wir von LDAP bekommen haben:
Holen der LDAP Gruppen für die Rollen-Mapping-Entscheidung (admin/editor/viewer)
Man kann im Developer Mode des Browsers (F12) auch den Netz-Fluss sehen. Hier wird der JWT erzeugt und in einem Cookie gespeichert (POST auf http://raspi:5001):
Als nächstes wird mit diesem Cookie die geschützte Seite aufgerufen (GET http://raspi:5001/wiki):
Alles was dahinter passiert, also die Kommunikation der Maschinen untereinander, sehen wir im Browser nicht.
5.2 Maschinen-Zugriff (Client → WebApp2 → OpenBao → Wiki-API)
Der Maschinen-Zugriff läuft in zwei Phasen:
- Phase A – Authentifizierung: Client loggt sich per LDAP ein und bekommt einen temporären API-Key aus OpenBao
- Phase B – API-Zugriff: Client ruft mit diesem temporären Key die geschützten Endpunkte auf (Live-Validation gegen OpenBao)
┌─ Phase A: Authenticate ─────────────────┐
│ │
│ POST /v1/authenticate │
│ { username, password } │
Client ───────┼─────────────────────────────────▶ webapp2│
│ │
│ ┌─ LDAP-Bind prüft Credentials ──┐ │
│ │ Erfolg → Gruppen → Rolle │ │
│ │ Fehler → 401 zurück │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─ OpenBao: temporäres Secret ────┐ │
│ │ UUID + role + expires_at │ │
│ │ (TTL: 60 Minuten) │ │
│ └──────────┬───────────────────────┘ │
│ │ POST /v1/secret/data/... │
│ ▼ │
│ OpenBao │
│ │ │
Client ◄──────┼─────────────┘ │
{ api_key, │ │
role, └──────────────────────────────────────────┘
expires_at }
┌─ Phase B: API-Zugriff ────────────────────┐
│ GET /v1/wiki/articles │
│ + X-API-Key (temporärer Key) │
Client ───────┼─────────────────────────────────▶ webapp2 │
│ │
│ ┌─ Live-Validation via OpenBao ─────┐ │
│ │ GET /v1/secret/data/.../<key> │ │
│ │ → prüft: existiert? abgelaufen? │ │
│ │ → extrahiert: role │ │
│ │ → Fallback: statische Keys │ │
│ └──────────┬─────────────────────────┘ │
│ │ valid → X-User-Role: admin │
│ ▼ │
│ wiki-api │
│ │ │
│ │ [prüft Rolle gegen Artikel] │
│ ▼ │
│ SQLite │
│ │ │
Client ◄──────┼─────────────┘ │
JSON └───────────────────────────────────────────┘
Phase A: Authentifizierung (POST /v1/authenticate)
Der Client sendet LDAP-Benutzername und -Passwort an den neuen Endpunkt:
curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"achim","password":"achim123"}' \
http://localhost:5002/v1/authenticate
Antwort:
{
"api_key": "a4bcb173-78fc-47ee-a5f5-99f8cf4f0679",
"role": "admin",
"expires_at": "2026-06-23T06:32:37.047766+00:00"
}
Was passiert intern:
- WebApp2 führt einen LDAP-Bind mit den übergebenen Credentials durch
- Bei Erfolg werden die LDAP-Gruppen des Benutzers abgefragt → höchste Rolle ermittelt (
admin/editor/viewer) - Ein temporäres Secret wird in OpenBao angelegt (
secret/data/wiki-api-keys/temp/<uuid>) - Das Secret enthält:
owner,role,expires_at(standardmäßig 60 Minuten TTL) - Der Client bekommt den API-Key + Rolle + Ablaufzeit zurück
Phase B: API-Zugriff (mit dem temporären Key)
Mit dem erhaltenen Key ruft der Client die Wiki-API-Endpunkte auf – genau wie zuvor:
curl -s -H "X-API-Key: a4bcb173-78fc-47ee-a5f5-99f8cf4f0679" \
http://localhost:5002/v1/wiki/articles | python3 -m json.tool
Der Unterschied: Jeder Request wird live gegen OpenBao validiert, nicht gegen ein statisches Mapping:
- WebApp2 frägt bei OpenBao nach:
GET /v1/secret/data/wiki-api-keys/temp/<key> - Existiert nicht → 401 Unauthorized
- Abgelaufen (
expires_atin der Vergangenheit) → 401 Unauthorized - Gültig → Rolle aus dem Secret extrahiert →
X-User-Role-Header gesetzt → Proxy an Wiki-API - Wiki-API prüft wie gehabt die Rolle gegen den Artikel → 200 oder 403
Fallback: Statische API-Keys
Für Testzwecke und als Fallback (falls OpenBao nicht erreichbar ist) bleiben die statischen Keys erhalten. Diese werden ohne TTL direkt im Code gemappt und überspringen die OpenBao-Validation:
| API-Key | Rolle |
|---|---|
poc-api-key-secret-2026 | admin |
poc-viewer-key-2026 | viewer |
poc-editor-key-2026 | editor |
Wichtig: Statische Keys haben keine Ablaufzeit – sie sind nur für den PoC gedacht.
Rollen-Hierarchie (wie gehabt)
| Rolle | Beispiele | Darf |
|---|---|---|
viewer | helpdesk | Artikel mit required_role: viewer lesen |
editor | entwickler, redakteur | viewer + editor lesen + Artikel anlegen/bearbeiten |
admin | achim | Alle Artikel lesen + löschen |
6. Kommunikation nachstellen (manuelle Befehle)
6.1 LDAP-Login (wie WebApp 1 intern)
# Genau das macht die App:
USERNAME="achim"
PASSWORD="achim123"
USER_DN="uid=${USERNAME},ou=people,dc=example,dc=com"
# LDAP-Bind durchführen
ldapwhoami -x -H ldap://localhost:3389 -D "$USER_DN" -w "$PASSWORD"
# Erfolg → "dn:uid=achim,ou=people,dc=example,dc=com"
# Fehler → ldap_bind: Invalid credentials (49)
# Falsches Passwort testen
ldapwhoami -x -H ldap://localhost:3389 \
-D "uid=achim,ou=people,dc=example,dc=com" -w "falsches-passwort"
# → ldap_bind: Invalid credentials (49)
6.2 API-Key-Zugriff via WebApp2 → Wiki-API
Hinweis: Die wichtigsten Tests für WebApp2 stehen in Abschnitt 3.4.
Hier nur eine ergänzende Abfrage des Viewers mit Artikelzählung.
# Wiki-Artikel als viewer (nur sichtbare Artikel zählen)
curl -s -H "X-API-Key: poc-viewer-key-2026" \
http://localhost:5002/v1/wiki/articles | python3 -c "
import sys, json
data = json.load(sys.stdin)
for a in data:
print(f' [{a[\"id\"]}] {a[\"title\"]}')
print(f'→ {len(data)} Artikel sichtbar')
"
# API-Key validieren (auth-frei, kein X-API-Key nötig)
curl -s -X POST -H "Content-Type: application/json" \
-d '{"api_key":"poc-viewer-key-2026"}' \
http://localhost:5002/v1/validate
6.3 Vollständiger Flow: Authenticate → API-Zugriff (aktuell)
⏳ Voraussetzung: WebApp2 muss laufen und bereit sein:
# Health-Check – solange wiederholen, bis "status": "ok" erscheint
curl -s http://localhost:5002/health
# Schritt 1: LDAP-Login → temporärer API-Key aus OpenBao holen
RESULT=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"username":"achim","password":"achim123"}' \
http://localhost:5002/v1/authenticate)
echo "Authentifiziert:"
echo "$RESULT" | python3 -m json.tool
# Key extrahieren
API_KEY=$(echo "$RESULT" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['api_key'])")
# Schritt 2: Mit dem temporären Key Wiki-Artikel abrufen
echo ""
echo "Wiki-Artikel:"
curl -s -H "X-API-Key: ${API_KEY}" \
http://localhost:5002/v1/wiki/articles | python3 -c "
import sys, json
data = json.load(sys.stdin)
for a in data:
print(f' [{a[\"id\"]}] {a[\"title\"]}')
print(f'→ {len(data)} Artikel')
"
# Schritt 3: Direkt die OpenBao-Secrets inspizieren (optional)
echo ""
echo "Temporäres Secret in OpenBao:"
curl -s -H "X-Vault-Token: s.5P16Xr4iAP3Pdwz7ipg3gzBw" \
"http://127.0.0.1:8200/v1/secret/data/wiki-api-keys/temp/${API_KEY}" \
| python3 -c "
import sys, json
data = json.load(sys.stdin).get('data',{}).get('data',{})
for k,v in data.items():
print(f' {k}: {v}')
"
Ergebnis (live getestet): Der Client authentifiziert sich via LDAP, bekommt
von WebApp2 einen temporären UUID-Key (60 Min TTL), der in OpenBao gespeichert wird.
Der API-Zugriff validiert jeden Request live gegen OpenBao – inkl. Ablaufprüfung.
Siehe auch: Abschnitt 5.2 Maschinen-Zugriff für das vollständige Workflow-Diagramm.
6.4 Session-Cookie manuell nachstellen
# Session-Token aus dem Cookie extrahieren
curl -v -X POST http://localhost:5001/ \
-d "username=achim&password=achim123" 2>&1 | grep -i "set-cookie"
# Mit dem Cookie die geschützte Seite aufrufen
COOKIE="session=..."
curl -s -b "$COOKIE" http://localhost:5001/wiki
6.5 Container-internen Traffic beobachten
# LDAP-Logs via podman logs (slapd loggt nach stdout, nicht in Datei)
podman logs -f ldap
# WebApp-Logs live
podman logs -f webapp1
podman logs -f webapp2
podman logs -f wiki-api
Hinweis: OpenLDAP im Container loggt standardmäßig nach stdout/stderr,
nicht in /var/log/slapd.log. Für detaillierte LDAP-Traces müsste man
das Logging in der slapd.conf oder via loglevel aktivieren.
6.6 LDAP-Monitoring
# Alle Einträge im LDAP
ldapsearch -x -H ldap://localhost:3389 \
-D "cn=admin,dc=example,dc=com" -w "iam-poc-admin" \
-b "dc=example,dc=com"
# Nur Benutzer
ldapsearch -x -H ldap://localhost:3389 \
-D "cn=admin,dc=example,dc=com" -w "iam-poc-admin" \
-b "dc=example,dc=com" "(objectClass=inetOrgPerson)" uid cn mail
# Nur Gruppen (einschließlich der Rollen-Gruppen)
ldapsearch -x -H ldap://localhost:3389 \
-D "cn=admin,dc=example,dc=com" -w "iam-poc-admin" \
-b "dc=example,dc=com" "(objectClass=groupOfUniqueNames)" cn uniqueMember
6.7 API-Token-Generierung (simuliert)
# Simulierten API-Token generieren (für Doku-Tests)
echo -n '{"sub":"achim","role":"admin","exp":9999999999}' | base64 -w0
echo ".$(openssl rand -hex 24)." | tr -d '\n'
7. Auto-Start (systemd)
Alle Container sind als systemd-User-Services mit Restart-Policy on-failure konfiguriert:
# Services für alle Container generieren (soweit nicht schon geschehen)
podman generate systemd --restart-policy=on-failure --name webapp1 \
> ~/.config/systemd/user/podman-webapp1.service
podman generate systemd --restart-policy=on-failure --name webapp2 \
> ~/.config/systemd/user/podman-webapp2.service
podman generate systemd --restart-policy=on-failure --name wiki-api \
> ~/.config/systemd/user/podman-wiki-api.service
# Services aktivieren (bereits erledigt)
systemctl --user daemon-reload
systemctl --user enable podman-webapp1.service podman-webapp2.service podman-wiki-api.service
# Prüfen
systemctl --user status podman-webapp1.service
systemctl --user status podman-webapp2.service
systemctl --user status podman-wiki-api.service
Hinweis: linger muss aktiv sein (bereits erledigt):
sudo loginctl enable-linger achim
8. Netzwerkübersicht
┌─────────────────────────────────────────────────────────────────────┐
│ raspi (Host) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ ┌──────────┐
│ │ webapp1 │ │ webapp2 │ │ ldap │ │openbao│ │ wiki-api │
│ │5001→5000│ │5002→5000│ │3389→389 │ │ :8200 │ │5003→5000│
│ │ Flask │ │ Gateway │ │ LDAP │ │ Vault │ │ FastAPI │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬───┘ └────┬─────┘
│ │ │ │ │ │
│ └──────────────────┬────────────────────────────────────────┘
│ │ iam-net (Podman bridge)
│ │ DNS: ldap, webapp1, webapp2, wiki-api, openbao
│
│ Internet / LAN:
│ http://raspi:5001 → webapp1 (LDAP-Login + Wiki-UI)
│ http://raspi:5002 → webapp2 (API-Key Gateway)
│ http://raspi:5003 → wiki-api (direkt, nur für Tests)
│ http://raspi:8200 → OpenBao API
│ ldap://raspi:3389 → OpenLDAP (extern)
└─────────────────────────────────────────────────────────────────────┘
9. Troubleshooting
9.1 WebApp1: Login schlägt fehl
# Prüfen: LDAP läuft?
curl -s http://localhost:5001/health
# Prüfen: LDAP-Verbindung vom Container aus
podman exec webapp1 python3 -c "
from ldap3 import Server, Connection, ALL
s = Server('ldap', port=389, get_info=ALL)
c = Connection(s, user='cn=admin,dc=example,dc=com', password='iam-poc-admin', auto_bind=True)
print('LDAP OK:', c)
c.unbind()
"
# Prüfen: DNS-Auflösung im Container
podman exec webapp1 getent hosts ldap
9.2 WebApp2: API-Key wird nicht akzeptiert
# Prüfen: Environment-Variable gesetzt?
podman exec webapp2 env | grep API_KEY
# Health (immer ohne Key erreichbar)
curl -s http://localhost:5002/health
9.3 Port-Konflikte
# Prüfen, ob Ports belegt sind
ss -tlnp | grep -E "5001|5002|5003"
# Falls belegt: Container neu starten
podman stop webapp1 && podman rm webapp1
podman run -d --name webapp1 --network iam-net \
-p 5001:5000 \
-e LDAP_HOST=ldap \
-e LDAP_PORT=389 \
localhost/webapp1-ldap:latest
9.4 Wiki-API nicht erreichbar
# Prüfen: Container läuft?
podman ps | grep wiki-api
# Prüfen: Direkter Zugriff im iam-net
curl -s http://localhost:5003/health
# Logs prüfen
podman logs wiki-api
# Volume prüfen
podman volume inspect wiki-data
9.5 OpenBao nicht erreichbar oder sealed
# Status prüfen
curl -s http://127.0.0.1:8200/v1/sys/health | python3 -m json.tool
# → "sealed": true → muss unsealed werden
# Unseal (falls nötig)
# Siehe OpenBao-Doku für Unseal-Keys
10. Zugangsdaten (PoC)
| Service | Zugang | Details |
|---|---|---|
| WebApp1 (LDAP-Login) | http://raspi:5001 | Benutzer: achim / Passwort: achim123 |
| WebApp1 (LDAP-Login) | http://raspi:5001 | Benutzer: testuser / Passwort: test123 |
| WebApp2 (API-Gateway) | http://raspi:5002 | Admin-Key: poc-api-key-secret-2026 / Viewer-Key: poc-viewer-key-2026 / Editor-Key: poc-editor-key-2026 |
| Wiki-API (direkt) | http://raspi:5003 | Nur via X-User-Role-Header (intern, kein externer Zugriff) |
| LDAP (ext. Zugriff) | ldap://raspi:3389 | Admin: cn=admin,dc=example,dc=com / iam-poc-admin |
| OpenBao API | http://raspi:8200 | Root-Token: s.5P16Xr4iAP3Pdwz7ipg3gzBw (Netzwerk: iam-net) |
11. Offene Punkte / Nächste Schritte
- [ ] Wiki-UI aufhübschen (CSS, bessere Navigation)
- [ ] Keycloak oder MidPoint-Integration?
- [ ] Swagger-Doku des Wiki-API nutzen (
/docs) - [ ] Automatisierte Integrationstests für alle Flows
- [ ] Systeme härten
12. Quellen
- LDAP Installation — Voraussetzung für WebApp1
- OpenBao Entity — Secrets Management
- PAM Zielarchitektur — Gesamtarchitektur
- OpenBao Installation — OpenBao auf dem Pi
- Wiki-API — Backend-API-Server
- Swimlane — Visualisierung des Datenflusses
- System härten — Sicherheitsmaßnahmen
Anhang: Vollständiger Quellcode
app.py (WebApp1)
"""
WebApp 1: Protected App mit LDAP-Authentifizierung + Wiki-UI
IAM PoC - Flask-App mit LDAP-Login und Wiki-Artikel-Anzeige
"""
from flask import Flask, request, redirect, render_template_string, session, url_for
from ldap3 import Server, Connection, ALL, core
import os
import requests
app = Flask(__name__)
app.secret_key = os.urandom(24)
# LDAP-Konfiguration
LDAP_HOST = os.getenv("LDAP_HOST", "ldap")
LDAP_PORT = int(os.getenv("LDAP_PORT", "389"))
LDAP_BASE_DN = os.getenv("LDAP_BASE_DN", "dc=example,dc=com")
LDAP_USER_DN_TPL = os.getenv("LDAP_USER_DN_TEMPLATE", "uid={username},ou=people,dc=example,dc=com")
LDAP_ADMIN_DN = os.getenv("LDAP_ADMIN_DN", "cn=admin,dc=example,dc=com")
LDAP_ADMIN_PW = os.getenv("LDAP_ADMIN_PW", "iam-poc-admin")
# Wiki-API (direkt im iam-net)
WIKI_API_URL = os.getenv("WIKI_API_URL", "http://wiki-api:5000")
# Rollen-Mapping von LDAP-Gruppe → Wiki-Rolle
# Ein User bekommt die höchste Rolle aus allen seinen Gruppen
GROUP_ROLE_MAP = {
"admin": "admin",
"admins": "admin", # Alte Gruppe
"editor": "editor",
"developers": "editor", # Entwickler bekommen Editor-Rechte
"helpdesk": "viewer",
"viewer": "viewer",
}
ROLE_LEVELS = {"admin": 3, "editor": 2, "viewer": 1}
def get_user_role(username: str) -> str:
"""Ermittelt die höchste Rolle eines Users anhand seiner LDAP-Gruppen."""
try:
conn = Connection(
Server(LDAP_HOST, port=LDAP_PORT, get_info=ALL),
user=LDAP_ADMIN_DN, password=LDAP_ADMIN_PW, auto_bind=True
)
conn.search(
search_base=f"ou=groups,{LDAP_BASE_DN}",
search_filter=f"(uniqueMember=uid={username},ou=people,{LDAP_BASE_DN})",
attributes=["cn"]
)
max_level = 0
best_role = "viewer" # Default
for entry in conn.entries:
group_cn = str(entry.cn.value) if hasattr(entry.cn, 'value') else str(entry.cn)
wiki_role = GROUP_ROLE_MAP.get(group_cn)
if wiki_role and ROLE_LEVELS.get(wiki_role, 0) > max_level:
max_level = ROLE_LEVELS[wiki_role]
best_role = wiki_role
conn.unbind()
print(f"[get_user_role] {username} → {best_role}")
return best_role
except Exception as e:
print(f"[get_user_role] Fehler: {e}")
return "viewer"
def fetch_wiki_articles(role: str) -> list | None:
"""Ruft Wiki-Artikel vom API-Server ab (mit der User-Rolle)."""
try:
resp = requests.get(
f"{WIKI_API_URL}/v1/wiki/articles",
headers={"X-User-Role": role},
timeout=5
)
if resp.status_code == 200:
return resp.json()
return None
except requests.exceptions.ConnectionError:
return None
def fetch_single_article(article_id: int, role: str) -> dict | None:
"""Ruft einen einzelnen Wiki-Artikel ab."""
try:
resp = requests.get(
f"{WIKI_API_URL}/v1/wiki/articles/{article_id}",
headers={"X-User-Role": role},
timeout=5
)
if resp.status_code == 200:
return resp.json()
return None
except requests.exceptions.ConnectionError:
return None
# ── Templates ────────────────────────────────────────────
LOGIN_PAGE = """
IAM PoC - Login
card">
🔐 IAM PoC
color
:#666;">Bitte mit LDAP-Benutzer anmelden
{% if error %}error">{{ error }}{% endif %}
post">
Benutzername
text" name="username" required autofocus>
Passwort
password" name="password" required>
submit">Anmelden
hint">Testuser: achim / achim123 · testuser / test123
"""
WIKI_PAGE = """
IAM PoC - Wiki
header">
📖 IAM PoC Wiki
user">
{{ username }}
role-badge">{{ role }}
{{ url_for('logout') }}">Abmelden
container">
{% if article %}
{{ url_for('wiki') }}" class="back-link">← Zurück zur Übersicht
article-detail">
{{ article.title }}
meta">
Autor: {{ article.author }}
Rolle: {{ article.required_role }}
Aktualisiert: {{ article.updated_at[:10] }}
content">{{ article.content_html|safe }}
{% elif articles is not none %}
article-list">
{% if articles %}
{% for a in articles %}
article-item" onclick="location.href='?article={{ a.id }}'">
{{ a.title }}
meta">
✍️ {{ a.author }}
🕐 {{ a.updated_at[:10] }}
role-tag">{{ a.required_role }}
{% endfor %}
{% else %}
empty">📭 Keine Artikel gefunden
{% endif %}
{% else %}
article-detail" style="text-align:center;padding:40px;color:#888;">
⚠️ Wiki-API nicht erreichbar.
Prüfe ob `wiki-api` Container läuft.
{% endif %}
footer">
IAM PoC — Wiki via WebApp1 · OpenLDAP · FastAPI · SQLite
"""
def render_markdown_simple(text: str) -> str:
"""Sehr einfaches Markdown-Rendering für die Demo.
Wandelt die wichtigsten Elemente in HTML um."""
import re
html = text
# Code-Blöcke (```...```)
html = re.sub(r'```(\w*)\n(.*?)```', r'\2
', html, flags=re.DOTALL)
# Tabellen: | a | b |
html = re.sub(r'\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)*)', _render_table, html)
# Überschriften (## → h2, ### → h3)
html = re.sub(r'^### (.+)$', r'\1
', html, flags=re.MULTILINE)
html = re.sub(r'^## (.+)$', r'\1
', html, flags=re.MULTILINE)
html = re.sub(r'^# (.+)$', r'\1
', html, flags=re.MULTILINE)
# Fett/Kursiv
html = re.sub(r'\*\*(.+?)\*\*', r'\1', html)
html = re.sub(r'\*(.+?)\*', r'\1', html)
# Listen
html = re.sub(r'^- (.+)$', r'\1 ', html, flags=re.MULTILINE)
html = re.sub(r'(.* (\n.* )*)', r'\1
', html)
# Zeilenumbrüche
html = html.replace('\n', '
')
return html
def _render_table(match):
"""Wandelt eine Markdown-Tabelle in HTML um."""
lines = match.group(0).strip().split('\n')
html = '\n'
for i, line in enumerate(lines):
if line.strip().startswith('|---'):
continue
cells = [c.strip() for c in line.strip().strip('|').split('|')]
tag = 'th' if i == 0 else 'td'
html += ' \n' + ''.join(f' {c}\n' for c in cells) + ' \n'
html += ''
return html
# ── Routes ────────────────────────────────────────────────
@app.route("/", methods=["GET", "POST"])
def login():
if "username" in session:
return redirect(url_for("wiki"))
error = None
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if authenticate_ldap(username, password):
session["username"] = username
session["role"] = get_user_role(username)
return redirect(url_for("wiki"))
else:
error = "Ungültiger Benutzername oder Passwort"
return render_template_string(LOGIN_PAGE, error=error)
@app.route("/wiki")
def wiki():
if "username" not in session:
return redirect(url_for("login"))
username = session["username"]
role = session.get("role", "viewer")
# Einzelartikel?
article_id = request.args.get("article")
single_article = None
if article_id:
data = fetch_single_article(int(article_id), role)
if data:
data["content_html"] = render_markdown_simple(data.get("content", ""))
single_article = data
# Artikel-Liste
articles = fetch_wiki_articles(role)
return render_template_string(WIKI_PAGE,
username=username,
role=role,
articles=articles,
article=single_article)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/health")
def health():
return {"status": "ok", "app": "webapp1-ldap-with-wiki"}, 200
# ── Hilfsfunktionen ───────────────────────────────────────
def authenticate_ldap(username, password):
"""Authentifiziert einen Benutzer gegen LDAP."""
user_dn = LDAP_USER_DN_TPL.replace("{username}", username)
server = Server(LDAP_HOST, port=LDAP_PORT, get_info=ALL)
try:
conn = Connection(server, user=user_dn, password=password, auto_bind=True)
conn.unbind()
return True
except core.exceptions.LDAPBindError:
return False
except Exception as e:
print(f"LDAP-Fehler: {e}")
return False
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)
Dockerfile (WebApp1)
# WebApp 1: Protected App mit LDAP-Authentifizierung + Wiki-UI
#
# IAM PoC - Flask-App mit LDAP-Login und Wiki-Artikel-Anzeige
FROM docker.io/python:3.12-alpine
RUN pip install --no-cache-dir flask ldap3 requests
COPY app.py /app/app.py
WORKDIR /app
ENV LDAP_HOST="ldap"
ENV LDAP_PORT="389"
EXPOSE 5000
CMD ["python", "app.py"]
app.py (WebApp2)
"""
WebApp 2: API-Key Gateway mit dynamischer Key-Vergabe (OpenBao + LDAP)
IAM PoC - Authentifizierung gegen LDAP, temporäre Secrets aus OpenBao,
Live-Validation jedes Requests gegen OpenBao
"""
from flask import Flask, request, jsonify, Response
from ldap3 import Server, Connection, ALL, core
from datetime import datetime, timezone, timedelta
import os
import requests
import uuid
from typing import Optional
app = Flask(__name__)
app.secret_key = os.urandom(24)
# ── Konfiguration ──────────────────────────────────────
WIKI_API_URL = os.getenv("WIKI_API_URL", "http://wiki-api:5000")
OPENBAO_ADDR = os.getenv("OPENBAO_ADDR", "http://openbao:8200")
OPENBAO_TOKEN = ***"OPENBAO_TOKEN", "")
SECRET_PATH = os.getenv("OPENBAO_SECRET_PATH", "secret/data/wiki-api-keys")
TEMP_TTL_MIN = int(os.getenv("TEMP_KEY_TTL_MINUTES", "60"))
# LDAP-Konfiguration (gleiche Werte wie WebApp1)
LDAP_HOST = os.getenv("LDAP_HOST", "ldap")
LDAP_PORT = int(os.getenv("LDAP_PORT", "389"))
LDAP_BASE_DN = os.getenv("LDAP_BASE_DN", "dc=example,dc=com")
LDAP_ADMIN_DN = os.getenv("LDAP_ADMIN_DN", "cn=admin,dc=example,dc=com")
LDAP_ADMIN_PW = os.getenv("LDAP_ADMIN_PW", "iam-poc-admin")
# Fallback-Keys (werden nur genutzt, wenn OpenBao nicht erreichbar ist)
API_KEY_ROLE_MAP: dict[str, str] = {
"poc-api-key-secret-2026": "admin",
"poc-viewer-key-2026": "viewer",
"poc-editor-key-2026": "editor",
}
# Rollen-Mapping von LDAP-Gruppe → Wiki-Rolle
GROUP_ROLE_MAP = {
"admin": "admin",
"admins": "admin",
"editor": "editor",
"developers": "editor",
"helpdesk": "viewer",
"viewer": "viewer",
}
ROLE_LEVELS = {"admin": 3, "editor": 2, "viewer": 1}
# ── LDAP-Hilfsfunktionen (aus WebApp1 übernommen) ──────
def authenticate_ldap(username: str, password: str) -> bool:
"""Authentifiziert einen Benutzer gegen LDAP."""
user_dn = f"uid={username},ou=people,dc=example,dc=com"
server = Server(LDAP_HOST, port=LDAP_PORT, get_info=ALL)
try:
conn = Connection(server, user=user_dn, password=*** auto_bind=True)
conn.unbind()
return True
except core.exceptions.LDAPBindError:
return False
except Exception as e:
print(f"[LDAP] Fehler: {e}")
return False
def get_user_role(username: str) -> str:
"""Ermittelt die höchste Rolle eines Users anhand seiner LDAP-Gruppen."""
try:
conn = Connection(
Server(LDAP_HOST, port=LDAP_PORT, get_info=ALL),
user=LDAP_ADMIN_DN, password=*** auto_bind=True
)
conn.search(
search_base=f"ou=groups,{LDAP_BASE_DN}",
search_filter=f"(uniqueMember=uid={username},ou=people,{LDAP_BASE_DN})",
attributes=["cn"]
)
max_level = 0
best_role = "viewer"
for entry in conn.entries:
group_cn = str(entry.cn.value) if hasattr(entry.cn, 'value') else str(entry.cn)
wiki_role = GROUP_ROLE_MAP.get(group_cn)
if wiki_role and ROLE_LEVELS.get(wiki_role, 0) > max_level:
max_level = ROLE_LEVELS[wiki_role]
best_role = wiki_role
conn.unbind()
print(f"[get_user_role] {username} → {best_role}")
return best_role
except Exception as e:
print(f"[get_user_role] Fehler: {e}")
return "viewer"
# ── OpenBao-API ─────────────────────────────────────────
def _bao_headers() -> dict:
return {"X-Vault-Token": OPENBAO_TOKEN, "Content-Type": "application/json"}
def create_temp_secret(username: str, role: str) -> Optional[dict]:
"""Erzeugt ein temporäres Secret in OpenBao und gibt die Details zurück."""
if not OPENBAO_TOKEN:
***"[WARN] Kein OPENBAO_TOKEN – kann kein temporäres Secret erstellen")
return None
api_key = str(uuid.uuid4())
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=TEMP_TTL_MIN)).isoformat()
payload = {
"data": {
"owner": username,
"role": role,
"expires_at": expires_at,
}
}
try:
resp = requests.post(
f"{OPENBAO_ADDR}/v1/{SECRET_PATH}/temp/{api_key}",
headers=_bao_headers(),
json=payload,
timeout=5
)
if resp.status_code in (200, 204):
print(f"[OpenBao] Temporäres Secret erstellt: {api_key[:12]}... → {role} (bis {expires_at})")
return {"api_key": api_key, "role": role, "expires_at": expires_at}
else:
print(f"[OpenBao] Fehler beim Erstellen: HTTP {resp.status_code}")
return None
except Exception as e:
print(f"[OpenBao] Fehler: {e}")
return None
def validate_temp_secret(api_key: str) -> Optional[str]:
"""Validiert einen temporären API-Key live gegen OpenBao.
Gibt die Rolle zurück oder None wenn ungültig."""
# 1. Fallback: Statische Keys prüfen (kurzer Pfad)
if api_key in API_KEY_ROLE_MAP:
return API_KEY_ROLE_MAP[api_key]
# 2. Live-Validation gegen OpenBao
if not OPENBAO_TOKEN:
*** None
try:
resp = requests.get(
f"{OPENBAO_ADDR}/v1/{SECRET_PATH}/temp/{api_key}",
headers=_bao_headers(),
timeout=5
)
if resp.status_code == 200:
data = resp.json().get("data", {}).get("data", {})
expires_at = data.get("expires_at", "")
role = data.get("role", "")
if not role:
return None
# Ablaufzeit prüfen
if expires_at:
try:
exp = datetime.fromisoformat(expires_at)
if exp < datetime.now(timezone.utc):
print(f"[Validation] Key {api_key[:12]}... abgelaufen ({expires_at})")
return None
except ValueError:
pass # Ungültiges Datum – trotzdem akzeptieren
print(f"[Validation] Key {api_key[:12]}... → role {role}")
return role
return None
except Exception as e:
print(f"[Validation] Fehler: {e}")
return None
# ── Auth-Filter ─────────────────────────────────────────
def require_api_key() -> Optional[str]:
"""Prüft X-API-Key und gibt die Rolle zurück."""
auth_header = request.headers.get("X-API-Key", "")
return validate_temp_secret(auth_header)
@app.before_request
def check_auth():
"""API-Key-Prüfung auf alle Endpunkte außer /health und /v1/* Auth-Endpunkte."""
if request.path in ("/health", "/v1/authenticate", "/v1/validate"):
return None
if request.method == "OPTIONS":
return None
role = require_api_key()
if not role:
return jsonify({
"error": "Unauthorized",
"message": "Gültiger X-API-Key erforderlich"
}), 401
request.user_role = role
# ── Proxy ───────────────────────────────────────────────
def _proxy(method: str, path: str, body=None):
"""Leitet einen Request an den internen Wiki-API-Server weiter."""
url = f"{WIKI_API_URL}{path}"
headers = {
"X-User-Role": request.user_role,
"Content-Type": "application/json",
}
try:
resp = requests.request(method, url, headers=headers, json=body, timeout=5)
return Response(
resp.content,
status=resp.status_code,
content_type=resp.headers.get("Content-Type", "application/json")
)
except requests.exceptions.ConnectionError:
return jsonify({
"error": "Service Unavailable",
"message": "Wiki-API-Server nicht erreichbar"
}), 503
# ── Eigene Endpunkte ────────────────────────────────────
@app.route("/health")
def health():
"""Health-Check (immer ohne Auth)."""
return jsonify({
"status": "ok",
"app": "webapp2-api",
"backend": WIKI_API_URL,
"auth_mode": "openbao-live+ldap",
"temp_key_ttl_min": TEMP_TTL_MIN,
"openbao_connected": bool(OPENBAO_TOKEN),
})
@app.route("/v1/authenticate", methods=["POST"])
def authenticate():
"""
LDAP-Login → temporärer API-Key aus OpenBao.
Body: { "username": "...", "password": "***" }
Return: { "api_key": "***", "role": "...", "expires_at": "..." }
"""
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return jsonify({"error": "Bad Request", "message": "username und password erforderlich"}), 400
# 1. LDAP-Authentifizierung
if not authenticate_ldap(username, password):
return jsonify({"error": "Unauthorized", "message": "Ungültiger Benutzername oder Passwort"}), 401
# 2. Rolle aus LDAP-Gruppen ermitteln
role = get_user_role(username)
# 3. Temporäres Secret in OpenBao anlegen
secret = create_temp_secret(username, role)
if not secret:
# Fallback: Statischen Key für die Rolle ausgeben (wenn OpenBao nicht geht)
for key, key_role in API_KEY_ROLE_MAP.items():
if key_role == role:
return jsonify({
"api_key": key,
"role": role,
"expires_at": None,
"warning": "Fallback-Key (OpenBao nicht verfügbar)"
})
return jsonify({"error": "Service Unavailable", "message": "Keine Keys verfügbar"}), 503
return jsonify(secret)
@app.route("/v1/validate", methods=["POST"])
def validate():
"""Validiert einen API-Key (sowohl statisch als auch temporär)."""
data = request.get_json(silent=True) or {}
received_key = data.get("api_key", "")
role = validate_temp_secret(received_key)
if role:
return jsonify({"valid": True, "service": "webapp2-api", "role": role})
return jsonify({"valid": False}), 401