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:
sysops
2026-08-27 18:16:54 +02:00
co-authored by Claude Sonnet 5
parent e4793303fc
commit 5915a4e7b1
8 changed files with 377 additions and 0 deletions
+31
View File
@@ -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
}