Files
nexarch/archive/internal/dsgvorequest/dsgvorequest.go
T
sysops af892e1536 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
2026-08-30 22:16:53 +02:00

98 lines
4.0 KiB
Go

// 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
}