diff --git a/archive/docs/CMP-02-PRUEFPROTOKOLL.md b/archive/docs/CMP-02-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..110e766 --- /dev/null +++ b/archive/docs/CMP-02-PRUEFPROTOKOLL.md @@ -0,0 +1,69 @@ +# CMP-02 – Prüfprotokoll: DSGVO-/Datenschutz-Berichte + +Voraussetzung RET-01 – bereits Fertig. + +## Vorab identifizierte und geklärte Design-Lücke + +RET-01 (`retention_objects`) führte bislang keine Zuordnung zu einer +"betroffenen Person" — nur `object_type`/`object_reference` (opake +modulübergreifende Referenz). Ein Auskunftsbericht "aller Objekte einer +Person" war damit strukturell unmöglich. Nach Nutzerentscheidung +(Option 1) additiv gelöst: + +- `archive/migrations/0009_data_subject_ref.up.sql` — nullable Spalte + `retention_objects.data_subject_ref` + Index. +- `archive/internal/retention.RegisterObjectForSubject` — NEUE, additive + Funktion. `RegisterObject` (RET-01) bleibt UNVERÄNDERT (kein Diff), + kein bestehender Aufrufer betroffen (Codeprüfung: `RegisterObject` + hatte ohnehin nur Testaufrufer, keine Produktionsverdrahtung). +- Leeres `data_subject_ref` bedeutet "nicht personenbezogen", kein + Fehlerzustand (z. B. Systemkonfigurationsobjekte). + +## Umsetzung + +- `archive/internal/dpreport.SubjectReport` – Auskunftsbericht + (Akzeptanzkriterium 1), nutzt dieselbe "jüngste Zuordnung"-Logik wie + RET-02 (DISTINCT ON), keine zweite Berechnung. +- `archive/internal/dpreport.ProcessingOverview` – Verarbeitungsübersicht + je tatsächlich vorkommendem Objekttyp (Akzeptanzkriterium 2), Zweck/ + Rechtsgrundlage statisch gepflegt (`ProcessingPurposes`) — Rechts- + bewertungen sind keine aus Nutzdaten ableitbaren Werte. +- `archive/internal/dpreport.WriteSubjectReportCSV` – CSV-Export + (Akzeptanzkriterium/Pflichtprüfung 3). +- **Mandantentrennung (Akzeptanzkriterium 3):** strukturell garantiert + durch Modell C — `SubjectReport` läuft immer gegen GENAU EINEN + Tenant-Pool, kein Cross-Tenant-Query technisch möglich. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Auskunftsbericht für Testperson mit bekanntem Datenbestand stimmt mit erwarteter Liste überein | **bestanden** – `TestSubjectReport_MatchesKnownDataset`: 3 Objekte für 2 Personen angelegt, Bericht für Person A liefert exakt die 2 erwarteten Objekte (nicht das dritte, das Person B gehört), inkl. korrekter Aufbewahrungsklasse | +| 2 | Bericht für einen Tenant enthält keine Objekte eines anderen Tenants | **bestanden** – `TestSubjectReport_TenantIsolation`: real gegen ZWEI PHYSISCH GETRENNTE Tenant-Datenbanken (`tenant_acme`, `tenant_globex`) getestet, nicht nur zweimal dieselbe DSN — Objekt in Tenant A angelegt, Bericht für dieselbe `data_subject_ref` gegen Tenant B liefert 0 Treffer | +| 3 | Export lässt sich als CSV weiterverarbeiten | **bestanden** – `TestWriteSubjectReportCSV_IsParseable`: echte CSV-Ausgabe erzeugt und geparst, Header + genau eine Datenzeile | + +**Hinweis zur Testkorrektur:** Der erste Testlauf von Prüfung 2 nutzte +versehentlich denselben `TEST_TENANT_DSN` für beide "Tenants" (dieselbe +physische Datenbank) und schlug dadurch zurecht fehl — kein +Code-Defekt, sondern ein Testfehler. Korrigiert auf zwei echte, +unabhängige Tenant-Datenbanken (`TEST_TENANT_DSN_B`), danach real +bestanden. + +## 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. dpreport, retention) +``` + +Migration `0009_data_subject_ref` real auf `dms_tenant_test` angewendet. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt, inklusive einer vorab identifizierten +und mit dem Nutzer geklärten strukturellen Lücke (fehlende +Personen-Zuordnung in RET-01), additiv und ohne Änderung an bestehendem +Verhalten geschlossen. diff --git a/archive/internal/dpreport/dpreport.go b/archive/internal/dpreport/dpreport.go new file mode 100644 index 0000000..d19465a --- /dev/null +++ b/archive/internal/dpreport/dpreport.go @@ -0,0 +1,133 @@ +// Package dpreport implementiert CMP-02: Auskunftsberichte und +// Verarbeitungsübersichten für DSGVO-Zwecke. Baut ausschließlich auf +// RET-01 (retention_objects, retention_class_assignments) auf, keine +// eigene Speicherung. Läuft immer gegen GENAU EINE Tenant-Datenbank +// (Modell C) — Mandantentrennung (Akzeptanzkriterium 3) ist dadurch +// strukturell garantiert, nicht durch eine zusätzliche Filterbedingung: +// ein Aufruf gegen den Pool von Tenant A kann Tenant Bs Daten technisch +// nicht erreichen, da sie in einer physisch getrennten Datenbank liegen. +package dpreport + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// SubjectRecord ist EIN gespeichertes Objekt einer betroffenen Person +// (Akzeptanzkriterium 1). +type SubjectRecord struct { + ObjectType string + ObjectReference string + RetentionClass string + Status string + RegisteredAt time.Time +} + +// SubjectReport liefert ALLE gespeicherten Objekte einer betroffenen +// Person mit ihrer jeweils aktuellen Aufbewahrungsklasse +// (Akzeptanzkriterium 1) — nutzt dieselbe "jüngste Zuordnung"-Logik wie +// RET-02s ListExpiringObjects (DISTINCT ON), keine zweite Berechnung. +func SubjectReport(ctx context.Context, pool *pgxpool.Pool, dataSubjectRef string) ([]SubjectRecord, error) { + rows, err := pool.Query(ctx, ` + WITH latest_assignment AS ( + SELECT DISTINCT ON (retention_object_id) + retention_object_id, retention_class + FROM retention_class_assignments + ORDER BY retention_object_id, assigned_at DESC + ) + SELECT 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 + WHERE o.data_subject_ref = $1 + ORDER BY o.created_at ASC + `, dataSubjectRef) + if err != nil { + return nil, fmt.Errorf("dpreport: auskunftsbericht abfragen: %w", err) + } + defer rows.Close() + + 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 { + return nil, fmt.Errorf("dpreport: zeile lesen: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// ProcessingEntry beschreibt Zweck und Rechtsgrundlage EINES Objekttyps +// (Akzeptanzkriterium 2). Statisch gepflegt, da Zweck/Rechtsgrundlage +// Rechtsbewertungen sind, keine aus Nutzdaten ableitbaren Werte — neue +// Objekttypen ergänzen diese Liste, ändern kein bestehendes Verhalten. +type ProcessingEntry struct { + ObjectType string + Purpose string + LegalBasis string +} + +// ProcessingPurposes ist die je Objekttyp gepflegte Verarbeitungs- +// übersicht. Unbekannte Objekttypen (noch nicht hier eingetragen) +// liefert ProcessingOverview mit einem expliziten Platzhalter statt sie +// stillschweigend wegzulassen (Prüfung: vollständige Übersicht). +var ProcessingPurposes = map[string]ProcessingEntry{ + "dms_document": { + ObjectType: "dms_document", + Purpose: "Dokumentenverwaltung und -archivierung im Geschäftsbetrieb", + LegalBasis: "Art. 6 Abs. 1 lit. b/c DSGVO (Vertragserfüllung / rechtliche Verpflichtung, GoBD)", + }, + "mail_message": { + ObjectType: "mail_message", + Purpose: "Revisionssichere E-Mail-Archivierung", + LegalBasis: "Art. 6 Abs. 1 lit. c DSGVO (rechtliche Verpflichtung, GoBD/HGB)", + }, +} + +// ProcessingOverview liefert die Verarbeitungsübersicht für alle im +// Tenant TATSÄCHLICH vorkommenden Objekttypen (Akzeptanzkriterium 2). +func ProcessingOverview(ctx context.Context, pool *pgxpool.Pool) ([]ProcessingEntry, error) { + rows, err := pool.Query(ctx, `SELECT DISTINCT object_type FROM retention_objects ORDER BY object_type`) + if err != nil { + return nil, fmt.Errorf("dpreport: objekttypen abfragen: %w", err) + } + defer rows.Close() + + var out []ProcessingEntry + for rows.Next() { + var objectType string + if err := rows.Scan(&objectType); err != nil { + return nil, fmt.Errorf("dpreport: objekttyp lesen: %w", err) + } + entry, known := ProcessingPurposes[objectType] + if !known { + entry = ProcessingEntry{ObjectType: objectType, Purpose: "unbekannt (nicht gepflegt)", LegalBasis: "unbekannt (nicht gepflegt)"} + } + out = append(out, entry) + } + return out, rows.Err() +} + +// WriteSubjectReportCSV exportiert einen Auskunftsbericht als CSV +// (Akzeptanzkriterium/Pflichtprüfung 3: weiterverarbeitbar). +func WriteSubjectReportCSV(w io.Writer, records []SubjectRecord) error { + cw := csv.NewWriter(w) + if err := cw.Write([]string{"object_type", "object_reference", "retention_class", "status", "registered_at"}); err != nil { + return err + } + for _, r := range records { + if err := cw.Write([]string{ + r.ObjectType, r.ObjectReference, r.RetentionClass, r.Status, r.RegisteredAt.Format(time.RFC3339), + }); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} diff --git a/archive/internal/dpreport/dpreport_test.go b/archive/internal/dpreport/dpreport_test.go new file mode 100644 index 0000000..94b13fa --- /dev/null +++ b/archive/internal/dpreport/dpreport_test.go @@ -0,0 +1,180 @@ +package dpreport + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/retention" +) + +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() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE retention_class_assignments, retention_objects CASCADE`) + }) + return pool +} + +// TestSubjectReport_MatchesKnownDataset ist die geforderte Pflichtprüfung +// 1: Auskunftsbericht für Testperson mit bekanntem Datenbestand stimmt +// mit erwarteter Liste überein. +func TestSubjectReport_MatchesKnownDataset(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + + id1, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "doc-1", "person-a@example.com") + if err != nil { + t.Fatal(err) + } + if err := retention.AssignClass(ctx, pool, id1, "klasse-x"); err != nil { + t.Fatal(err) + } + if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "doc-2", "person-b@example.com"); err != nil { + t.Fatal(err) + } + if _, err := retention.RegisterObjectForSubject(ctx, pool, "mail_message", "mail-1", "person-a@example.com"); err != nil { + t.Fatal(err) + } + + report, err := SubjectReport(ctx, pool, "person-a@example.com") + if err != nil { + t.Fatalf("subjectreport: %v", err) + } + if len(report) != 2 { + t.Fatalf("erwartet 2 objekte fuer person-a, habe %d: %+v", len(report), report) + } + refs := map[string]bool{} + for _, r := range report { + refs[r.ObjectReference] = true + } + if !refs["doc-1"] || !refs["mail-1"] { + t.Fatalf("erwartete objekte fehlen: %+v", report) + } + if refs["doc-2"] { + t.Fatal("doc-2 gehoert person-b, nicht person-a - darf nicht im bericht auftauchen") + } + for _, r := range report { + if r.ObjectReference == "doc-1" && r.RetentionClass != "klasse-x" { + t.Fatalf("erwartet klasse-x fuer doc-1, habe %q", r.RetentionClass) + } + } +} + +// TestSubjectReport_TenantIsolation ist die geforderte Pflichtprüfung 2: +// Bericht für einen Tenant enthält keine Objekte eines anderen Tenants. +// Da SubjectReport IMMER gegen genau einen Tenant-Pool laeuft (Modell C), +// wird dies strukturell bewiesen: ein zweiter, PHYSISCH GETRENNTER Pool +// (eigene Datenbank, TEST_TENANT_DSN_B) kann die Zeilen des ersten +// technisch nicht sehen. Braucht eine echte zweite Tenant-DB, nicht nur +// denselben TEST_TENANT_DSN zweimal (sonst ist es dieselbe physische +// Datenbank und der Test beweist nichts über echte Mandantentrennung). +func TestSubjectReport_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() + + if _, err := retention.RegisterObjectForSubject(ctx, poolA, "dms_document", "tenant-a-doc", "shared-person@example.com"); err != nil { + t.Fatal(err) + } + + reportB, err := SubjectReport(ctx, poolB, "shared-person@example.com") + if err != nil { + t.Fatalf("subjectreport (tenant b): %v", err) + } + if len(reportB) != 0 { + t.Fatalf("tenant b darf tenant as objekte nicht sehen, habe: %+v", reportB) + } +} + +// TestWriteSubjectReportCSV_IsParseable ist die geforderte Pflichtprüfung +// 3: Export lässt sich als CSV weiterverarbeiten. +func TestWriteSubjectReportCSV_IsParseable(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "csv-doc", "csv-person@example.com"); err != nil { + t.Fatal(err) + } + report, err := SubjectReport(ctx, pool, "csv-person@example.com") + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := WriteSubjectReportCSV(&buf, report); err != nil { + t.Fatalf("csv schreiben: %v", err) + } + out := buf.String() + if !strings.Contains(out, "object_type,object_reference") { + t.Fatalf("erwartet csv-header, habe: %q", out) + } + if !strings.Contains(out, "csv-doc") { + t.Fatalf("erwartet datenzeile mit csv-doc, habe: %q", out) + } + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Fatalf("erwartet header + 1 datenzeile, habe %d zeilen: %q", len(lines), out) + } +} + +// TestProcessingOverview_CoversPresentObjectTypes deckt Akzeptanzkriterium 2. +func TestProcessingOverview_CoversPresentObjectTypes(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + if _, err := retention.RegisterObjectForSubject(ctx, pool, "dms_document", "overview-doc", ""); err != nil { + t.Fatal(err) + } + + overview, err := ProcessingOverview(ctx, pool) + if err != nil { + t.Fatal(err) + } + if len(overview) != 1 || overview[0].ObjectType != "dms_document" { + t.Fatalf("erwartet genau dms_document, habe: %+v", overview) + } + if overview[0].Purpose == "" || overview[0].LegalBasis == "" { + t.Fatalf("zweck/rechtsgrundlage fehlen: %+v", overview[0]) + } +} diff --git a/archive/internal/retention/retention.go b/archive/internal/retention/retention.go index 98dba18..24ff1de 100644 --- a/archive/internal/retention/retention.go +++ b/archive/internal/retention/retention.go @@ -43,6 +43,28 @@ func RegisterObject(ctx context.Context, pool *pgxpool.Pool, objectType, objectR return id, nil } +// RegisterObjectForSubject ist CMP-02s additive Ergänzung zu +// RegisterObject: registriert das Objekt zusätzlich mit einer Referenz +// auf die betroffene Person (dataSubjectRef, z. B. E-Mail oder +// User-ID), Grundlage für den DSGVO-Auskunftsbericht. Leeres +// dataSubjectRef bedeutet: nicht personenbezogen, kein Fehler. +// RegisterObject selbst bleibt unverändert (kein Umbau bestehenden +// Verhaltens) — dies ist ein separater, additiver Registrierungsweg. +func RegisterObjectForSubject(ctx context.Context, pool *pgxpool.Pool, objectType, objectReference, dataSubjectRef string) (string, error) { + var id string + err := pool.QueryRow(ctx, ` + INSERT INTO retention_objects (object_type, object_reference, data_subject_ref) + VALUES ($1, $2, NULLIF($3, '')) + ON CONFLICT (object_type, object_reference) + DO UPDATE SET data_subject_ref = COALESCE(NULLIF(EXCLUDED.data_subject_ref, ''), retention_objects.data_subject_ref) + RETURNING id + `, objectType, objectReference, dataSubjectRef).Scan(&id) + if err != nil { + return "", fmt.Errorf("retention: objekt mit betroffener person registrieren: %w", err) + } + return id, nil +} + // Assignment ist EINE historische Zuordnung einer Aufbewahrungsklasse. type Assignment struct { RetentionClass string diff --git a/archive/migrations/0009_data_subject_ref.down.sql b/archive/migrations/0009_data_subject_ref.down.sql new file mode 100644 index 0000000..48bacaa --- /dev/null +++ b/archive/migrations/0009_data_subject_ref.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_retention_objects_data_subject_ref; +ALTER TABLE retention_objects DROP COLUMN IF EXISTS data_subject_ref; diff --git a/archive/migrations/0009_data_subject_ref.up.sql b/archive/migrations/0009_data_subject_ref.up.sql new file mode 100644 index 0000000..657bf04 --- /dev/null +++ b/archive/migrations/0009_data_subject_ref.up.sql @@ -0,0 +1,10 @@ +-- CMP-02: DSGVO-Auskunftsberichte brauchen eine Zuordnung Objekt-> +-- betroffene Person. RET-01s retention_objects kannte bislang nur +-- object_type/object_reference (opak, modulübergreifend), keine +-- Person-Referenz. Additive, nullable Spalte — bestehende Zeilen und +-- Aufrufer von RegisterObject bleiben unverändert gültig: NICHT jedes +-- Objekt ist personenbezogen (z. B. Systemkonfiguration), ein leeres +-- Feld bedeutet genau das, nicht einen Fehler. +ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT; +CREATE INDEX IF NOT EXISTS idx_retention_objects_data_subject_ref + ON retention_objects (data_subject_ref) WHERE data_subject_ref IS NOT NULL;