- 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
134 lines
4.8 KiB
Go
134 lines
4.8 KiB
Go
// 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()
|
|
}
|