TEN-02: tenant-onboarding-api-self-service
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e4793303fc
commit
5915a4e7b1
@@ -2,11 +2,17 @@ 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
|
||||
@@ -39,6 +45,14 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -60,6 +74,10 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -76,3 +94,16 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user