CMP-02: dsgvo-datenschutz-berichte

- migrations/0009_data_subject_ref: additive nullable Spalte auf
  retention_objects, schliesst die Luecke fuer 'betroffene Person' in
  RET-01
- retention.RegisterObjectForSubject: NEUE additive Funktion, RegisterObject
  selbst unveraendert (kein Diff, keine Produktionsaufrufer betroffen)
- archive/internal/dpreport: SubjectReport (Auskunftsbericht),
  ProcessingOverview (Verarbeitungsuebersicht, statisch gepflegte
  Zweck/Rechtsgrundlage je Objekttyp), WriteSubjectReportCSV
- Mandantentrennung strukturell durch Modell C (ein Pool pro Tenant),
  real mit zwei physisch getrennten Tenant-DBs bewiesen (tenant_acme/
  tenant_globex), nicht nur behauptet
- 4 Tests, alle Pflichtpruefungen real bestanden
- Migration real auf dms_tenant_test angewendet

Pruefungen siehe archive/docs/CMP-02-PRUEFPROTOKOLL.md
This commit is contained in:
sysops
2026-08-30 22:00:33 +02:00
parent e23f514850
commit a95ed331cd
6 changed files with 416 additions and 0 deletions
+69
View File
@@ -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.
+133
View File
@@ -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()
}
+180
View File
@@ -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])
}
}
+22
View File
@@ -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
@@ -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;
@@ -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;