// 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 { // RetentionObjectID ist CMP-07s additive Ergänzung (RET-01-interne // ID) — nötig, um für ein gefundenes Objekt den Löschworkflow // (RET-03/CMP-06) anzustoßen. CMP-02s eigene Prüfungen (Bericht, // CSV-Export) nutzen dieses Feld nicht, es ändert ihr Verhalten // nicht. RetentionObjectID string 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.id, 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.RetentionObjectID, &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() }