Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c344dea218 |
@@ -111,10 +111,21 @@ func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, err
|
||||
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}
|
||||
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
|
||||
@@ -145,7 +156,7 @@ func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id, db_name FROM tenants
|
||||
SELECT id, slug, db_name FROM tenants
|
||||
WHERE status = $1 AND deletion_scheduled_at <= now()
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`, string(StatusPendingDeletion))
|
||||
@@ -153,11 +164,11 @@ func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
||||
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
||||
}
|
||||
|
||||
type due struct{ id, dbName string }
|
||||
type due struct{ id, slug, dbName string }
|
||||
var candidates []due
|
||||
for rows.Next() {
|
||||
var d due
|
||||
if err := rows.Scan(&d.id, &d.dbName); err != nil {
|
||||
if err := rows.Scan(&d.id, &d.slug, &d.dbName); err != nil {
|
||||
rows.Close()
|
||||
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
||||
}
|
||||
@@ -170,11 +181,35 @@ func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -38,7 +38,9 @@ func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
previous_status TEXT,
|
||||
deletion_scheduled_at TIMESTAMPTZ
|
||||
deletion_scheduled_at TIMESTAMPTZ,
|
||||
retention_block_reason TEXT,
|
||||
retention_checked_at TIMESTAMPTZ
|
||||
)`); err != nil {
|
||||
t.Fatalf("registry-schema: %v", err)
|
||||
}
|
||||
|
||||
@@ -38,14 +38,17 @@ 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
|
||||
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); err != nil {
|
||||
&t.PreviousStatus, &t.DeletionScheduledAt, &t.RetentionBlockReason, &t.RetentionCheckedAt); err != nil {
|
||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ type Tenant struct {
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user