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
|
||||
|
||||
Reference in New Issue
Block a user