--- id: PROJ-65 title: Physische Tenant-Trennung im Storage-Layer status: In Review created: 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_`), 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 unter `store//` ab — der Pfad ist rein content-adressiert, keine Tenant-Information im Pfad. - `email_refs`-Tabelle (`storage.go:273-281`) ist eine M:N-Verknüpfung `email_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 über `Store`-Methoden, keine Pfad-Konstruktion in `internal/api/`). ## Warum "ein Ordner pro Tenant" nicht trivial ist Eine naive Umsetzung ("Mail-Datei nach `store/tenant_/` statt `store//` 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 unter `store/tenant_//`. - 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//` 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-Verzeichnis `0700`), plus Audit-Log-Nachweis, dass jeder Lesezugriff über `tenant_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) - [x] Storage-Verzeichnis-Berechtigungen geprüft/dokumentiert: `store/` gehört dem `archivmail`-Prozess-User, Modus `0700` (kein Gruppen-/World-Zugriff). - [x] `archivmail status` (`cmd_status.go`) bekommt einen Prüfpunkt, der die Storage-Verzeichnis-Permissions verifiziert und bei zu offenen Rechten warnt (analog `checkEncryption` aus PROJ-49). - [x] 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) - [x] Hardlink-Erstellung bei `Save()` für jeden `email_refs`-Eintrag (inkl. Message-ID- und SHA-256-Dedup-Zweige, nicht nur den initialen Schreibpfad). - [x] Hardlink-Bereinigung bei `Delete()` berücksichtigt alle Tenants, die die Mail referenzierten (Tenant-Set wird VOR dem DB-Löschen erfasst, da `Delete()` die Mail komplett entfernt statt nur eine Tenant-Referenz). - [x] 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. - [x] Bestehende Dateisystem-Operationen (`filePath`, `Load`, `Delete`) bleiben für den Root-Pfad `store//` unverändert kompatibel — Tenant-Hardlinks sind rein additiv, keine bestehende Funktion liest von dort. - [x] 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-tenants` erweitern 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_//` — gleiches 2-Zeichen-Sharding wie `filePath()`, 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 darf `Save()`/Import niemals scheitern lassen (Warn-Log via `slog.Default()`, kein Fehler-Return). Idempotent (Stat-Check vor `os.Link`, `os.ErrExist` wird ignoriert). - `unlinkTenantDirs(id, tenantIDs)`: entfernt die Hardlinks aus allen übergebenen Tenant-Verzeichnissen, ignoriert `os.ErrNotExist`. - `TenantsForMail(ctx, id)`: liefert die Vereinigung aus `emails.tenant_id` (primärer Tenant) und allen `email_refs`-Einträgen (Cross-Tenant-Dedup) — das vollständige Sichtbarkeits-Set für eine Mail. - `BackfillTenantDirs(ctx)`: iteriert `GetAllIDs()`, legt fehlende Hardlinks nach; zählt `linked`/`errCount`. Idempotent, für den Backfill-Befehl. ### Wiring in `internal/storage/storage.go` - `Save()`: `linkTenantDir()` an allen drei Stellen ergänzt, an denen bisher `email_refs` per `INSERT ... ON CONFLICT DO NOTHING` befü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. aller `email_refs`, nicht nur eine einzelne Tenant-Referenz — nach dem `DELETE FROM email_refs` wüsste der Code nicht mehr, welche Tenant-Ordner überhaupt einen Link hatten). `unlinkTenantDirs()` läuft erst NACH dem erfolgreichen `os.Remove()` der kanonischen Datei. ### Neues CLI-Subcommand `archivmail migrate-tenant-dirs` - `cmd/archivmail/cmd_migrate_tenant_dirs.go`, registriert in `main.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 bleibt `true`, kein Hard-Fail) wenn `store_path` Modus `&0o077 != 0` ist (Gruppe/Andere haben Zugriff). `New()` legt neue Verzeichnisse bereits mit `0o700` an — 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 über `Store`-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 (Verzeichnis `find store/tenant_/` 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`, `tar` erhält Hardlinks standardmäßig; ein naives `cp -r` würde sie zu vollen Kopien auflösen — kein Datenverlust, aber Speicherplatz-Verdopplung im Backup). - **`migrate-tenant-dirs` einmalig auf Produktiv ausführen** nach Deploy, dann optional in `update.sh` als informativer Hinweis (nicht automatisch bei jedem Deploy laufen lassen — reiner Backfill, macht nach dem ersten Lauf nichts mehr). - Kein lokaler `go build`/`go test` mö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_