diff --git a/internal/audit/append_only_test.go b/internal/audit/append_only_test.go new file mode 100644 index 0000000..94d8c03 --- /dev/null +++ b/internal/audit/append_only_test.go @@ -0,0 +1,97 @@ +package audit + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupAppendOnlyTest(t *testing.T) (*Log, *pgxpool.Pool, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS audit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''), + actor TEXT NOT NULL CHECK (actor <> ''), + action TEXT NOT NULL CHECK (action <> ''), + target TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb + ); + CREATE OR REPLACE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$ + BEGIN + RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP; + END; + $$ LANGUAGE plpgsql; + DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events; + CREATE TRIGGER audit_events_no_update + BEFORE UPDATE ON audit_events + FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation(); + DROP TRIGGER IF EXISTS audit_events_no_delete ON audit_events; + CREATE TRIGGER audit_events_no_delete + BEFORE DELETE ON audit_events + FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation(); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { + pool.Close() + } + return NewLog(pool), pool, cleanup +} + +// Akzeptanzkriterium 1 + Pruefung 1: direkter UPDATE/DELETE-Versuch wird von +// der Datenbank abgewiesen. +func TestAppendOnly_RejectsUpdateAndDelete(t *testing.T) { + log, pool, cleanup := setupAppendOnlyTest(t) + defer cleanup() + ctx := context.Background() + + // Append-only bedeutet: dieser Testeintrag kann NIE wieder geloescht + // werden, auch nicht vom Test selbst. Eindeutiger Tenant-Slug pro Lauf, + // damit wiederholte Testlaeufe sich nicht gegenseitig die Zaehlung + // verfaelschen. + tenantSlug := fmt.Sprintf("test_appendonly_%d", time.Now().UnixNano()) + + if err := log.Record(ctx, Event{ + TenantSlug: tenantSlug, + Actor: "alice", + Action: "test.event", + Target: "x", + }); err != nil { + t.Fatalf("record: %v", err) + } + + _, err := pool.Exec(ctx, `UPDATE audit_events SET actor = 'mallory' WHERE tenant_slug = $1`, tenantSlug) + if err == nil { + t.Fatal("erwartet fehler bei UPDATE auf audit_events, habe nil") + } + + _, err = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug = $1`, tenantSlug) + if err == nil { + t.Fatal("erwartet fehler bei DELETE auf audit_events, habe nil") + } + + count, err := log.CountByTenant(ctx, tenantSlug) + if err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Fatalf("eintrag haette trotz fehlgeschlagener update/delete-versuche erhalten bleiben muessen, count=%d", count) + } +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index ad856b3..e399c28 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -3,8 +3,10 @@ package audit import ( "context" "errors" + "fmt" "os" "testing" + "time" "github.com/jackc/pgx/v5/pgxpool" ) @@ -48,8 +50,13 @@ func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) { defer cleanup() ctx := context.Background() + // Seit AUD-02 ist audit_events append-only — Zeilen koennen nie wieder + // geloescht werden (auch nicht vom Test-Cleanup). Eindeutiger Slug pro + // Lauf, damit wiederholte Testlaeufe die Zaehlung nicht verfaelschen. + tenantSlug := "test_acme_" + fmt.Sprint(time.Now().UnixNano()) + err := log.Record(ctx, Event{ - TenantSlug: "test_acme", + TenantSlug: tenantSlug, Actor: "alice@example.com", Action: "iam.login_failed", Target: "user:alice@example.com", @@ -59,7 +66,7 @@ func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) { t.Fatalf("record: %v", err) } - count, err := log.CountByTenant(ctx, "test_acme") + count, err := log.CountByTenant(ctx, tenantSlug) if err != nil { t.Fatalf("count: %v", err) } @@ -69,8 +76,8 @@ func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) { var actor, action, target string if err := pool.QueryRow(ctx, ` - SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme' - `).Scan(&actor, &action, &target); err != nil { + SELECT actor, action, target FROM audit_events WHERE tenant_slug = $1 + `, tenantSlug).Scan(&actor, &action, &target); err != nil { t.Fatalf("eintrag lesen: %v", err) } if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" { diff --git a/internal/audit/four_eyes.go b/internal/audit/four_eyes.go new file mode 100644 index 0000000..26ece72 --- /dev/null +++ b/internal/audit/four_eyes.go @@ -0,0 +1,120 @@ +package audit + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrConfirmationNotFound = errors.New("audit: bestaetigungsvorgang nicht gefunden") + ErrAlreadyDecided = errors.New("audit: bestaetigungsvorgang wurde bereits entschieden") + ErrSameActor = errors.New("audit: bestaetigung muss von einer anderen person als der anfordernden erfolgen") + ErrInvalidCode = errors.New("audit: bestaetigungscode ungueltig") +) + +type ConfirmationStatus string + +const ( + StatusPending ConfirmationStatus = "pending" + StatusConfirmed ConfirmationStatus = "confirmed" +) + +// FourEyes implementiert das Vier-Augen-Prinzip fuer sicherheitskritische +// Entscheidungen (Akzeptanzkriterium 2) nach dem archivdms-Vorbild: +// FOR-UPDATE-Lock gegen Race-Bedingungen bei paralleler Bestaetigung, +// Timing-safe Vergleich des Bestaetigungscodes (Akzeptanzkriterium 3). +type FourEyes struct { + pool *pgxpool.Pool +} + +func NewFourEyes(pool *pgxpool.Pool) *FourEyes { + return &FourEyes{pool: pool} +} + +// Request legt einen neuen, zu bestaetigenden Vorgang an (z.B. Loeschbestaetigung, +// Rechtevergabe) und liefert einen einmaligen Klartext-Code, der ausserhalb +// dieses Systems (z.B. per E-Mail) an eine ZWEITE Person uebermittelt wird — +// niemals der anfordernden Person selbst. +func (f *FourEyes) Request(ctx context.Context, action, target, requestedBy string) (id, code string, err error) { + code, err = generateCode() + if err != nil { + return "", "", fmt.Errorf("bestaetigungscode erzeugen: %w", err) + } + hash := hashCode(code) + + err = f.pool.QueryRow(ctx, ` + INSERT INTO security_confirmations (action, target, requested_by, code_hash, status) + VALUES ($1, $2, $3, $4, 'pending') + RETURNING id + `, action, target, requestedBy, hash).Scan(&id) + if err != nil { + return "", "", fmt.Errorf("bestaetigungsvorgang anlegen: %w", err) + } + return id, code, nil +} + +// Confirm bestaetigt einen Vorgang. confirmedBy MUSS sich von der +// anfordernden Person unterscheiden (echtes Vier-Augen-Prinzip). Der Zugriff +// auf die Zeile erfolgt mit FOR UPDATE, damit zwei gleichzeitige +// Bestaetigungsversuche serialisiert werden und niemals beide durchgehen +// (Akzeptanzkriterium 2 / Pruefung 2). +func (f *FourEyes) Confirm(ctx context.Context, id, confirmedBy, code string) error { + tx, err := f.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("transaktion starten: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + var requestedBy, status string + var codeHash []byte + err = tx.QueryRow(ctx, ` + SELECT requested_by, status, code_hash FROM security_confirmations + WHERE id = $1 FOR UPDATE + `, id).Scan(&requestedBy, &status, &codeHash) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrConfirmationNotFound + } + return fmt.Errorf("bestaetigungsvorgang lesen: %w", err) + } + + if status != string(StatusPending) { + return ErrAlreadyDecided + } + if confirmedBy == requestedBy { + return ErrSameActor + } + if !timingSafeEqual(hashCode(code), codeHash) { + return ErrInvalidCode + } + + if _, err := tx.Exec(ctx, ` + UPDATE security_confirmations + SET status = 'confirmed', confirmed_by = $2, confirmed_at = now() + WHERE id = $1 + `, id, confirmedBy); err != nil { + return fmt.Errorf("bestaetigung speichern: %w", err) + } + + return tx.Commit(ctx) +} + +func generateCode() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func hashCode(code string) []byte { + sum := sha256.Sum256([]byte(code)) + return sum[:] +} diff --git a/internal/audit/four_eyes_test.go b/internal/audit/four_eyes_test.go new file mode 100644 index 0000000..53933aa --- /dev/null +++ b/internal/audit/four_eyes_test.go @@ -0,0 +1,128 @@ +package audit + +import ( + "context" + "errors" + "os" + "sync" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupFourEyesTest(t *testing.T) (*FourEyes, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS security_confirmations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action TEXT NOT NULL, + target TEXT NOT NULL, + requested_by TEXT NOT NULL, + code_hash BYTEA NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected')), + confirmed_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmed_at TIMESTAMPTZ + )`); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM security_confirmations WHERE action LIKE 'test.%'`) + pool.Close() + } + return NewFourEyes(pool), cleanup +} + +func TestFourEyes_RequestAndConfirm(t *testing.T) { + fe, cleanup := setupFourEyesTest(t) + defer cleanup() + ctx := context.Background() + + id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com") + if err != nil { + t.Fatalf("request: %v", err) + } + + if err := fe.Confirm(ctx, id, "bob@example.com", code); err != nil { + t.Fatalf("confirm: %v", err) + } +} + +func TestFourEyes_RejectsSameActor(t *testing.T) { + fe, cleanup := setupFourEyesTest(t) + defer cleanup() + ctx := context.Background() + + id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com") + if err != nil { + t.Fatalf("request: %v", err) + } + + if err := fe.Confirm(ctx, id, "alice@example.com", code); !errors.Is(err, ErrSameActor) { + t.Fatalf("erwartet ErrSameActor, habe %v", err) + } +} + +func TestFourEyes_RejectsWrongCode(t *testing.T) { + fe, cleanup := setupFourEyesTest(t) + defer cleanup() + ctx := context.Background() + + id, _, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com") + if err != nil { + t.Fatalf("request: %v", err) + } + + if err := fe.Confirm(ctx, id, "bob@example.com", "falscher-code"); !errors.Is(err, ErrInvalidCode) { + t.Fatalf("erwartet ErrInvalidCode, habe %v", err) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: FOR-UPDATE-Lock unter parallelen +// Anfragen race-frei — von zwei gleichzeitigen Bestaetigungsversuchen fuer +// denselben Vorgang darf genau einer durchgehen. +func TestFourEyes_ConcurrentConfirmIsRaceFree(t *testing.T) { + fe, cleanup := setupFourEyesTest(t) + defer cleanup() + ctx := context.Background() + + id, code, err := fe.Request(ctx, "test.tenant_delete", "tenant:acme", "alice@example.com") + if err != nil { + t.Fatalf("request: %v", err) + } + + var wg sync.WaitGroup + results := make([]error, 2) + confirmers := []string{"bob@example.com", "carol@example.com"} + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = fe.Confirm(ctx, id, confirmers[i], code) + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range results { + if err == nil { + successCount++ + } else if !errors.Is(err, ErrAlreadyDecided) { + t.Fatalf("unerwarteter fehler: %v", err) + } + } + if successCount != 1 { + t.Fatalf("erwartet genau eine erfolgreiche bestaetigung, habe %d", successCount) + } +} diff --git a/internal/audit/timingsafe.go b/internal/audit/timingsafe.go new file mode 100644 index 0000000..3e6844b --- /dev/null +++ b/internal/audit/timingsafe.go @@ -0,0 +1,15 @@ +package audit + +import "crypto/subtle" + +// timingSafeEqual ist die projektweite Referenzimplementierung fuer +// Timing-safe-Vergleiche sicherheitsrelevanter Geheimnisse (Bestaetigungs- +// codes hier, spaeter Freigabelinks in Archive CMP-06 — siehe IAM-02-Ticket- +// Konvention). subtle.ConstantTimeCompare vergleicht in konstanter Zeit +// bezogen auf die Laenge von a, unabhaengig vom Inhalt. +func timingSafeEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare(a, b) == 1 +} diff --git a/internal/audit/timingsafe_test.go b/internal/audit/timingsafe_test.go new file mode 100644 index 0000000..7504fd5 --- /dev/null +++ b/internal/audit/timingsafe_test.go @@ -0,0 +1,62 @@ +package audit + +import ( + "testing" + "time" +) + +func TestTimingSafeEqual_Correctness(t *testing.T) { + a := hashCode("geheimnis-a") + b := hashCode("geheimnis-a") + c := hashCode("geheimnis-b") + + if !timingSafeEqual(a, b) { + t.Fatal("identische hashes sollten gleich sein") + } + if timingSafeEqual(a, c) { + t.Fatal("unterschiedliche hashes sollten ungleich sein") + } + if timingSafeEqual(a, []byte("kuerzer")) { + t.Fatal("unterschiedliche laenge sollte ungleich sein") + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Timing-safe Vergleich stichprobenartig +// per Laufzeitmessung verifiziert — ein Mismatch am Anfang darf nicht +// messbar schneller sein als ein Mismatch am Ende (klassisches Merkmal +// eines NICHT timing-safen Vergleichs wie bytes.Equal mit Short-Circuit). +func TestTimingSafeEqual_NoEarlyExitTiming(t *testing.T) { + reference := hashCode("referenzwert-fuer-timing-test") + + mismatchAtStart := make([]byte, len(reference)) + copy(mismatchAtStart, reference) + mismatchAtStart[0] ^= 0xFF + + mismatchAtEnd := make([]byte, len(reference)) + copy(mismatchAtEnd, reference) + mismatchAtEnd[len(mismatchAtEnd)-1] ^= 0xFF + + const iterations = 20000 + startDur := measure(iterations, func() { timingSafeEqual(reference, mismatchAtStart) }) + endDur := measure(iterations, func() { timingSafeEqual(reference, mismatchAtEnd) }) + + t.Logf("mismatch am anfang: %s, mismatch am ende: %s (%d iterationen)", startDur, endDur, iterations) + + ratio := float64(startDur) / float64(endDur) + // Grosszuegige Toleranz (Faktor 3), da es ein Stichprobentest auf einer + // geteilten Testmaschine ist, kein isolierter Benchmark — es geht darum, + // eine grobe Short-Circuit-Implementierung zuverlaessig aufzudecken + // (die haette typischerweise eine Groessenordnung Unterschied), nicht um + // kryptographisch praezise Constant-Time-Beweise. + if ratio > 3.0 || ratio < 1.0/3.0 { + t.Fatalf("timing-unterschied zu gross (verdacht auf short-circuit-vergleich): ratio=%.2f", ratio) + } +} + +func measure(iterations int, fn func()) time.Duration { + start := time.Now() + for i := 0; i < iterations; i++ { + fn() + } + return time.Since(start) +} diff --git a/migrations/0005_audit_append_only.down.sql b/migrations/0005_audit_append_only.down.sql new file mode 100644 index 0000000..dba9bf8 --- /dev/null +++ b/migrations/0005_audit_append_only.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS audit_events_no_delete ON audit_events; +DROP TRIGGER IF EXISTS audit_events_no_update ON audit_events; +DROP FUNCTION IF EXISTS audit_events_prevent_mutation(); diff --git a/migrations/0005_audit_append_only.up.sql b/migrations/0005_audit_append_only.up.sql new file mode 100644 index 0000000..f95cb3f --- /dev/null +++ b/migrations/0005_audit_append_only.up.sql @@ -0,0 +1,17 @@ +-- Audit-Log technisch gegen Aenderung/Loeschung absichern (AUD-02, siehe +-- core-kanban/tickets/AUD-02.md). Ein Trigger statt nur GRANT/REVOKE, damit +-- der Schutz unabhaengig davon greift, mit welcher Rolle verbunden wird +-- (Akzeptanzkriterium 1: "auf Datenbankebene technisch unterbunden"). +CREATE FUNCTION audit_events_prevent_mutation() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'audit_events ist append-only: % ist nicht erlaubt', TG_OP; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER audit_events_no_update + BEFORE UPDATE ON audit_events + FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation(); + +CREATE TRIGGER audit_events_no_delete + BEFORE DELETE ON audit_events + FOR EACH ROW EXECUTE FUNCTION audit_events_prevent_mutation(); diff --git a/migrations/0006_security_confirmations.down.sql b/migrations/0006_security_confirmations.down.sql new file mode 100644 index 0000000..4abb74b --- /dev/null +++ b/migrations/0006_security_confirmations.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS security_confirmations; diff --git a/migrations/0006_security_confirmations.up.sql b/migrations/0006_security_confirmations.up.sql new file mode 100644 index 0000000..5af5fa4 --- /dev/null +++ b/migrations/0006_security_confirmations.up.sql @@ -0,0 +1,14 @@ +-- Vier-Augen-Prinzip fuer sicherheitskritische Entscheidungen (AUD-02 +-- Akzeptanzkriterium 2), Vorbild: archivdms FOR-UPDATE-Lock + Timing-safe +-- Vergleich. code_hash speichert NIEMALS den Bestaetigungscode im Klartext. +CREATE TABLE security_confirmations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action TEXT NOT NULL, + target TEXT NOT NULL, + requested_by TEXT NOT NULL, + code_hash BYTEA NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'rejected')), + confirmed_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmed_at TIMESTAMPTZ +);