Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c344dea218 | ||
|
|
45bc10719a |
@@ -129,26 +129,3 @@ 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 ++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
+1
-13
@@ -8,7 +8,6 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -34,24 +33,13 @@ 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
|
||||
// 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.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 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)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||
|
||||
@@ -19,9 +19,6 @@ 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) {
|
||||
@@ -40,11 +37,6 @@ 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"
|
||||
@@ -55,6 +47,5 @@ func Load() (Config, error) {
|
||||
RegistryDSN: dsn,
|
||||
AdminDSN: adminDSN,
|
||||
TenantDSNTemplate: dsnTemplate,
|
||||
TenantSchemaPath: schemaPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ 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
|
||||
@@ -46,49 +43,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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
|
||||
// retention ist die Pruef-Schnittstelle gegen Archive RET-03/CMP-06 (TEN-08).
|
||||
// Default NoRetentionCheck{}, bis Archive angebunden ist — siehe retention.go.
|
||||
retention RetentionChecker
|
||||
}
|
||||
|
||||
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
||||
return &Lifecycle{registry: registry, adminPool: adminPool, retention: NoRetentionCheck{}}
|
||||
}
|
||||
|
||||
// WithRetentionChecker ersetzt den Retention-Checker (z.B. im Test durch einen
|
||||
// Fake, oder in Produktion durch den echten Archive-RET-03-Client). Gibt
|
||||
// dasselbe *Lifecycle zurueck, um Verkettung beim Aufbau zu erlauben.
|
||||
func (l *Lifecycle) WithRetentionChecker(checker RetentionChecker) *Lifecycle {
|
||||
l.retention = checker
|
||||
return l
|
||||
}
|
||||
|
||||
// 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, slug, 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, slug, dbName string }
|
||||
var candidates []due
|
||||
for rows.Next() {
|
||||
var d due
|
||||
if err := rows.Scan(&d.id, &d.slug, &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 {
|
||||
// TEN-08: vor der physischen Loeschung gegen Archive RET-03/CMP-06 pruefen.
|
||||
// Solange eine Sperre besteht, bleibt der Tenant in pending_deletion
|
||||
// ("zur Loeschung vorgemerkt, aber gesperrt") — der Grund wird
|
||||
// festgehalten (Akzeptanzkriterium 2), die naechste Sweeper-Runde
|
||||
// prueft automatisch erneut (Akzeptanzkriterium 3), ohne dass ein
|
||||
// manueller Re-Trigger noetig waere.
|
||||
result, err := l.retention.CheckTenantRetention(ctx, c.id)
|
||||
if err != nil {
|
||||
return processed, fmt.Errorf("retention-pruefung fuer tenant %q: %w", c.id, err)
|
||||
}
|
||||
if result.Blocked {
|
||||
slog.Warn("tenant-loeschung wegen aufbewahrungspflicht/legal-hold zurueckgehalten",
|
||||
"tenant_slug", c.slug, "reason", result.Reason)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE tenants SET retention_block_reason = $2, retention_checked_at = now()
|
||||
WHERE id = $1
|
||||
`, c.id, result.Reason); err != nil {
|
||||
return processed, fmt.Errorf("retention-sperrgrund fuer tenant %q speichern: %w", c.id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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,
|
||||
retention_block_reason = NULL, retention_checked_at = now()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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,
|
||||
retention_block_reason TEXT,
|
||||
retention_checked_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")
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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,17 +2,11 @@ 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
|
||||
@@ -45,14 +39,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -74,10 +60,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -94,16 +76,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -35,28 +35,25 @@ 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) {
|
||||
// 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.
|
||||
// retention_block_reason/retention_checked_at (TEN-08) aus demselben Grund
|
||||
// fuer die Admin-Einsehbarkeit des Sperrgrunds (Akzeptanzkriterium 2).
|
||||
var t Tenant
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at,
|
||||
retention_block_reason, retention_checked_at
|
||||
FROM tenants WHERE slug = $1
|
||||
`, slug)
|
||||
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
||||
&t.PreviousStatus, &t.DeletionScheduledAt, &t.RetentionBlockReason, &t.RetentionCheckedAt); err != nil {
|
||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package tenant
|
||||
|
||||
import "context"
|
||||
|
||||
// RetentionResult ist das Ergebnis einer Pruefung gegen Archive RET-03/CMP-06
|
||||
// vor einer endgueltigen Tenant-Loeschung (TEN-08).
|
||||
type RetentionResult struct {
|
||||
// Blocked ist true, solange GoBD-relevante Daten des Tenants unter
|
||||
// Aufbewahrungspflicht oder Legal Hold stehen (Akzeptanzkriterium 1).
|
||||
Blocked bool
|
||||
// Reason beschreibt Aufbewahrungsklasse/Frist oder Legal-Hold-Grund,
|
||||
// fuer Admins einsehbar (Akzeptanzkriterium 2). Nur aussagekraeftig, wenn Blocked true ist.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RetentionChecker ist die Schnittstelle zu Archive RET-03 (Loeschworkflow &
|
||||
// Aufbewahrungssperre) / CMP-06 (Vier-Augen-Freigabe fuer Loeschungen).
|
||||
// Core kennt bewusst keine Retention-Logik selbst — diese Kachel ruft nur auf,
|
||||
// siehe TEN-08 "Nicht Bestandteil dieser Kachel". Solange Archive RET-03 noch
|
||||
// nicht implementiert ist, wird ein no-op-Checker verwendet (siehe
|
||||
// NoRetentionCheck), der niemals blockiert — Core faellt damit auf das
|
||||
// TEN-04-Verhalten vor diesem Ticket zurueck, statt fehlzuschlagen.
|
||||
type RetentionChecker interface {
|
||||
CheckTenantRetention(ctx context.Context, tenantID string) (RetentionResult, error)
|
||||
}
|
||||
|
||||
// NoRetentionCheck ist der Platzhalter-Checker, solange Archive RET-03 noch
|
||||
// nicht angebunden ist — blockiert nie. Wird in Produktion durch den echten
|
||||
// HTTP-Client gegen Archive ersetzt, sobald RET-03 existiert.
|
||||
type NoRetentionCheck struct{}
|
||||
|
||||
func (NoRetentionCheck) CheckTenantRetention(context.Context, string) (RetentionResult, error) {
|
||||
return RetentionResult{Blocked: false}, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeRetentionChecker simuliert Archive RET-03/CMP-06 in Tests — echte
|
||||
// Anbindung existiert noch nicht (siehe retention.go), diese Kachel ruft nur auf.
|
||||
type fakeRetentionChecker struct {
|
||||
blocked map[string]string // tenantID -> Grund
|
||||
}
|
||||
|
||||
func (f fakeRetentionChecker) CheckTenantRetention(_ context.Context, tenantID string) (RetentionResult, error) {
|
||||
if reason, ok := f.blocked[tenantID]; ok {
|
||||
return RetentionResult{Blocked: true, Reason: reason}, nil
|
||||
}
|
||||
return RetentionResult{Blocked: false}, nil
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: Loeschung eines Tenants mit aktiver
|
||||
// GoBD-Aufbewahrungspflicht wird abgewiesen, Grund wird protokolliert
|
||||
// (Akzeptanzkriterium 2).
|
||||
func TestLifecycle_ProcessDueDeletions_BlockedByRetention(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_retention_blocked")
|
||||
ctx := context.Background()
|
||||
|
||||
tenantBeforeSchedule, err := registry.GetBySlug(ctx, "lc_retention_blocked")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_retention_blocked", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
|
||||
lifecycle.WithRetentionChecker(fakeRetentionChecker{
|
||||
blocked: map[string]string{
|
||||
tenantBeforeSchedule.ID: "GoBD-Aufbewahrungsfrist bis 2034-01-01 (Buchungsbeleg-Klasse)",
|
||||
},
|
||||
})
|
||||
|
||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("process due deletions: %v", err)
|
||||
}
|
||||
if processed != 0 {
|
||||
t.Fatalf("erwartet 0 tatsaechlich verarbeitete loeschungen, habe %d", processed)
|
||||
}
|
||||
|
||||
after, err := registry.GetBySlug(ctx, "lc_retention_blocked")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach sweep: %v", err)
|
||||
}
|
||||
if after.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status = %q, want pending_deletion (gesperrt, nicht geloescht)", after.Status)
|
||||
}
|
||||
if after.RetentionBlockReason == nil || *after.RetentionBlockReason == "" {
|
||||
t.Fatal("erwartet gesetzten retention_block_reason (Akzeptanzkriterium 2)")
|
||||
}
|
||||
if after.RetentionCheckedAt == nil {
|
||||
t.Fatal("erwartet gesetzten retention_checked_at")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||
dbNameForSlug("lc_retention_blocked")).Scan(&exists); err != nil {
|
||||
t.Fatalf("pg_database pruefen: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("tenant-datenbank haette NICHT geloescht werden duerfen (retention-sperre)")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: Loeschung eines Tenants mit Legal Hold
|
||||
// wird ebenfalls abgewiesen — derselbe Mechanismus wie GoBD-Frist, nur anderer Grund.
|
||||
func TestLifecycle_ProcessDueDeletions_BlockedByLegalHold(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_legal_hold")
|
||||
ctx := context.Background()
|
||||
|
||||
tenant, err := registry.GetBySlug(ctx, "lc_legal_hold")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_legal_hold", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
|
||||
lifecycle.WithRetentionChecker(fakeRetentionChecker{
|
||||
blocked: map[string]string{
|
||||
tenant.ID: "Legal Hold: laufendes Gerichtsverfahren, Aktenzeichen XY-2026-042",
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := lifecycle.ProcessDueDeletions(ctx); err != nil {
|
||||
t.Fatalf("process due deletions: %v", err)
|
||||
}
|
||||
|
||||
after, err := registry.GetBySlug(ctx, "lc_legal_hold")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach sweep: %v", err)
|
||||
}
|
||||
if after.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status = %q, want pending_deletion", after.Status)
|
||||
}
|
||||
if after.RetentionBlockReason == nil || *after.RetentionBlockReason == "" {
|
||||
t.Fatal("erwartet gesetzten retention_block_reason")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: nach Aufhebung aller Sperren wird die
|
||||
// Loeschung bei der naechsten Sweep-Runde automatisch ausgefuehrt — kein
|
||||
// manueller Re-Trigger noetig, derselbe Sweeper-Aufruf greift erneut.
|
||||
func TestLifecycle_ProcessDueDeletions_ExecutesAfterRetentionCleared(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_retention_cleared")
|
||||
ctx := context.Background()
|
||||
|
||||
tenant, err := registry.GetBySlug(ctx, "lc_retention_cleared")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_retention_cleared", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
|
||||
blockingChecker := fakeRetentionChecker{blocked: map[string]string{tenant.ID: "Aufbewahrungsfrist laeuft noch"}}
|
||||
lifecycle.WithRetentionChecker(blockingChecker)
|
||||
|
||||
if _, err := lifecycle.ProcessDueDeletions(ctx); err != nil {
|
||||
t.Fatalf("erster sweep (blockiert): %v", err)
|
||||
}
|
||||
blockedState, err := registry.GetBySlug(ctx, "lc_retention_cleared")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach erstem sweep: %v", err)
|
||||
}
|
||||
if blockedState.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status nach erstem sweep = %q, want pending_deletion", blockedState.Status)
|
||||
}
|
||||
|
||||
// Sperre aufgehoben: naechster Checker blockiert nicht mehr (fakeRetentionChecker.blocked leer).
|
||||
lifecycle.WithRetentionChecker(fakeRetentionChecker{})
|
||||
|
||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("zweiter sweep (unblockiert): %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("erwartet genau 1 verarbeitete loeschung im zweiten sweep, habe %d", processed)
|
||||
}
|
||||
|
||||
final, err := registry.GetBySlug(ctx, "lc_retention_cleared")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach zweitem sweep: %v", err)
|
||||
}
|
||||
if final.Status != StatusDeleted {
|
||||
t.Fatalf("status = %q, want deleted", final.Status)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ type Status string
|
||||
|
||||
const (
|
||||
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 {
|
||||
@@ -22,6 +26,16 @@ type Tenant struct {
|
||||
DBDSN string
|
||||
Status Status
|
||||
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
|
||||
// RetentionBlockReason ist nur gesetzt, wenn eine faellige Loeschung wegen
|
||||
// GoBD-Aufbewahrungspflicht oder Legal Hold zurueckgehalten wurde (TEN-08,
|
||||
// siehe internal/tenant/retention.go) — fuer Admins einsehbar (Akzeptanzkriterium 2).
|
||||
RetentionBlockReason *string
|
||||
RetentionCheckedAt *time.Time
|
||||
}
|
||||
|
||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS superadmins;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- 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()
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tenants DROP COLUMN previous_status;
|
||||
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 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,2 @@
|
||||
ALTER TABLE tenants DROP COLUMN retention_block_reason;
|
||||
ALTER TABLE tenants DROP COLUMN retention_checked_at;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- TEN-08: Haelt fest, warum eine faellige Tenant-Loeschung zurueckgehalten wurde
|
||||
-- (GoBD-Aufbewahrungspflicht oder Legal Hold aus Archive RET-03), damit Admins
|
||||
-- den Grund einsehen koennen (Akzeptanzkriterium 2), ohne dass die Registry
|
||||
-- selbst modulspezifische Retention-Logik kennen muss — nur den Grund-Text.
|
||||
ALTER TABLE tenants ADD COLUMN retention_block_reason TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN retention_checked_at TIMESTAMPTZ;
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 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()
|
||||
);
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/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