- 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
134 lines
4.6 KiB
Go
134 lines
4.6 KiB
Go
// 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
|
|
}
|