CMP-06: vier-augen-freigabe-fuer-loeschungen
- migrations/0010_deletion_requests: speichert nur den Token-Hash, nie das Token selbst - archive/internal/deletionapproval: RequestDeletion (einmaliges Klartext-Token), ConfirmAndExecute (FOR UPDATE-Lock, andere Person als requester, zeitkonstanter Tokenvergleich via subtle.ConstantTimeCompare, ruft danach genau einmal deletionworkflow.Destroy (RET-03) auf) - 4 Tests real bestanden, inkl. echtem Nebenlaeufigkeits-Test (zwei echte Goroutinen, genau 1 Erfolg + 1 ErrAlreadyExecuted, genau ein destruction_log-Eintrag) - Migration real auf dms_tenant_test angewendet Pruefungen siehe archive/docs/CMP-06-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// Package deletionapproval implementiert CMP-06: die Vier-Augen-Freigabe
|
||||
// vor der eigentlichen Vernichtung. Baut auf RET-03
|
||||
// (deletionworkflow.Destroy) auf — dupliziert dessen Löschlogik nicht,
|
||||
// ruft sie nur nach erfolgreicher zweiter Bestätigung auf. Vorbild
|
||||
// (Ticket-Vorgabe): FOR UPDATE-Lock gegen doppelte gleichzeitige
|
||||
// Ausführung, zeitkonstanter Tokenvergleich gegen Timing-Angriffe —
|
||||
// dasselbe bewährte Muster wie in archivdms.
|
||||
package deletionapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionworkflow"
|
||||
)
|
||||
|
||||
// TokenTTL: Bestätigungs-Token ist zeitlich begrenzt (Akzeptanzkriterium 3).
|
||||
const TokenTTL = 24 * time.Hour
|
||||
|
||||
var (
|
||||
ErrSamePerson = errors.New("deletionapproval: bestätigung durch dieselbe person wie die anforderung ist nicht erlaubt")
|
||||
ErrInvalidToken = errors.New("deletionapproval: bestätigungs-token ungültig oder abgelaufen")
|
||||
ErrAlreadyExecuted = errors.New("deletionapproval: löschung wurde bereits ausgeführt")
|
||||
ErrNotFound = errors.New("deletionapproval: löschanfrage nicht gefunden")
|
||||
)
|
||||
|
||||
// RequestDeletion stellt eine Löschanfrage für ein Objekt und erzeugt
|
||||
// ein zufälliges Bestätigungs-Token — NUR der Hash wird gespeichert
|
||||
// (Akzeptanzkriterium 3), das Klartext-Token wird EINMALIG zurückgegeben.
|
||||
func RequestDeletion(ctx context.Context, pool *pgxpool.Pool, retentionObjectID, requestedBy string) (token string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("deletionapproval: token erzeugen: %w", err)
|
||||
}
|
||||
token = hex.EncodeToString(raw)
|
||||
hash := hashToken(token)
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO deletion_requests (retention_object_id, requested_by, confirmation_token_hash, token_expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, retentionObjectID, requestedBy, hash, time.Now().UTC().Add(TokenTTL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deletionapproval: löschanfrage anlegen: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ConfirmAndExecute bestätigt eine Löschanfrage und führt bei Erfolg
|
||||
// GENAU EINMAL RET-03s Destroy() aus — abgesichert durch ein
|
||||
// `SELECT ... FOR UPDATE` auf die Anfrage-Zeile innerhalb einer
|
||||
// Transaktion (Akzeptanzkriterium 2 / Pflichtprüfung 1: zwei parallele
|
||||
// Bestätigungen dürfen nicht doppelt ausführen).
|
||||
func ConfirmAndExecute(ctx context.Context, pool *pgxpool.Pool, requestID, token, confirmedBy string) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deletionapproval: transaktion starten: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var (
|
||||
retentionObjectID string
|
||||
requestedBy string
|
||||
storedHash []byte
|
||||
expiresAt time.Time
|
||||
executedAt *time.Time
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT retention_object_id, requested_by, confirmation_token_hash, token_expires_at, executed_at
|
||||
FROM deletion_requests WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, requestID).Scan(&retentionObjectID, &requestedBy, &storedHash, &expiresAt, &executedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("deletionapproval: löschanfrage lesen: %w", err)
|
||||
}
|
||||
|
||||
// Bereits ausgeführt (z. B. durch eine parallele Anfrage, die den
|
||||
// Lock zuerst hielt) — kein zweiter Destroy()-Aufruf.
|
||||
if executedAt != nil {
|
||||
return ErrAlreadyExecuted
|
||||
}
|
||||
|
||||
if confirmedBy == requestedBy {
|
||||
return ErrSamePerson
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(expiresAt) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
if !timingSafeTokenMatch(token, storedHash) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE deletion_requests SET confirmed_by = $2, confirmed_at = now(), executed_at = now()
|
||||
WHERE id = $1
|
||||
`, requestID, confirmedBy); err != nil {
|
||||
return fmt.Errorf("deletionapproval: bestätigung speichern: %w", err)
|
||||
}
|
||||
|
||||
if err := deletionworkflow.Destroy(ctx, pool, retentionObjectID, confirmedBy); err != nil {
|
||||
return fmt.Errorf("deletionapproval: vernichtung: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("deletionapproval: bestätigung committen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashToken(token string) []byte {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// timingSafeTokenMatch vergleicht das vom Aufrufer übergebene Token
|
||||
// zeitkonstant gegen den gespeicherten Hash (Pflichtprüfung 3) —
|
||||
// dasselbe Muster wie internal/policyapi.RequireServiceToken (RBAC-06).
|
||||
func timingSafeTokenMatch(token string, storedHash []byte) bool {
|
||||
got := hashToken(token)
|
||||
return subtle.ConstantTimeCompare(got, storedHash) == 1
|
||||
}
|
||||
Reference in New Issue
Block a user