Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c344dea218 | ||
|
|
45bc10719a | ||
|
|
8da9c67d08 |
@@ -44,3 +44,88 @@ Keine Commits in dieser Session.
|
|||||||
Keine Änderungen ermittelbar.
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-08-27 17:26 – 17:28 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- .gitignore | 2 ++
|
||||||
|
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
||||||
|
- go.mod | 5 +++++
|
||||||
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
|
- internal/db/db.go | 11 +++++++++++
|
||||||
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-08-27 17:28 – 17:29 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- .gitignore | 2 ++
|
||||||
|
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
||||||
|
- go.mod | 5 +++++
|
||||||
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
|
- internal/db/db.go | 11 +++++++++++
|
||||||
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-08-27 17:31 – 17:31 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- .gitignore | 2 ++
|
||||||
|
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
||||||
|
- go.mod | 5 +++++
|
||||||
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
|
- internal/db/db.go | 11 +++++++++++
|
||||||
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-08-27 17:36 – 17:36 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- .gitignore | 2 ++
|
||||||
|
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
||||||
|
- go.mod | 5 +++++
|
||||||
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
|
- internal/db/db.go | 11 +++++++++++
|
||||||
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-08-27 17:36 – 17:37 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- .gitignore | 2 ++
|
||||||
|
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
||||||
|
- go.mod | 5 +++++
|
||||||
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
|
- internal/db/db.go | 11 +++++++++++
|
||||||
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+18
-3
@@ -7,6 +7,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -15,16 +16,30 @@ func main() {
|
|||||||
log.Fatalf("config: %v", err)
|
log.Fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("db: %v", err)
|
log.Fatalf("registry db: %v", err)
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
defer registryPool.Close()
|
||||||
|
|
||||||
|
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("admin db: %v", err)
|
||||||
|
}
|
||||||
|
defer adminPool.Close()
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(registryPool)
|
||||||
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
|
|
||||||
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.
|
||||||
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch
|
|||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.6.0
|
require github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
golang.org/x/crypto v0.17.0 // indirect
|
||||||
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
|
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -11,7 +11,14 @@ import (
|
|||||||
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
|
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
|
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
|
||||||
RegistryDSN string
|
RegistryDSN string
|
||||||
|
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
|
||||||
|
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
|
||||||
|
AdminDSN string
|
||||||
|
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||||
|
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
||||||
|
TenantDSNTemplate string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -20,10 +27,25 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
|
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
|
||||||
|
if dsnTemplate == "" {
|
||||||
|
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
||||||
|
}
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
|
return Config{
|
||||||
|
ListenAddr: addr,
|
||||||
|
RegistryDSN: dsn,
|
||||||
|
AdminDSN: adminDSN,
|
||||||
|
TenantDSNTemplate: dsnTemplate,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
||||||
|
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
|
||||||
|
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
|
||||||
|
// TEN-01 und werden dort nachgezogen.
|
||||||
|
type Handler struct {
|
||||||
|
provisioner *Provisioner
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(p *Provisioner) *Handler {
|
||||||
|
return &Handler{provisioner: p}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTenantRequest struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createTenantRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
if err == ErrInvalidSlug {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(t)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
|
||||||
|
type Provisioner struct {
|
||||||
|
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
|
||||||
|
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
|
||||||
|
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
|
||||||
|
adminPool *pgxpool.Pool
|
||||||
|
registry *Registry
|
||||||
|
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||||
|
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
|
||||||
|
dsnTemplate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
|
||||||
|
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
|
||||||
|
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
|
||||||
|
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
|
||||||
|
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
|
||||||
|
if err := ValidateSlug(slug); err != nil {
|
||||||
|
return Tenant{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := dbNameForSlug(slug)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := Tenant{
|
||||||
|
Slug: slug,
|
||||||
|
Name: name,
|
||||||
|
DBName: dbName,
|
||||||
|
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
|
||||||
|
Status: StatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := p.registry.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
p.rollbackDatabase(ctx, dbName)
|
||||||
|
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := p.registry.insertTx(ctx, tx, t)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
p.rollbackDatabase(ctx, dbName)
|
||||||
|
return Tenant{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
p.rollbackDatabase(ctx, dbName)
|
||||||
|
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
|
||||||
|
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
|
||||||
|
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
||||||
|
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
|
||||||
|
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
|
||||||
|
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
|
||||||
|
//
|
||||||
|
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
|
||||||
|
//
|
||||||
|
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
|
||||||
|
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
defer adminPool.Close()
|
||||||
|
|
||||||
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
defer registryPool.Close()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
registry := NewRegistry(registryPool)
|
||||||
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
|
||||||
|
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
|
||||||
|
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
|
||||||
|
})
|
||||||
|
|
||||||
|
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision acme: %v", err)
|
||||||
|
}
|
||||||
|
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision globex: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tenantA.DBName == tenantB.DBName {
|
||||||
|
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
|
||||||
|
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
|
||||||
|
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
|
||||||
|
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect tenant a: %v", err)
|
||||||
|
}
|
||||||
|
defer poolA.Close()
|
||||||
|
|
||||||
|
var globexVisible bool
|
||||||
|
err = poolA.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
|
||||||
|
)
|
||||||
|
`, tenantB.DBName).Scan(&globexVisible)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pruefung tenant-trennung: %v", err)
|
||||||
|
}
|
||||||
|
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
|
||||||
|
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
|
||||||
|
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
|
||||||
|
var currentDB string
|
||||||
|
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(¤tDB); err != nil {
|
||||||
|
t.Fatalf("current_database: %v", err)
|
||||||
|
}
|
||||||
|
if currentDB != tenantA.DBName {
|
||||||
|
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
|
||||||
|
}
|
||||||
|
if currentDB == tenantB.DBName {
|
||||||
|
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
|
||||||
|
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
|
||||||
|
// niemals Geschaeftsdaten eines Mandanten.
|
||||||
|
type Registry struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(pool *pgxpool.Pool) *Registry {
|
||||||
|
return &Registry{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
|
||||||
|
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
|
||||||
|
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, created_at
|
||||||
|
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
|
||||||
|
|
||||||
|
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
|
||||||
|
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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,
|
||||||
|
&t.PreviousStatus, &t.DeletionScheduledAt, &t.RetentionBlockReason, &t.RetentionCheckedAt); err != nil {
|
||||||
|
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||||
|
}
|
||||||
|
return t, 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
|
||||||
|
FROM tenants ORDER BY created_at
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenants auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Tenant
|
||||||
|
for rows.Next() {
|
||||||
|
var t Tenant
|
||||||
|
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("tenant lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
|
||||||
|
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
|
||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ID string
|
||||||
|
Slug string
|
||||||
|
Name string
|
||||||
|
DBName string
|
||||||
|
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
|
||||||
|
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
|
||||||
|
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
|
||||||
|
|
||||||
|
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
|
||||||
|
|
||||||
|
func ValidateSlug(slug string) error {
|
||||||
|
if !slugPattern.MatchString(slug) {
|
||||||
|
return ErrInvalidSlug
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbNameForSlug(slug string) string {
|
||||||
|
return "tenant_" + slug
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateSlug(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
slug string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"acme", false},
|
||||||
|
{"acme_gmbh", false},
|
||||||
|
{"a1", false},
|
||||||
|
{"", true},
|
||||||
|
{"a", true},
|
||||||
|
{"1acme", true},
|
||||||
|
{"Acme", true},
|
||||||
|
{"acme-gmbh", true},
|
||||||
|
{"acme;drop table tenants", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
err := ValidateSlug(c.slug)
|
||||||
|
if (err != nil) != c.wantErr {
|
||||||
|
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBNameForSlug(t *testing.T) {
|
||||||
|
got := dbNameForSlug("acme")
|
||||||
|
want := "tenant_acme"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS tenants;
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Control-plane registry: tenant list + connection info (Modell C).
|
|
||||||
-- Core TEN-01 (siehe core-kanban).
|
|
||||||
CREATE TABLE tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
db_dsn TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Control-plane registry: Tenant-Liste + Verbindungsinformationen (Modell C).
|
||||||
|
-- Enthaelt AUSSCHLIESSLICH Tenant-Metadaten, keine Geschaeftsdaten eines Mandanten.
|
||||||
|
-- Core TEN-01 (siehe core-kanban/tickets/TEN-01.md).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE 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()
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
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