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:
@@ -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)
|
||||
}
|
||||
@@ -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_<id>/, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<id>/` (`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. | – |
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -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_
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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_<id>/` 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/<shard>/<id> 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
|
||||
}
|
||||
Reference in New Issue
Block a user