Mein IAM+PAM System auf dem Raspberry Pi

Words
2463
Reading
11 min
Listen
Play
3M

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:

IAM+PAM.jpg

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

  1. Vorbedingungen
  2. WebApp 1 – LDAP-Login mit Wiki-Oberfläche
  3. WebApp 2 – API-Key Gateway
  4. Wiki-API-Server
  5. Authentifizierungs-Flows
  6. Manuelle Befehle nachstellen
  7. Auto-Start (systemd)
  8. Netzwerkübersicht
  9. Troubleshooting
  10. Zugangsdaten
  11. Offene Punkte
  12. Quellen

Übersicht

Fünf Container demonstrieren die Authentifizierungs- und Autorisierungs-Flows im IAM PoC:

ContainerPortAuth-MethodeBackend
ldap — OpenLDAP3389389LDAP-Bind (Benutzername + Passwort)LDAP-Datenbank
openbao — Secrets:8200X-Vault-TokenStorage Backend
webapp1 — LDAP-Login+Wiki50015000Benutzername/Passwort via LDAP-BindOpenLDAP + Wiki-API
webapp2 — API-Gateway50025000X-API-Key → Live-Validation via OpenBao, /v1/authenticateWiki-API
wiki-api — Wiki-Backend50035000X-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

ContainerPortNetzwerk
openbao8200iam-net
ldap3389iam-net
webapp15001iam-net
webapp25002iam-net
wiki-api5003iam-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:

ParameterZweck
--network iam-netVerbindet Container mit LDAP (DNS-Name ldap)
-p 5001:5000Host-Port 5001 → internen Flask-Port 5000
-e LDAP_HOST=ldapLDAP-Hostname (Container-Name im iam-net)
-e LDAP_PORT=389LDAP-Port (intern, kein Port-Mapping nötig)

grafik.png

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"}

grafik.png


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-2026 etc.)

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/authenticate und /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:5000 fü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'"}

Test 1 +2

grafik.png

Test 5+6+7

Test 8


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).

MethodePfadBeschreibungBenötigte Rolle
GET/v1/wiki/articlesAlle sichtbaren Artikelbeliebig
GET/v1/wiki/articles/{id}Einzelner Artikelabhängig vom Artikel
POST/v1/wiki/articlesNeuen Artikel anlegeneditor / admin
PUT/v1/wiki/articles/{id}Artikel aktualisiereneditor / admin
DELETE/v1/wiki/articles/{id}Artikel löschenadmin
GET/healthHealth-Checkkeiner

grafik.png

grafik.png

4.4 Seed-Daten

Beim ersten Start werden automatisch 5 Beispiel-Artikel angelegt:

IDTitelBenötigte Rolle
1Willkommen im Wikiviewer
2VPN-Einrichtung (Helpdesk)viewer
3Kunden-Datenbank – Admin-Handbuchadmin
4Anleitung: Passwort-Reset für Kundeneditor
5Server-Monitoring – Übersichtviewer

4.5 Datenbank

  • Typ: SQLite
  • Pfad: /data/wiki.db (im Container, via Volume wiki-data persistent)
  • 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:

  1. Benutzer öffnet http://raspi:5001/ → Login-Formular wird geladen
  2. Benutzer gibt achim / achim123 ein und sendet ab (POST)
  3. WebApp1 konstruiert den LDAP-Bind-DN: uid=achim,ou=people,dc=example,dc=com
  4. WebApp1 verbindet sich via ldap3 zum LDAP-Server (DNS: ldap, Port 389, Container-intern)
  5. 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
  6. 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)
  7. Session lebt serverseitig (Flask-Session) – kein JWT im Cookie, nur eine signierte Session-ID
  8. 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.

grafik.png

Nach dem Anmelden:

grafik.png

Dies ist der Inhalt des Json Web Token (JWT), mit dem Inhalt, den wir von LDAP bekommen haben:

grafik.png
Holen der LDAP Gruppen für die Rollen-Mapping-Entscheidung (admin/editor/viewer)

grafik.png

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):

grafik.png

Als nächstes wird mit diesem Cookie die geschützte Seite aufgerufen (GET http://raspi:5001/wiki):
grafik.png

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:

  1. Phase A – Authentifizierung: Client loggt sich per LDAP ein und bekommt einen temporären API-Key aus OpenBao
  2. 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"
}

grafik.png
Was passiert intern:

  1. WebApp2 führt einen LDAP-Bind mit den übergebenen Credentials durch
  2. Bei Erfolg werden die LDAP-Gruppen des Benutzers abgefragt → höchste Rolle ermittelt (admin/editor/viewer)
  3. Ein temporäres Secret wird in OpenBao angelegt (secret/data/wiki-api-keys/temp/<uuid>)
  4. Das Secret enthält: owner, role, expires_at (standardmäßig 60 Minuten TTL)
  5. 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:

  1. WebApp2 frägt bei OpenBao nach: GET /v1/secret/data/wiki-api-keys/temp/<key>
  2. Existiert nicht → 401 Unauthorized
  3. Abgelaufen (expires_at in der Vergangenheit) → 401 Unauthorized
  4. Gültig → Rolle aus dem Secret extrahiert → X-User-Role-Header gesetzt → Proxy an Wiki-API
  5. 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-KeyRolle
poc-api-key-secret-2026admin
poc-viewer-key-2026viewer
poc-editor-key-2026editor

Wichtig: Statische Keys haben keine Ablaufzeit – sie sind nur für den PoC gedacht.

Rollen-Hierarchie (wie gehabt)

RolleBeispieleDarf
viewerhelpdeskArtikel mit required_role: viewer lesen
editorentwickler, redakteurviewer + editor lesen + Artikel anlegen/bearbeiten
adminachimAlle 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)

LDAP Tests

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}')
"

grafik.png

grafik.png

grafik.png

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

grafik.png

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'

grafik.png

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 
  50015000    50025000    3389389      :8200     50035000
   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)

ServiceZugangDetails
WebApp1 (LDAP-Login)http://raspi:5001Benutzer: achim / Passwort: achim123
WebApp1 (LDAP-Login)http://raspi:5001Benutzer: testuser / Passwort: test123
WebApp2 (API-Gateway)http://raspi:5002Admin-Key: poc-api-key-secret-2026 / Viewer-Key: poc-viewer-key-2026 / Editor-Key: poc-editor-key-2026
Wiki-API (direkt)http://raspi:5003Nur via X-User-Role-Header (intern, kein externer Zugriff)
LDAP (ext. Zugriff)ldap://raspi:3389Admin: cn=admin,dc=example,dc=com / iam-poc-admin
OpenBao APIhttp://raspi:8200Root-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


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
    
    
    
    
    Mein IAM+PAM System auf dem Raspberry Pi | Ecency