Merge branch 'feature/ten-08-tenant-loeschung-unter-retention-vorbehalt-gobd' into feature/qa-05-abnahme-compliance-pruefung-core

# Conflicts:
#	internal/tenant/registry.go
This commit is contained in:
sysops
2026-08-29 17:04:55 +02:00
8 changed files with 264 additions and 10 deletions
+44 -7
View File
@@ -24,7 +24,8 @@ var (
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 {
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt,
&t.RetentionBlockReason, &t.RetentionCheckedAt); err != nil {
return Tenant{}, err
}
return t, nil
@@ -46,7 +47,8 @@ func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []St
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
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at,
retention_block_reason, retention_checked_at
`, slug, string(to), previousStatus, deletionAt, from)
t, err := scanTenantWithLifecycle(row)
@@ -111,10 +113,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 +158,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 +166,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 +183,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)