From e23f51485099436a1f2923e05ff0f72bd889b42f Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 14:27:21 +0200 Subject: [PATCH] =?UTF-8?q?RET-03:=20l=C3=B6schworkflow-und-aufbewahrungss?= =?UTF-8?q?perre-legal-hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- archive/docs/RET-03-PRUEFPROTOKOLL.md | 64 +++++ .../deletionworkflow/deletionworkflow.go | 181 ++++++++++++ .../deletionworkflow/deletionworkflow_test.go | 269 ++++++++++++++++++ .../0008_legal_hold_destruction.down.sql | 5 + .../0008_legal_hold_destruction.up.sql | 46 +++ 5 files changed, 565 insertions(+) create mode 100644 archive/docs/RET-03-PRUEFPROTOKOLL.md create mode 100644 archive/internal/deletionworkflow/deletionworkflow.go create mode 100644 archive/internal/deletionworkflow/deletionworkflow_test.go create mode 100644 archive/migrations/0008_legal_hold_destruction.down.sql create mode 100644 archive/migrations/0008_legal_hold_destruction.up.sql diff --git a/archive/docs/RET-03-PRUEFPROTOKOLL.md b/archive/docs/RET-03-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..f57b664 --- /dev/null +++ b/archive/docs/RET-03-PRUEFPROTOKOLL.md @@ -0,0 +1,64 @@ +# RET-03 – Prüfprotokoll: Löschworkflow & Aufbewahrungssperre (Legal Hold) + +Voraussetzung RET-01, RET-02 – beide bereits Fertig. + +## Umsetzung + +- `archive/migrations/0008_legal_hold_destruction.up/down.sql`: + - `legal_holds` – historisiert (Sperre wird NIE gelöscht, nur + `released_at`/`released_by` gesetzt), Partial-Unique-Index erlaubt + höchstens eine AKTIVE Sperre je Objekt. + - `destruction_log` – append-only, real per Postgres-TRIGGER gegen + UPDATE/DELETE geschützt (`destruction_log_immutable()`, + `RAISE EXCEPTION`) — DB-Ebene, nicht nur Anwendungscode. +- `archive/internal/deletionworkflow`: + - `SetLegalHold` — Begründung PFLICHT (`ErrReasonRequired`), + `ErrAlreadyOnHold` bei bereits aktiver Sperre (DB-Unique-Index als + zusätzliche Absicherung gegen Wettlaufsituationen). + - `ReleaseLegalHold` — hebt auf, OHNE die Zeile zu löschen (das + Aufheben selbst bleibt dauerhaft nachvollziehbar). + - `ReleaseExpired` — Freigabeprozess: `active` → `expired`, NIEMALS + direkt `deleted`. Objekte mit aktiver Sperre werden übersprungen, + unabhängig vom Fristablauf. + - `Destroy` — verlangt vorherigen Status `expired` + (`ErrNotReleased` sonst), prüft die Sperre ZUSÄTZLICH direkt vor der + Vernichtung erneut (Verteidigung in der Tiefe gegen eine + zwischenzeitlich gesetzte Sperre), schreibt Statuswechsel und + Protokolleintrag in EINER Transaktion. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Objekt mit aktiver Sperre widersteht einem direkten Löschversuch | **bestanden** – `TestDestroy_ObjectWithActiveHoldResistsDeletion`: Objekt bereits im Status `expired` (simuliert vorherige Freigabe), Sperre danach gesetzt, `Destroy` liefert `ErrOnLegalHold`, Status bleibt unverändert | +| 2 | Protokolleintrag nach Vernichtung ist nachträglich nicht änderbar (DB-Constraint oder Trigger) | **bestanden** – `TestDestructionLog_IsImmutable`: nach echter `Destroy`-Ausführung direkter `UPDATE`/`DELETE` per rohem SQL (umgeht die Go-API vollständig) — beide scheitern real am Postgres-Trigger, nicht nur weil das Paket keine Änderungsfunktion anbietet | +| 3 | Aufheben einer Sperre ist selbst protokolliert | **bestanden** – `TestReleaseLegalHold_IsItselfLogged`: nach `ReleaseLegalHold` sind `released_at`/`released_by` real gesetzt, die ursprüngliche Sperr-Zeile bleibt bestehen (kein DELETE) | + +**Zusätzliche Tests (Akzeptanzkriterium 1, nicht in den drei +Pflichtprüfungen, aber je AC mindestens ein Test):** +- `TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected` — fälliges + Objekt wird `expired` (NICHT `deleted`), gesperrtes fälliges Objekt + bleibt `active`, nicht fälliges Objekt bleibt `active`. +- `TestSetLegalHold_RequiresReason`, `TestDestroy_RequiresPriorRelease`. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. deletionworkflow, 6 Tests) +``` + +Migration `0008_legal_hold_destruction` real auf `dms_tenant_test` +angewendet (Test-DB, in der auch RET-06-API läuft) — sauber, ohne +Fehler. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt. Damit ist das RET-Epic (RET-01 bis +RET-10) vollständig abgeschlossen: Objektmodell, Fristenengine, +Modul-Adapter (Interface + Dienst), Konfigurationsoberfläche (Backend + +Frontend + CORS), Benachrichtigungen, WORM-Speicher und jetzt +Löschworkflow mit Legal Hold. diff --git a/archive/internal/deletionworkflow/deletionworkflow.go b/archive/internal/deletionworkflow/deletionworkflow.go new file mode 100644 index 0000000..c77722d --- /dev/null +++ b/archive/internal/deletionworkflow/deletionworkflow.go @@ -0,0 +1,181 @@ +// 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 +} diff --git a/archive/internal/deletionworkflow/deletionworkflow_test.go b/archive/internal/deletionworkflow/deletionworkflow_test.go new file mode 100644 index 0000000..8410f31 --- /dev/null +++ b/archive/internal/deletionworkflow/deletionworkflow_test.go @@ -0,0 +1,269 @@ +package deletionworkflow + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS retention_objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL, + object_reference TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (object_type, object_reference) + ); + CREATE TABLE IF NOT EXISTS retention_class_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + retention_class TEXT NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS retention_class_rules ( + retention_class TEXT PRIMARY KEY, duration INTERVAL NOT NULL, + active BOOLEAN NOT NULL DEFAULT true + ); + CREATE TABLE IF NOT EXISTS legal_holds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + reason TEXT NOT NULL, set_by TEXT NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(), + released_at TIMESTAMPTZ, released_by TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active + ON legal_holds (retention_object_id) WHERE released_at IS NULL; + CREATE TABLE IF NOT EXISTS destruction_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT, + object_type TEXT NOT NULL, object_reference TEXT NOT NULL, + destroyed_at TIMESTAMPTZ NOT NULL DEFAULT now(), destroyed_by TEXT NOT NULL + ); + CREATE OR REPLACE FUNCTION destruction_log_immutable() RETURNS TRIGGER AS $$ + BEGIN + RAISE EXCEPTION 'destruction_log ist unveraenderlich (RET-03) - % auf id=% nicht erlaubt', TG_OP, OLD.id; + END; + $$ LANGUAGE plpgsql; + DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log; + CREATE TRIGGER trg_destruction_log_no_update BEFORE UPDATE ON destruction_log FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable(); + DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log; + CREATE TRIGGER trg_destruction_log_no_delete BEFORE DELETE ON destruction_log FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable(); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE destruction_log, legal_holds, retention_class_assignments, retention_objects CASCADE; TRUNCATE retention_class_rules`) + }) + return pool +} + +func insertObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ref string) string { + t.Helper() + var id string + if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ('dms_document', $1) RETURNING id`, ref).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +// TestDestroy_ObjectWithActiveHoldResistsDeletion ist die geforderte +// Pflichtprüfung 1: Objekt mit aktiver Sperre widersteht einem direkten +// Löschversuch. +func TestDestroy_ObjectWithActiveHoldResistsDeletion(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + objID := insertObject(t, ctx, pool, "gesperrt-doc") + // Simuliert bereits erfolgte Freigabe (status "expired"), um zu + // beweisen, dass die Sperre AUCH DANN noch blockiert, nicht nur vor + // der Freigabe. + if _, err := pool.Exec(ctx, `UPDATE retention_objects SET status = 'expired' WHERE id = $1`, objID); err != nil { + t.Fatal(err) + } + + if err := SetLegalHold(ctx, pool, objID, "laufendes gerichtsverfahren az. 12/34", "admin@acme.example"); err != nil { + t.Fatalf("sperre setzen: %v", err) + } + + err := Destroy(ctx, pool, objID, "worker") + if !errors.Is(err, ErrOnLegalHold) { + t.Fatalf("erwartet ErrOnLegalHold, habe: %v", err) + } + + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, objID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "expired" { + t.Fatalf("status haette unveraendert bleiben muessen, ist %q", status) + } +} + +// TestSetLegalHold_RequiresReason ist Akzeptanzkriterium 2: +// Begründungspflicht beim Setzen. +func TestSetLegalHold_RequiresReason(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + objID := insertObject(t, ctx, pool, "ohne-begruendung-doc") + + if err := SetLegalHold(ctx, pool, objID, "", "admin@acme.example"); !errors.Is(err, ErrReasonRequired) { + t.Fatalf("erwartet ErrReasonRequired, habe: %v", err) + } +} + +// TestDestructionLog_IsImmutable ist die geforderte Pflichtprüfung 2: +// Protokolleintrag nach Vernichtung ist nachträglich nicht änderbar +// (DB-Constraint/Trigger, nicht nur Anwendungslogik). +func TestDestructionLog_IsImmutable(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + objID := insertObject(t, ctx, pool, "vernichtet-doc") + if _, err := pool.Exec(ctx, `UPDATE retention_objects SET status = 'expired' WHERE id = $1`, objID); err != nil { + t.Fatal(err) + } + if err := Destroy(ctx, pool, objID, "worker"); err != nil { + t.Fatalf("destroy: %v", err) + } + + var logID string + if err := pool.QueryRow(ctx, `SELECT id FROM destruction_log WHERE retention_object_id = $1`, objID).Scan(&logID); err != nil { + t.Fatal(err) + } + + // Direkter UPDATE-Versuch (umgeht die Go-API vollständig) — muss am + // Postgres-Trigger scheitern, nicht nur weil das Paket keine + // Update-Funktion anbietet. + _, err := pool.Exec(ctx, `UPDATE destruction_log SET destroyed_by = 'manipuliert' WHERE id = $1`, logID) + if err == nil { + t.Fatal("erwartet fehler beim direkten UPDATE auf destruction_log, trigger hat nicht gegriffen") + } + + _, err = pool.Exec(ctx, `DELETE FROM destruction_log WHERE id = $1`, logID) + if err == nil { + t.Fatal("erwartet fehler beim direkten DELETE auf destruction_log, trigger hat nicht gegriffen") + } +} + +// TestReleaseLegalHold_IsItselfLogged ist die geforderte Pflichtprüfung +// 3: Aufheben einer Sperre ist selbst protokolliert. +func TestReleaseLegalHold_IsItselfLogged(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + objID := insertObject(t, ctx, pool, "aufgehoben-doc") + if err := SetLegalHold(ctx, pool, objID, "vorlaeufige pruefung", "admin@acme.example"); err != nil { + t.Fatalf("sperre setzen: %v", err) + } + + if err := ReleaseLegalHold(ctx, pool, objID, "admin2@acme.example"); err != nil { + t.Fatalf("sperre aufheben: %v", err) + } + + var releasedBy *string + var releasedAt *time.Time + if err := pool.QueryRow(ctx, `SELECT released_by, released_at FROM legal_holds WHERE retention_object_id = $1`, objID).Scan(&releasedBy, &releasedAt); err != nil { + t.Fatal(err) + } + if releasedBy == nil || *releasedBy != "admin2@acme.example" || releasedAt == nil { + t.Fatalf("aufhebung wurde nicht protokolliert: released_by=%v released_at=%v", releasedBy, releasedAt) + } + + onHold, err := IsOnLegalHold(ctx, pool, objID) + if err != nil { + t.Fatal(err) + } + if onHold { + t.Fatal("objekt haette nach dem aufheben nicht mehr als gesperrt gelten duerfen") + } +} + +// TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected ist +// Akzeptanzkriterium 1 (kein Sofortlöschen, nur Statuswechsel) UND +// Akzeptanzkriterium 2 (Sperre wirkt auch bei abgelaufener Frist). +func TestReleaseExpired_NoImmediateDeletionAndHoldIsRespected(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_rules (retention_class, duration) VALUES ('klasse-kurz', '1 day')`); err != nil { + t.Fatal(err) + } + + dueObjID := insertObject(t, ctx, pool, "faellig-doc") + heldObjID := insertObject(t, ctx, pool, "faellig-aber-gesperrt-doc") + notDueObjID := insertObject(t, ctx, pool, "nicht-faellig-doc") + + past := time.Now().UTC().Add(-48 * time.Hour) + future := time.Now().UTC().Add(-1 * time.Hour) // faellig erst in > 1 tag + + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, dueObjID, past); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, heldObjID, past); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, 'klasse-kurz', $2)`, notDueObjID, future); err != nil { + t.Fatal(err) + } + if err := SetLegalHold(ctx, pool, heldObjID, "laufendes verfahren", "admin@acme.example"); err != nil { + t.Fatal(err) + } + + released, err := ReleaseExpired(ctx, pool, time.Now().UTC()) + if err != nil { + t.Fatalf("releaseexpired: %v", err) + } + if len(released) != 1 || released[0] != dueObjID { + t.Fatalf("erwartet genau das faellige, ungesperrte objekt, habe: %v", released) + } + + var dueStatus, heldStatus, notDueStatus string + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, dueObjID).Scan(&dueStatus); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, heldObjID).Scan(&heldStatus); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, notDueObjID).Scan(¬DueStatus); err != nil { + t.Fatal(err) + } + + // Akzeptanzkriterium 1: "expired", NICHT "deleted" - keine Sofortloeschung. + if dueStatus != "expired" { + t.Fatalf("faelliges objekt: status = %q, want expired (keine sofortloeschung)", dueStatus) + } + if heldStatus != "active" { + t.Fatalf("gesperrtes objekt haette trotz faelligkeit aktiv bleiben muessen, ist %q", heldStatus) + } + if notDueStatus != "active" { + t.Fatalf("nicht faelliges objekt haette aktiv bleiben muessen, ist %q", notDueStatus) + } +} + +// TestDestroy_RequiresPriorRelease beweist, dass Destroy nicht direkt von +// "active" aus aufgerufen werden kann (Workflow-Reihenfolge erzwungen). +func TestDestroy_RequiresPriorRelease(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + objID := insertObject(t, ctx, pool, "noch-aktiv-doc") + + if err := Destroy(ctx, pool, objID, "worker"); !errors.Is(err, ErrNotReleased) { + t.Fatalf("erwartet ErrNotReleased, habe: %v", err) + } +} diff --git a/archive/migrations/0008_legal_hold_destruction.down.sql b/archive/migrations/0008_legal_hold_destruction.down.sql new file mode 100644 index 0000000..7586130 --- /dev/null +++ b/archive/migrations/0008_legal_hold_destruction.down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log; +DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log; +DROP FUNCTION IF EXISTS destruction_log_immutable(); +DROP TABLE IF EXISTS destruction_log; +DROP TABLE IF EXISTS legal_holds; diff --git a/archive/migrations/0008_legal_hold_destruction.up.sql b/archive/migrations/0008_legal_hold_destruction.up.sql new file mode 100644 index 0000000..12ebb93 --- /dev/null +++ b/archive/migrations/0008_legal_hold_destruction.up.sql @@ -0,0 +1,46 @@ +-- RET-03: Löschworkflow & Aufbewahrungssperre (Legal Hold). +-- +-- legal_holds ist historisiert (kein UPDATE der Sperr-Zeile selbst +-- ausser released_at/released_by beim Aufheben) — jede Sperre und jede +-- Aufhebung bleibt dauerhaft nachvollziehbar (Akzeptanzkriterium 3: +-- Aufheben ist selbst protokolliert). Ein Partial-Unique-Index erlaubt +-- je Objekt hoechstens EINE AKTIVE Sperre gleichzeitig. +CREATE TABLE IF NOT EXISTS legal_holds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + set_by TEXT NOT NULL, + set_at TIMESTAMPTZ NOT NULL DEFAULT now(), + released_at TIMESTAMPTZ, + released_by TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active + ON legal_holds (retention_object_id) WHERE released_at IS NULL; + +-- destruction_log ist APPEND-ONLY, real durchgesetzt ueber Trigger (nicht +-- nur Anwendungscode) — Akzeptanzkriterium 3 / Pflichtpruefung 2 verlangt +-- ausdruecklich "DB-Constraint oder Trigger". +CREATE TABLE IF NOT EXISTS destruction_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT, + object_type TEXT NOT NULL, + object_reference TEXT NOT NULL, + destroyed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + destroyed_by TEXT NOT NULL +); + +CREATE OR REPLACE FUNCTION destruction_log_immutable() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'destruction_log ist unveraenderlich (RET-03) - % auf id=% nicht erlaubt', TG_OP, OLD.id; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_destruction_log_no_update ON destruction_log; +CREATE TRIGGER trg_destruction_log_no_update + BEFORE UPDATE ON destruction_log + FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable(); + +DROP TRIGGER IF EXISTS trg_destruction_log_no_delete ON destruction_log; +CREATE TRIGGER trg_destruction_log_no_delete + BEFORE DELETE ON destruction_log + FOR EACH ROW EXECUTE FUNCTION destruction_log_immutable();