From a15fa37619f29c63cf39fe0369de991e70943c71 Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 4 Jul 2026 13:03:52 +0200 Subject: [PATCH] feat(PROJ-65): Physische Tenant-Trennung im Storage-Layer (Hardlink-Ordner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jeder Tenant bekommt ein eigenes Verzeichnis store/tenant_/, 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. --- cmd/archivmail/cmd_migrate_tenant_dirs.go | 53 ++++ cmd/archivmail/cmd_status.go | 25 ++ cmd/archivmail/main.go | 3 + docs/GOBD_DSGVO_CHECKLIST.md | 13 +- features/INDEX.md | 2 +- features/PROJ-65-physische-tenant-trennung.md | 254 ++++++++++++++++++ internal/storage/storage.go | 14 + internal/storage/tenant_dirs.go | 137 ++++++++++ 8 files changed, 493 insertions(+), 8 deletions(-) create mode 100644 cmd/archivmail/cmd_migrate_tenant_dirs.go create mode 100644 features/PROJ-65-physische-tenant-trennung.md create mode 100644 internal/storage/tenant_dirs.go diff --git a/cmd/archivmail/cmd_migrate_tenant_dirs.go b/cmd/archivmail/cmd_migrate_tenant_dirs.go new file mode 100644 index 0000000..c60c2fc --- /dev/null +++ b/cmd/archivmail/cmd_migrate_tenant_dirs.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + + "archivmail/config" + "archivmail/internal/storage" +) + +// runMigrateTenantDirs backfills per-tenant hardlink directories (PROJ-65) +// for mails that were archived before this version. New mails get their +// tenant hardlink at Save() time already — this is a one-time catch-up run +// for the existing archive after upgrading. Idempotent: safe to re-run, +// only fills in missing links. +// +// Usage: archivmail migrate-tenant-dirs [-config /etc/archivmail/config.yml] +func runMigrateTenantDirs(args []string) { + fs := flag.NewFlagSet("migrate-tenant-dirs", flag.ExitOnError) + configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") + fs.Parse(args) + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfg, err := config.Load(*configPath) + if err != nil { + logger.Error("failed to load config", "err", err) + os.Exit(1) + } + + storeCfg := storage.Config{ + Dir: cfg.Storage.StorePath, + Keyfile: cfg.Storage.Keyfile, + DSN: cfg.Database.DSN(), + CompressEnabled: cfg.Storage.Compress, + } + mailStore, err := storage.New(storeCfg) + if err != nil { + logger.Error("storage init failed", "err", err) + os.Exit(1) + } + defer mailStore.Close() + + logger.Info("migrate-tenant-dirs: starting backfill") + linked, errCount, err := mailStore.BackfillTenantDirs(context.Background()) + if err != nil { + logger.Error("migrate-tenant-dirs: failed", "err", err) + os.Exit(1) + } + logger.Info("migrate-tenant-dirs: complete", "linked", linked, "errors", errCount) +} diff --git a/cmd/archivmail/cmd_status.go b/cmd/archivmail/cmd_status.go index 1fc18c5..23699fa 100644 --- a/cmd/archivmail/cmd_status.go +++ b/cmd/archivmail/cmd_status.go @@ -45,6 +45,7 @@ func runStatus(args []string) { checkPostgres(cfg), checkManticore(cfg), checkStorage(cfg), + checkStoragePermissions(cfg), checkEncryption(cfg), checkAuditLog(cfg), checkRetention(cfg), @@ -180,6 +181,30 @@ func checkStorage(cfg *config.Config) checkResult { return checkResult{Name: "Storage", OK: ok, Detail: detail} } +// checkStoragePermissions warns (PROJ-65) if the mail storage directory is +// readable/writable/executable by group or world. Physical tenant separation +// (per-tenant hardlink dirs under store/tenant_/, see internal/storage/ +// tenant_dirs.go) is only a meaningful defense-in-depth layer if the storage +// tree itself isn't broadly readable — otherwise any local user could bypass +// the DB-gated access control entirely by reading files directly. Like the +// encryption/retention checks this never hard-fails (OK stays true), it only +// surfaces the state so an operator can tighten it. +func checkStoragePermissions(cfg *config.Config) checkResult { + storePath := cfg.Storage.StorePath + fi, err := os.Stat(storePath) + if err != nil { + return checkResult{Name: "Storage-Rechte", OK: true, + Detail: fmt.Sprintf("store_path %s nicht erreichbar: %v", storePath, err)} + } + mode := fi.Mode().Perm() + if mode&0o077 != 0 { + return checkResult{Name: "Storage-Rechte", OK: true, + Detail: fmt.Sprintf("WARNUNG — %s hat Modus %04o, Gruppe/Andere haben Zugriff (empfohlen: 0700)", storePath, mode)} + } + return checkResult{Name: "Storage-Rechte", OK: true, + Detail: fmt.Sprintf("%s Modus %04o — nur Owner-Zugriff", storePath, mode)} +} + // checkEncryption reports whether at-rest AES-256-GCM encryption is active // (PROJ-49). A configured, readable 32-byte keyfile yields status "enabled"; // everything else yields "disabled" with a concrete reason. Disabled is NOT a diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index e88f56e..e074c6e 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -56,6 +56,9 @@ func main() { case "migrate-tenants": runMigrateTenants(os.Args[2:]) return + case "migrate-tenant-dirs": + runMigrateTenantDirs(os.Args[2:]) + return case "reindex": runReindex(os.Args[2:]) return diff --git a/docs/GOBD_DSGVO_CHECKLIST.md b/docs/GOBD_DSGVO_CHECKLIST.md index 9a70509..5cb2e08 100644 --- a/docs/GOBD_DSGVO_CHECKLIST.md +++ b/docs/GOBD_DSGVO_CHECKLIST.md @@ -8,16 +8,15 @@ Stand: 2026-07-04, basierend auf Code-Review (nicht nur Spec-Review). **Gut abgedeckt:** Verschlüsselung at-rest (AES-256-GCM) mit Pflicht-Warnung (PROJ-49), Volltextsuche (Manticore), unveränderliches Audit-Log per DB-Trigger + append-only JSON-Lines (PROJ-48), Retention/Löschsperre inkl. Dokumentenart-Kategorien (PROJ-34, PROJ-51), DSGVO-Löschersuchen-Workflow -mit GoBD-Vorrang (PROJ-50), Vollständigkeits-Reconciliation (PROJ-52), Multi-Tenant-Rollenmodell, -Integritätsprüfung per SHA-256 (PROJ-18), Export in EML/MBOX/ZIP/CSV (PROJ-12, PROJ-15, PROJ-39, PROJ-47). +mit GoBD-Vorrang (PROJ-50), Vollständigkeits-Reconciliation (PROJ-52), physische Tenant-Trennung im +Storage-Layer (PROJ-65), Multi-Tenant-Rollenmodell, Integritätsprüfung per SHA-256 (PROJ-18), +Export in EML/MBOX/ZIP/CSV (PROJ-12, PROJ-15, PROJ-39, PROJ-47). **Verbleibende Lücken (priorisiert):** -1. **Physische Tenant-Trennung fehlt** – Storage ist logisch (DB) getrennt, nicht auf Dateisystem- - Ebene (Punkt 15, bekannt seit Tenant-Isolation-Review). -2. **Zeitstempel/Signaturerhalt (BSI TR 03125)** – nicht implementiert (Nice-to-have, niedrige +1. **Zeitstempel/Signaturerhalt (BSI TR 03125)** – nicht implementiert (Nice-to-have, niedrige Priorität, nur relevant bei signierten Mails im Kundenkreis). -3. **Informationspflicht der Mitarbeiter** – organisatorisch, nicht im Code lösbar (Punkt 13). +2. **Informationspflicht der Mitarbeiter** – organisatorisch, nicht im Code lösbar (Punkt 13). --- @@ -39,7 +38,7 @@ Integritätsprüfung per SHA-256 (PROJ-18), Export in EML/MBOX/ZIP/CSV (PROJ-12, | 12 | **Zeitstempel/Signaturerhalt (BSI TR 03125)** | ❌ Fehlt | Keine S/MIME- oder PGP-Signaturprüfung, kein qualifizierter Zeitstempel-Dienst im Code gefunden. Für die meisten KMU nicht zwingend erforderlich. | Als Backlog-Item vermerken, nur bei Bedarf (signierte Mails im Kundenkreis) als neue Spec aufnehmen. | | 13 | **Informationspflicht der Mitarbeiter** | ❌ Fehlt (organisatorisch) | Nicht im Code prüfbar/lösbar. | Organisatorische Maßnahme: Betriebsvereinbarung / Datenschutzhinweis außerhalb des Systems. | | 14 | **Trennung/Kennzeichnung personenbezogener Daten, Löschkonflikt Art. 5/17/32** | ✅ Erfüllt | PROJ-50 (deployt 2026-06-13): `internal/storage/dsgvo_requests.go` — Tabelle `dsgvo_requests`, Workflow sucht betroffene Mails (From/To/CC, Volltext via Manticore `AnyAddress`), löscht NUR Mails ohne aktive Löschsperre (`store.Delete` respektiert `retain_until`), lehnt den Rest mit Begründung "Aufbewahrungspflicht hat Vorrang" ab und protokolliert (`EventDSGVORequest`). Bekannte Deviation: `bcc_addr` ist im Schema vorhanden, aber `mailparser` befüllt BCC nicht (kein BCC-Header in archivierten Mails) — Such-Vollständigkeitszusage entsprechend einschränken. | Deviation (BCC) in Kunden-Doku erwähnen, sonst keine Aktion nötig. | -| 15 | **Mandantentrennung/Zugriffskontrolle nach Rolle (Multi-Tenant)** | ✅ Erfüllt | PROJ-21 Multi-Tenancy, `tenantAccessAllowed()` als Standardmuster (`internal/api/import_handlers.go:22`), Tenant-Quotas (PROJ-29), Pro-Tenant-Retention (`tenants.retention_days`). Laut Memory: keine physische Storage-Trennung pro Tenant (logische Trennung über `email_refs`/DB). | Bekannte Lücke (siehe Memory `project_tenant_isolation_review.md`): physische Storage-Trennung als optionales Härtungsfeature evaluieren, falls von Kunden gefordert. | +| 15 | **Mandantentrennung/Zugriffskontrolle nach Rolle (Multi-Tenant)** | ✅ Erfüllt | PROJ-21 Multi-Tenancy, `tenantAccessAllowed()` als Standardmuster (`internal/api/import_handlers.go:22`), Tenant-Quotas (PROJ-29), Pro-Tenant-Retention (`tenants.retention_days`). PROJ-65 (2026-07-04): zusätzlich physische Tenant-Trennung im Storage-Layer — Hardlink-Verzeichnisse `store/tenant_/` (`internal/storage/tenant_dirs.go`), Dedup-Modell bleibt erhalten (keine Speicherplatz-Verdopplung), Backfill für Bestandsdaten (`archivmail migrate-tenant-dirs`). Storage-Verzeichnis-Permissions (0700) werden zusätzlich per `archivmail status` überwacht (`checkStoragePermissions`). 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. | – | | 16 | **Verschlüsselung at-rest** | ✅ Erfüllt | PROJ-49 (deployt 2026-06-13): AES-256-GCM (`internal/storage/storage.go`, `encrypt()`), inkl. gzip vor Verschlüsselung (PROJ-36). `Store.EncryptionEnabled()` + `warnEncryptionStatus()` gibt beim Start eine WARN-Zeile aus wenn Keyfile fehlt/unlesbar/falsche Größe; `archivmail status` prüft `checkEncryption`; Dashboard (`handleSystemStats`) zeigt `encryption.enabled` systemweit. `Keyfile` bleibt technisch optional (Abwärtskompatibilität bestehender Installationen), aber Zustand ist jetzt sichtbar statt stillschweigend. | – | --- diff --git a/features/INDEX.md b/features/INDEX.md index 8caeed8..9581417 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -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 | diff --git a/features/PROJ-65-physische-tenant-trennung.md b/features/PROJ-65-physische-tenant-trennung.md new file mode 100644 index 0000000..c24620a --- /dev/null +++ b/features/PROJ-65-physische-tenant-trennung.md @@ -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_`), 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_ diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 91ec7a3..53a88bb 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -436,6 +436,7 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int VALUES ($1, $2) ON CONFLICT (email_id, tenant_id) DO NOTHING `, existingID, *tenantID) + s.linkTenantDir(existingID, *tenantID) } return existingID, nil } @@ -518,6 +519,7 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int VALUES ($1, $2) ON CONFLICT (email_id, tenant_id) DO NOTHING `, conflictID, *tenantID) + s.linkTenantDir(conflictID, *tenantID) } return conflictID, nil } @@ -548,6 +550,9 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int ON CONFLICT (email_id, tenant_id) DO NOTHING `, id, *tenantID) } + if tenantID != nil { + s.linkTenantDir(id, *tenantID) + } return id, nil } @@ -737,6 +742,11 @@ func (s *Store) Load(id string) ([]byte, error) { func (s *Store) Delete(id string) error { ctx := context.Background() + // Captured before the DB rows are deleted below (PROJ-65): once emails/ + // email_refs are gone we can no longer ask which tenant directories held + // a hardlink to this mail. + var tenantIDs []int64 + if s.db != nil { var until *time.Time _ = s.db.QueryRow(ctx, `SELECT retain_until FROM emails WHERE id=$1`, id).Scan(&until) @@ -744,6 +754,8 @@ func (s *Store) Delete(id string) error { return ErrRetentionLock } + tenantIDs, _ = s.TenantsForMail(ctx, id) + tx, err := s.db.Begin(ctx) if err != nil { return fmt.Errorf("storage: delete: begin tx: %w", err) @@ -774,6 +786,8 @@ func (s *Store) Delete(id string) error { return fmt.Errorf("storage: delete: file: %w", err) } + s.unlinkTenantDirs(id, tenantIDs) + return nil } diff --git a/internal/storage/tenant_dirs.go b/internal/storage/tenant_dirs.go new file mode 100644 index 0000000..3233dfb --- /dev/null +++ b/internal/storage/tenant_dirs.go @@ -0,0 +1,137 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5" +) + +// tenantFilePath returns the per-tenant hardlink path for a mail (PROJ-65). +// It mirrors filePath's 2-char shard layout, nested under a tenant directory, +// so a `find store/tenant_/` gives a physically browsable view of exactly +// what one tenant can see, without duplicating file content on disk. +func (s *Store) tenantFilePath(tenantID int64, id string) string { + return filepath.Join(s.dir, "store", fmt.Sprintf("tenant_%d", tenantID), id[:2], id) +} + +// linkTenantDir ensures a hardlink to mail id exists under tenantID's +// directory (PROJ-65). Best-effort: the canonical content-addressed file +// under store// remains the source of truth and the only thing +// DB-driven access (Load/Delete) ever touches; this hardlink is purely an +// additional, physically browsable view for defense-in-depth / manual +// filesystem audits. A failure here must never fail the caller's Save/import. +func (s *Store) linkTenantDir(id string, tenantID int64) { + src := s.filePath(id) + dst := s.tenantFilePath(tenantID, id) + + if _, err := os.Stat(dst); err == nil { + return // already linked + } + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + s.logLinkWarn("mkdir tenant dir", id, tenantID, err) + return + } + if err := os.Link(src, dst); err != nil { + if errors.Is(err, os.ErrExist) { + return + } + s.logLinkWarn("hardlink", id, tenantID, err) + } +} + +// unlinkTenantDirs best-effort removes a mail's hardlinks from every tenant +// directory it was visible under (PROJ-65). Called from Delete() after the +// canonical file has already been removed, using the tenant set captured +// before the DB rows were deleted. A missing link is not an error. +func (s *Store) unlinkTenantDirs(id string, tenantIDs []int64) { + for _, tid := range tenantIDs { + dst := s.tenantFilePath(tid, id) + if err := os.Remove(dst); err != nil && !errors.Is(err, os.ErrNotExist) { + s.logLinkWarn("unlink", id, tid, err) + } + } +} + +func (s *Store) logLinkWarn(op, id string, tenantID int64, err error) { + slog.Default().Warn("storage: tenant dir link failed", "op", op, "id", id, "tenant_id", tenantID, "err", err) +} + +// TenantsForMail returns every tenant ID a mail is currently visible under: +// its primary emails.tenant_id plus any additional email_refs entries +// (cross-tenant dedup, PROJ-32/PROJ-37). Used by Delete() to know which +// tenant hardlinks to clean up, and by the migrate-tenant-dirs backfill. +func (s *Store) TenantsForMail(ctx context.Context, id string) ([]int64, error) { + if s.db == nil { + return nil, nil + } + seen := map[int64]bool{} + var primary *int64 + err := s.db.QueryRow(ctx, `SELECT tenant_id FROM emails WHERE id = $1`, id).Scan(&primary) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("storage: tenants for mail: %w", err) + } + if primary != nil { + seen[*primary] = true + } + + rows, err := s.db.Query(ctx, `SELECT DISTINCT tenant_id FROM email_refs WHERE email_id = $1`, id) + if err != nil { + return nil, fmt.Errorf("storage: tenants for mail refs: %w", err) + } + defer rows.Close() + for rows.Next() { + var tid int64 + if err := rows.Scan(&tid); err == nil { + seen[tid] = true + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("storage: tenants for mail refs rows: %w", err) + } + + out := make([]int64, 0, len(seen)) + for tid := range seen { + out = append(out, tid) + } + return out, nil +} + +// BackfillTenantDirs walks all existing mails and creates any missing +// per-tenant hardlinks (PROJ-65). Intended for the one-time +// `archivmail migrate-tenant-dirs` CLI backfill after upgrading to a version +// with tenant directories — mails saved before that point only exist under +// the root content-addressed path. Idempotent: re-running only fills gaps. +func (s *Store) BackfillTenantDirs(ctx context.Context) (linked int, errCount int, err error) { + if s.db == nil { + return 0, 0, nil + } + ids, err := s.GetAllIDs(ctx) + if err != nil { + return 0, 0, fmt.Errorf("storage: backfill tenant dirs: list ids: %w", err) + } + for _, id := range ids { + tenantIDs, terr := s.TenantsForMail(ctx, id) + if terr != nil { + errCount++ + continue + } + for _, tid := range tenantIDs { + dst := s.tenantFilePath(tid, id) + if _, statErr := os.Stat(dst); statErr == nil { + continue // already linked + } + s.linkTenantDir(id, tid) + if _, statErr := os.Stat(dst); statErr == nil { + linked++ + } else { + errCount++ + } + } + } + return linked, errCount, nil +}