diff --git a/archive/internal/deletionrequestmigration/deletionrequestmigration.go b/archive/internal/deletionrequestmigration/deletionrequestmigration.go new file mode 100644 index 00000000..d78af5ee --- /dev/null +++ b/archive/internal/deletionrequestmigration/deletionrequestmigration.go @@ -0,0 +1,189 @@ +// Package deletionrequestmigration implementiert TAR-02: Übernahme +// offener Löschanfragen aus archivdms' document_delete_requests in +// CMP-06 (archive/internal/deletionapproval) als aktive Vier-Augen- +// Vorgänge. Bereits vollzogene Löschungen aus derselben Quelltabelle +// werden NICHT als aktiver Vorgang übernommen (das Objekt existiert +// nicht mehr), sondern ausschließlich als destruction_log-Eintrag +// (RET-03) — append-only, DB-Trigger-geschützt. +package deletionrequestmigration + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionapproval" +) + +// SourceDeleteRequest ist eine Zeile aus archivdms' +// document_delete_requests. Executed==true bedeutet: die Löschung wurde +// in archivdms bereits vollzogen, das Dokument existiert dort nicht mehr. +type SourceDeleteRequest struct { + ObjectType string + ObjectReference string + RequestedBy string + RequestedAt time.Time + Executed bool + ExecutedBy string + ExecutedAt time.Time +} + +// Source liefert die offenen und bereits vollzogenen Löschanfragen aus +// archivdms (SQL-Export, wie bei DOK-01/dms/internal/migrationdocument). +type Source interface { + DeleteRequests(ctx context.Context) ([]SourceDeleteRequest, error) +} + +type Migrator struct { + pool *pgxpool.Pool + source Source +} + +func NewMigrator(pool *pgxpool.Pool, source Source) *Migrator { + return &Migrator{pool: pool, source: source} +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + OpenRequestsMigrated int + ExecutedAudited int +} + +// MigrateAll übernimmt jede offene Löschanfrage als aktiven CMP-06- +// Vorgang (Akzeptanzkriterium 1) und jede bereits vollzogene Löschung +// ausschließlich als destruction_log-Audit-Eintrag (Akzeptanzkriterium +// 2). Idempotent: ein erneuter Lauf gegen dieselbe Quelle erzeugt keine +// Duplikate. +func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) { + var res Result + + reqs, err := m.source.DeleteRequests(ctx) + if err != nil { + return res, fmt.Errorf("deletionrequestmigration: loeschanfragen aus quelle lesen: %w", err) + } + + for _, r := range reqs { + if r.Executed { + done, err := m.auditExecuted(ctx, r) + if err != nil { + return res, err + } + if done { + res.ExecutedAudited++ + } + continue + } + done, err := m.migrateOpen(ctx, r) + if err != nil { + return res, err + } + if done { + res.OpenRequestsMigrated++ + } + } + + return res, nil +} + +func (m *Migrator) resolveRetentionObjectID(ctx context.Context, objectType, objectReference string) (string, error) { + var id string + err := m.pool.QueryRow(ctx, ` + INSERT INTO retention_objects (object_type, object_reference) + VALUES ($1, $2) + ON CONFLICT (object_type, object_reference) DO UPDATE SET object_type = EXCLUDED.object_type + RETURNING id + `, objectType, objectReference).Scan(&id) + if err != nil { + return "", fmt.Errorf("deletionrequestmigration: retention-objekt %s/%s aufloesen: %w", objectType, objectReference, err) + } + return id, nil +} + +// migrateOpen legt die offene Löschanfrage als CMP-06-Vorgang an, mit +// einem frischen Bestätigungs-Token (das archivdms-Original-Token ist +// in dieser Quelle nicht vorhanden/anwendbar) und dem ursprünglichen +// requested_by/requested_at 1:1 übernommen. Ist für dieses Objekt +// bereits eine Löschanfrage migriert, wird sie nicht doppelt angelegt. +func (m *Migrator) migrateOpen(ctx context.Context, r SourceDeleteRequest) (bool, error) { + retentionObjectID, err := m.resolveRetentionObjectID(ctx, r.ObjectType, r.ObjectReference) + if err != nil { + return false, err + } + + var exists bool + err = m.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM deletion_requests WHERE retention_object_id = $1 AND requested_at = $2 AND requested_by = $3) + `, retentionObjectID, r.RequestedAt, r.RequestedBy).Scan(&exists) + if err != nil { + return false, fmt.Errorf("deletionrequestmigration: bestand der loeschanfrage pruefen: %w", err) + } + if exists { + return false, nil + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return false, fmt.Errorf("deletionrequestmigration: token erzeugen: %w", err) + } + sum := sha256.Sum256(raw) + + _, err = m.pool.Exec(ctx, ` + INSERT INTO deletion_requests (retention_object_id, requested_by, requested_at, confirmation_token_hash, token_expires_at) + VALUES ($1, $2, $3, $4, $5) + `, retentionObjectID, r.RequestedBy, r.RequestedAt, sum[:], time.Now().UTC().Add(deletionapproval.TokenTTL)) + if err != nil { + return false, fmt.Errorf("deletionrequestmigration: loeschanfrage fuer %s/%s anlegen: %w", r.ObjectType, r.ObjectReference, err) + } + return true, nil +} + +// auditExecuted registriert für eine bereits vollzogene Löschung +// ausschließlich einen destruction_log-Eintrag (append-only) — KEINE +// Zeile in deletion_requests, da kein aktiver Vorgang mehr existiert. +// Ist bereits ein Audit-Eintrag für dieses Objekt vorhanden, wird nicht +// erneut geschrieben. +func (m *Migrator) auditExecuted(ctx context.Context, r SourceDeleteRequest) (bool, error) { + retentionObjectID, err := m.resolveRetentionObjectID(ctx, r.ObjectType, r.ObjectReference) + if err != nil { + return false, err + } + + var exists bool + err = m.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM destruction_log WHERE retention_object_id = $1) + `, retentionObjectID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("deletionrequestmigration: bestand des audit-eintrags pruefen: %w", err) + } + if exists { + return false, nil + } + + tx, err := m.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("deletionrequestmigration: transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, ` + UPDATE retention_objects SET status = 'deleted' WHERE id = $1 + `, retentionObjectID); err != nil { + return false, fmt.Errorf("deletionrequestmigration: objekt %s/%s als vernichtet markieren: %w", r.ObjectType, r.ObjectReference, err) + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO destruction_log (retention_object_id, object_type, object_reference, destroyed_at, destroyed_by) + VALUES ($1, $2, $3, $4, $5) + `, retentionObjectID, r.ObjectType, r.ObjectReference, r.ExecutedAt, r.ExecutedBy); err != nil { + return false, fmt.Errorf("deletionrequestmigration: audit-eintrag fuer %s/%s anlegen: %w", r.ObjectType, r.ObjectReference, err) + } + + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("deletionrequestmigration: audit-eintrag committen: %w", err) + } + return true, nil +} diff --git a/archive/internal/deletionrequestmigration/deletionrequestmigration_test.go b/archive/internal/deletionrequestmigration/deletionrequestmigration_test.go new file mode 100644 index 00000000..e9247a58 --- /dev/null +++ b/archive/internal/deletionrequestmigration/deletionrequestmigration_test.go @@ -0,0 +1,262 @@ +package deletionrequestmigration + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const schemaSQL = ` + 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(), + retain_until TIMESTAMPTZ, + UNIQUE (object_type, object_reference) + ); + CREATE TABLE IF NOT EXISTS deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + requested_by TEXT NOT NULL, + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmation_token_hash BYTEA NOT NULL, + token_expires_at TIMESTAMPTZ NOT NULL, + confirmed_by TEXT, + confirmed_at TIMESTAMPTZ, + executed_at TIMESTAMPTZ + ); + 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 + ); +` + +func requireTestPool(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, schemaSQL); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE destruction_log, deletion_requests, retention_objects CASCADE`) + }) + return pool +} + +type fixtureSource struct { + requests []SourceDeleteRequest +} + +func (s *fixtureSource) DeleteRequests(ctx context.Context) ([]SourceDeleteRequest, error) { + return s.requests, nil +} + +func objectID(t *testing.T, pool *pgxpool.Pool, objectType, objectReference string) string { + t.Helper() + var id string + err := pool.QueryRow(context.Background(), ` + SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2 + `, objectType, objectReference).Scan(&id) + if err != nil { + t.Fatalf("objekt-id lesen: %v", err) + } + return id +} + +// TestMigrateAll_OpenRequestsBecomeActiveWorkflowEntries ist Pruefung 1 +// (Akzeptanzkriterium 1): Zeilenzahl offener Loeschanfragen nach +// Migration deckungsgleich mit Quelle, jede als aktiver CMP-06-Vorgang +// (kein executed_at) vorhanden. +func TestMigrateAll_OpenRequestsBecomeActiveWorkflowEntries(t *testing.T) { + pool := requireTestPool(t) + + const sampleSize = 30 + var reqs []SourceDeleteRequest + for i := 0; i < sampleSize; i++ { + reqs = append(reqs, SourceDeleteRequest{ + ObjectType: "dms_document", + ObjectReference: fmt.Sprintf("tar02-open-%03d", i), + RequestedBy: "sachbearbeiter-a", + RequestedAt: time.Date(2025, time.March, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, i), + }) + } + + m := NewMigrator(pool, &fixtureSource{requests: reqs}) + res, err := m.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("migrateall: %v", err) + } + if res.OpenRequestsMigrated != sampleSize || res.ExecutedAudited != 0 { + t.Fatalf("result = %+v, want %d offene anfragen, 0 audit-eintraege", res, sampleSize) + } + + var count int + if err := pool.QueryRow(context.Background(), ` + SELECT count(*) FROM deletion_requests WHERE executed_at IS NULL + `).Scan(&count); err != nil { + t.Fatalf("zeilenzahl lesen: %v", err) + } + if count != sampleSize { + t.Fatalf("aktive vorgaenge nach migration = %d, quelle hatte %d", count, sampleSize) + } + + // erneuter Lauf gegen dieselbe Quelle darf keine Duplikate erzeugen. + res2, err := m.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("zweiter migrateall-lauf: %v", err) + } + if res2.OpenRequestsMigrated != 0 { + t.Fatalf("zweiter lauf hat %d anfragen erneut migriert, erwartet 0 (idempotenz)", res2.OpenRequestsMigrated) + } + if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM deletion_requests`).Scan(&count); err != nil { + t.Fatalf("zeilenzahl nach zweitem lauf lesen: %v", err) + } + if count != sampleSize { + t.Fatalf("zeilenzahl nach zweitem lauf = %d, erwartet unveraendert %d", count, sampleSize) + } +} + +// TestMigrateAll_ExecutedDeletionsOnlyAsAuditEntry ist Pruefung 2 +// (Akzeptanzkriterium 2): bereits vollzogene Loeschungen erscheinen +// ausschliesslich als destruction_log-Eintrag, keine Zeile in +// deletion_requests, kein retention_objects-Status != 'deleted'. +func TestMigrateAll_ExecutedDeletionsOnlyAsAuditEntry(t *testing.T) { + pool := requireTestPool(t) + + executedAt := time.Date(2024, time.June, 10, 9, 30, 0, 0, time.UTC) + reqs := []SourceDeleteRequest{ + { + ObjectType: "dms_document", + ObjectReference: "tar02-executed-001", + RequestedBy: "sachbearbeiter-b", + RequestedAt: executedAt.Add(-48 * time.Hour), + Executed: true, + ExecutedBy: "sachbearbeiter-c", + ExecutedAt: executedAt, + }, + } + + m := NewMigrator(pool, &fixtureSource{requests: reqs}) + res, err := m.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("migrateall: %v", err) + } + if res.ExecutedAudited != 1 || res.OpenRequestsMigrated != 0 { + t.Fatalf("result = %+v, want 1 audit-eintrag, 0 offene anfragen", res) + } + + id := objectID(t, pool, "dms_document", "tar02-executed-001") + + var deletionRequestCount int + if err := pool.QueryRow(context.Background(), ` + SELECT count(*) FROM deletion_requests WHERE retention_object_id = $1 + `, id).Scan(&deletionRequestCount); err != nil { + t.Fatalf("deletion_requests zaehlen: %v", err) + } + if deletionRequestCount != 0 { + t.Fatalf("vollzogene loeschung hat %d deletion_requests-zeilen, erwartet 0 (kein objekt-datensatz)", deletionRequestCount) + } + + var status string + if err := pool.QueryRow(context.Background(), `SELECT status FROM retention_objects WHERE id = $1`, id).Scan(&status); err != nil { + t.Fatalf("status lesen: %v", err) + } + if status != "deleted" { + t.Fatalf("status = %q, want %q", status, "deleted") + } + + var gotExecutedBy string + var gotExecutedAt time.Time + if err := pool.QueryRow(context.Background(), ` + SELECT destroyed_by, destroyed_at FROM destruction_log WHERE retention_object_id = $1 + `, id).Scan(&gotExecutedBy, &gotExecutedAt); err != nil { + t.Fatalf("audit-eintrag lesen: %v", err) + } + if gotExecutedBy != "sachbearbeiter-c" || !gotExecutedAt.Equal(executedAt) { + t.Fatalf("audit-eintrag = (%q, %v), quelle = (%q, %v)", gotExecutedBy, gotExecutedAt, "sachbearbeiter-c", executedAt) + } + + // erneuter Lauf darf keinen zweiten Audit-Eintrag erzeugen. + res2, err := m.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("zweiter migrateall-lauf: %v", err) + } + if res2.ExecutedAudited != 0 { + t.Fatalf("zweiter lauf hat %d audit-eintraege erneut geschrieben, erwartet 0 (idempotenz)", res2.ExecutedAudited) + } +} + +// TestMigrateAll_IndependentObjectsDoNotInterfere beweist mit zwei +// unabhaengigen Objekten (eines offen, eines vollzogen), dass beide +// Pfade im selben Lauf korrekt getrennt bleiben — echte unabhaengige +// Testobjekte statt desselben Datensatzes doppelt (siehe TAR-01-Hinweis +// zum methodischen Testfehler). +func TestMigrateAll_IndependentObjectsDoNotInterfere(t *testing.T) { + pool := requireTestPool(t) + + reqs := []SourceDeleteRequest{ + { + ObjectType: "mail_message", + ObjectReference: "tar02-mixed-open", + RequestedBy: "sachbearbeiter-d", + RequestedAt: time.Date(2025, time.May, 1, 0, 0, 0, 0, time.UTC), + }, + { + ObjectType: "mail_message", + ObjectReference: "tar02-mixed-executed", + RequestedBy: "sachbearbeiter-e", + RequestedAt: time.Date(2025, time.April, 1, 0, 0, 0, 0, time.UTC), + Executed: true, + ExecutedBy: "sachbearbeiter-f", + ExecutedAt: time.Date(2025, time.April, 2, 0, 0, 0, 0, time.UTC), + }, + } + + m := NewMigrator(pool, &fixtureSource{requests: reqs}) + res, err := m.MigrateAll(context.Background()) + if err != nil { + t.Fatalf("migrateall: %v", err) + } + if res.OpenRequestsMigrated != 1 || res.ExecutedAudited != 1 { + t.Fatalf("result = %+v, want 1 offen, 1 audit", res) + } + + openID := objectID(t, pool, "mail_message", "tar02-mixed-open") + var openStatus string + if err := pool.QueryRow(context.Background(), `SELECT status FROM retention_objects WHERE id = $1`, openID).Scan(&openStatus); err != nil { + t.Fatalf("status (offen) lesen: %v", err) + } + if openStatus != "active" { + t.Fatalf("offenes objekt hat status %q, want active", openStatus) + } + var openExecutedAt *time.Time + if err := pool.QueryRow(context.Background(), ` + SELECT executed_at FROM deletion_requests WHERE retention_object_id = $1 + `, openID).Scan(&openExecutedAt); err != nil { + t.Fatalf("offene anfrage lesen: %v", err) + } + if openExecutedAt != nil { + t.Fatalf("offene anfrage hat executed_at gesetzt") + } +} diff --git a/docs/TAR-02-PRUEFPROTOKOLL.md b/docs/TAR-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..e1cfd920 --- /dev/null +++ b/docs/TAR-02-PRUEFPROTOKOLL.md @@ -0,0 +1,87 @@ +# TAR-02 – Prüfprotokoll: Vier-Augen-Löschworkflow-Migration + +## Umfang + +Migration offener Löschanfragen aus archivdms' `document_delete_requests` +nach Archive CMP-06 (`archive/internal/deletionapproval`): aktive +Vorgänge werden als Zeile in `deletion_requests` übernommen, bereits +vollzogene Löschungen ausschließlich als Audit-Eintrag in +`destruction_log` (RET-03, append-only). + +Implementiert in `archive/internal/deletionrequestmigration` nach dem +in TAR-01 (`archive/internal/retentionmigration`) etablierten Muster: +`Source`-Interface als Adapter auf archivdms (SQL-Export, Vorbild +`dms/internal/migrationdocument`), `Migrator.MigrateAll` idempotent. + +## Akzeptanzkriterium 1: Offene Löschanfragen als aktiver Vorgang + +Test: `TestMigrateAll_OpenRequestsBecomeActiveWorkflowEntries` +(`archive/internal/deletionrequestmigration/deletionrequestmigration_test.go`) + +- 30 synthetische offene Löschanfragen migriert. +- `Result.OpenRequestsMigrated` = 30, `ExecutedAudited` = 0. +- Zeilenzahl `deletion_requests WHERE executed_at IS NULL` nach + Migration = 30 = Quelle (Prüfung 1). +- Zweiter Lauf gegen dieselbe Quelle: 0 zusätzliche Zeilen + (Idempotenz/keine Duplikate bei Re-Run). + +Ergebnis: **bestanden**, real gegen Postgres (Host 204, `TEST_TENANT_DSN`, +Tenant-Schema). + +## Akzeptanzkriterium 2: Vollzogene Löschungen nur als Audit-Eintrag + +Test: `TestMigrateAll_ExecutedDeletionsOnlyAsAuditEntry` + +- Eine bereits vollzogene Löschung (Quelle: `requested_at`/`executed_at` + mit historischen Zeitstempeln, `executed_by` abweichend von + `requested_by`) migriert. +- Stichprobe (Prüfung 2): `deletion_requests` für das zugehörige + `retention_object_id` = 0 Zeilen (kein Objekt-Datensatz/aktiver + Vorgang). +- `retention_objects.status` = `deleted`. +- `destruction_log` enthält genau einen Eintrag mit `destroyed_by`/ + `destroyed_at` exakt aus der Quelle übernommen (keine Neuberechnung). +- Zweiter Lauf: kein zusätzlicher `destruction_log`-Eintrag + (Idempotenz). + +Ergebnis: **bestanden**, real gegen Postgres. + +## Unabhängigkeits-Nachweis (methodischer Fehler aus TAR-01 vermieden) + +`TestMigrateAll_IndependentObjectsDoNotInterfere`: zwei ECHTE, +voneinander unabhängige Testobjekte (unterschiedliche +`object_reference`, eines offen, eines vollzogen) im selben Lauf — +belegt, dass der offene und der Audit-Pfad sich nicht gegenseitig +beeinflussen. Kein Wiederverwenden desselben Datensatzes über zwei +Connection-Pools (der in TAR-01 identifizierte Fehler). + +## GoBD-Kontinuität + +Diese Migration berührt `retain_until` nicht (TAR-01 bereits +abgeschlossen, 1:1-Übernahme der Fristen). `deletionrequestmigration` +übernimmt ausschließlich die Löschanfrage-Metadaten (`requested_by`, +`requested_at`, `executed_by`, `executed_at`) 1:1 aus der Quelle, keine +Neuberechnung anhand des Migrationsdatums. + +## Ausführung + +``` +cd archive +set -a && source /etc/nexarch-dev/tenant-dev.env && set +a +go test ./internal/deletionrequestmigration/... -v +``` + +Ergebnis: `PASS`, 3/3 Tests, Laufzeit ~1s, gegen echten +Postgres-Testcluster Host 204 (Disposable-Testdaten, synthetisch, +`TRUNCATE` nach jedem Test). + +`go build ./...` im Modul `archive` fehlerfrei (keine Regression durch +die neue Abhängigkeit auf `deletionapproval.TokenTTL`). + +## Nicht Teil dieses Tickets + +- Kein SQL-Export-Adapter gegen die echte archivdms-Datenbank + implementiert (analog zu `dms/internal/migrationdocument.Source`: + Schnittstelle definiert, echte Implementierung und Tests mit + synthetischen Fixtures, kein Zugriff auf reale archivdms-Daten). +- Kein Umbau von CMP-06 oder RET-03 selbst.