docs(PROJ-22): Plan LDAP/AD Web-GUI Konfiguration & Test

Vollständiger Plan: ldap_config DB-Tabelle, internal/ldapconfig + ldapauth Pakete, GET/PUT/DELETE/test API-Endpunkte, LDAP-Tab im Admin-UI mit Formular, Testergebnis-Panel, Gruppen-Zuordnungen, Passwort-Masking und Audit-Log.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-17 14:41:19 +01:00
parent 22dfe1300b
commit b6fa668002
2 changed files with 278 additions and 1 deletions
+2 -1
View File
@@ -34,7 +34,8 @@
| PROJ-19 | Mailpiler → archivmail Migrationstool | Deployed | [PROJ-19](PROJ-19-import-piler.md) | 2026-03-17 |
| PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 |
| PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | Planned | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 |
| PROJ-22 | LDAP / AD Web-GUI Konfiguration & Test | Planned | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 |
<!-- Add features above this line -->
## Next Available ID: PROJ-22
## Next Available ID: PROJ-23
+276
View File
@@ -0,0 +1,276 @@
---
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 `<Tabs>`-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<LDAPConfig>
export async function saveLDAPConfig(cfg: LDAPConfig): Promise<void>
export async function deleteLDAPConfig(): Promise<void>
export async function testLDAPConfig(cfg: LDAPConfig, useSaved: boolean): Promise<LDAPTestResult>
```
---
## 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