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.
This commit is contained in:
@@ -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.
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS retention_class_assignments;
|
||||||
|
DROP TABLE IF EXISTS retention_objects;
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user