feat(PROJ-65): Physische Tenant-Trennung im Storage-Layer (Hardlink-Ordner)

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.
This commit is contained in:
sysops
2026-07-04 13:03:52 +02:00
parent cc30440e99
commit a15fa37619
8 changed files with 493 additions and 8 deletions
+1 -1
View File
@@ -80,7 +80,7 @@
| PROJ-62 | Fix Cross-Tenant IDOR bei POP3-Konto-Löschung/-Import (Sicherheitsbug) | Deployed | [PROJ-62](PROJ-62-fix-pop3-tenant-idor.md) | 2026-06-25 |
| PROJ-63 | Defensive Tenant-Scope-Härtung der Tenant-Verwaltungs-Endpunkte (FUND-2) | Deployed | [PROJ-63](PROJ-63-harden-tenant-admin-scope.md) | 2026-06-25 |
| PROJ-64 | Session-Invalidation bei Passwort-Change + Datei-Permissions-Härtung (Security-Audit) | Deployed | [PROJ-64](PROJ-64-session-invalidation-file-permissions.md) | 2026-07-03 |
| PROJ-65 | Physische Tenant-Trennung im Storage-Layer | Planned | [PROJ-65](PROJ-65-physische-tenant-trennung.md) | 2026-07-04 |
| PROJ-65 | Physische Tenant-Trennung im Storage-Layer | In Review | [PROJ-65](PROJ-65-physische-tenant-trennung.md) | 2026-07-04 |
<!-- Add features above this line -->
@@ -0,0 +1,254 @@
---
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_<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 unter
`store/<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ü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_<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
unter `store/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-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/<hash-prefix>/<hash>` 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_<id>/<hash-prefix>/<hash>`
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_<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`, `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_