TAR-02: Vier-Augen-Löschworkflow-Migration #275

Merged
patrick merged 1 commits from feature/tar-02-vier-augen-loeschworkflow-migration into main 2026-10-14 22:14:01 +02:00
3 changed files with 538 additions and 0 deletions

No files matched your search

@@ -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
}
@@ -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")
}
}
+87
View File
@@ -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.