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