TEN-08: tenant-loeschung-unter-retention-vorbehalt-gobd (RetentionChecker-Schnittstelle gegen Archive RET-03/CMP-06, ProcessDueDeletions haelt gesperrte Tenants zurueck)
This commit is contained in:
@@ -111,10 +111,21 @@ func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, err
|
|||||||
type Lifecycle struct {
|
type Lifecycle struct {
|
||||||
registry *Registry
|
registry *Registry
|
||||||
adminPool *pgxpool.Pool
|
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 {
|
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
|
// 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) }()
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
rows, err := tx.Query(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()
|
WHERE status = $1 AND deletion_scheduled_at <= now()
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
`, string(StatusPendingDeletion))
|
`, string(StatusPendingDeletion))
|
||||||
@@ -153,11 +164,11 @@ func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
|||||||
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
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
|
var candidates []due
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d due
|
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()
|
rows.Close()
|
||||||
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
||||||
}
|
}
|
||||||
@@ -170,11 +181,35 @@ func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
|||||||
|
|
||||||
processed := 0
|
processed := 0
|
||||||
for _, c := range candidates {
|
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 {
|
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)
|
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `
|
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
|
WHERE id = $1
|
||||||
`, c.id, string(StatusDeleted)); err != nil {
|
`, c.id, string(StatusDeleted)); err != nil {
|
||||||
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
|
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',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
previous_status TEXT,
|
previous_status TEXT,
|
||||||
deletion_scheduled_at TIMESTAMPTZ
|
deletion_scheduled_at TIMESTAMPTZ,
|
||||||
|
retention_block_reason TEXT,
|
||||||
|
retention_checked_at TIMESTAMPTZ
|
||||||
)`); err != nil {
|
)`); err != nil {
|
||||||
t.Fatalf("registry-schema: %v", err)
|
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
|
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
||||||
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
||||||
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
// 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
|
var t Tenant
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at,
|
||||||
|
retention_block_reason, retention_checked_at
|
||||||
FROM tenants WHERE slug = $1
|
FROM tenants WHERE slug = $1
|
||||||
`, slug)
|
`, slug)
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
||||||
&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 Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||||
}
|
}
|
||||||
return t, nil
|
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.
|
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
|
||||||
PreviousStatus *string
|
PreviousStatus *string
|
||||||
DeletionScheduledAt *time.Time
|
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
|
// 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