Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5915a4e7b1 | ||
|
|
e4793303fc |
@@ -129,3 +129,26 @@ Keine Commits in dieser Session.
|
|||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
- 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 ++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+13
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -33,13 +34,24 @@ func main() {
|
|||||||
registry := tenant.NewRegistry(registryPool)
|
registry := tenant.NewRegistry(registryPool)
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
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
|
||||||
|
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
||||||
|
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
||||||
|
superadmins := user.NewSuperadminStore(registryPool)
|
||||||
|
userHandler := user.NewHandler(nil, superadmins)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
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)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type Config struct {
|
|||||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
||||||
TenantDSNTemplate string
|
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) {
|
func Load() (Config, error) {
|
||||||
@@ -37,6 +40,11 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
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")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
@@ -47,5 +55,6 @@ func Load() (Config, error) {
|
|||||||
RegistryDSN: dsn,
|
RegistryDSN: dsn,
|
||||||
AdminDSN: adminDSN,
|
AdminDSN: adminDSN,
|
||||||
TenantDSNTemplate: dsnTemplate,
|
TenantDSNTemplate: dsnTemplate,
|
||||||
|
TenantSchemaPath: schemaPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package tenant
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
// 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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(t)
|
_ = 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
|
|
||||||
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
|
|
||||||
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
|
|
||||||
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
|
|
||||||
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
|
|
||||||
// Nicht-aktiv-Zustaende genau vorliegt.
|
|
||||||
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
|
|
||||||
)
|
|
||||||
|
|
||||||
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
|
|
||||||
var t Tenant
|
|
||||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
|
|
||||||
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
|
||||||
return Tenant{}, err
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
|
|
||||||
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
|
|
||||||
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
|
|
||||||
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
|
|
||||||
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
|
|
||||||
// sprechenden Fehler liefert statt eines stillen No-Ops.
|
|
||||||
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
|
|
||||||
from := make([]string, len(allowedFrom))
|
|
||||||
for i, s := range allowedFrom {
|
|
||||||
from[i] = string(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
|
||||||
UPDATE tenants
|
|
||||||
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
|
|
||||||
WHERE slug = $1 AND status = ANY($5)
|
|
||||||
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
|
||||||
`, slug, string(to), previousStatus, deletionAt, from)
|
|
||||||
|
|
||||||
t, err := scanTenantWithLifecycle(row)
|
|
||||||
if err == nil {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, getErr := r.GetBySlug(ctx, slug)
|
|
||||||
if getErr != nil {
|
|
||||||
return Tenant{}, ErrTenantNotFound
|
|
||||||
}
|
|
||||||
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
|
|
||||||
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
|
|
||||||
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
|
|
||||||
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
|
|
||||||
// die Rueckkehr nach StatusActive.
|
|
||||||
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
|
|
||||||
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
|
|
||||||
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
|
|
||||||
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
|
|
||||||
existing, err := r.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return Tenant{}, ErrTenantNotFound
|
|
||||||
}
|
|
||||||
prev := string(existing.Status)
|
|
||||||
deletionAt := time.Now().Add(grace)
|
|
||||||
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
|
|
||||||
// stellt exakt den zuvor gesicherten Zustand wieder her.
|
|
||||||
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
existing, err := r.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return Tenant{}, ErrTenantNotFound
|
|
||||||
}
|
|
||||||
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
|
|
||||||
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
|
|
||||||
}
|
|
||||||
restoreTo := Status(*existing.PreviousStatus)
|
|
||||||
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
|
|
||||||
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
|
|
||||||
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
|
|
||||||
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
|
|
||||||
type Lifecycle struct {
|
|
||||||
registry *Registry
|
|
||||||
adminPool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
|
||||||
return &Lifecycle{registry: registry, adminPool: adminPool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
|
|
||||||
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
|
|
||||||
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
|
|
||||||
t, err := l.registry.GetBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return ErrTenantNotFound
|
|
||||||
}
|
|
||||||
if t.Status != StatusActive {
|
|
||||||
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
|
|
||||||
"tenant_slug", slug, "tenant_status", t.Status)
|
|
||||||
return ErrTenantNotActive
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
|
|
||||||
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
|
|
||||||
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
|
|
||||||
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
|
|
||||||
// laufende Core-Instanzen.
|
|
||||||
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
|
||||||
tx, err := l.registry.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback(ctx) }()
|
|
||||||
|
|
||||||
rows, err := tx.Query(ctx, `
|
|
||||||
SELECT id, db_name FROM tenants
|
|
||||||
WHERE status = $1 AND deletion_scheduled_at <= now()
|
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
`, string(StatusPendingDeletion))
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type due struct{ id, dbName string }
|
|
||||||
var candidates []due
|
|
||||||
for rows.Next() {
|
|
||||||
var d due
|
|
||||||
if err := rows.Scan(&d.id, &d.dbName); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
|
||||||
}
|
|
||||||
candidates = append(candidates, d)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
processed := 0
|
|
||||||
for _, c := range candidates {
|
|
||||||
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
|
|
||||||
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(ctx, `
|
|
||||||
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
|
|
||||||
WHERE id = $1
|
|
||||||
`, c.id, string(StatusDeleted)); err != nil {
|
|
||||||
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
|
|
||||||
}
|
|
||||||
processed++
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
return processed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
|
|
||||||
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
|
|
||||||
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if _, err := l.ProcessDueDeletions(ctx); err != nil {
|
|
||||||
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, 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(),
|
|
||||||
previous_status TEXT,
|
|
||||||
deletion_scheduled_at TIMESTAMPTZ
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("registry-schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
lifecycle := NewLifecycle(registry, adminPool)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
_ = provisioner
|
|
||||||
return registry, lifecycle, adminPool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
|
|
||||||
t.Helper()
|
|
||||||
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
|
|
||||||
t.Fatalf("provision %s: %v", slug, err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
|
||||||
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
|
|
||||||
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
|
|
||||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_suspend")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
suspended, err := registry.Suspend(ctx, "lc_suspend")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
if suspended.Status != StatusSuspended {
|
|
||||||
t.Fatalf("status = %q, want suspended", suspended.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reactivate: %v", err)
|
|
||||||
}
|
|
||||||
if reactivated.Status != StatusActive {
|
|
||||||
t.Fatalf("status = %q, want active", reactivated.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
|
|
||||||
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
|
|
||||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_invalid")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
|
|
||||||
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
|
|
||||||
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
|
|
||||||
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
|
|
||||||
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
|
|
||||||
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
|
|
||||||
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
|
|
||||||
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
|
|
||||||
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("schedule deletion: %v", err)
|
|
||||||
}
|
|
||||||
if scheduled.Status != StatusPendingDeletion {
|
|
||||||
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
|
|
||||||
}
|
|
||||||
if scheduled.DeletionScheduledAt == nil {
|
|
||||||
t.Fatal("erwartet gesetzte deletion_scheduled_at")
|
|
||||||
}
|
|
||||||
|
|
||||||
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cancel deletion: %v", err)
|
|
||||||
}
|
|
||||||
if restored.Status != StatusActive {
|
|
||||||
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
|
|
||||||
// 'suspended' zurueckkehren, NICHT zu 'active'.
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
|
|
||||||
t.Fatalf("schedule deletion: %v", err)
|
|
||||||
}
|
|
||||||
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cancel deletion: %v", err)
|
|
||||||
}
|
|
||||||
if restoredSuspended.Status != StatusSuspended {
|
|
||||||
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
|
|
||||||
// Zugriffsversuch einen klaren Fehler.
|
|
||||||
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
|
|
||||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
|
|
||||||
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
|
|
||||||
t.Fatalf("suspend: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
|
|
||||||
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
|
|
||||||
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
|
|
||||||
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
|
|
||||||
// das RunSweeper periodisch aufruft).
|
|
||||||
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
|
|
||||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
|
||||||
defer cleanup()
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_due")
|
|
||||||
provisionTestTenant(t, registry, adminPool, "lc_not_due")
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
|
|
||||||
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
|
|
||||||
t.Fatalf("schedule deletion (due): %v", err)
|
|
||||||
}
|
|
||||||
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
|
|
||||||
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
|
|
||||||
t.Fatalf("schedule deletion (not due): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("process due deletions: %v", err)
|
|
||||||
}
|
|
||||||
if processed != 1 {
|
|
||||||
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
|
|
||||||
}
|
|
||||||
|
|
||||||
due, err := registry.GetBySlug(ctx, "lc_due")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get lc_due: %v", err)
|
|
||||||
}
|
|
||||||
if due.Status != StatusDeleted {
|
|
||||||
t.Fatalf("lc_due status = %q, want deleted", due.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get lc_not_due: %v", err)
|
|
||||||
}
|
|
||||||
if notDue.Status != StatusPendingDeletion {
|
|
||||||
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
|
|
||||||
var exists bool
|
|
||||||
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
|
||||||
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
|
|
||||||
t.Fatalf("pg_database pruefen: %v", err)
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,17 @@ package tenant
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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
|
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
||||||
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
||||||
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
// (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
|
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
||||||
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
||||||
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
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)
|
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 {
|
if err != nil {
|
||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
p.rollbackDatabase(ctx, dbName)
|
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
|
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) {
|
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
||||||
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
_, _ = 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,22 +35,28 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
||||||
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
|
||||||
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
|
||||||
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
|
||||||
var t Tenant
|
var t Tenant
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
||||||
FROM tenants WHERE slug = $1
|
FROM tenants WHERE slug = $1
|
||||||
`, slug)
|
`, slug)
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
||||||
&t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||||
}
|
}
|
||||||
return t, nil
|
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) {
|
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ type Status string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StatusActive Status = "active"
|
StatusActive Status = "active"
|
||||||
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
|
|
||||||
StatusSuspended Status = "suspended"
|
|
||||||
StatusPendingDeletion Status = "pending_deletion"
|
|
||||||
StatusDeleted Status = "deleted"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
@@ -26,11 +22,6 @@ type Tenant struct {
|
|||||||
DBDSN string
|
DBDSN string
|
||||||
Status Status
|
Status Status
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
// PreviousStatus und DeletionScheduledAt sind nur waehrend
|
|
||||||
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
|
|
||||||
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
|
|
||||||
PreviousStatus *string
|
|
||||||
DeletionScheduledAt *time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
|
||||||
|
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
|
||||||
|
// dieser Kachel und daher hier noch nicht angebunden.
|
||||||
|
type Handler struct {
|
||||||
|
users *TenantUserStore
|
||||||
|
superadmins *SuperadminStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
|
||||||
|
return &Handler{users: users, superadmins: superadmins}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createUserRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.Create(r.Context(), req.Email, req.Name)
|
||||||
|
writeUserResult(w, u, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
|
||||||
|
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
|
||||||
|
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
|
||||||
|
writeUserResult(w, u, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUserResult(w http.ResponseWriter, u User, err error) {
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
default:
|
||||||
|
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(u)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
|
||||||
|
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
|
||||||
|
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
|
||||||
|
// fuer dasselbe Muster.
|
||||||
|
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||||
|
t.Fatalf("testdatenbank anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect testdatenbank: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
||||||
|
t.Fatalf("schema anwenden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
adminPool.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersSchema = `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
const superadminsSchema = `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE superadmins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
|
||||||
|
func TestTenantUserStore_CRUD(t *testing.T) {
|
||||||
|
pool := setupTestDB(t, "test_iam01_users", usersSchema)
|
||||||
|
store := NewTenantUserStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := store.Create(ctx, "alice@example.com", "Alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if created.Status != StatusActive {
|
||||||
|
t.Fatalf("erwartet status active, hat %q", created.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Email != "alice@example.com" {
|
||||||
|
t.Fatalf("get email = %q", got.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
|
||||||
|
t.Fatalf("update ergebnis unerwartet: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivated, err := store.Deactivate(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deactivate: %v", err)
|
||||||
|
}
|
||||||
|
if deactivated.Status != StatusInactive {
|
||||||
|
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negativfall: doppelte E-Mail-Adresse.
|
||||||
|
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
|
||||||
|
t.Fatalf("create second: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
|
||||||
|
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negativfall: fehlender Benutzer.
|
||||||
|
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
|
||||||
|
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
|
||||||
|
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
|
||||||
|
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
|
||||||
|
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
|
||||||
|
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
|
||||||
|
store := NewSuperadminStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create superadmin: %v", err)
|
||||||
|
}
|
||||||
|
if created.Status != StatusActive {
|
||||||
|
t.Fatalf("erwartet status active, hat %q", created.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Email != "root@nexarch.internal" {
|
||||||
|
t.Fatalf("get email = %q", got.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
|
||||||
|
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivated, err := store.Deactivate(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deactivate: %v", err)
|
||||||
|
}
|
||||||
|
if deactivated.Status != StatusInactive {
|
||||||
|
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
|
||||||
|
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
|
||||||
|
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
|
||||||
|
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
|
||||||
|
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
|
||||||
|
type SuperadminStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
|
||||||
|
return &SuperadminStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u User
|
||||||
|
u.Email, u.Name, u.Status = email, name, StatusActive
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO superadmins (email, name, status)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`, u.Email, u.Name, u.Status)
|
||||||
|
|
||||||
|
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return User{}, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM superadmins WHERE id = $1
|
||||||
|
`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM superadmins ORDER BY created_at
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("superadmins auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("superadmin lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE superadmins SET status = $2, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, name, status, created_at, updated_at
|
||||||
|
`, id, StatusInactive))
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
|
||||||
|
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
|
||||||
|
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
|
||||||
|
type TenantUserStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
|
||||||
|
return &TenantUserStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u User
|
||||||
|
u.Email, u.Name, u.Status = email, name, StatusActive
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, name, status)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`, u.Email, u.Name, u.Status)
|
||||||
|
|
||||||
|
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return User{}, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM users ORDER BY created_at
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("benutzer auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("benutzer lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
|
||||||
|
// Feld unveraendert.
|
||||||
|
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
|
||||||
|
if email != "" {
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET email = COALESCE(NULLIF($2, ''), email),
|
||||||
|
name = COALESCE(NULLIF($3, ''), name),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, name, status, created_at, updated_at
|
||||||
|
`, id, email, name)
|
||||||
|
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
|
||||||
|
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE users SET status = $2, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, name, status, created_at, updated_at
|
||||||
|
`, id, StatusInactive))
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (User, error) {
|
||||||
|
var u User
|
||||||
|
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return User{}, fmt.Errorf("benutzer lesen: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
|
||||||
|
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
|
||||||
|
func mapWriteErr(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return ErrEmailTaken
|
||||||
|
}
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("benutzer schreiben: %w", err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
|
||||||
|
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
|
||||||
|
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
|
||||||
|
// dagegen mandantenuebergreifend in der Registry und sind ueber
|
||||||
|
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
|
||||||
|
// tenant_id-NULL-Sonderfall in User.
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive Status = "active"
|
||||||
|
StatusInactive Status = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Status Status
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
|
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
|
||||||
|
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
|
||||||
|
var ErrNotFound = errors.New("user: nicht gefunden")
|
||||||
|
|
||||||
|
func ValidateEmail(email string) error {
|
||||||
|
if !emailPattern.MatchString(email) {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateEmail(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
email string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"a@b.de", false},
|
||||||
|
{"a.b+c@sub.example.com", false},
|
||||||
|
{"", true},
|
||||||
|
{"keine-email", true},
|
||||||
|
{"a@b", true},
|
||||||
|
{"@b.de", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
err := ValidateEmail(c.email)
|
||||||
|
if (err != nil) != c.wantErr {
|
||||||
|
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS superadmins;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
|
||||||
|
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
|
||||||
|
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
|
||||||
|
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
|
||||||
|
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
|
||||||
|
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
|
||||||
|
CREATE TABLE superadmins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE tenants DROP COLUMN previous_status;
|
|
||||||
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md).
|
|
||||||
-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit
|
|
||||||
-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt"
|
|
||||||
-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen.
|
|
||||||
ALTER TABLE tenants ADD COLUMN previous_status TEXT;
|
|
||||||
ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ;
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
|
||||||
|
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
|
||||||
|
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
|
||||||
|
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
|
||||||
|
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
|
||||||
|
-- nur innerhalb dieser einen Tenant-Datenbank.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
|
||||||
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
|
||||||
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
|
||||||
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
|
||||||
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
|
||||||
ROLE="nexarch_test"
|
|
||||||
|
|
||||||
export PGPASSWORD="$PASS"
|
|
||||||
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants;"
|
|
||||||
|
|
||||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
|
||||||
for db in $dbs; do
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
|
||||||
Reference in New Issue
Block a user