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:
@@ -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