Files
archivmail/features/PROJ-21-multi-tenancy.md
T
sysops bd09992441 feat(PROJ-21): Phase 6+7 abschliessen — Dienste-Tab nur superadmin
Frontend: Dienste-Tab (Systemdienste starten/stoppen) ist jetzt nur
noch für superadmin sichtbar. domain_admin sieht: Dashboard, Benutzer,
Audit-Log, Import, LDAP (eigener Mandant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:18:28 +01:00

14 KiB

id, title, status, created
id title status created
PROJ-21 Multi-Mandanten-Fähigkeit (Multi-Tenancy) Deployed 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.gotenantstore.New() + srv.SetTenants()
  • src/lib/api.tsTenant, 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.goSave() 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.goSession.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.gotenantMiddleware(), tenantFromCtx(); auth() + authAdmin() Helper; handleListUsers tenant-gefiltert; handleStorageStats via StatsByTenant(); handleGetMail/Attachment/Raw mit Tenant-Isolation

Phase 5 — SMTP Domain-Routing:

  • internal/smtpd/smtpd.goDomainToTenantFunc; Daemon.domainToTenant + defaultTenantID; SetDomainToTenant(); resolveTenantFromRcpts()
  • config/config.goSMTPConfig.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.goTenantID *int64 Feld + Save(ctx, ..., tenantID)
  • cmd/archivmail/cmd_import.go, cmd/archivmail-import/main.goSave(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-/ 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

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

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

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

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

{
  "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)