From c344dea218191e98f79f3397fe2893786e3f7ebf Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 22:51:05 +0200 Subject: [PATCH] TEN-08: tenant-loeschung-unter-retention-vorbehalt-gobd (RetentionChecker-Schnittstelle gegen Archive RET-03/CMP-06, ProcessDueDeletions haelt gesperrte Tenants zurueck) --- internal/tenant/lifecycle.go | 45 ++++- internal/tenant/lifecycle_test.go | 4 +- internal/tenant/registry.go | 7 +- internal/tenant/retention.go | 34 ++++ internal/tenant/retention_test.go | 164 ++++++++++++++++++ internal/tenant/tenant.go | 5 + .../0004_tenant_retention_block.down.sql | 2 + migrations/0004_tenant_retention_block.up.sql | 6 + 8 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 internal/tenant/retention.go create mode 100644 internal/tenant/retention_test.go create mode 100644 migrations/0004_tenant_retention_block.down.sql create mode 100644 migrations/0004_tenant_retention_block.up.sql diff --git a/internal/tenant/lifecycle.go b/internal/tenant/lifecycle.go index 82793ad..e9e5676 100644 --- a/internal/tenant/lifecycle.go +++ b/internal/tenant/lifecycle.go @@ -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) diff --git a/internal/tenant/lifecycle_test.go b/internal/tenant/lifecycle_test.go index 262acaa..905ebd3 100644 --- a/internal/tenant/lifecycle_test.go +++ b/internal/tenant/lifecycle_test.go @@ -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) } diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go index 49fe2f1..83065ca 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -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 diff --git a/internal/tenant/retention.go b/internal/tenant/retention.go new file mode 100644 index 0000000..df38f2d --- /dev/null +++ b/internal/tenant/retention.go @@ -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 +} diff --git a/internal/tenant/retention_test.go b/internal/tenant/retention_test.go new file mode 100644 index 0000000..08cda0e --- /dev/null +++ b/internal/tenant/retention_test.go @@ -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) + } +} diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go index 9778bc8..1359720 100644 --- a/internal/tenant/tenant.go +++ b/internal/tenant/tenant.go @@ -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 diff --git a/migrations/0004_tenant_retention_block.down.sql b/migrations/0004_tenant_retention_block.down.sql new file mode 100644 index 0000000..921aa1b --- /dev/null +++ b/migrations/0004_tenant_retention_block.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tenants DROP COLUMN retention_block_reason; +ALTER TABLE tenants DROP COLUMN retention_checked_at; diff --git a/migrations/0004_tenant_retention_block.up.sql b/migrations/0004_tenant_retention_block.up.sql new file mode 100644 index 0000000..2463ae9 --- /dev/null +++ b/migrations/0004_tenant_retention_block.up.sql @@ -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;