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>
84 lines
2.5 KiB
Go
84 lines
2.5 KiB
Go
package tenant
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
var ErrMissingField = errors.New("tenant: pflichtfeld fehlt")
|
|
|
|
// OnboardingService implementiert TEN-02: Self-Service-Anlage eines neuen
|
|
// Mandanten inklusive Ersteinrichtung des Administrator-Kontos in einem
|
|
// Vorgang. Schlaegt die Admin-Anlage fehl, wird der bereits provisionierte
|
|
// Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation,
|
|
// da CREATE DATABASE keine echte cross-database Transaktion erlaubt).
|
|
type OnboardingService struct {
|
|
provisioner *Provisioner
|
|
// tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
|
// die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der
|
|
// erste Administrator angelegt werden kann.
|
|
tenantSchemaPath string
|
|
}
|
|
|
|
func NewOnboardingService(provisioner *Provisioner, tenantSchemaPath string) *OnboardingService {
|
|
return &OnboardingService{provisioner: provisioner, tenantSchemaPath: tenantSchemaPath}
|
|
}
|
|
|
|
type OnboardResult struct {
|
|
Tenant Tenant
|
|
Admin user.User
|
|
}
|
|
|
|
func (s *OnboardingService) Onboard(ctx context.Context, slug, tenantName, adminEmail, adminName string) (OnboardResult, error) {
|
|
if tenantName == "" {
|
|
return OnboardResult{}, fmt.Errorf("%w: name", ErrMissingField)
|
|
}
|
|
if adminName == "" {
|
|
return OnboardResult{}, fmt.Errorf("%w: admin-name", ErrMissingField)
|
|
}
|
|
if err := ValidateSlug(slug); err != nil {
|
|
return OnboardResult{}, err
|
|
}
|
|
if err := user.ValidateEmail(adminEmail); err != nil {
|
|
return OnboardResult{}, err
|
|
}
|
|
|
|
t, err := s.provisioner.Provision(ctx, slug, tenantName)
|
|
if err != nil {
|
|
return OnboardResult{}, err
|
|
}
|
|
|
|
admin, err := s.setupAdmin(ctx, t, adminEmail, adminName)
|
|
if err != nil {
|
|
_ = s.provisioner.Deprovision(ctx, t)
|
|
return OnboardResult{}, err
|
|
}
|
|
|
|
return OnboardResult{Tenant: t, Admin: admin}, nil
|
|
}
|
|
|
|
func (s *OnboardingService) setupAdmin(ctx context.Context, t Tenant, adminEmail, adminName string) (user.User, error) {
|
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
if err != nil {
|
|
return user.User{}, fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
schema, err := os.ReadFile(s.tenantSchemaPath)
|
|
if err != nil {
|
|
return user.User{}, fmt.Errorf("tenant-schema lesen: %w", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, string(schema)); err != nil {
|
|
return user.User{}, fmt.Errorf("tenant-schema anwenden: %w", err)
|
|
}
|
|
|
|
store := user.NewTenantUserStore(pool)
|
|
return store.Create(ctx, adminEmail, adminName)
|
|
}
|