---
id: PROJ-21
title: Multi-Mandanten-Fähigkeit (Multi-Tenancy)
status: Deployed
created: 2026-03-17
---

## Phase 1 implementiert (2026-03-17)

- `internal/tenantstore/store.go` — DB-Schema (`tenants`, `tenant_domains`, `tenant_ldap`); Migration: `tenant_id` zu `users` + `audit_log`; CRUD + Domain-Management + `GetByDomain` für SMTP-Routing
- `internal/api/ldap_tenants.go` — 8 Tenant-API-Routen (LIST/CREATE/GET/PATCH/DELETE Tenants + Domain-Verwaltung); wired via `SetTenants()`
- `cmd/archivmail/main.go` — `tenantstore.New()` + `srv.SetTenants()`
- `src/lib/api.ts` — `Tenant`, `TenantDomain`, alle 7 API-Funktionen
- `src/app/admin/page.tsx` — Mandanten-Tab mit Tabelle, Domain-Dialog, Löschen-Bestätigung

## Phase 2, 3, 5, 8 implementiert (2026-03-17)

**Phase 2a — Rollen & userstore:**
- `internal/userstore/userstore.go` — Neue Rollen `domain_admin`, `superadmin`; `User.TenantID *int64`; `Create()` mit TenantID; `ListByTenant()`; `UpsertLDAPUser()` mit tenantID-Parameter; Scan-Helpers aktualisiert

**Phase 2b — storage.Save() + email_refs:**
- `internal/storage/storage.go` — `Save()` neue Signatur `(ctx, raw, time, tenantID *int64)`; DB-Migrationen: `emails.tenant_id`, Tabelle `email_refs`; neue Methoden `GetTenantForMail()`, `GetAllIDsByTenant()`, `StatsByTenant()`; `insertMeta`/`insertMetaMinimal` mit tenantID

**Phase 2c — JWT tenant-aware:**
- `internal/auth/auth.go` — `Session.TenantID *int64`; JWT-Claims `tenant_id`; `ValidateToken()` extrahiert tenant_id; `HasRole()` Hierarchie: superadmin > admin > domain_admin > auditor > user

**Phase 3 — Tenant-Middleware:**
- `internal/api/server.go` — `tenantMiddleware()`, `tenantFromCtx()`; `auth()` + `authAdmin()` Helper; `handleListUsers` tenant-gefiltert; `handleStorageStats` via `StatsByTenant()`; `handleGetMail/Attachment/Raw` mit Tenant-Isolation

**Phase 5 — SMTP Domain-Routing:**
- `internal/smtpd/smtpd.go` — `DomainToTenantFunc`; `Daemon.domainToTenant` + `defaultTenantID`; `SetDomainToTenant()`; `resolveTenantFromRcpts()`
- `config/config.go` — `SMTPConfig.TenantRouting`, `SMTPConfig.DefaultTenantID`
- `cmd/archivmail/main.go` — Tenant-Routing-Verdrahtung; `tenantSt` vor smtpDaemon initialisiert

**Phase 8 — Migrations-Script:**
- `cmd/archivmail/cmd_migrate_tenants.go` — Subkommando `migrate-tenants`: default-Tenant anlegen, Users/Emails/Audit assignen, email_refs seeden, admins → domain_admin, superadmin erstellen

**Alle Save()-Aufrufer angepasst:**
- `internal/imap/importer.go`, `internal/pop3/importer.go` — `TenantID *int64` Feld + `Save(ctx, ..., tenantID)`
- `cmd/archivmail/cmd_import.go`, `cmd/archivmail-import/main.go` — `Save(ctx, ..., nil)`

**Phase 4 implementiert + QA-approved (2026-03-18)**

**Phase 6+7 implementiert (2026-03-18):**
- Tenant-Management-Routen (Phase 1 bereits enthalten)
- `/api/tenant/ldap` Routen via PROJ-23
- Frontend: Dienste-Tab nur für superadmin sichtbar
- Frontend: Security/Tenants/Modules/LDAP-Global nur für superadmin
- Frontend: domain_admin sieht nur eigene Nutzer/Audit/LDAP-Tab

## Phase 4 -- QA-Bericht (2026-03-18, Code-Review)

### Gesamtbewertung: APPROVED -- alle 5 Pruefpunkte bestanden

| # | Pruefpunkt | Ergebnis | Details |
|---|-----------|----------|---------|
| X1 | Pro-Mandant-Verzeichnis tenant-<id>/ | PASS | `tenant_manager.go:69` |
| X2 | ForTenant(nil) nil-safe | PASS | `tenant_manager.go:47` |
| X3 | handleSearch nutzt tenant_id aus Session | PASS | `server.go:627-633` |
| X4 | Thread-Safety (RWMutex + Channel) | PASS | `tenant_manager.go:16,53-62` / `tenant_worker.go:25` |
| X5 | Close() auf allen Indexen | PASS | `tenant_manager.go:91-110`, `main.go:121` |

Dateien: `internal/index/tenant_manager.go`, `internal/index/tenant_worker.go`, `internal/api/server.go`, `cmd/archivmail/main.go`

## Ziel

Das System soll mehrere Kunden (Mandanten/Tenants) auf einer einzigen Instanz betreiben können. Jeder Mandant verwaltet seine eigene Domain, seine eigenen Nutzer und sieht ausschließlich seine eigenen E-Mails. Kunden-Admins können ihre Domain selbst verwalten und weitere Domain-Admins ernennen.

---

## Rollenmodell (neu)

| Rolle | Bisherig | Beschreibung |
|---|---|---|
| `superadmin` | — (neu) | Systemweite Kontrolle: Mandanten anlegen/löschen, alle Daten einsehen, technische Konfiguration |
| `domain_admin` | `admin` (umbenannt/erweitert) | Verwaltet Nutzer, Domains und IMAP-Konten **innerhalb des eigenen Mandanten** |
| `auditor` | `auditor` | Lese-Zugriff auf Audit-Log und E-Mails des eigenen Mandanten |
| `user` | `user` | Sucht und liest E-Mails des eigenen Mandanten |

> Bisherige `admin`-Konten werden bei der Migration zu `domain_admin` umgestellt.
> Ein initiales `superadmin`-Konto wird beim ersten Start angelegt.

---

## Datenbankschema-Änderungen

### Neue Tabelle: `tenants`

```sql
CREATE TABLE tenants (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(255) UNIQUE NOT NULL,   -- Anzeigename (z.B. "Mustermann GmbH")
    slug        VARCHAR(100) UNIQUE NOT NULL,   -- URL-sicherer Bezeichner (z.B. "mustermann")
    active      BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE tenant_domains (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    domain      VARCHAR(255) UNIQUE NOT NULL,   -- z.B. "mustermann.de"
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

### Änderungen an bestehenden Tabellen

```sql
-- users: Mandant zuweisen
ALTER TABLE users ADD COLUMN tenant_id BIGINT REFERENCES tenants(id);
-- superadmin: tenant_id = NULL (systemweit)
-- alle anderen: tenant_id REQUIRED

-- emails: Mandant zuweisen
ALTER TABLE emails ADD COLUMN tenant_id BIGINT REFERENCES tenants(id);
CREATE INDEX idx_emails_tenant ON emails(tenant_id);

-- imap_accounts: erbt Mandant über owner → user.tenant_id (kein direktes Feld nötig)

-- audit_log: Mandant-Kontext
ALTER TABLE audit_log ADD COLUMN tenant_id BIGINT REFERENCES tenants(id);
```

### Neue Tabelle: `tenant_ldap` (PROJ-16 Phase B)

Ermöglicht pro-Mandant-LDAP-Konfiguration durch Domain-Admins ohne `config.yml`-Zugriff.
Wird in Phase 1 als leere Tabelle angelegt; befüllt durch PROJ-16 Phase B.

```sql
CREATE TABLE tenant_ldap (
    tenant_id       BIGINT PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
    enabled         BOOLEAN NOT NULL DEFAULT false,
    url             TEXT NOT NULL DEFAULT '',
    bind_dn         TEXT NOT NULL DEFAULT '',
    bind_password   BYTEA,                          -- AES-256-GCM verschlüsselt
    base_dn         TEXT NOT NULL DEFAULT '',
    user_filter     TEXT NOT NULL DEFAULT '(sAMAccountName=%s)',
    tls             BOOLEAN NOT NULL DEFAULT false,
    tls_skip_verify BOOLEAN NOT NULL DEFAULT false,
    default_role    VARCHAR(20) NOT NULL DEFAULT 'user',
    group_mappings  JSONB                           -- [{group_dn, role}, ...]
);
```

### Xapian-Index

Jeder Mandant bekommt ein **eigenes Xapian-Verzeichnis**:

```
/var/archivmail/xapian/
    tenant-1/
    tenant-2/
    ...
```

Begründung: Vollständige Datenisolation, kein Risiko von Datenlecks durch falsche Filterung, einfaches Löschen eines Mandanten.

---

## Komponenten-Änderungen

### 1. `internal/userstore`

- Neues Feld `TenantID *int64` in `User`-Struct
- `Create`, `List`, `Delete` erhalten `tenantID`-Parameter
- Neue Methode: `ListByTenant(tenantID int64)`
- Neue Methode: `CountAdminsByTenant(tenantID int64)`

### 2. `internal/storage`

- `Save(raw, date, tenantID)` — speichert `tenant_id` in `emails`-Tabelle
- `Search`, `GetMail` etc. filtern immer nach `tenant_id`
- Physische Dateien bleiben im gemeinsamen Store (Dedup funktioniert weiter via SHA-256)

### 3. `internal/index`

- `New(path, batchSize, backend)` → `NewForTenant(basePath, tenantID, batchSize, backend)`
- Index-Manager verwaltet einen Index-Pool: `map[int64]Indexer`
- Lazy-Loading: Index wird beim ersten Zugriff geöffnet

### 4. `internal/smtpd`

- Nach `DATA`-Empfang: Empfänger-Domain aus `To:`-Header extrahieren
- Domain gegen `tenant_domains`-Tabelle auflösen → `tenant_id` bestimmen
- Falls keine Domain passt: konfigurierbare Fallback-Strategie (ablehnen oder default-Mandant)

### 5. `internal/api`

**Neue Middleware: `tenantFromSession`**

```go
// Jeder Request erhält den Mandant aus der Session injiziert.
// superadmin: kann per Header X-Tenant-ID einen Mandant imitieren.
func (s *Server) tenantMiddleware(next http.HandlerFunc) http.HandlerFunc
```

**Neue Routen (nur superadmin):**

```
POST   /api/tenants                    Mandant anlegen
GET    /api/tenants                    Alle Mandanten auflisten
GET    /api/tenants/{id}               Mandant-Details
PATCH  /api/tenants/{id}               Mandant bearbeiten (name, active)
DELETE /api/tenants/{id}               Mandant löschen (mit allen Daten)
POST   /api/tenants/{id}/domains       Domain hinzufügen
DELETE /api/tenants/{id}/domains/{did} Domain entfernen
GET    /api/tenants/{id}/users         Nutzer eines Mandanten
```

**Bestehende Routen: tenant-aware**

```
GET  /api/users        → nur Nutzer des eigenen Mandanten (domain_admin)
POST /api/users        → Nutzer im eigenen Mandanten anlegen (domain_admin)
GET  /api/search       → filtert automatisch nach tenant_id der Session
GET  /api/mails/{id}   → Zugriff nur wenn email.tenant_id == session.tenant_id
GET  /api/audit        → filtert nach tenant_id
```

### 6. `internal/auth` (JWT)

JWT-Claims erweitern:

```json
{
  "sub": "username",
  "role": "domain_admin",
  "tenant_id": 3,
  "tenant_slug": "mustermann",
  "exp": 1234567890
}
```

`superadmin`-Token hat `tenant_id: null`.

---

## Zugriffsmatrix

| Aktion | superadmin | domain_admin | auditor | user |
|--------|:---:|:---:|:---:|:---:|
| Mandanten verwalten | ✓ | — | — | — |
| Eigene Nutzer verwalten | ✓ | ✓ | — | — |
| Eigene Domains verwalten | ✓ | ✓ | — | — |
| LDAP konfigurieren (eigener Mandant) | ✓ | ✓ | — | — |
| LDAP konfigurieren (alle Mandanten) | ✓ | — | — | — |
| E-Mails lesen (eigener Mandant) | ✓ | ✓ | ✓ | ✓ |
| E-Mails anderer Mandanten | ✓ | — | — | — |
| Audit-Log (eigener Mandant) | ✓ | ✓ | ✓ | — |
| Audit-Log aller Mandanten | ✓ | — | — | — |
| Systemkonfiguration | ✓ | — | — | — |

---

## SMTP-Routing (E-Mail-Eingang)

```
Eingehende E-Mail (RCPT TO: user@mustermann.de)
    │
    ▼
Domain-Lookup: SELECT tenant_id FROM tenant_domains WHERE domain = 'mustermann.de'
    │
    ├─ Gefunden → tenant_id = 3 → Save(raw, tenant_id=3)
    │
    └─ Nicht gefunden → Fallback-Konfiguration:
          "reject"         → 550 No such domain
          "default:<id>"   → Default-Mandant (für Single-Tenant-Migration)
```

---

## Frontend-Änderungen

### Login-Seite
- Optional: Mandant-Auswahl (Dropdown oder automatisch per E-Mail-Domain)
- Kein Mandant-Feld nötig wenn Domain-Erkennung aktiviert

### Admin-Bereich (superadmin)
- Neue Tab-Seite `/admin/tenants`: Mandanten-Übersicht, anlegen, deaktivieren
- Pro Mandant: Domains, Nutzeranzahl, E-Mail-Anzahl, Letzter Import

### Admin-Bereich (domain_admin)
- Gleiche UI wie heute, aber nur eigene Nutzer/Domains sichtbar
- Kein Zugriff auf System-Tabs (Dienste, Systemstats)

---

## Migrations-Strategie (bestehende Installation)

```
1. Migrations-Script ausführen:
   - Tabellen tenant + tenant_domains anlegen
   - Default-Mandant anlegen: name="default", slug="default"
   - Alle bestehenden users → tenant_id = default-Mandant
   - Alle bestehenden emails → tenant_id = default-Mandant
   - Alle bestehenden admin-Nutzer → Rolle bleibt admin (= domain_admin des default-Mandanten)
   - Einen superadmin-Nutzer anlegen (Passwort per CLI-Flag)

2. Xapian-Index verschieben:
   - /var/archivmail/xapian/ → /var/archivmail/xapian/tenant-<id>/

3. config.yml erweitern:
   - smtp.tenant_routing: "domain" | "default:<id>"
   - default_tenant_id: 1
```

---

## Umsetzungsreihenfolge (Phasen)

| Phase | Inhalt | Abhängigkeit |
|---|---|---|
| **Phase 1** | DB-Schema: `tenants`, `tenant_domains`, `tenant_ldap` (leer), Migration | — |
| **Phase 2** | `userstore` + `storage` tenant-aware, JWT erweitern, `UpsertLDAPUser` + `tenant_id` | Phase 1 |
| **Phase 3** | API-Middleware + alle bestehenden Handler tenant-gefiltert | Phase 2 |
| **Phase 4** | Xapian: pro-Tenant-Index, Index-Manager | Phase 2 |
| **Phase 5** | SMTP: Domain → Tenant-Routing | Phase 1 |
| **Phase 6** | Neue API-Routen (Tenant-Management + `/api/tenant/ldap`) | Phase 3 |
| **Phase 7** | Frontend: superadmin-UI, domain_admin-Beschränkungen, LDAP-Konfig-Tab | Phase 6 |
| **Phase 8** | Migrations-Script + CLI-Befehl `archivmail migrate-tenants` | alle |

> **PROJ-16 Abhängigkeit:**
> - PROJ-16 Phase A (Single-Tenant-LDAP via `config.yml`) ist unabhängig von PROJ-21 umsetzbar.
> - PROJ-16 Phase B (Pro-Mandant-LDAP, `tenant_ldap`-Tabelle, Domain-Admin-UI) **erfordert PROJ-21 Phase 2+6** als Voraussetzung.

---

## Offene Entscheidungen (vor Implementierung klären)

1. **Login ohne Mandanten-Angabe**: Erkennung automatisch per E-Mail-Domain, oder Mandant-Auswahl im Login?
2. **E-Mail-Dedup über Mandanten hinweg**: Gleiche E-Mail bei zwei Mandanten — eine Datei oder zwei? (Empfehlung: eine Datei, zwei DB-Einträge mit unterschiedlicher `tenant_id`)
3. **Superadmin-Konto**: Eigener Nutzer ohne Mandant, oder Mandant "system"?
4. **Domain-Admin darf weitere Domain-Admins ernennen**: Ja (wie geplant) — aber nicht `superadmin`

---

## Akzeptanzkriterien

- [ ] Mandant A kann keine E-Mails von Mandant B sehen (auch nicht durch manipulierte API-Aufrufe)
- [ ] Domain-Admin kann Nutzer anlegen/bearbeiten, aber keine anderen Mandanten sehen
- [ ] Superadmin kann alle Mandanten einsehen und zwischen ihnen wechseln
- [ ] SMTP-Eingang ordnet E-Mails korrekt dem Mandanten per Empfänger-Domain zu
- [ ] Bestehende Single-Tenant-Installation lässt sich ohne Datenverlust migrieren
- [ ] Löschen eines Mandanten entfernt alle zugehörigen Daten (users, emails, imap, index)
