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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user