CMP-07: dsgvo-loeschantrag-recht-auf-loeschung
- dpreport.SubjectRecord additiv um RetentionObjectID erweitert (CMP-02, bestehendes Verhalten unveraendert) - migrations/0011_dsgvo_decision_log: vollstaendiges Protokoll jeder Einzelentscheidung - archive/internal/dsgvorequest.ProcessDeletionRequest: ruft ausschliesslich CMP-02 (Suche), RET-03 (Sperrpruefung), CMP-06 (Freigabe) auf - keine zweite Aufbewahrungs-/Freigabelogik (vermeidet den im Ticket dokumentierten archivmail-Fehler) - 3 Tests real bestanden: gemischter Datenbestand (1 Loeschung + 1 Ablehnung, Loeschung vollstaendig bis zur tatsaechlichen Vernichtung durchgefuehrt), Legal Hold blockiert trotz abgelaufener Frist, Mandantentrennung real ueber zwei physisch getrennte Tenant-DBs - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-07-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// Package dsgvorequest implementiert CMP-07: den Workflow für einen
|
||||
// DSGVO-Löschantrag (Art. 17). Ruft AUSSCHLIESSLICH bereits fertige
|
||||
// Bausteine auf — CMP-02 (Suche), RET-03/deletionworkflow
|
||||
// (Aufbewahrungsprüfung, Legal Hold), CMP-06/deletionapproval
|
||||
// (Vier-Augen-Freigabe) — und baut KEINE zweite Prüf-/Freigabelogik
|
||||
// (Ticket-Vorgabe "Bekannte Fehler vermeiden": genau das hatte das
|
||||
// Vorgängerprojekt archivmail falsch gemacht).
|
||||
package dsgvorequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionapproval"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionworkflow"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/dpreport"
|
||||
)
|
||||
|
||||
const (
|
||||
OutcomeDeletionRequested = "deletion_requested"
|
||||
OutcomeRejected = "rejected"
|
||||
OutcomeAlreadyDeleted = "already_deleted"
|
||||
)
|
||||
|
||||
// Decision ist das Ergebnis EINER Einzelentscheidung (Akzeptanzkriterium
|
||||
// 2: niemals eine pauschale Sammelentscheidung).
|
||||
type Decision struct {
|
||||
RetentionObjectID string
|
||||
ObjectType string
|
||||
ObjectReference string
|
||||
Outcome string
|
||||
Reason string
|
||||
// DeletionRequestToken ist NUR bei Outcome==OutcomeDeletionRequested
|
||||
// gesetzt — das einmalige Klartext-Token für die zweite Bestätigung
|
||||
// (CMP-06). Wird NICHT protokolliert (siehe CMP-06: nur der Hash
|
||||
// landet in der DB).
|
||||
DeletionRequestToken string
|
||||
}
|
||||
|
||||
// ProcessDeletionRequest ist der DSGVO-Löschantrag-Workflow
|
||||
// (Akzeptanzkriterium 1). Findet über CMP-02 alle Objekte der
|
||||
// betroffenen Person (mandantenscharf durch den übergebenen
|
||||
// Tenant-Pool, Akzeptanzkriterium/Pflichtprüfung 3), entscheidet JEDES
|
||||
// Objekt EINZELN: aktive Aufbewahrungspflicht (Frist nicht abgelaufen
|
||||
// ODER Legal Hold) → Ablehnung mit Begründung; sonst → Löschung wird
|
||||
// über CMP-06 angestoßen (nicht sofort ausgeführt — die zweite,
|
||||
// unabhängige Bestätigung bleibt erforderlich). Jede Entscheidung wird
|
||||
// dauerhaft protokolliert (Akzeptanzkriterium 4).
|
||||
func ProcessDeletionRequest(ctx context.Context, pool *pgxpool.Pool, dataSubjectRef, requestedBy string) ([]Decision, error) {
|
||||
records, err := dpreport.SubjectReport(ctx, pool, dataSubjectRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dsgvorequest: objekte der betroffenen person ermitteln: %w", err)
|
||||
}
|
||||
|
||||
decisions := make([]Decision, 0, len(records))
|
||||
for _, rec := range records {
|
||||
d := Decision{RetentionObjectID: rec.RetentionObjectID, ObjectType: rec.ObjectType, ObjectReference: rec.ObjectReference}
|
||||
|
||||
if rec.Status == "deleted" {
|
||||
d.Outcome = OutcomeAlreadyDeleted
|
||||
d.Reason = "Objekt wurde bereits zuvor vernichtet"
|
||||
} else {
|
||||
onHold, err := deletionworkflow.IsOnLegalHold(ctx, pool, rec.RetentionObjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dsgvorequest: sperrstatus prüfen (%s): %w", rec.ObjectReference, err)
|
||||
}
|
||||
switch {
|
||||
case onHold:
|
||||
d.Outcome = OutcomeRejected
|
||||
d.Reason = "Aufbewahrungssperre (Legal Hold) aktiv"
|
||||
case rec.Status == "active":
|
||||
d.Outcome = OutcomeRejected
|
||||
d.Reason = "Aufbewahrungsfrist ist noch nicht abgelaufen"
|
||||
default: // "expired", keine Sperre: keine aktive Aufbewahrungspflicht
|
||||
token, err := deletionapproval.RequestDeletion(ctx, pool, rec.RetentionObjectID, requestedBy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dsgvorequest: löschung anstoßen (%s): %w", rec.ObjectReference, err)
|
||||
}
|
||||
d.Outcome = OutcomeDeletionRequested
|
||||
d.Reason = "keine aktive Aufbewahrungspflicht, Löschung über Vier-Augen-Freigabe angestoßen"
|
||||
d.DeletionRequestToken = token
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO dsgvo_decision_log (data_subject_ref, retention_object_id, object_type, object_reference, outcome, reason, decided_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, dataSubjectRef, d.RetentionObjectID, d.ObjectType, d.ObjectReference, d.Outcome, d.Reason, requestedBy); err != nil {
|
||||
return nil, fmt.Errorf("dsgvorequest: entscheidung protokollieren (%s): %w", rec.ObjectReference, err)
|
||||
}
|
||||
|
||||
decisions = append(decisions, d)
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
Reference in New Issue
Block a user