// Package deletionworkflow implementiert RET-03: den kontrollierten // Löschworkflow für abgelaufene Aufbewahrungsobjekte (Freigabe → // Vernichtung) und die Aufbewahrungssperre (Legal Hold), die jede // Löschung unabhängig vom Fristablauf verhindert. Baut auf RET-01 // (retention_objects.status) und RET-02 (Fristenberechnung) auf, keine // eigene Fristenlogik. package deletionworkflow import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ErrReasonRequired wird geliefert, wenn beim Setzen einer Sperre keine // Begründung angegeben wurde (Akzeptanzkriterium 2: Begründungspflicht). var ErrReasonRequired = errors.New("deletionworkflow: begründung ist beim setzen einer aufbewahrungssperre pflicht") // ErrAlreadyOnHold wird geliefert, wenn für das Objekt bereits eine // aktive Sperre existiert (Partial-Unique-Index erzwingt das auch auf // DB-Ebene). var ErrAlreadyOnHold = errors.New("deletionworkflow: objekt steht bereits unter aufbewahrungssperre") // ErrOnLegalHold wird von Destroy geliefert, wenn eine aktive Sperre die // Vernichtung verhindert (Akzeptanzkriterium 2: Sperre überstimmt jede // Fristregel). var ErrOnLegalHold = errors.New("deletionworkflow: objekt steht unter aufbewahrungssperre, vernichtung nicht erlaubt") // ErrNotReleased wird geliefert, wenn Destroy für ein Objekt aufgerufen // wird, das noch nicht über ReleaseExpired freigegeben wurde // (Akzeptanzkriterium 1: kein Sprung von "active" direkt zu "deleted"). var ErrNotReleased = errors.New("deletionworkflow: objekt ist nicht zur vernichtung freigegeben (status != expired)") // SetLegalHold setzt eine Aufbewahrungssperre für ein Objekt. reason ist // PFLICHT (Akzeptanzkriterium 2). Existiert bereits eine aktive Sperre, // wird ErrAlreadyOnHold geliefert (der Partial-Unique-Index // idx_legal_holds_active verhindert eine zweite aktive Zeile auch bei // gleichzeitigen Aufrufen). func SetLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, reason, setBy string) error { if reason == "" { return ErrReasonRequired } _, err := pool.Exec(ctx, ` INSERT INTO legal_holds (retention_object_id, reason, set_by) VALUES ($1, $2, $3) `, retentionObjectID, reason, setBy) if err != nil { var pgErr interface{ SQLState() string } if errors.As(err, &pgErr) && pgErr.SQLState() == "23505" { return ErrAlreadyOnHold } return fmt.Errorf("deletionworkflow: sperre setzen: %w", err) } return nil } // ReleaseLegalHold hebt die aktive Sperre eines Objekts auf. Die // ursprüngliche Zeile bleibt bestehen (released_at/released_by werden // gesetzt, kein DELETE) — das Aufheben ist dadurch selbst dauerhaft // protokolliert (Akzeptanzkriterium/Pflichtprüfung 3). func ReleaseLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, releasedBy string) error { tag, err := pool.Exec(ctx, ` UPDATE legal_holds SET released_at = now(), released_by = $2 WHERE retention_object_id = $1 AND released_at IS NULL `, retentionObjectID, releasedBy) if err != nil { return fmt.Errorf("deletionworkflow: sperre aufheben: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("deletionworkflow: keine aktive sperre für objekt %q gefunden", retentionObjectID) } return nil } // IsOnLegalHold prüft, ob ein Objekt aktuell unter Sperre steht. func IsOnLegalHold(ctx context.Context, pool *pgxpool.Pool, retentionObjectID string) (bool, error) { var exists bool err := pool.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM legal_holds WHERE retention_object_id = $1 AND released_at IS NULL) `, retentionObjectID).Scan(&exists) if err != nil { return false, fmt.Errorf("deletionworkflow: sperrstatus prüfen: %w", err) } return exists, nil } // ReleaseExpired ist der Freigabeprozess (Akzeptanzkriterium 1): setzt // den Status abgelaufener Objekte von "active" auf "expired" — KEINE // automatische Sofortlöschung. Objekte unter aktiver Aufbewahrungssperre // werden übersprungen, unabhängig vom Fristablauf (Akzeptanzkriterium // 2). Liefert die IDs der freigegebenen Objekte. func ReleaseExpired(ctx context.Context, pool *pgxpool.Pool, asOf time.Time) ([]string, error) { rows, err := pool.Query(ctx, ` WITH latest_assignment AS ( SELECT DISTINCT ON (retention_object_id) retention_object_id, retention_class, assigned_at FROM retention_class_assignments ORDER BY retention_object_id, assigned_at DESC ), due AS ( SELECT o.id FROM retention_objects o JOIN latest_assignment a ON a.retention_object_id = o.id JOIN retention_class_rules r ON r.retention_class = a.retention_class AND r.active WHERE o.status = 'active' AND (a.assigned_at + r.duration) <= $1 AND NOT EXISTS ( SELECT 1 FROM legal_holds h WHERE h.retention_object_id = o.id AND h.released_at IS NULL ) ) UPDATE retention_objects SET status = 'expired' WHERE id IN (SELECT id FROM due) RETURNING id `, asOf) if err != nil { return nil, fmt.Errorf("deletionworkflow: freigabeprozess: %w", err) } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("deletionworkflow: freigegebene id lesen: %w", err) } ids = append(ids, id) } return ids, rows.Err() } // Destroy vernichtet EIN Objekt, das zuvor über ReleaseExpired freigegeben // wurde (status "expired") — kein direkter Sprung von "active". // Verweigert die Vernichtung, wenn ZWISCHENZEITLICH eine Sperre gesetzt // wurde (Verteidigung in der Tiefe, zusätzlich zu ReleaseExpireds eigenem // Sperr-Ausschluss). Erzeugt einen unveränderlichen Protokolleintrag // (destruction_log, per DB-Trigger gegen UPDATE/DELETE geschützt). func Destroy(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, destroyedBy string) error { onHold, err := IsOnLegalHold(ctx, pool, retentionObjectID) if err != nil { return err } if onHold { return ErrOnLegalHold } tx, err := pool.Begin(ctx) if err != nil { return fmt.Errorf("deletionworkflow: transaktion starten: %w", err) } defer func() { _ = tx.Rollback(ctx) }() var objectType, objectReference string err = tx.QueryRow(ctx, ` UPDATE retention_objects SET status = 'deleted' WHERE id = $1 AND status = 'expired' RETURNING object_type, object_reference `, retentionObjectID).Scan(&objectType, &objectReference) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotReleased } return fmt.Errorf("deletionworkflow: objekt als vernichtet markieren: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO destruction_log (retention_object_id, object_type, object_reference, destroyed_by) VALUES ($1, $2, $3, $4) `, retentionObjectID, objectType, objectReference, destroyedBy); err != nil { return fmt.Errorf("deletionworkflow: protokolleintrag erzeugen: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("deletionworkflow: vernichtung committen: %w", err) } return nil }