AUD-02: unveraenderliches-protokoll-append-only
Append-only per Trigger (nicht nur GRANT/REVOKE): audit_events_prevent_mutation() wirft bei jedem UPDATE/DELETE auf audit_events eine Exception, unabhaengig von der verbindenden Rolle (Akzeptanzkriterium 1). internal/audit/four_eyes.go: Vier-Augen-Prinzip fuer sicherheitskritische Entscheidungen (Loeschbestaetigung, Rechtevergabe), 1:1 nach archivdms- Vorbild. Request erzeugt einen Klartext-Code (wird ausserhalb des Systems an eine ZWEITE Person uebermittelt) und speichert nur dessen SHA-256-Hash. Confirm sperrt die Zeile mit FOR UPDATE (Akzeptanzkriterium 2 — serialisiert zwei gleichzeitige Bestaetigungsversuche, verhindert doppelte Ausfuehrung), weist eine Bestaetigung durch dieselbe Person wie die anfordernde ab (ErrSameActor, echtes Vier-Augen-Prinzip statt nur Code-Pruefung), und vergleicht den Code timing-safe (Akzeptanzkriterium 3). internal/audit/timingsafe.go: timingSafeEqual als projektweite Referenz- implementierung (subtle.ConstantTimeCompare) fuer sicherheitsrelevante Vergleiche — andere Module (z.B. Archive CMP-06 Freigabelinks) uebernehmen dasselbe Muster laut IAM-02-Konvention. Nebenbei behoben: AUD-01s eigener Test nutzte einen festen Tenant-Slug mit DELETE-basiertem Cleanup — seit dem neuen Append-only-Trigger schlaegt dieses Cleanup lautlos fehl, wodurch Zeilen sich ueber Testlaeufe hinweg summierten und die Zaehl-Assertion brach. Auf eindeutigen Slug pro Lauf umgestellt (direkte, notwendige Folge dieser Kachel, keine Umgestaltung von AUD-01 selbst). Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Direkter UPDATE/DELETE-Versuch von der Datenbank abgewiesen — TestAppendOnly_RejectsUpdateAndDelete: beide Operationen scheitern, Eintrag bleibt unveraendert erhalten. PASS. 2. Vier-Augen-Prinzip mit FOR-UPDATE-Lock race-frei unter parallelen Anfragen — TestFourEyes_ConcurrentConfirmIsRaceFree: zwei gleichzeitige Bestaetigungsversuche fuer denselben Vorgang, genau einer erfolgreich, der andere ErrAlreadyDecided. PASS. 3. Timing-safe Vergleich per Laufzeitmessung stichprobenartig verifiziert — TestTimingSafeEqual_NoEarlyExitTiming: Mismatch am Anfang (603µs) vs. am Ende (574µs) ueber 20000 Iterationen, kein Hinweis auf Short-Circuit- Vergleich (Ratio innerhalb Faktor 3 Toleranz). PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b12d53f469
commit
1bfb2efd94
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user