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.
138 lines
4.5 KiB
Go
138 lines
4.5 KiB
Go
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
|
|
}
|