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
+14
View File
@@ -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
}