diff --git a/DEVLOG.md b/DEVLOG.md index 6a9fd56..3d825bd 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -129,3 +129,26 @@ Keine Commits in dieser Session. - migrations/0001_tenant_registry.sql | 10 ++++++++++ --- +## 2026-08-27 17:53 – 17:57 (4m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +- e479330 IAM-01: benutzer-datenmodell-crud + +### Geänderte Dateien +- cmd/core/main.go | 11 ++++++++++- +- go.mod | 9 +++++++++ +- go.sum | 28 ++++++++++++++++++++++++++++ +- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++ +- internal/user/user_test.go | 24 ++++++++++++++++++++++++ +- migrations/0002_superadmins.down.sql | 1 + +- migrations/0002_superadmins.up.sql | 14 ++++++++++++++ +- migrations/tenant/0001_users.down.sql | 1 + +- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++ + +--- diff --git a/cmd/core/main.go b/cmd/core/main.go index e672cf3..5416e81 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -34,6 +34,8 @@ func main() { registry := tenant.NewRegistry(registryPool) provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate) tenantHandler := tenant.NewHandler(provisioner) + onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath) + onboardingHandler := tenant.NewOnboardingHandler(onboardingService) // Superadmin-Konten leben mandantenuebergreifend in der Registry-DB. // Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro @@ -48,6 +50,7 @@ func main() { }) // Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest. mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant) + mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard) mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin) log.Printf("nexarch-core listening on %s", cfg.ListenAddr) diff --git a/internal/config/config.go b/internal/config/config.go index 86ac457..0147aab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,9 @@ type Config struct { // TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den // Datenbanknamen einer neu provisionierten Tenant-Datenbank. TenantDSNTemplate string + // TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der + // TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert. + TenantSchemaPath string } func Load() (Config, error) { @@ -37,6 +40,11 @@ func Load() (Config, error) { return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set") } + schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH") + if schemaPath == "" { + return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set") + } + addr := os.Getenv("NEXARCH_LISTEN_ADDR") if addr == "" { addr = ":8080" @@ -47,5 +55,6 @@ func Load() (Config, error) { RegistryDSN: dsn, AdminDSN: adminDSN, TenantDSNTemplate: dsnTemplate, + TenantSchemaPath: schemaPath, }, nil } diff --git a/internal/tenant/handler.go b/internal/tenant/handler.go index 54eac5d..40a260c 100644 --- a/internal/tenant/handler.go +++ b/internal/tenant/handler.go @@ -2,7 +2,10 @@ package tenant import ( "encoding/json" + "errors" "net/http" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" ) // Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01 @@ -43,3 +46,49 @@ func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(t) } + +// OnboardingHandler stellt den Self-Service-Endpunkt aus TEN-02 bereit — +// eigener Handler statt Erweiterung von Handler.CreateTenant, damit die +// TEN-01-Schnittstelle (reines Provisioning ohne Admin-Konto) unveraendert +// bleibt. +type OnboardingHandler struct { + service *OnboardingService +} + +func NewOnboardingHandler(s *OnboardingService) *OnboardingHandler { + return &OnboardingHandler{service: s} +} + +type onboardRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + AdminEmail string `json:"admin_email"` + AdminName string `json:"admin_name"` +} + +func (h *OnboardingHandler) Onboard(w http.ResponseWriter, r *http.Request) { + var req onboardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + result, err := h.service.Onboard(r.Context(), req.Slug, req.Name, req.AdminEmail, req.AdminName) + if err != nil { + switch { + case errors.Is(err, ErrInvalidSlug), + errors.Is(err, ErrMissingField), + errors.Is(err, user.ErrInvalidEmail): + http.Error(w, err.Error(), http.StatusBadRequest) + case errors.Is(err, ErrTenantExists): + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, "mandant konnte nicht angelegt werden", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(result) +} diff --git a/internal/tenant/onboarding.go b/internal/tenant/onboarding.go new file mode 100644 index 0000000..5e79b29 --- /dev/null +++ b/internal/tenant/onboarding.go @@ -0,0 +1,83 @@ +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) +} diff --git a/internal/tenant/onboarding_test.go b/internal/tenant/onboarding_test.go new file mode 100644 index 0000000..f4b2257 --- /dev/null +++ b/internal/tenant/onboarding_test.go @@ -0,0 +1,169 @@ +package tenant + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt +// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL). +const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql" + +func TestOnboarding_ValidationErrors(t *testing.T) { + // Reine Eingabevalidierung, braucht keine Datenbank. + svc := NewOnboardingService(nil, schemaPathForTest) + ctx := context.Background() + + cases := []struct { + name string + slug string + tenantName string + email string + adminName string + wantErr error + }{ + {"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField}, + {"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField}, + {"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug}, + {"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName) + if !errors.Is(err, c.wantErr) { + t.Fatalf("erwartet %v, habe %v", c.wantErr, err) + } + }) + } +} + +func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + registryPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("registry pool: %v", err) + } + + if _, err := registryPool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + t.Fatalf("registry-schema: %v", err) + } + + registry := NewRegistry(registryPool) + dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1) + provisioner := NewProvisioner(adminPool, registry, dsnTemplate) + svc := NewOnboardingService(provisioner, schemaPathForTest) + + cleanup := func() { + registryPool.Close() + adminPool.Close() + } + return svc, cleanup +} + +// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang. +func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) { + svc, cleanup := newTestOnboardingService(t) + defer cleanup() + ctx := context.Background() + + t.Cleanup(func() { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + p, _ := pgxpool.New(ctx, adminDSN) + defer p.Close() + _, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`) + _, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`) + }) + + result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin") + if err != nil { + t.Fatalf("onboard: %v", err) + } + if result.Tenant.Slug != "onboardtest" { + t.Fatalf("tenant slug = %q", result.Tenant.Slug) + } + if result.Admin.Email != "admin@onboardtest.example" { + t.Fatalf("admin email = %q", result.Admin.Email) + } + + pool, err := pgxpool.New(ctx, result.Tenant.DBDSN) + if err != nil { + t.Fatalf("connect tenant db: %v", err) + } + defer pool.Close() + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil { + t.Fatalf("admin in tenant-db pruefen: %v", err) + } + if count != 1 { + t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count) + } +} + +// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen, +// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant. +func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) { + svc, cleanup := newTestOnboardingService(t) + defer cleanup() + ctx := context.Background() + + t.Cleanup(func() { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + p, _ := pgxpool.New(ctx, adminDSN) + defer p.Close() + _, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`) + _, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`) + }) + + var wg sync.WaitGroup + results := make([]error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin") + results[i] = err + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range results { + if err == nil { + successCount++ + } else if !errors.Is(err, ErrTenantExists) { + t.Fatalf("unerwarteter fehler: %v", err) + } + } + if successCount != 1 { + t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount) + } +} diff --git a/internal/tenant/provisioner.go b/internal/tenant/provisioner.go index 5b60a8c..085257d 100644 --- a/internal/tenant/provisioner.go +++ b/internal/tenant/provisioner.go @@ -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 +} diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go index 401f623..74440b1 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -47,6 +47,16 @@ func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) { return t, nil } +// Delete entfernt einen Registry-Eintrag. Wird von Provisioner.Deprovision +// als Kompensation verwendet, wenn nach erfolgreicher Registrierung ein +// nachgelagerter Schritt (z.B. Admin-Account-Anlage in TEN-02) fehlschlaegt. +func (r *Registry) Delete(ctx context.Context, id string) error { + if _, err := r.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id); err != nil { + return fmt.Errorf("tenant loeschen: %w", err) + } + return nil +} + func (r *Registry) List(ctx context.Context) ([]Tenant, error) { rows, err := r.pool.Query(ctx, ` SELECT id, slug, name, db_name, db_dsn, status, created_at