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