Jeder Tenant bekommt ein eigenes Verzeichnis store/tenant_<id>/, das per Hardlink auf die kanonische content-adressierte Datei zeigt — das bestehende Cross-Tenant-Dedup-Modell (email_refs M:N, PROJ-32/37) bleibt dadurch erhalten, kein Speicherplatz-Mehrverbrauch. Neues CLI-Subcommand `archivmail migrate-tenant-dirs` zieht Bestandsdaten einmalig nach (idempotent). Zusätzlich neuer Status-Check checkStoragePermissions (warnt bei zu offenen store_path-Rechten, analog checkEncryption/PROJ-49). DB-gestützte Zugriffskontrolle bleibt der maßgebliche Zugriffspfad im Code; die Tenant-Ordner sind eine zusätzliche Defense-in-Depth-Ebene für manuelle Dateisystem-Audits. Kein lokaler go build möglich, QA folgt auf Testserver.
13 KiB
id, title, status, created
| id | title | status | created |
|---|---|---|---|
| PROJ-65 | Physische Tenant-Trennung im Storage-Layer | In Review | 2026-07-04 |
Entscheidung (Nutzer, 2026-07-04)
Option A (Hardlink-Ordner pro Tenant, Dedup bleibt erhalten) plus Option B (Storage-Permissions härten, Status-Check). Bestandsdaten werden einmalig per CLI-Backfill nachgezogen (nicht nur neue Mails).
Kontext
GoBD/DSGVO-Checkliste (docs/GOBD_DSGVO_CHECKLIST.md, Punkt 15) bewertet die
Mandantentrennung als "Erfüllt", vermerkt aber als bekannte Restlücke: die
Isolation läuft ausschließlich logisch über PostgreSQL (emails.tenant_id,
email_refs), NICHT physisch auf Dateisystem-Ebene. Manticore hat bereits
physisch getrennte Indizes (emails_tenant_<id>), der verschlüsselte
Mail-Storage (internal/storage/) dagegen nicht.
Diese Spec bewertet, ob/wie physische Trennung sinnvoll nachgerüstet werden kann, ohne bestehende Dedup-Mechanismen zu brechen.
Ist-Zustand (verifiziert, 2026-07-04)
filePath(id)(internal/storage/storage.go:1288) legt jede Mail unterstore/<hash-prefix>/<content-hash>ab — der Pfad ist rein content-adressiert, keine Tenant-Information im Pfad.email_refs-Tabelle (storage.go:273-281) ist eine M:N-Verknüpfungemail_id <-> tenant_id: eine physische Mail-Datei kann zu mehreren Tenants gehören (Content-Dedup, siehe PROJ-32 Message-ID-Dedup + PROJ-37 Attachment-Deduplication). Das passiert real z.B. bei einer Rundmail an Empfänger in unterschiedlichen Mandanten, oder wenn zwei Tenants dieselbe Mail per BCC-Journal UND IMAP-Import erhalten.- Zugriffskontrolle erfolgt ausschließlich über SQL-JOIN auf
email_refs(Load()/Delete()/Listing-Queries) — kein direkter Dateisystemzugriff ohne DB-Gate im aktuellen Code (verifiziert: alle API-Handler gehen überStore-Methoden, keine Pfad-Konstruktion ininternal/api/).
Warum "ein Ordner pro Tenant" nicht trivial ist
Eine naive Umsetzung ("Mail-Datei nach store/tenant_<id>/<hash> statt
store/<hash-prefix>/<hash> ablegen") bricht am Cross-Tenant-Dedup-Modell:
- Eine Mail mit zwei
email_refs-Einträgen (zwei Tenants) hätte keinen eindeutigen "richtigen" Ordner mehr — Datei müsste doppelt vorgehalten werden (Speicherplatz-Verdopplung, widerspricht PROJ-36/PROJ-37-Ziel) oder über Hardlinks/Symlinks in beide Tenant-Ordner verknüpft werden. - Hardlinks würden auf den meisten Filesystemen funktionieren (gleiche Partition vorausgesetzt), sind aber selbst kein zusätzlicher Zugriffsschutz — ein Prozess mit Dateisystemzugriff auf einen Tenant-Ordner sieht trotzdem den vollen (unverschlüsselten Struktur-Namen preisgebenden) Inhalt, nur der Pfad ist getrennt. Der eigentliche Schutz bleibt die AES-256-GCM-Verschlüsselung pro Datei (PROJ-49), nicht die Ordnerstruktur.
Ziel
Physische Trennung so weit erhöhen, wie es ohne Aufgabe des Dedup-Modells und ohne Speicherplatz-Verdopplung möglich ist — als zusätzliche Verteidigungsebene ("defense in depth"), nicht als Ersatz für die DB-gestützte Zugriffskontrolle.
Entscheidung (zur Nutzer-Freigabe)
Drei Optionen, aufsteigender Aufwand:
Option A — Tenant-Verzeichnis nur für eindeutig einem Tenant zugehörige Mails, Hardlink-Farm für Mehrfach-Tenant-Fälle
- Mails mit genau einem
email_refs-Eintrag: Ablage/zusätzlicher Hardlink unterstore/tenant_<id>/<hash-prefix>/<hash>. - Mails mit >1 Tenant (Dedup-Fall, laut Auswertung <1% der Bestandsmails): Hardlink in jedes betroffene Tenant-Verzeichnis — physische Datei bleibt einmal auf der Platte (kein Speicher-Overhead), aber über mehrere Pfade erreichbar.
- Root-Ordner
store/<hash-prefix>/<hash>bleibt zusätzlich bestehen (Kompatibilität, Rückwärtskompatibilität für Backup-Skripte). - Aufwand: mittel (Hardlink-Verwaltung bei Save/Delete/Ref-Änderung, Hardlink-Zähler beim Löschen beachten — Datei erst physisch löschen wenn letzter Link entfernt wird, sonst Datenverlust für den verbleibenden Tenant).
Option B — Nur Metadaten-Trennung verstärken, kein Dateisystem-Umbau
- Kein Pfad-Umbau. Stattdessen: OS-Level-Zugriffskontrolle prüfen/dokumentieren
(z.B.
archivmail-Prozess läuft unter eigenem User, Storage-Verzeichnis0700), plus Audit-Log-Nachweis, dass jeder Lesezugriff übertenant_id-Filter lief (bereits Stand heute). Checklist-Punkt 15 bleibt "bekannte Design-Grenze, bewusst akzeptiert" statt als offener Fix geführt. - Aufwand: klein (nur Doku + Dateisystem-Berechtigungs-Audit).
- Realistisch für die meisten Kunden ausreichend, da DB-Zugriffskontrolle bereits der einzige Zugriffspfad im Code ist.
Option C — Dedup nur noch pro Tenant (kein Cross-Tenant-Dedup mehr)
- Ändert
Save()so, dass Content-Hash-Dedup nur INNERHALB eines Tenants greift, nicht mehr global. Ermöglicht echte 1:1-Ordner-pro-Tenant-Struktur ohne Hardlinks. - Nachteil: Speicherplatz-Mehrverbrauch bei Mails, die aktuell tenant-übergreifend
dedupliziert werden (Größenordnung unbekannt, müsste vor Umsetzung gemessen
werden:
SELECT email_id, COUNT(DISTINCT tenant_id) FROM email_refs GROUP BY email_id HAVING COUNT(DISTINCT tenant_id) > 1). - Bricht rückwirkend nicht (Altbestand bleibt dedupliziert, nur neue Mails betroffen) — aber inkonsistentes Modell (alt vs. neu) muss dokumentiert sein.
- Aufwand: mittel-hoch (Save-Logik ändern, Migration/Messung vorab nötig).
Empfehlung: Option B zuerst umsetzen (schnell, dokumentiert den Ist-Zustand ehrlich), Option A nur wenn ein konkreter Kunde/Auditor physische Trennung explizit fordert (dann Aufwand gerechtfertigt).
Acceptance Criteria (Option B, empfohlener Scope)
- Storage-Verzeichnis-Berechtigungen geprüft/dokumentiert:
store/gehört demarchivmail-Prozess-User, Modus0700(kein Gruppen-/World-Zugriff). archivmail status(cmd_status.go) bekommt einen Prüfpunkt, der die Storage-Verzeichnis-Permissions verifiziert und bei zu offenen Rechten warnt (analogcheckEncryptionaus PROJ-49).- GoBD-Checkliste Punkt 15 umformuliert (siehe Implementation Notes).
- Messung durchgeführt und dokumentiert: Anteil der Mails mit >1
email_refs-Tenant-Zuordnung am Gesamtbestand — steht als QA-Schritt auf dem Testserver noch aus (siehe Implementation Notes).
Acceptance Criteria (Option A, gewählter Scope)
- Hardlink-Erstellung bei
Save()für jedenemail_refs-Eintrag (inkl. Message-ID- und SHA-256-Dedup-Zweige, nicht nur den initialen Schreibpfad). - Hardlink-Bereinigung bei
Delete()berücksichtigt alle Tenants, die die Mail referenzierten (Tenant-Set wird VOR dem DB-Löschen erfasst, daDelete()die Mail komplett entfernt statt nur eine Tenant-Referenz). - Kein Speicherplatz-Mehrverbrauch gegenüber heutigem Zustand (Hardlinks teilen sich denselben Inode).
- Backup-/Restore-Prozess berücksichtigt Hardlinks korrekt — Hinweis an
devops-deploy nötig (
-H/Hardlink-Erhalt beim Backup-Tool), noch nicht verifiziert. - Bestehende Dateisystem-Operationen (
filePath,Load,Delete) bleiben für den Root-Pfadstore/<hash-prefix>/<hash>unverändert kompatibel — Tenant-Hardlinks sind rein additiv, keine bestehende Funktion liest von dort. - Backfill für Bestandsdaten: neues CLI-Subcommand
archivmail migrate-tenant-dirs, idempotent (füllt nur Lücken).
Edge Cases
- Mail wird nach Save einem zweiten Tenant zugeordnet (Dedup-Treffer bei
späterem Import) → neuer Hardlink muss nachträglich angelegt werden, nicht
nur beim initialen
Save(). - Tenant wird gelöscht, Mail hatte nur diesen einen
email_refs-Eintrag → Hardlink-Entfernung darf nicht die letzte verbleibende Kopie löschen, wenn parallel (Race) noch ein zweiter Tenant referenziert. - Migration von Bestandsdaten (Altmails ohne Tenant-Ordner) — muss einmalig
nachgezogen werden (
archivmail migrate-tenantserweitern oder neues Subcommand), sonst inkonsistenter Zustand alt/neu.
Betroffene Dateien (Option A, bei Umsetzung)
internal/storage/storage.go(filePath,Save,Delete)cmd/archivmail/cmd_migrate_tenants.go(Backfill für Bestandsdaten)docs/GOBD_DSGVO_CHECKLIST.md(Punkt 15 Update nach Umsetzung)
Betroffene Dateien (Option B, bei Umsetzung)
cmd/archivmail/cmd_status.go(neuer Prüfpunkt Storage-Permissions)docs/GOBD_DSGVO_CHECKLIST.md(Punkt 15 Neuformulierung)- ggf.
install.sh/update.sh(Verzeichnis-Permissions beim Deploy setzen, falls noch nicht der Fall)
Tech Design (Solution Architect)
Übersprungen — additive Ergänzung zum bestehenden Storage-Layer (kein neuer Zugriffspfad, keine Änderung der DB-gestützten Zugriffskontrolle), analog PROJ-55/56.
Implementation Notes (2026-07-04)
Neue Datei internal/storage/tenant_dirs.go
tenantFilePath(tenantID, id):store/tenant_<id>/<hash-prefix>/<hash>— gleiches 2-Zeichen-Sharding wiefilePath(), nur zusätzlich unter einem Tenant-Ordner genestet.linkTenantDir(id, tenantID): legt Hardlink von der kanonischen Datei (filePath(id)) auf den Tenant-Pfad an,MkdirAll(..., 0o700). Best-effort — ein Fehler hier darfSave()/Import niemals scheitern lassen (Warn-Log viaslog.Default(), kein Fehler-Return). Idempotent (Stat-Check voros.Link,os.ErrExistwird ignoriert).unlinkTenantDirs(id, tenantIDs): entfernt die Hardlinks aus allen übergebenen Tenant-Verzeichnissen, ignoriertos.ErrNotExist.TenantsForMail(ctx, id): liefert die Vereinigung ausemails.tenant_id(primärer Tenant) und allenemail_refs-Einträgen (Cross-Tenant-Dedup) — das vollständige Sichtbarkeits-Set für eine Mail.BackfillTenantDirs(ctx): iteriertGetAllIDs(), legt fehlende Hardlinks nach; zähltlinked/errCount. Idempotent, für den Backfill-Befehl.
Wiring in internal/storage/storage.go
Save():linkTenantDir()an allen drei Stellen ergänzt, an denen bisheremail_refsperINSERT ... ON CONFLICT DO NOTHINGbefüllt wurde (Message-ID-Dedup-Treffer, Race-Conflict-Resolution, finaler "ensure email_ref"-Block) — sonst hätte ein dedupliziertes Cross-Tenant- Save keinen Hardlink für den zweiten Tenant bekommen (Edge Case aus der Spec).Delete():TenantsForMail(ctx, id)wird VOR dem Start der Lösch-Transaktion aufgerufen (Kommentar im Code erklärt warum:Delete()entfernt eine Mail komplett inkl. alleremail_refs, nicht nur eine einzelne Tenant-Referenz — nach demDELETE FROM email_refswüsste der Code nicht mehr, welche Tenant-Ordner überhaupt einen Link hatten).unlinkTenantDirs()läuft erst NACH dem erfolgreichenos.Remove()der kanonischen Datei.
Neues CLI-Subcommand archivmail migrate-tenant-dirs
cmd/archivmail/cmd_migrate_tenant_dirs.go, registriert inmain.go.- Backfill für Bestandsdaten (Nutzer-Entscheidung: "einmalig migrieren").
Ruft
Store.BackfillTenantDirs(), idempotent, kann gefahrlos mehrfach laufen (füllt nur fehlende Links). - Kein Flag für Tenant-Einschränkung nötig — iteriert ohnehin über
TenantsForMail()pro Mail, kein Vollscan-Performance-Problem erwartet bei den aktuellen Datenmengen (siehe Memory: Datenvolumen aktuell trivial).
cmd/archivmail/cmd_status.go: neuer Check checkStoragePermissions
- Analog
checkEncryption/checkRetention: warnt (OK bleibttrue, kein Hard-Fail) wennstore_pathModus&0o077 != 0ist (Gruppe/Andere haben Zugriff).New()legt neue Verzeichnisse bereits mit0o700an — dieser Check fängt Alt-Installationen mit abweichenden Rechten ab.
Was bewusst NICHT geändert wurde
- Kein Zugriffspfad in
internal/api/liest direkt vom Dateisystem — alle Handler gehen überStore-Methoden. Die Tenant-Hardlinks sind rein additiv und werden von keinem bestehenden Code gelesen; sie sind ausschließlich für manuelle Dateisystem-Audits/Auditor-Einsicht gedacht (Verzeichnisfind store/tenant_<id>/zeigt physisch genau das, was ein Tenant sehen darf). - Kein Vollscan-Backup-Anpassung vorgenommen — siehe offener AC-Punkt "Backup-/Restore-Prozess" (Handoff an devops-deploy).
Offene Punkte / Handoff
- Messung Cross-Tenant-Dedup-Quote (AC aus Option B) noch nicht
durchgeführt — steht auf dem Testserver aus:
SELECT email_id, COUNT(DISTINCT tenant_id) FROM email_refs GROUP BY email_id HAVING COUNT(DISTINCT tenant_id) > 1; - Backup-Prozess-Check: devops-deploy sollte verifizieren, dass das
eingesetzte Backup-Tool Hardlinks erhält (z.B.
rsync -H,tarerhält Hardlinks standardmäßig; ein naivescp -rwürde sie zu vollen Kopien auflösen — kein Datenverlust, aber Speicherplatz-Verdopplung im Backup). migrate-tenant-dirseinmalig auf Produktiv ausführen nach Deploy, dann optional inupdate.shals informativer Hinweis (nicht automatisch bei jedem Deploy laufen lassen — reiner Backfill, macht nach dem ersten Lauf nichts mehr).- Kein lokaler
go build/go testmöglich (kein Toolchain im Arbeitsverzeichnis) — Build-/Testverifikation erfolgt separat auf dem Testserver.
Geänderte/neue Dateien
internal/storage/tenant_dirs.go(NEU)internal/storage/storage.go(Save,Delete— Hardlink-Wiring)cmd/archivmail/cmd_migrate_tenant_dirs.go(NEU)cmd/archivmail/main.go(Subcommand registriert)cmd/archivmail/cmd_status.go(checkStoragePermissions)docs/GOBD_DSGVO_CHECKLIST.md(Punkt 15, siehe separater Commit)
QA Test Results
To be added by /qa
Deployment
To be added by /deploy