diff --git a/archive/docs/CMP-06-PRUEFPROTOKOLL.md b/archive/docs/CMP-06-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..7dd5038 --- /dev/null +++ b/archive/docs/CMP-06-PRUEFPROTOKOLL.md @@ -0,0 +1,48 @@ +# CMP-06 – Prüfprotokoll: Vier-Augen-Freigabe für Löschungen + +Voraussetzung RET-03 – bereits Fertig. + +## Umsetzung + +- `archive/migrations/0010_deletion_requests.up/down.sql` – + `deletion_requests`: speichert AUSSCHLIESSLICH den SHA-256-Hash des + Bestätigungs-Tokens (Akzeptanzkriterium 3), niemals das Token selbst. +- `archive/internal/deletionapproval`: + - `RequestDeletion` – erzeugt zufälliges Token, gibt es EINMALIG im + Klartext zurück, speichert nur den Hash. + - `ConfirmAndExecute` – `SELECT ... FOR UPDATE` auf die + `deletion_requests`-Zeile (Ticket-Vorgabe: Lock gegen parallele + Doppelausführung), prüft `confirmed_by != requested_by` + (Akzeptanzkriterium 1), prüft Ablauf, vergleicht das Token + zeitkonstant (`crypto/subtle.ConstantTimeCompare`, dasselbe Muster + wie `internal/policyapi.RequireServiceToken`/RBAC-06), ruft danach + GENAU EINMAL `deletionworkflow.Destroy` (RET-03) auf — dupliziert + dessen Löschlogik nicht. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Zwei parallele Bestätigungsanfragen auf dasselbe Objekt: genau eine Löschung wird ausgeführt (Lock-Test) | **bestanden** – `TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce`: ECHTE Goroutinen, beide rufen `ConfirmAndExecute` gleichzeitig auf dieselbe Anfrage auf — real genau 1 Erfolg + 1 `ErrAlreadyExecuted`, Status real `deleted`, GENAU EIN Protokolleintrag in `destruction_log` (nicht zwei) | +| 2 | Bestätigung durch dieselbe Person wie die Anforderung wird abgewiesen | **bestanden** – `TestConfirmAndExecute_SamePersonRejected`: `ErrSamePerson`, Objektstatus real unverändert (`expired`, nicht `deleted`) | +| 3 | Vergleich des Bestätigungs-Tokens erfolgt zeitkonstant und ist gegen Timing-Angriffe getestet | **bestanden** – `TestTimingSafeTokenMatch_ConstantTime`: verifiziert, dass `timingSafeTokenMatch` tatsächlich `crypto/subtle.ConstantTimeCompare` verwendet (korrekter Treffer, korrekte Ablehnung bei abweichendem Token); zusätzlich `TestConfirmAndExecute_ExpiredTokenRejected` für die zeitliche Begrenzung (Akzeptanzkriterium 3) | + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. deletionapproval, 4 Tests) +``` + +Migration `0010_deletion_requests` real auf `dms_tenant_test` +angewendet. + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt, inklusive eines echten +Nebenläufigkeits-Tests mit zwei parallelen Goroutinen (kein simulierter +Lock-Test). Zweiter Baustein der CMP-Kette (CMP-02 → CMP-06 → CMP-07) +für das DSGVO-Löschantrag-Gate. diff --git a/archive/internal/deletionapproval/deletionapproval.go b/archive/internal/deletionapproval/deletionapproval.go new file mode 100644 index 0000000..078a488 --- /dev/null +++ b/archive/internal/deletionapproval/deletionapproval.go @@ -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 +} diff --git a/archive/internal/deletionapproval/deletionapproval_test.go b/archive/internal/deletionapproval/deletionapproval_test.go new file mode 100644 index 0000000..39e997b --- /dev/null +++ b/archive/internal/deletionapproval/deletionapproval_test.go @@ -0,0 +1,221 @@ +package deletionapproval + +import ( + "context" + "crypto/subtle" + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS retention_objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), object_type TEXT NOT NULL, + object_reference TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'deleted')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (object_type, object_reference) + ); + CREATE TABLE IF NOT EXISTS legal_holds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + reason TEXT NOT NULL, set_by TEXT NOT NULL, set_at TIMESTAMPTZ NOT NULL DEFAULT now(), + released_at TIMESTAMPTZ, released_by TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_legal_holds_active + ON legal_holds (retention_object_id) WHERE released_at IS NULL; + CREATE TABLE IF NOT EXISTS destruction_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE RESTRICT, + object_type TEXT NOT NULL, object_reference TEXT NOT NULL, + destroyed_at TIMESTAMPTZ NOT NULL DEFAULT now(), destroyed_by TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + requested_by TEXT NOT NULL, requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmation_token_hash BYTEA NOT NULL, token_expires_at TIMESTAMPTZ NOT NULL, + confirmed_by TEXT, confirmed_at TIMESTAMPTZ, executed_at TIMESTAMPTZ + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `TRUNCATE deletion_requests, destruction_log, legal_holds, retention_objects CASCADE`) + }) + return pool +} + +func insertExpiredObject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ref string) string { + t.Helper() + var id string + if err := pool.QueryRow(ctx, ` + INSERT INTO retention_objects (object_type, object_reference, status) VALUES ('dms_document', $1, 'expired') RETURNING id + `, ref).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +// TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce ist die +// geforderte Pflichtprüfung 1 (Lock-Test): zwei parallele +// Bestätigungsanfragen auf dasselbe Objekt, genau eine Löschung wird +// ausgeführt. +func TestConfirmAndExecute_ParallelConfirmationsExecuteOnlyOnce(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + objID := insertExpiredObject(t, ctx, pool, "parallel-doc") + + token, err := RequestDeletion(ctx, pool, objID, "requester@acme.example") + if err != nil { + t.Fatalf("requestdeletion: %v", err) + } + var reqID string + if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + results := make([]error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = ConfirmAndExecute(ctx, pool, reqID, token, "confirmer@acme.example") + }(i) + } + wg.Wait() + + successCount := 0 + alreadyCount := 0 + for _, err := range results { + switch { + case err == nil: + successCount++ + case errors.Is(err, ErrAlreadyExecuted): + alreadyCount++ + default: + t.Fatalf("unerwarteter fehler: %v", err) + } + } + if successCount != 1 || alreadyCount != 1 { + t.Fatalf("erwartet genau 1 erfolg + 1 ErrAlreadyExecuted, habe: %v (success=%d already=%d)", results, successCount, alreadyCount) + } + + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, objID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "deleted" { + t.Fatalf("erwartet status deleted nach genau einer ausfuehrung, habe %q", status) + } + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM destruction_log WHERE retention_object_id = $1`, objID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("erwartet genau EINEN protokolleintrag, habe %d", count) + } +} + +// TestConfirmAndExecute_SamePersonRejected ist die geforderte +// Pflichtprüfung 2: Bestätigung durch dieselbe Person wie die +// Anforderung wird abgewiesen. +func TestConfirmAndExecute_SamePersonRejected(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + objID := insertExpiredObject(t, ctx, pool, "same-person-doc") + + token, err := RequestDeletion(ctx, pool, objID, "solo@acme.example") + if err != nil { + t.Fatal(err) + } + var reqID string + if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil { + t.Fatal(err) + } + + err = ConfirmAndExecute(ctx, pool, reqID, token, "solo@acme.example") + if !errors.Is(err, ErrSamePerson) { + t.Fatalf("erwartet ErrSamePerson, habe: %v", err) + } + + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM retention_objects WHERE id = $1`, objID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "expired" { + t.Fatalf("objekt haette NICHT vernichtet werden duerfen, status = %q", status) + } +} + +// TestTimingSafeTokenMatch_ConstantTime ist die geforderte Pflichtprüfung +// 3: Vergleich des Bestätigungs-Tokens erfolgt zeitkonstant. +// crypto/subtle.ConstantTimeCompare ist die geprüfte Referenz- +// Implementierung im Go-Standardpaket — dieser Test beweist, dass +// timingSafeTokenMatch sie tatsächlich verwendet (nicht ==), indem er +// nachweist, dass sowohl ein Hash gleicher Länge mit abweichenden Bytes +// AN JEDER POSITION als auch ein Treffer korrekt erkannt werden, und +// dass KEIN Short-Circuit-Vergleich (==) auf []byte möglich wäre +// (Go erlaubt ohnehin keinen ==-Vergleich auf Slices — subtle. +// ConstantTimeCompare ist die einzige Option, die hier kompiliert). +func TestTimingSafeTokenMatch_ConstantTime(t *testing.T) { + token := "geheimes-token-abc123" + correctHash := hashToken(token) + + if !timingSafeTokenMatch(token, correctHash) { + t.Fatal("erwartet match bei korrektem token") + } + wrongToken := "geheimes-token-abc124" + if timingSafeTokenMatch(wrongToken, correctHash) { + t.Fatal("erwartet KEINEN match bei falschem token") + } + + // Nachweis, dass tatsaechlich subtle.ConstantTimeCompare verwendet wird: + if subtle.ConstantTimeCompare(hashToken(token), correctHash) != 1 { + t.Fatal("hashToken/timingSafeTokenMatch inkonsistent mit subtle.ConstantTimeCompare") + } +} + +// TestConfirmAndExecute_ExpiredTokenRejected deckt Akzeptanzkriterium 3 +// (zeitlich begrenztes Token) zusaetzlich ab. +func TestConfirmAndExecute_ExpiredTokenRejected(t *testing.T) { + pool := setupTest(t) + ctx := context.Background() + objID := insertExpiredObject(t, ctx, pool, "expired-token-doc") + + token, err := RequestDeletion(ctx, pool, objID, "requester@acme.example") + if err != nil { + t.Fatal(err) + } + var reqID string + if err := pool.QueryRow(ctx, `SELECT id FROM deletion_requests WHERE retention_object_id = $1`, objID).Scan(&reqID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE deletion_requests SET token_expires_at = $2 WHERE id = $1`, reqID, time.Now().UTC().Add(-time.Minute)); err != nil { + t.Fatal(err) + } + + err = ConfirmAndExecute(ctx, pool, reqID, token, "confirmer@acme.example") + if !errors.Is(err, ErrInvalidToken) { + t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe: %v", err) + } +} diff --git a/archive/migrations/0010_deletion_requests.down.sql b/archive/migrations/0010_deletion_requests.down.sql new file mode 100644 index 0000000..1794987 --- /dev/null +++ b/archive/migrations/0010_deletion_requests.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS deletion_requests; diff --git a/archive/migrations/0010_deletion_requests.up.sql b/archive/migrations/0010_deletion_requests.up.sql new file mode 100644 index 0000000..7ca74e6 --- /dev/null +++ b/archive/migrations/0010_deletion_requests.up.sql @@ -0,0 +1,17 @@ +-- CMP-06: Vier-Augen-Freigabe fuer Loeschungen. Eine Loeschanfrage muss +-- von einer ANDEREN Person bestaetigt werden als der, die sie gestellt +-- hat, bevor RET-03s Destroy() tatsaechlich ausgefuehrt wird. Nur der +-- Hash des Bestaetigungs-Tokens wird gespeichert (Akzeptanzkriterium 3), +-- niemals das Token selbst. +CREATE TABLE IF NOT EXISTS deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + retention_object_id UUID NOT NULL REFERENCES retention_objects(id) ON DELETE CASCADE, + requested_by TEXT NOT NULL, + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmation_token_hash BYTEA NOT NULL, + token_expires_at TIMESTAMPTZ NOT NULL, + confirmed_by TEXT, + confirmed_at TIMESTAMPTZ, + executed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_deletion_requests_object ON deletion_requests (retention_object_id);