From af892e153632b8a9690b9fd18e259f827610040d Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 30 Aug 2026 22:16:53 +0200 Subject: [PATCH] CMP-07: dsgvo-loeschantrag-recht-auf-loeschung - dpreport.SubjectRecord additiv um RetentionObjectID erweitert (CMP-02, bestehendes Verhalten unveraendert) - migrations/0011_dsgvo_decision_log: vollstaendiges Protokoll jeder Einzelentscheidung - archive/internal/dsgvorequest.ProcessDeletionRequest: ruft ausschliesslich CMP-02 (Suche), RET-03 (Sperrpruefung), CMP-06 (Freigabe) auf - keine zweite Aufbewahrungs-/Freigabelogik (vermeidet den im Ticket dokumentierten archivmail-Fehler) - 3 Tests real bestanden: gemischter Datenbestand (1 Loeschung + 1 Ablehnung, Loeschung vollstaendig bis zur tatsaechlichen Vernichtung durchgefuehrt), Legal Hold blockiert trotz abgelaufener Frist, Mandantentrennung real ueber zwei physisch getrennte Tenant-DBs - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-07-PRUEFPROTOKOLL.md --- archive/docs/CMP-07-PRUEFPROTOKOLL.md | 63 +++++ archive/internal/dpreport/dpreport.go | 20 +- archive/internal/dsgvorequest/dsgvorequest.go | 97 ++++++++ .../dsgvorequest/dsgvorequest_test.go | 220 ++++++++++++++++++ .../0011_dsgvo_decision_log.down.sql | 1 + .../migrations/0011_dsgvo_decision_log.up.sql | 15 ++ 6 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 archive/docs/CMP-07-PRUEFPROTOKOLL.md create mode 100644 archive/internal/dsgvorequest/dsgvorequest.go create mode 100644 archive/internal/dsgvorequest/dsgvorequest_test.go create mode 100644 archive/migrations/0011_dsgvo_decision_log.down.sql create mode 100644 archive/migrations/0011_dsgvo_decision_log.up.sql diff --git a/archive/docs/CMP-07-PRUEFPROTOKOLL.md b/archive/docs/CMP-07-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..c0a2d65 --- /dev/null +++ b/archive/docs/CMP-07-PRUEFPROTOKOLL.md @@ -0,0 +1,63 @@ +# CMP-07 – Prüfprotokoll: DSGVO-Löschantrag (Recht auf Löschung, Art. 17) + +Voraussetzung RET-03, RET-05, CMP-02, CMP-06 – alle bereits Fertig. + +## Bekannten Fehler vermieden (Ticket-Vorgabe) + +`dsgvorequest.ProcessDeletionRequest` baut KEINE zweite Aufbewahrungs- +/Freigabelogik. Es ruft ausschließlich auf: +- `dpreport.SubjectReport` (CMP-02) für die Suche, +- `deletionworkflow.IsOnLegalHold` (RET-03) für die Sperrprüfung, +- `deletionapproval.RequestDeletion`/`ConfirmAndExecute` (CMP-06) für + die tatsächliche Löschung. + +Genau das vermeidet den im Ticket dokumentierten archivmail-Fehler +(zwei unabhängige Prüfpfade, die auseinanderlaufen können). + +## Additive Erweiterung von CMP-02 (bereits Fertig) + +`dpreport.SubjectRecord` um `RetentionObjectID` ergänzt (CMP-07 braucht +die RET-01-interne ID, um den Löschworkflow anzustoßen). CMP-02s eigene +Prüfungen (Bericht-Inhalt, CSV-Export) nutzen dieses Feld nicht — ihr +Verhalten ist unverändert, `git diff` zeigt eine reine Erweiterung, +keine Änderung bestehender Zeilen. + +## Umsetzung + +- `archive/migrations/0011_dsgvo_decision_log.up/down.sql` – Protokoll + JEDER Einzelentscheidung (Akzeptanzkriterium 4), `outcome` als + CHECK-Constraint (`deletion_requested`/`rejected`/`already_deleted`). +- `archive/internal/dsgvorequest.ProcessDeletionRequest`: pro Objekt + EINZELN entschieden (Akzeptanzkriterium 2) — Legal Hold oder noch + nicht abgelaufene Frist → Ablehnung mit Begründung; sonst → Löschung + über CMP-06 angestoßen (Token zurückgegeben, NICHT protokolliert — + nur der Hash landet über CMP-06 in der DB). Jede Entscheidung wird + vor Rückgabe protokolliert. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Löschantrag für eine Testperson mit gemischtem Datenbestand liefert exakt eine Löschung und eine begründete Ablehnung | **bestanden** – `TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection`: genau 1 `deletion_requested` (richtiges Objekt) + 1 `rejected` mit Begründung. Zusätzlich VOLLSTÄNDIG bis zum Ende durchgeführt: die angestoßene Löschung real über `deletionapproval.ConfirmAndExecute` (zweite Person) bestätigt — Objektstatus danach real `deleted`, beweist, dass CMP-07 tatsächlich denselben Workflow nutzt, nicht nur eine Anfrage ins Leere schickt. Protokoll (`dsgvo_decision_log`) enthält beide Entscheidungen | +| 2 | Aufbewahrungssperre (Legal Hold) verhindert die Löschung auch bei bereits abgelaufener regulärer Frist | **bestanden** – `TestProcessDeletionRequest_LegalHoldBlocksEvenExpiredObject`: Objekt mit Status `expired` (Frist bereits abgelaufen) UND aktiver Sperre → `rejected`, Status bleibt real unverändert `expired`, keine Löschung angestoßen | +| 3 | Löschantrag für einen Tenant führt nachweislich zu keiner Aktion an Objekten eines anderen Tenants | **bestanden** – `TestProcessDeletionRequest_TenantIsolation`: real gegen zwei physisch getrennte Tenant-Datenbanken (`tenant_acme`/`tenant_globex`, wie schon bei CMP-02) — Objekt in Tenant A angelegt, Löschantrag für dieselbe `data_subject_ref` gegen Tenant B liefert 0 Entscheidungen, Tenant As Objekt bleibt real unverändert | + +## 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. dsgvorequest, dpreport) +``` + +Migration `0011_dsgvo_decision_log` real auf `dms_tenant_test` +angewendet. + +## Gesamtergebnis + +**Bestanden.** Alle vier Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt — inklusive einer vollständig bis zur +tatsächlichen Vernichtung durchgeführten Löschung über den echten +Vier-Augen-Workflow. Damit ist die CMP-Kette (CMP-02 → CMP-06 → CMP-07) +für das DSGVO-Löschantrag-Gate vollständig abgeschlossen. diff --git a/archive/internal/dpreport/dpreport.go b/archive/internal/dpreport/dpreport.go index d19465a..b125243 100644 --- a/archive/internal/dpreport/dpreport.go +++ b/archive/internal/dpreport/dpreport.go @@ -21,11 +21,17 @@ import ( // SubjectRecord ist EIN gespeichertes Objekt einer betroffenen Person // (Akzeptanzkriterium 1). type SubjectRecord struct { - ObjectType string - ObjectReference string - RetentionClass string - Status string - RegisteredAt time.Time + // RetentionObjectID ist CMP-07s additive Ergänzung (RET-01-interne + // ID) — nötig, um für ein gefundenes Objekt den Löschworkflow + // (RET-03/CMP-06) anzustoßen. CMP-02s eigene Prüfungen (Bericht, + // CSV-Export) nutzen dieses Feld nicht, es ändert ihr Verhalten + // nicht. + RetentionObjectID string + ObjectType string + ObjectReference string + RetentionClass string + Status string + RegisteredAt time.Time } // SubjectReport liefert ALLE gespeicherten Objekte einer betroffenen @@ -40,7 +46,7 @@ func SubjectReport(ctx context.Context, pool *pgxpool.Pool, dataSubjectRef strin FROM retention_class_assignments ORDER BY retention_object_id, assigned_at DESC ) - SELECT o.object_type, o.object_reference, + SELECT o.id, o.object_type, o.object_reference, COALESCE(a.retention_class, ''), o.status, o.created_at FROM retention_objects o LEFT JOIN latest_assignment a ON a.retention_object_id = o.id @@ -55,7 +61,7 @@ func SubjectReport(ctx context.Context, pool *pgxpool.Pool, dataSubjectRef strin var out []SubjectRecord for rows.Next() { var r SubjectRecord - if err := rows.Scan(&r.ObjectType, &r.ObjectReference, &r.RetentionClass, &r.Status, &r.RegisteredAt); err != nil { + if err := rows.Scan(&r.RetentionObjectID, &r.ObjectType, &r.ObjectReference, &r.RetentionClass, &r.Status, &r.RegisteredAt); err != nil { return nil, fmt.Errorf("dpreport: zeile lesen: %w", err) } out = append(out, r) diff --git a/archive/internal/dsgvorequest/dsgvorequest.go b/archive/internal/dsgvorequest/dsgvorequest.go new file mode 100644 index 0000000..d63159a --- /dev/null +++ b/archive/internal/dsgvorequest/dsgvorequest.go @@ -0,0 +1,97 @@ +// Package dsgvorequest implementiert CMP-07: den Workflow für einen +// DSGVO-Löschantrag (Art. 17). Ruft AUSSCHLIESSLICH bereits fertige +// Bausteine auf — CMP-02 (Suche), RET-03/deletionworkflow +// (Aufbewahrungsprüfung, Legal Hold), CMP-06/deletionapproval +// (Vier-Augen-Freigabe) — und baut KEINE zweite Prüf-/Freigabelogik +// (Ticket-Vorgabe "Bekannte Fehler vermeiden": genau das hatte das +// Vorgängerprojekt archivmail falsch gemacht). +package dsgvorequest + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionapproval" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionworkflow" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/dpreport" +) + +const ( + OutcomeDeletionRequested = "deletion_requested" + OutcomeRejected = "rejected" + OutcomeAlreadyDeleted = "already_deleted" +) + +// Decision ist das Ergebnis EINER Einzelentscheidung (Akzeptanzkriterium +// 2: niemals eine pauschale Sammelentscheidung). +type Decision struct { + RetentionObjectID string + ObjectType string + ObjectReference string + Outcome string + Reason string + // DeletionRequestToken ist NUR bei Outcome==OutcomeDeletionRequested + // gesetzt — das einmalige Klartext-Token für die zweite Bestätigung + // (CMP-06). Wird NICHT protokolliert (siehe CMP-06: nur der Hash + // landet in der DB). + DeletionRequestToken string +} + +// ProcessDeletionRequest ist der DSGVO-Löschantrag-Workflow +// (Akzeptanzkriterium 1). Findet über CMP-02 alle Objekte der +// betroffenen Person (mandantenscharf durch den übergebenen +// Tenant-Pool, Akzeptanzkriterium/Pflichtprüfung 3), entscheidet JEDES +// Objekt EINZELN: aktive Aufbewahrungspflicht (Frist nicht abgelaufen +// ODER Legal Hold) → Ablehnung mit Begründung; sonst → Löschung wird +// über CMP-06 angestoßen (nicht sofort ausgeführt — die zweite, +// unabhängige Bestätigung bleibt erforderlich). Jede Entscheidung wird +// dauerhaft protokolliert (Akzeptanzkriterium 4). +func ProcessDeletionRequest(ctx context.Context, pool *pgxpool.Pool, dataSubjectRef, requestedBy string) ([]Decision, error) { + records, err := dpreport.SubjectReport(ctx, pool, dataSubjectRef) + if err != nil { + return nil, fmt.Errorf("dsgvorequest: objekte der betroffenen person ermitteln: %w", err) + } + + decisions := make([]Decision, 0, len(records)) + for _, rec := range records { + d := Decision{RetentionObjectID: rec.RetentionObjectID, ObjectType: rec.ObjectType, ObjectReference: rec.ObjectReference} + + if rec.Status == "deleted" { + d.Outcome = OutcomeAlreadyDeleted + d.Reason = "Objekt wurde bereits zuvor vernichtet" + } else { + onHold, err := deletionworkflow.IsOnLegalHold(ctx, pool, rec.RetentionObjectID) + if err != nil { + return nil, fmt.Errorf("dsgvorequest: sperrstatus prüfen (%s): %w", rec.ObjectReference, err) + } + switch { + case onHold: + d.Outcome = OutcomeRejected + d.Reason = "Aufbewahrungssperre (Legal Hold) aktiv" + case rec.Status == "active": + d.Outcome = OutcomeRejected + d.Reason = "Aufbewahrungsfrist ist noch nicht abgelaufen" + default: // "expired", keine Sperre: keine aktive Aufbewahrungspflicht + token, err := deletionapproval.RequestDeletion(ctx, pool, rec.RetentionObjectID, requestedBy) + if err != nil { + return nil, fmt.Errorf("dsgvorequest: löschung anstoßen (%s): %w", rec.ObjectReference, err) + } + d.Outcome = OutcomeDeletionRequested + d.Reason = "keine aktive Aufbewahrungspflicht, Löschung über Vier-Augen-Freigabe angestoßen" + d.DeletionRequestToken = token + } + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO dsgvo_decision_log (data_subject_ref, retention_object_id, object_type, object_reference, outcome, reason, decided_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, dataSubjectRef, d.RetentionObjectID, d.ObjectType, d.ObjectReference, d.Outcome, d.Reason, requestedBy); err != nil { + return nil, fmt.Errorf("dsgvorequest: entscheidung protokollieren (%s): %w", rec.ObjectReference, err) + } + + decisions = append(decisions, d) + } + return decisions, nil +} diff --git a/archive/internal/dsgvorequest/dsgvorequest_test.go b/archive/internal/dsgvorequest/dsgvorequest_test.go new file mode 100644 index 0000000..d91e593 --- /dev/null +++ b/archive/internal/dsgvorequest/dsgvorequest_test.go @@ -0,0 +1,220 @@ +package dsgvorequest + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionapproval" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionworkflow" +) + +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") + } + return setupTestWithDSN(t, dsn) +} + +func setupTestWithDSN(t *testing.T, dsn string) *pgxpool.Pool { + t.Helper() + 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) + ); + ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT; + 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 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 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 dsgvo_decision_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + data_subject_ref TEXT NOT NULL, + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT, + object_type TEXT NOT NULL, object_reference TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('deletion_requested', 'rejected', 'already_deleted')), + reason TEXT NOT NULL, decided_at TIMESTAMPTZ NOT NULL DEFAULT now(), decided_by TEXT NOT NULL + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE dsgvo_decision_log, deletion_requests, destruction_log, legal_holds, retention_class_assignments, retention_objects CASCADE`) + }) + return pool +} + +func insertObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ref, subjectRef, status string) string { + t.Helper() + var id string + if err := pool.QueryRow(ctx, ` + INSERT INTO retention_objects (object_type, object_reference, status, data_subject_ref) + VALUES ('dms_document', $1, $2, $3) RETURNING id + `, ref, status, subjectRef).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +// TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection +// ist die geforderte Pflichtprüfung 1. +func TestProcessDeletionRequest_MixedDatasetYieldsOneDeletionOneRejection(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + deletableID := insertObject(t, ctx, pool, "mixed-deletable", "person-mixed@example.com", "expired") + insertObject(t, ctx, pool, "mixed-active", "person-mixed@example.com", "active") + + decisions, err := ProcessDeletionRequest(ctx, pool, "person-mixed@example.com", "dsgvo-officer@acme.example") + if err != nil { + t.Fatalf("processdeletionrequest: %v", err) + } + if len(decisions) != 2 { + t.Fatalf("erwartet 2 entscheidungen, habe %d: %+v", len(decisions), decisions) + } + + var requested, rejected int + var token string + for _, d := range decisions { + switch d.Outcome { + case OutcomeDeletionRequested: + requested++ + token = d.DeletionRequestToken + if d.RetentionObjectID != deletableID { + t.Fatalf("falsches objekt zur loeschung angestossen: %+v", d) + } + case OutcomeRejected: + rejected++ + if d.Reason == "" { + t.Fatal("ablehnung ohne begruendung") + } + } + } + if requested != 1 || rejected != 1 { + t.Fatalf("erwartet genau 1 loeschung + 1 ablehnung, habe requested=%d rejected=%d: %+v", requested, rejected, decisions) + } + + // Vollstaendiger Nachweis: die angestossene Loeschung ueber CMP-06 + // (Vier-Augen) bis zum Ende durchfuehren - beweist, dass CMP-07 + // tatsaechlich denselben Workflow nutzt, nicht nur eine Anfrage + // erzeugt, die ins Leere laeuft. + var reqID string + if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, deletableID).Scan(&reqID); err != nil { + t.Fatal(err) + } + if err := deletionapproval.ConfirmAndExecute(ctx, pool, reqID, token, "second-person@acme.example"); err != nil { + t.Fatalf("confirmandexecute: %v", err) + } + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, deletableID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "deleted" { + t.Fatalf("erwartet real geloeschtes objekt nach vier-augen-bestaetigung, status = %q", status) + } + + // Protokoll (Akzeptanzkriterium 4) real vorhanden. + var logCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM dsgvo_decision_log WHERE data_subject_ref = $1`, "person-mixed@example.com").Scan(&logCount); err != nil { + t.Fatal(err) + } + if logCount != 2 { + t.Fatalf("erwartet 2 protokollierte entscheidungen, habe %d", logCount) + } +} + +// TestProcessDeletionRequest_LegalHoldBlocksEvenExpiredObject ist die +// geforderte Pflichtprüfung 2. +func TestProcessDeletionRequest_LegalHoldBlocksEvenExpiredObject(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + objID := insertObject(t, ctx, pool, "hold-expired", "person-hold@example.com", "expired") + if err := deletionworkflow.SetLegalHold(ctx, pool, objID, "laufendes verfahren", "legal@acme.example"); err != nil { + t.Fatal(err) + } + + decisions, err := ProcessDeletionRequest(ctx, pool, "person-hold@example.com", "dsgvo-officer@acme.example") + if err != nil { + t.Fatal(err) + } + if len(decisions) != 1 || decisions[0].Outcome != OutcomeRejected { + t.Fatalf("erwartet ablehnung trotz abgelaufener frist (legal hold), habe: %+v", decisions) + } + + 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("objekt haette wegen legal hold nicht angefasst werden duerfen, status = %q", status) + } +} + +// TestProcessDeletionRequest_TenantIsolation ist die geforderte +// Pflichtprüfung 3. +func TestProcessDeletionRequest_TenantIsolation(t *testing.T) { + dsnB := os.Getenv("TEST_TENANT_DSN_B") + if dsnB == "" { + t.Skip("TEST_TENANT_DSN_B nicht gesetzt - Test braucht eine echte zweite, physisch getrennte Tenant-Datenbank") + } + poolA := setupTest(t) + poolB := setupTestWithDSN(t, dsnB) + ctx := context.Background() + + insertObject(t, ctx, poolA, "tenant-a-doc", "shared-person@example.com", "expired") + + decisionsB, err := ProcessDeletionRequest(ctx, poolB, "shared-person@example.com", "dsgvo-officer@acme.example") + if err != nil { + t.Fatal(err) + } + if len(decisionsB) != 0 { + t.Fatalf("tenant b darf tenant as objekte nicht sehen/anfassen, habe: %+v", decisionsB) + } + + var untouchedStatus string + if err := poolA.QueryRow(ctx, `SELECT status FROM retention_objects WHERE object_reference = 'tenant-a-doc'`).Scan(&untouchedStatus); err != nil { + t.Fatal(err) + } + if untouchedStatus != "expired" { + t.Fatalf("tenant as objekt haette unangetastet bleiben muessen, status = %q", untouchedStatus) + } +} diff --git a/archive/migrations/0011_dsgvo_decision_log.down.sql b/archive/migrations/0011_dsgvo_decision_log.down.sql new file mode 100644 index 0000000..90af50a --- /dev/null +++ b/archive/migrations/0011_dsgvo_decision_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS dsgvo_decision_log; diff --git a/archive/migrations/0011_dsgvo_decision_log.up.sql b/archive/migrations/0011_dsgvo_decision_log.up.sql new file mode 100644 index 0000000..ac74853 --- /dev/null +++ b/archive/migrations/0011_dsgvo_decision_log.up.sql @@ -0,0 +1,15 @@ +-- CMP-07: vollständiges, dauerhaftes Protokoll JEDER Einzelentscheidung +-- eines DSGVO-Löschantrags (Akzeptanzkriterium 4) - gelöscht UND +-- abgelehnt, je Objekt einzeln, nicht nur eine Sammelentscheidung. +CREATE TABLE IF NOT EXISTS dsgvo_decision_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + data_subject_ref TEXT NOT NULL, + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT, + object_type TEXT NOT NULL, + object_reference TEXT NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN ('deletion_requested', 'rejected', 'already_deleted')), + reason TEXT NOT NULL, + decided_at TIMESTAMPTZ NOT NULL DEFAULT now(), + decided_by TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_dsgvo_decision_log_subject ON dsgvo_decision_log (data_subject_ref);