- 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
98 lines
4.0 KiB
Go
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
|
|
}
|