--- id: PROJ-22 title: LDAP / Active Directory – Web-GUI Konfiguration & Test status: Planned created: 2026-03-17 depends_on: PROJ-16 --- ## Ziel LDAP/AD-Verbindungen sollen vollständig über die Web-GUI eingetragen, bearbeitet und getestet werden können — ohne direkten Zugriff auf `config.yml` oder den Server. --- ## Abhängigkeiten | Projekt | Abhängigkeit | |---|---| | PROJ-16 | Backend-LDAP-Authentifizierung muss implementiert sein (Phase A) | | PROJ-21 | Für Multi-Tenant-Betrieb (Phase B) — PROJ-22 Phase A ist unabhängig | --- ## Kernproblem: Config-Speicherung LDAP ist aktuell nur in `config.yml` konfigurierbar — eine GUI kann diese Datei nicht sicher lesen/schreiben. Lösung: **LDAP-Konfiguration wird in der Datenbank gespeichert.** `config.yml` bleibt als Fallback für initiale Einrichtung erhalten. **Priorität:** DB-Konfiguration > `config.yml` --- ## Datenbankschema ### Neue Tabelle: `ldap_config` (Single-Tenant / Phase A) ```sql CREATE TABLE ldap_config ( id BIGSERIAL PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false, url TEXT NOT NULL, -- ldap://... oder ldaps://... bind_dn TEXT NOT NULL, bind_password BYTEA NOT NULL, -- AES-256-GCM verschlüsselt base_dn TEXT NOT NULL, user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)', tls BOOLEAN NOT NULL DEFAULT false, -- STARTTLS tls_skip_verify BOOLEAN NOT NULL DEFAULT false, default_role VARCHAR(20) NOT NULL DEFAULT 'user', group_mappings JSONB, -- [{group_dn, role}, ...] updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by TEXT -- Username des letzten Bearbeiters ); -- Maximal ein Datensatz (Single-Tenant). Nach PROJ-21 wird tenant_ldap verwendet. ``` > **Multi-Tenant (PROJ-21 Phase B):** `ldap_config` wird durch `tenant_ldap` (aus PROJ-21) > ersetzt. Die API-Endpunkte bleiben identisch — nur der Routing-Kontext ändert sich. --- ## Backend ### Neues Paket: `internal/ldapconfig` ``` internal/ldapconfig/ store.go — CRUD für ldap_config Tabelle, Passwort-Ver/Entschlüsselung ``` **Methoden:** ```go func (s *Store) Get(ctx) (*LDAPConfig, error) // Passwort maskiert func (s *Store) GetWithPassword(ctx) (*LDAPConfig, error) // inkl. entschlüsseltem PW func (s *Store) Save(ctx, cfg LDAPConfig) error // Upsert func (s *Store) Delete(ctx) error // LDAP deaktivieren ``` ### Neues Paket: `internal/ldapauth` ``` internal/ldapauth/ client.go — Verbindung, Bind, User-Suche, Gruppen-Abfrage test.go — TestConnection(cfg) → TestResult ``` **TestResult:** ```go type TestResult struct { OK bool Message string LatencyMS int64 ServerInfo string // z.B. "OpenLDAP 2.6" oder "Active Directory" UsersFound int // Anzahl User im base_dn ErrorDetail string } ``` ### API-Endpunkte (admin only) ``` GET /api/admin/ldap Aktuelle Konfiguration abrufen (Passwort maskiert: "••••••") PUT /api/admin/ldap Konfiguration speichern (leeres Passwort = bestehendes behalten) DELETE /api/admin/ldap LDAP deaktivieren und Konfiguration löschen POST /api/admin/ldap/test Verbindung testen ``` **`PUT /api/admin/ldap` Request:** ```json { "enabled": true, "url": "ldap://192.168.1.10:389", "bind_dn": "CN=archivmail-svc,OU=ServiceAccounts,DC=corp,DC=local", "bind_password": "", // leer = bestehendes Passwort beibehalten "base_dn": "OU=Users,DC=corp,DC=local", "user_filter": "(sAMAccountName=%s)", "tls": false, "tls_skip_verify": false, "default_role": "user", "group_mappings": [ { "group_dn": "CN=archivmail-admins,OU=Groups,DC=corp,DC=local", "role": "domain_admin" }, { "group_dn": "CN=archivmail-auditors,OU=Groups,DC=corp,DC=local", "role": "auditor" } ] } ``` **`POST /api/admin/ldap/test` Request:** ```json { "use_saved": true // true = gespeicherte Konfig testen; false = Body-Felder verwenden } // Bei use_saved=false: gleicher Body wie PUT (zum Testen vor dem Speichern) ``` **`POST /api/admin/ldap/test` Response:** ```json { "ok": true, "message": "Verbindung erfolgreich", "latency_ms": 12, "server_info": "Active Directory (Windows Server 2022)", "users_found": 247, "error_detail": "" } ``` ### Audit-Log Jede Änderung der LDAP-Konfiguration wird geloggt: - `action=ldap_config_updated, changed_by=admin` - `action=ldap_config_deleted, changed_by=admin` - `action=ldap_test, result=ok|fail, latency_ms=12` --- ## Frontend ### Neuer Tab im Admin-Bereich: „LDAP" Eingebaut zwischen „Import" und „Module" im bestehenden ``-Block in `src/app/admin/page.tsx`. #### Layout ``` ┌─────────────────────────────────────────────────────┐ │ LDAP / Active Directory [●] Aktiv │ ├─────────────────────────────────────────────────────┤ │ │ │ Server-URL [ldap://192.168.1.10:389 ] │ │ Bind-DN [CN=svc,DC=corp,DC=local ] │ │ Bind-Passwort [•••••••• ] [↺] │ │ Base-DN [OU=Users,DC=corp,DC=local ] │ │ Benutzer-Filter [(sAMAccountName=%s) ] │ │ │ │ [ ] STARTTLS [ ] TLS-Zertifikat ignorieren │ │ │ │ Standard-Rolle [Benutzer ▼] │ │ │ │ Gruppen-Zuordnungen [+ Hinzuf.] │ │ ┌────────────────────────────────────────────────┐ │ │ │ CN=admins,... → Domain-Admin [🗑] │ │ │ │ CN=auditors,... → Auditor [🗑] │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ [Verbindung testen] [Speichern] [Konfiguration │ │ löschen] │ │ │ │ ┌─ Testergebnis ─────────────────────────────────┐ │ │ │ ✓ Verbindung erfolgreich (12 ms) │ │ │ │ Server: Active Directory (Windows Server 22) │ │ │ │ Benutzer im Verzeichnis: 247 │ │ │ └────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ``` #### Verhalten - **Laden:** Beim Öffnen des Tabs wird `GET /api/admin/ldap` aufgerufen. Passwort-Feld zeigt `••••••`, echter Wert nie übertragen. - **Passwort-Feld:** Leer lassen = bestehendes Passwort unverändert. Passwort-Symbol `↺` setzt Feld zurück (zwingt Neueingabe). - **Verbindung testen:** Sendet aktuellen Formular-Inhalt (ungespeichert) an `POST /api/admin/ldap/test` mit `use_saved=false`. Testergebnis erscheint direkt unter dem Formular. - **Speichern:** `PUT /api/admin/ldap`, danach Reload. Erfolgsmeldung via Toast. - **Aktivieren/Deaktivieren:** Toggle oben rechts — sendet `PUT` mit `enabled: true/false`, kein Reload nötig. - **Gruppen-Zuordnungen:** Zeilen dynamisch hinzufügen/entfernen. Kein separater Speichern-Button — beim Haupt-„Speichern" mitübertragen. - **Konfiguration löschen:** Bestätigungs-Dialog — `DELETE /api/admin/ldap`. #### Neue API-Funktionen in `src/lib/api.ts` ```typescript export interface LDAPConfig { enabled: boolean; url: string; bind_dn: string; bind_password: string; // immer "••••••" in GET-Response base_dn: string; user_filter: string; tls: boolean; tls_skip_verify: boolean; default_role: string; group_mappings: { group_dn: string; role: string }[]; updated_at?: string; updated_by?: string; } export interface LDAPTestResult { ok: boolean; message: string; latency_ms: number; server_info: string; users_found: number; error_detail: string; } export async function getLDAPConfig(): Promise export async function saveLDAPConfig(cfg: LDAPConfig): Promise export async function deleteLDAPConfig(): Promise export async function testLDAPConfig(cfg: LDAPConfig, useSaved: boolean): Promise ``` --- ## Umsetzungsreihenfolge | Schritt | Inhalt | |---|---| | 1 | DB-Tabelle `ldap_config` anlegen (`initSchema`) | | 2 | `internal/ldapconfig`: Store mit AES-Passwort-Verschlüsselung | | 3 | `internal/ldapauth`: TestConnection-Funktion | | 4 | API-Handler: GET / PUT / DELETE / POST test | | 5 | `auth.Manager.Login` nutzt DB-Config (Fallback: config.yml) | | 6 | `src/lib/api.ts`: neue Funktionen | | 7 | `src/app/admin/page.tsx`: neuer LDAP-Tab | | 8 | Audit-Log für alle LDAP-Aktionen | --- ## Sicherheit - Passwort wird **niemals** im Klartext über die API zurückgegeben (GET maskiert mit `••••••`) - Passwort-Speicherung: AES-256-GCM, gleiche Methode wie IMAP-Passwörter (`internal/imap/store.go`) - Leeres Passwort im PUT = bestehendes beibehalten (kein versehentliches Löschen) - `tls_skip_verify: true` löst Admin-Warnung im UI aus - LDAP-Konfig nur für `admin` / `domain_admin` sichtbar — nie für `user` oder `auditor` --- ## Akzeptanzkriterien - [ ] Admin kann LDAP-Verbindung vollständig über die Web-GUI konfigurieren - [ ] „Verbindung testen" gibt Latenz, Server-Typ und User-Anzahl zurück — auch mit ungespeicherten Werten - [ ] Passwort wird niemals im Klartext an das Frontend gesendet - [ ] Bestehendes Passwort bleibt erhalten wenn Feld leer gelassen wird - [ ] Gruppen-Zuordnungen sind im UI verwaltbar (hinzufügen, entfernen) - [ ] Alle Änderungen landen im Audit-Log - [ ] config.yml-Konfiguration funktioniert weiterhin als Fallback wenn keine DB-Konfig vorhanden - [ ] STARTTLS und LDAPS werden korrekt unterstützt und getestet