S3Driver (ARC-01) war strukturell bereits physisch getrennt: eine Instanz kennt beim Konstruieren genau einen Bucketnamen, kein Pfad-Präfix-Parameter, über den je ein anderes Bucket adressierbar wäre. Was fehlte, war die automatisierte Provisionierung dieser Trennung und der Nachweis dafür. Neue Datei provision.go: BucketNameForTenant liefert den deterministischen Bucketnamen je Mandant. ProvisionTenant legt in EINEM Aufruf sowohl die Registry-Zeile in derselben tenants-Tabelle wie Core TEN-01 (migrations/0001_tenant_registry.sql) als auch den Bucket an — schlägt die Bucket-Anlage fehl, wird die Registry-Zeile automatisch zurückgenommen, kein halb provisionierter Mandant. Core TEN-01 ist im aktuellen Stand ein Grundgerüst ohne eigene aufrufbare Provisionierungsfunktion — ProvisionTenant schreibt deshalb direkt über den Registry-DSN in dieselbe Tabelle, dokumentiert im Prüfprotokoll. Alle drei Pflichtprüfungen mit echten Nachweisen gegen eine reale lokale MinIO-Instanz und Postgres durchgeführt: physische Bucket-Trennung zweier Mandanten (ein in Mandant As Bucket geschriebenes Objekt ist über Mandant Bs Driver nicht erreichbar, weil es dort kein Objekt dieses Namens gibt, nicht weil ein Pfadfilter greift); ein nie provisionierter Pseudo-Mandant scheitert auf Bucket-Ebene (NoSuchBucket), bevor überhaupt eine Schlüsselsuche stattfinden könnte; ein Provisionierungsaufruf legt Datenbank-Registry- Zeile und Bucket nachweislich in einem Schritt an, inklusive Rollback-Test bei fehlschlagender Bucket-Anlage. go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet. Neue Testumgebungsvariablen TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY (t.Skip ohne sie, gleiche Konvention wie TEST_TENANT_DSN/TEST_MANTICORE_URL).
80 lines
3.6 KiB
Go
80 lines
3.6 KiB
Go
// 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
|
|
}
|