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
This commit is contained in:
@@ -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.
|
||||||
@@ -21,11 +21,17 @@ import (
|
|||||||
// SubjectRecord ist EIN gespeichertes Objekt einer betroffenen Person
|
// SubjectRecord ist EIN gespeichertes Objekt einer betroffenen Person
|
||||||
// (Akzeptanzkriterium 1).
|
// (Akzeptanzkriterium 1).
|
||||||
type SubjectRecord struct {
|
type SubjectRecord struct {
|
||||||
ObjectType string
|
// RetentionObjectID ist CMP-07s additive Ergänzung (RET-01-interne
|
||||||
ObjectReference string
|
// ID) — nötig, um für ein gefundenes Objekt den Löschworkflow
|
||||||
RetentionClass string
|
// (RET-03/CMP-06) anzustoßen. CMP-02s eigene Prüfungen (Bericht,
|
||||||
Status string
|
// CSV-Export) nutzen dieses Feld nicht, es ändert ihr Verhalten
|
||||||
RegisteredAt time.Time
|
// nicht.
|
||||||
|
RetentionObjectID string
|
||||||
|
ObjectType string
|
||||||
|
ObjectReference string
|
||||||
|
RetentionClass string
|
||||||
|
Status string
|
||||||
|
RegisteredAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubjectReport liefert ALLE gespeicherten Objekte einer betroffenen
|
// 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
|
FROM retention_class_assignments
|
||||||
ORDER BY retention_object_id, assigned_at DESC
|
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
|
COALESCE(a.retention_class, ''), o.status, o.created_at
|
||||||
FROM retention_objects o
|
FROM retention_objects o
|
||||||
LEFT JOIN latest_assignment a ON a.retention_object_id = o.id
|
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
|
var out []SubjectRecord
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r SubjectRecord
|
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)
|
return nil, fmt.Errorf("dpreport: zeile lesen: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS dsgvo_decision_log;
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user