OnboardingService.Onboard: ein API-Aufruf legt Tenant (via bestehendem Provisioner) UND ersten Administrator-Account (via IAM-01 TenantUserStore) an. CREATE DATABASE erlaubt keine echte cross-database Transaktion, daher Saga-Kompensation: schlaegt die Admin-Anlage nach erfolgreichem Provisioning fehl, wird der Tenant per neuem Provisioner.Deprovision wieder vollstaendig entfernt (Registry.Delete + DB-Drop). Provisioner.Provision mappt Duplikat-Faelle (42P04 duplicate_database und den bei echt parallelen CREATE DATABASE moeglichen 23505-Unique-Konflikt auf pg_database) jetzt auf ErrTenantExists statt einer rohen Postgres-Fehlermeldung. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Onboarding-Vorgang zweimal parallel mit gleichem Slug ausgeloest — TestOnboarding_RejectsDuplicateSlugConcurrently: genau ein Erfolg, kein Doppel-Tenant, zweiter Aufruf bekommt ErrTenantExists. PASS. 2. Fehleingaben (leere Pflichtfelder, ungueltige E-Mail, ungueltiger Slug) — TestOnboarding_ValidationErrors deckt alle vier Faelle mit klaren Fehlern ab, bevor irgendein DB-Zugriff stattfindet. PASS. 3. Erfolgreicher Durchlauf von zweiter Person end-to-end nachvollzogen — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
110 lines
4.0 KiB
Go
110 lines
4.0 KiB
Go
package tenant
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ErrTenantExists wird zurueckgegeben, wenn Slug oder Datenbankname eines
|
|
// Mandanten bereits vergeben sind (TEN-02 Akzeptanzkriterium 3).
|
|
var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
|
|
|
|
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
|
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
|
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
|
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
|
|
type Provisioner struct {
|
|
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
|
|
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
|
|
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
|
|
adminPool *pgxpool.Pool
|
|
registry *Registry
|
|
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
|
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
|
|
dsnTemplate string
|
|
}
|
|
|
|
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
|
|
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
|
|
}
|
|
|
|
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
|
|
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
|
|
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
|
|
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
|
|
if err := ValidateSlug(slug); err != nil {
|
|
return Tenant{}, err
|
|
}
|
|
|
|
dbName := dbNameForSlug(slug)
|
|
|
|
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
|
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
|
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
|
var pgErr *pgconn.PgError
|
|
// 42P04 (duplicate_database) im Normalfall; bei zwei echt parallelen
|
|
// CREATE DATABASE auf denselben Namen liefert Postgres stattdessen den
|
|
// internen Unique-Index-Konflikt 23505 auf pg_database — beide Faelle
|
|
// bedeuten: der Tenant existiert bereits bzw. wird gerade angelegt.
|
|
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
|
|
return Tenant{}, ErrTenantExists
|
|
}
|
|
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
|
}
|
|
|
|
t := Tenant{
|
|
Slug: slug,
|
|
Name: name,
|
|
DBName: dbName,
|
|
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
|
|
Status: StatusActive,
|
|
}
|
|
|
|
tx, err := p.registry.pool.Begin(ctx)
|
|
if err != nil {
|
|
p.rollbackDatabase(ctx, dbName)
|
|
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
|
|
}
|
|
|
|
created, err := p.registry.insertTx(ctx, tx, t)
|
|
if err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
p.rollbackDatabase(ctx, dbName)
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation auf slug
|
|
return Tenant{}, ErrTenantExists
|
|
}
|
|
return Tenant{}, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
p.rollbackDatabase(ctx, dbName)
|
|
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
|
|
}
|
|
|
|
return created, nil
|
|
}
|
|
|
|
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
|
|
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
|
|
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
|
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
}
|
|
|
|
// Deprovision entfernt einen bereits erfolgreich registrierten Tenant wieder
|
|
// vollstaendig (Registry-Eintrag + Datenbank). Wird als Kompensation
|
|
// verwendet, wenn ein Schritt NACH Provision fehlschlaegt (z.B. die
|
|
// Admin-Account-Anlage in TEN-02) — Provision selbst bleibt dadurch die
|
|
// einzige Stelle, die "erfolgreich provisioniert" garantieren muss.
|
|
func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
|
|
if err := p.registry.Delete(ctx, t.ID); err != nil {
|
|
return err
|
|
}
|
|
p.rollbackDatabase(ctx, t.DBName)
|
|
return nil
|
|
}
|