// ARC-06: physische Mandantentrennung im Objekt-Storage — jeder // Mandant bekommt einen EIGENEN Bucket statt einer gemeinsamen Ablage // mit Pfad-Präfix. Ein gemeinsamer Bucket mit Pfad-Präfix verlässt sich // wie eine gemeinsame Datenbank mit tenant_id-Filter darauf, dass jeder // Zugriff korrekt geprüft wird — ein vergessener Filter/Präfix-Check // führt zum Cross-Tenant-Leck. Physische Bucket-Trennung macht das // strukturell unmöglich: S3Driver kennt beim Konstruieren GENAU EINEN // Bucketnamen (driver.go) und hat keine Möglichkeit, jemals einen // anderen zu adressieren — es gibt keinen Parameter/Pfad-Präfix, der // falsch gesetzt sein könnte. package storage import ( "context" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/jackc/pgx/v5/pgxpool" ) // BucketNameForTenant liefert den deterministischen, physisch // getrennten Bucketnamen eines Mandanten (Akzeptanzkriterium 1) — die // EINE Stelle, die den Namen berechnet, damit Provisionierung // (ProvisionTenant) und späteres Öffnen des Buckets (NewS3Driver) // immer denselben Namen verwenden. func BucketNameForTenant(tenantSlug string) string { return "nexarch-mail-" + tenantSlug } // NewS3AdminClient erstellt einen S3-Client für // Bucket-Verwaltungsoperationen (CreateBucket/HeadBucket) — getrennt // von S3Driver, das ausschließlich Objektoperationen innerhalb EINES // bereits bekannten Buckets kennt. func NewS3AdminClient(ctx context.Context, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*s3.Client, error) { cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")), ) if err != nil { return nil, fmt.Errorf("storage: s3-admin-konfiguration laden: %w", err) } return s3.NewFromConfig(cfg, func(o *s3.Options) { if endpointURL != "" { o.BaseEndpoint = aws.String(endpointURL) } o.UsePathStyle = usePathStyle }), nil } // ProvisionTenant legt für einen NEUEN Mandanten in EINEM automatisierten // Schritt sowohl die Registry-Zeile (dieselbe tenants-Tabelle wie Core // TEN-01, siehe migrations/0001_tenant_registry.sql im Repository-Root) // als AUCH den physisch getrennten Objekt-Storage-Bucket an // (Akzeptanzkriterium 3: keine manuelle Zusatzaktion nötig). Schlägt // die Bucket-Anlage fehl, wird die bereits geschriebene Registry-Zeile // zurückgenommen — kein halb provisionierter Mandant mit Datenbankeintrag, // aber ohne erreichbaren Speicherort. func ProvisionTenant(ctx context.Context, registryPool *pgxpool.Pool, s3Admin *s3.Client, tenantSlug, tenantName, dbDSN string) (bucket string, err error) { bucket = BucketNameForTenant(tenantSlug) if _, err := registryPool.Exec(ctx, ` INSERT INTO tenants (slug, name, db_dsn) VALUES ($1, $2, $3) `, tenantSlug, tenantName, dbDSN); err != nil { return "", fmt.Errorf("storage: mandant in registry anlegen: %w", err) } if _, err := s3Admin.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}); err != nil { _, rollbackErr := registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, tenantSlug) if rollbackErr != nil { return "", fmt.Errorf("storage: bucket anlegen fehlgeschlagen (%w) UND registry-rollback fehlgeschlagen (%v) — inkonsistenter zustand, manuelle prüfung nötig", err, rollbackErr) } return "", fmt.Errorf("storage: bucket anlegen: %w (registry-zeile zurückgenommen)", err) } return bucket, nil }