- migrations/0008_legal_hold_destruction: legal_holds (historisiert, Partial-Unique-Index gegen doppelte aktive Sperre), destruction_log (append-only, per Postgres-Trigger gegen UPDATE/DELETE geschuetzt) - archive/internal/deletionworkflow: SetLegalHold (Begruendungspflicht), ReleaseLegalHold (Aufheben selbst protokolliert, keine Loeschung der Zeile), ReleaseExpired (Freigabeprozess active->expired, keine Sofortloeschung, Sperre wird respektiert), Destroy (verlangt vorherigen expired-Status, prueft Sperre erneut, transaktional mit Protokolleintrag) - 6 Tests, alle Pflichtpruefungen real bestanden (Sperre widersteht Loeschversuch, Protokoll real unveraenderlich per Trigger, Aufheben real protokolliert) - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/RET-03-PRUEFPROTOKOLL.md
182 lines
6.8 KiB
Go
182 lines
6.8 KiB
Go
// Package deletionworkflow implementiert RET-03: den kontrollierten
|
|
// Löschworkflow für abgelaufene Aufbewahrungsobjekte (Freigabe →
|
|
// Vernichtung) und die Aufbewahrungssperre (Legal Hold), die jede
|
|
// Löschung unabhängig vom Fristablauf verhindert. Baut auf RET-01
|
|
// (retention_objects.status) und RET-02 (Fristenberechnung) auf, keine
|
|
// eigene Fristenlogik.
|
|
package deletionworkflow
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ErrReasonRequired wird geliefert, wenn beim Setzen einer Sperre keine
|
|
// Begründung angegeben wurde (Akzeptanzkriterium 2: Begründungspflicht).
|
|
var ErrReasonRequired = errors.New("deletionworkflow: begründung ist beim setzen einer aufbewahrungssperre pflicht")
|
|
|
|
// ErrAlreadyOnHold wird geliefert, wenn für das Objekt bereits eine
|
|
// aktive Sperre existiert (Partial-Unique-Index erzwingt das auch auf
|
|
// DB-Ebene).
|
|
var ErrAlreadyOnHold = errors.New("deletionworkflow: objekt steht bereits unter aufbewahrungssperre")
|
|
|
|
// ErrOnLegalHold wird von Destroy geliefert, wenn eine aktive Sperre die
|
|
// Vernichtung verhindert (Akzeptanzkriterium 2: Sperre überstimmt jede
|
|
// Fristregel).
|
|
var ErrOnLegalHold = errors.New("deletionworkflow: objekt steht unter aufbewahrungssperre, vernichtung nicht erlaubt")
|
|
|
|
// ErrNotReleased wird geliefert, wenn Destroy für ein Objekt aufgerufen
|
|
// wird, das noch nicht über ReleaseExpired freigegeben wurde
|
|
// (Akzeptanzkriterium 1: kein Sprung von "active" direkt zu "deleted").
|
|
var ErrNotReleased = errors.New("deletionworkflow: objekt ist nicht zur vernichtung freigegeben (status != expired)")
|
|
|
|
// SetLegalHold setzt eine Aufbewahrungssperre für ein Objekt. reason ist
|
|
// PFLICHT (Akzeptanzkriterium 2). Existiert bereits eine aktive Sperre,
|
|
// wird ErrAlreadyOnHold geliefert (der Partial-Unique-Index
|
|
// idx_legal_holds_active verhindert eine zweite aktive Zeile auch bei
|
|
// gleichzeitigen Aufrufen).
|
|
func SetLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, reason, setBy string) error {
|
|
if reason == "" {
|
|
return ErrReasonRequired
|
|
}
|
|
_, err := pool.Exec(ctx, `
|
|
INSERT INTO legal_holds (retention_object_id, reason, set_by)
|
|
VALUES ($1, $2, $3)
|
|
`, retentionObjectID, reason, setBy)
|
|
if err != nil {
|
|
var pgErr interface{ SQLState() string }
|
|
if errors.As(err, &pgErr) && pgErr.SQLState() == "23505" {
|
|
return ErrAlreadyOnHold
|
|
}
|
|
return fmt.Errorf("deletionworkflow: sperre setzen: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReleaseLegalHold hebt die aktive Sperre eines Objekts auf. Die
|
|
// ursprüngliche Zeile bleibt bestehen (released_at/released_by werden
|
|
// gesetzt, kein DELETE) — das Aufheben ist dadurch selbst dauerhaft
|
|
// protokolliert (Akzeptanzkriterium/Pflichtprüfung 3).
|
|
func ReleaseLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, releasedBy string) error {
|
|
tag, err := pool.Exec(ctx, `
|
|
UPDATE legal_holds SET released_at = now(), released_by = $2
|
|
WHERE retention_object_id = $1 AND released_at IS NULL
|
|
`, retentionObjectID, releasedBy)
|
|
if err != nil {
|
|
return fmt.Errorf("deletionworkflow: sperre aufheben: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("deletionworkflow: keine aktive sperre für objekt %q gefunden", retentionObjectID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsOnLegalHold prüft, ob ein Objekt aktuell unter Sperre steht.
|
|
func IsOnLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (bool, error) {
|
|
var exists bool
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM legal_holds WHERE retention_object_id = $1 AND released_at IS NULL)
|
|
`, retentionObjectID).Scan(&exists)
|
|
if err != nil {
|
|
return false, fmt.Errorf("deletionworkflow: sperrstatus prüfen: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// ReleaseExpired ist der Freigabeprozess (Akzeptanzkriterium 1): setzt
|
|
// den Status abgelaufener Objekte von "active" auf "expired" — KEINE
|
|
// automatische Sofortlöschung. Objekte unter aktiver Aufbewahrungssperre
|
|
// werden übersprungen, unabhängig vom Fristablauf (Akzeptanzkriterium
|
|
// 2). Liefert die IDs der freigegebenen Objekte.
|
|
func ReleaseExpired(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]string, error) {
|
|
rows, err := pool.Query(ctx, `
|
|
WITH latest_assignment AS (
|
|
SELECT DISTINCT ON (retention_object_id)
|
|
retention_object_id, retention_class, assigned_at
|
|
FROM retention_class_assignments
|
|
ORDER BY retention_object_id, assigned_at DESC
|
|
),
|
|
due AS (
|
|
SELECT o.id
|
|
FROM retention_objects o
|
|
JOIN latest_assignment a ON a.retention_object_id = o.id
|
|
JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active
|
|
WHERE o.status = 'active'
|
|
AND (a.assigned_at + r.duration) <= $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM legal_holds h
|
|
WHERE h.retention_object_id = o.id AND h.released_at IS NULL
|
|
)
|
|
)
|
|
UPDATE retention_objects SET status = 'expired'
|
|
WHERE id IN (SELECT id FROM due)
|
|
RETURNING id
|
|
`, asOf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("deletionworkflow: freigabeprozess: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("deletionworkflow: freigegebene id lesen: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
// Destroy vernichtet EIN Objekt, das zuvor über ReleaseExpired freigegeben
|
|
// wurde (status "expired") — kein direkter Sprung von "active".
|
|
// Verweigert die Vernichtung, wenn ZWISCHENZEITLICH eine Sperre gesetzt
|
|
// wurde (Verteidigung in der Tiefe, zusätzlich zu ReleaseExpireds eigenem
|
|
// Sperr-Ausschluss). Erzeugt einen unveränderlichen Protokolleintrag
|
|
// (destruction_log, per DB-Trigger gegen UPDATE/DELETE geschützt).
|
|
func Destroy(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, destroyedBy string) error {
|
|
onHold, err := IsOnLegalHold(ctx, pool, retentionObjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if onHold {
|
|
return ErrOnLegalHold
|
|
}
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("deletionworkflow: transaktion starten: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var objectType, objectReference string
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE retention_objects SET status = 'deleted'
|
|
WHERE id = $1 AND status = 'expired'
|
|
RETURNING object_type, object_reference
|
|
`, retentionObjectID).Scan(&objectType, &objectReference)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNotReleased
|
|
}
|
|
return fmt.Errorf("deletionworkflow: objekt als vernichtet markieren: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO destruction_log (retention_object_id, object_type, object_reference, destroyed_by)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, retentionObjectID, objectType, objectReference, destroyedBy); err != nil {
|
|
return fmt.Errorf("deletionworkflow: protokolleintrag erzeugen: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("deletionworkflow: vernichtung committen: %w", err)
|
|
}
|
|
return nil
|
|
}
|