---
id: PROJ-22
title: LDAP / Active Directory – Web-GUI Konfiguration & Test
status: Deployed
created: 2026-03-17
depends_on: PROJ-16
---

## Implementiert (2026-03-17)

- `internal/ldapconfig/store.go` — CRUD für `ldap_config` Tabelle, AES-256-GCM Passwortverschlüsselung (identisch zu imap/store.go)
- `internal/ldapauth/client.go` — `TestConnection` + `Authenticate` via go-ldap/ldap/v3; STARTTLS + LDAPS + RootDSE-Abfrage + User-Zählung
- `internal/auth/auth.go` — `Login` mit LDAP-Fallback; `issueToken` als privater Helfer; Gruppen-Rollenzuordnung via `group_mappings`
- `internal/api/ldap_tenants.go` — Handler: GET/PUT/DELETE/POST-test `/api/admin/ldap`; Audit-Log für alle Aktionen
- `internal/api/server.go` — `ldapStore`/`tenantStore` Felder + Imports ergänzt
- `cmd/archivmail/main.go` — `ldapcfg.New()` initialisiert; `auth.New()` mit ldapStore; `srv.SetLDAP()`
- `src/lib/api.ts` — `LDAPConfig`, `LDAPTestResult`, `getLDAPConfig/saveLDAPConfig/deleteLDAPConfig/testLDAPConfig`
- `src/app/admin/page.tsx` — LDAP-Tab mit Formular, Passwort-Schutz, Gruppen-Mappings, Testergebnis-Card
- `go.mod` — `github.com/go-ldap/ldap/v3 v3.4.8` hinzugefügt

## 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
