From b37b7907922d4f423ea44d8846e48ec4242b0c32 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 01:25:31 +0200 Subject: [PATCH] feat(archive): RET-01 generisches Retention-Objektmodell internal/retention: object_type/object_reference als reine Textfelder (Adapter-Muster, keine Fremdschluessel auf DMS-/Mail-Tabellen). Aufbewahrungsklassen-Zuordnung historisiert (jede Zuordnung eigene, unveraenderliche Zeile). Migration real vorwaerts+rueckwaerts gegen die tatsaechlichen .sql-Dateien getestet, Mandantentrennung gegen echtes zweites Tenant-DB bewiesen. Grundlage fuer RET-05 (Adapter- Interface) und RET-02. --- archive/docs/RET-01-PRUEFPROTOKOLL.md | 64 +++++++++ archive/internal/retention/migration_test.go | 78 +++++++++++ archive/internal/retention/retention.go | 103 ++++++++++++++ archive/internal/retention/retention_test.go | 137 +++++++++++++++++++ archive/migrations/0002_retention.down.sql | 2 + archive/migrations/0002_retention.up.sql | 26 ++++ 6 files changed, 410 insertions(+) create mode 100644 archive/docs/RET-01-PRUEFPROTOKOLL.md create mode 100644 archive/internal/retention/migration_test.go create mode 100644 archive/internal/retention/retention.go create mode 100644 archive/internal/retention/retention_test.go create mode 100644 archive/migrations/0002_retention.down.sql create mode 100644 archive/migrations/0002_retention.up.sql diff --git a/archive/docs/RET-01-PRUEFPROTOKOLL.md b/archive/docs/RET-01-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..df72f7a --- /dev/null +++ b/archive/docs/RET-01-PRUEFPROTOKOLL.md @@ -0,0 +1,64 @@ +# RET-01 – Prüfprotokoll: Generisches Retention-Objektmodell + +Keine Vorbedingungen (Welle 1 des RET-Epics). + +## Grundsatzentscheidung: Adapter-Muster, keine Fremdschlüssel auf Modul-Tabellen + +`retention_objects.object_type`/`object_reference` sind reine +Textfelder — Archive importiert weder DMS- noch Mail-Code (eigene +Go-Module, physisch getrennte Verantwortung, dieselbe Disziplin wie +BAK-05s `reconcile`-Paket gegenüber `file_revisions`). Ein neues Modul +kann retention-pflichtige Objekte einbinden, ohne dieses Paket zu +ändern — nur `RegisterObject(objectType, objectReference)` aufrufen. + +Aufbewahrungsklassen-Zuordnung ist historisiert: `AssignClass` fügt +IMMER eine neue Zeile hinzu, ändert nie eine bestehende — die "aktuelle" +Klasse ist die mit dem jüngsten `assigned_at`. Damit bleibt +nachvollziehbar, wann und wie oft sich die Klasse eines Objekts +geändert hat, ohne eigene Audit-Tabelle. + +## Umsetzung + +- `migrations/0002_retention.up.sql`/`.down.sql` — `retention_objects`, + `retention_class_assignments`. +- `internal/retention.RegisterObject` — idempotent (`ON CONFLICT`). +- `internal/retention.AssignClass`/`CurrentClass`/`ClassHistory`. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Migration vorwärts und rückwärts getestet | **bestanden** — `TestMigration_ForwardAndBackward`: real gegen die tatsächlichen Migrationsdateien (nicht nachgebaut), vorwärts→Tabellen vorhanden, rückwärts→Tabellen weg, erneut vorwärts→sauber (kein Rest blockiert erneuten Lauf) | +| 2 | Testobjekt aus fiktivem DMS- und Mail-Adapter beide korrekt abgebildet | **bestanden** — `TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically`: `dms_document`/`mail_message` beide ohne modulspezifische Spalten abgebildet, zusätzlich Idempotenz bewiesen (erneute Registrierung liefert dieselbe ID) | +| 3 | Tenant-Isolation der Kern-Tabellen durch Negativtest belegt | **bestanden** — `TestTenantIsolation_Negativtest`: reales zweites Tenant-DB (`ret01_tenant_test_b`), Objekt aus Tenant A über Verbindung zu Tenant B abgefragt, `count=0` — technisch nicht sichtbar, keine gemeinsame Tabelle mit `tenant_id`-Filter (Modell C, TEN-01) | + +Zusätzlich: `TestAssignClass_IsHistoricized` — zwei Klassen-Zuordnungen, +`CurrentClass` liefert die jüngste, `ClassHistory` beide chronologisch. + +## Echte Verdrahtung auf 192.168.1.131 + +- Migration real gegen `dms_tenant_test` angewendet (`psql -f + migrations/0002_retention.up.sql`) — `retention_objects`, + `retention_class_assignments` bestätigt vorhanden (`\dt retention*`) +- Kein systemd-Dienst/Timer nötig — RET-01 ist reines Datenmodell + + Bibliothek, kein eigenständiger Prozess (Verbraucher sind spätere + RET-Tickets, allen voran RET-05 als Adapter-Interface) +- Zweites Tenant-DB (`ret01_tenant_test_b`) nur für den Isolationstest + angelegt, danach entfernt + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 8/8 Pakete mit Tests ok (backup, objectbackup, reconcile, restore, restoretest, retention, scrub, tenantbackup), 0 Fehlschläge +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — Migration gegen die tatsächlichen +`.sql`-Dateien (nicht nachgebaut) getestet, Mandantentrennung gegen ein +echtes zweites Tenant-DB bewiesen. Grundlage für RET-05 (Adapter- +Interface, als Nächstes) und RET-02 gelegt. diff --git a/archive/internal/retention/migration_test.go b/archive/internal/retention/migration_test.go new file mode 100644 index 0000000..639605f --- /dev/null +++ b/archive/internal/retention/migration_test.go @@ -0,0 +1,78 @@ +package retention + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestMigration_ForwardAndBackward ist Pruefung 3 fuer Akzeptanzkriterium +// 3: Migration laeuft gegen leere Datenbank durch UND ist rueckrollbar - +// real gegen die TATSAECHLICHEN Migrationsdateien, kein Nachbau. +func TestMigration_ForwardAndBackward(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + if _, err := exec.LookPath("psql"); err != nil { + t.Skip("psql nicht installiert, Integrationstest uebersprungen") + } + + _, thisFile, _, _ := runtime.Caller(0) + migrationsDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "migrations") + upSQL := filepath.Join(migrationsDir, "0002_retention.up.sql") + downSQL := filepath.Join(migrationsDir, "0002_retention.down.sql") + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + defer pool.Close() + // sauberer Ausgangszustand, falls von einem frueheren Testlauf uebrig. + _, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`) + + runPsql := func(sqlFile string) []byte { + t.Helper() + cmd := exec.CommandContext(ctx, "psql", dsn, "-v", "ON_ERROR_STOP=1", "-f", sqlFile) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("psql -f %s: %v (ausgabe: %s)", sqlFile, err, out) + } + return out + } + + tableExists := func(name string) bool { + var exists bool + if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)`, name).Scan(&exists); err != nil { + t.Fatalf("tabellenexistenz pruefen: %v", err) + } + return exists + } + + // vorwaerts + runPsql(upSQL) + if !tableExists("retention_objects") || !tableExists("retention_class_assignments") { + t.Fatal("migration vorwaerts: erwartete tabellen fehlen") + } + + // rueckwaerts + runPsql(downSQL) + if tableExists("retention_objects") || tableExists("retention_class_assignments") { + t.Fatal("migration rueckwaerts: tabellen haetten entfernt sein muessen") + } + + // erneut vorwaerts (beweist: rueckwaerts hat wirklich sauber + // aufgeraeumt, kein Rest, der einen zweiten Vorwaertslauf bloeckieren wuerde) + runPsql(upSQL) + if !tableExists("retention_objects") { + t.Fatal("zweiter vorwaertslauf nach rollback fehlgeschlagen") + } + // aufraeumen + _, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS retention_class_assignments, retention_objects CASCADE`) +} diff --git a/archive/internal/retention/retention.go b/archive/internal/retention/retention.go new file mode 100644 index 0000000..98dba18 --- /dev/null +++ b/archive/internal/retention/retention.go @@ -0,0 +1,103 @@ +// Package retention implementiert RET-01: ein generisches Datenmodell +// für aufbewahrungspflichtige Objekte, modulübergreifend über Adapter +// (Objekttyp + Objekt-Referenz als reine Textfelder) — Archive kennt die +// Fachobjekte anderer Module (DMS, Mail) nicht im Detail, nur ihren Typ +// und ihre Referenz. Keine Fremdschlüssel auf modulspezifische Tabellen, +// damit ein neues Modul retention-pflichtige Objekte einbinden kann, +// ohne dieses Paket zu ändern. +package retention + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Status eines Retention-Objekts. +type Status string + +const ( + StatusActive Status = "active" + StatusExpired Status = "expired" + StatusDeleted Status = "deleted" +) + +// RegisterObject registriert ein Objekt eines beliebigen Moduls unter +// seinem Typ+Referenz — idempotent (ON CONFLICT), ein Adapter kann ein +// bereits bekanntes Objekt gefahrlos erneut registrieren +// (Akzeptanzkriterium 1: bildet beliebige Objekttypen ab, ohne +// modulspezifische Spalten). +func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference string) (string, error) { + var id string + err := 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("retention: objekt registrieren: %w", err) + } + return id, nil +} + +// Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse. +type Assignment struct { + RetentionClass string + AssignedAt time.Time +} + +// AssignClass ordnet einem Retention-Objekt eine neue Aufbewahrungsklasse +// zu — fügt IMMER eine neue Zeile hinzu, ändert nie eine bestehende +// (Akzeptanzkriterium 2: historisierbar). +func AssignClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, retentionClass string) error { + _, err := pool.Exec(ctx, ` + INSERT INTO retention_class_assignments (retention_object_id, retention_class) + VALUES ($1, $2) + `, retentionObjectID, retentionClass) + if err != nil { + return fmt.Errorf("retention: aufbewahrungsklasse zuordnen: %w", err) + } + return nil +} + +// CurrentClass liefert die AKTUELLE Aufbewahrungsklasse (jüngste +// Zuordnung) eines Retention-Objekts. +func CurrentClass(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (Assignment, error) { + var a Assignment + err := pool.QueryRow(ctx, ` + SELECT retention_class, assigned_at FROM retention_class_assignments + WHERE retention_object_id = $1 + ORDER BY assigned_at DESC LIMIT 1 + `, retentionObjectID).Scan(&a.RetentionClass, &a.AssignedAt) + if err != nil { + return Assignment{}, fmt.Errorf("retention: aktuelle aufbewahrungsklasse lesen: %w", err) + } + return a, nil +} + +// ClassHistory liefert ALLE Zuordnungen eines Retention-Objekts, +// chronologisch aufsteigend — voller Nachvollzug der Historie. +func ClassHistory(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) ([]Assignment, error) { + rows, err := pool.Query(ctx, ` + SELECT retention_class, assigned_at FROM retention_class_assignments + WHERE retention_object_id = $1 + ORDER BY assigned_at ASC + `, retentionObjectID) + if err != nil { + return nil, fmt.Errorf("retention: klassenhistorie lesen: %w", err) + } + defer rows.Close() + + var history []Assignment + for rows.Next() { + var a Assignment + if err := rows.Scan(&a.RetentionClass, &a.AssignedAt); err != nil { + return nil, fmt.Errorf("retention: historien-zeile lesen: %w", err) + } + history = append(history, a) + } + return history, rows.Err() +} diff --git a/archive/internal/retention/retention_test.go b/archive/internal/retention/retention_test.go new file mode 100644 index 0000000..e3b40f3 --- /dev/null +++ b/archive/internal/retention/retention_test.go @@ -0,0 +1,137 @@ +package retention + +import ( + "context" + "os" + "testing" + + "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(), + 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() + ); +` + +func requireTestPool(t *testing.T, dsn string) *pgxpool.Pool { + t.Helper() + 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 retention_class_assignments, retention_objects CASCADE`) + }) + return pool +} + +// TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically ist +// Pruefung 2: Testobjekt aus fiktivem DMS- und Mail-Adapter beide +// korrekt abgebildet - KEINE modulspezifischen Spalten noetig, nur +// object_type/object_reference. +func TestRegisterObject_MapsDMSAndMailAdapterObjectsIdentically(t *testing.T) { + pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN")) + ctx := context.Background() + + dmsID, err := RegisterObject(ctx, pool, "dms_document", "doc-123") + if err != nil { + t.Fatalf("dms-adapter registrieren: %v", err) + } + mailID, err := RegisterObject(ctx, pool, "mail_message", "msg-456") + if err != nil { + t.Fatalf("mail-adapter registrieren: %v", err) + } + if dmsID == "" || mailID == "" || dmsID == mailID { + t.Fatalf("erwartet zwei unterschiedliche, gueltige ids, habe dms=%q mail=%q", dmsID, mailID) + } + + // Idempotenz: erneute Registrierung desselben Objekts liefert dieselbe id. + dmsIDAgain, err := RegisterObject(ctx, pool, "dms_document", "doc-123") + if err != nil { + t.Fatalf("erneute registrierung: %v", err) + } + if dmsIDAgain != dmsID { + t.Fatalf("erneute registrierung lieferte andere id: %q, want %q", dmsIDAgain, dmsID) + } +} + +// TestAssignClass_IsHistoricized ist Pruefung fuer Akzeptanzkriterium 2: +// Aufbewahrungsklasse ist eindeutig zugeordnet UND historisierbar. +func TestAssignClass_IsHistoricized(t *testing.T) { + pool := requireTestPool(t, os.Getenv("TEST_TENANT_DSN")) + ctx := context.Background() + + objID, err := RegisterObject(ctx, pool, "dms_document", "doc-hist") + if err != nil { + t.Fatalf("registrieren: %v", err) + } + if err := AssignClass(ctx, pool, objID, "klasse-A"); err != nil { + t.Fatalf("erste zuordnung: %v", err) + } + if err := AssignClass(ctx, pool, objID, "klasse-B"); err != nil { + t.Fatalf("zweite zuordnung: %v", err) + } + + current, err := CurrentClass(ctx, pool, objID) + if err != nil { + t.Fatalf("currentclass: %v", err) + } + if current.RetentionClass != "klasse-B" { + t.Fatalf("aktuelle klasse = %q, want klasse-B", current.RetentionClass) + } + + history, err := ClassHistory(ctx, pool, objID) + if err != nil { + t.Fatalf("classhistory: %v", err) + } + if len(history) != 2 || history[0].RetentionClass != "klasse-A" || history[1].RetentionClass != "klasse-B" { + t.Fatalf("erwartet [klasse-A, klasse-B] chronologisch, habe %+v", history) + } +} + +// TestTenantIsolation_Negativtest ist Pruefung 3: ein in Tenant-DB A +// registriertes Objekt ist ueber eine Verbindung zu Tenant-DB B technisch +// nicht sichtbar - real gegen zwei unabhaengige Datenbanken (Modell C, +// TEN-01), keine gemeinsame Tabelle mit tenant_id-Filter. +func TestTenantIsolation_Negativtest(t *testing.T) { + dsnA := os.Getenv("TEST_TENANT_DSN") + dsnB := os.Getenv("TEST_TENANT_DSN_B") + if dsnA == "" || dsnB == "" { + t.Skip("TEST_TENANT_DSN und TEST_TENANT_DSN_B nicht beide gesetzt, Integrationstest uebersprungen") + } + poolA := requireTestPool(t, dsnA) + poolB := requireTestPool(t, dsnB) + ctx := context.Background() + + if _, err := RegisterObject(ctx, poolA, "dms_document", "nur-in-tenant-a"); err != nil { + t.Fatalf("registrieren in tenant a: %v", err) + } + + var count int + if err := poolB.QueryRow(ctx, `SELECT count(*) FROM retention_objects WHERE object_reference = 'nur-in-tenant-a'`).Scan(&count); err != nil { + t.Fatalf("tenant b abfragen: %v", err) + } + if count != 0 { + t.Fatalf("objekt aus tenant a in tenant b sichtbar (count=%d) - mandantentrennung verletzt", count) + } +} diff --git a/archive/migrations/0002_retention.down.sql b/archive/migrations/0002_retention.down.sql new file mode 100644 index 0000000..ac4f549 --- /dev/null +++ b/archive/migrations/0002_retention.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS retention_class_assignments; +DROP TABLE IF EXISTS retention_objects; diff --git a/archive/migrations/0002_retention.up.sql b/archive/migrations/0002_retention.up.sql new file mode 100644 index 0000000..0a65922 --- /dev/null +++ b/archive/migrations/0002_retention.up.sql @@ -0,0 +1,26 @@ +-- RET-01: generisches Retention-Objektmodell. Modulübergreifend über +-- Adapter (object_type/object_reference als reine Textfelder, KEINE +-- Fremdschlüssel auf DMS-/Mail-Tabellen) — Archive kennt die +-- Fachobjekte anderer Module nicht, nur deren Typ+Referenz. +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) +); + +-- Historisiert: JEDE Zuordnung ist eine eigene, unveränderliche Zeile +-- (nie UPDATE) — "aktuelle" Aufbewahrungsklasse ist die mit dem +-- jüngsten assigned_at je retention_object_id. So bleibt nachvollziehbar, +-- wann sich die Klasse eines Objekts geändert hat (Akzeptanzkriterium 2). +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 INDEX IF NOT EXISTS idx_retention_class_assignments_object + ON retention_class_assignments (retention_object_id, assigned_at DESC);