Files
nexarch/internal/audit/audit_test.go
sysopsandClaude Sonnet 5 1bfb2efd94 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>
2026-08-27 19:57:51 +02:00

140 lines
4.4 KiB
Go

package audit
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupAuditTest(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
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
pool.Close()
}
return NewLog(pool), pool, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
log, pool, cleanup := setupAuditTest(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: tenantSlug,
Actor: "alice@example.com",
Action: "iam.login_failed",
Target: "user:alice@example.com",
Metadata: map[string]any{"reason": "falsches passwort"},
})
if err != nil {
t.Fatalf("record: %v", err)
}
count, err := log.CountByTenant(ctx, tenantSlug)
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
}
var actor, action, target string
if err := pool.QueryRow(ctx, `
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" {
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
// bereits vom zentralen Schreibpfad abgewiesen.
func TestRecord_RejectsMissingTenant(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
if !errors.Is(err, ErrMissingTenant) {
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
// Schutz haengt nicht allein von der Go-Validierung ab.
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
_, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
_, err := pool.Exec(ctx, `
INSERT INTO audit_events (tenant_slug, actor, action, target)
VALUES ('', 'alice', 'irgendwas', 'ziel')
`)
if err == nil {
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
}
}
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
}
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
}
}
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
t.Fatalf("record mit SystemTenant: %v", err)
}
}