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
+53
View File
@@ -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)
}
+25
View File
@@ -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
+3
View File
@@ -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