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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user