internal/audit: eigenes, strukturiertes Audit-Datenmodell (Akteur, Aktion, Zielobjekt, Zeitpunkt, Tenant) in der Registry-DB, getrennt von jedem allgemeinen Anwendungs-Log (eigenes Paket, eigene Tabelle audit_events, kein Logging-Framework). Log.Record ist der EINE zentrale Schreibpfad — es gibt keine zweite Schreibmoeglichkeit, ueber die ein Handler die Validierung umgehen koennte. Fehlender Tenant-Bezug wird zweifach verhindert (Akzeptanzkriterium 2): Log.Record weist leeren TenantSlug direkt ab (ErrMissingTenant), zusaetzlich erzwingt eine CHECK-Constraint in der Migration dasselbe auf Datenbankebene, selbst wenn Log.Record umgangen wuerde. Mandantenuebergreifende Ereignisse (z.B. Superadmin-Aktionen) nutzen den reservierten Wert audit.SystemTenant statt NULL oder leerem String — es gibt keinen Weg, ganz ohne Tenant-Bezug zu schreiben. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Automatisierter Test belegt genau einen Audit-Eintrag pro sicherheitsrelevantem Vorgang — TestRecord_PersistsExactlyOneEventPerSecurityIncident (simulierter fehlgeschlagener Login), Feldinhalte verifiziert. PASS. 2. Fehlender Tenant-Bezug durch Constraint/Test verhindert — TestRecord_RejectsMissingTenant (App-Ebene) UND TestConstraint_RejectsMissingTenantAtDatabaseLevel (direkter INSERT unter Umgehung von Log.Record, durch CHECK-Constraint abgewiesen). PASS. 3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
133 lines
4.1 KiB
Go
133 lines
4.1 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"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()
|
|
|
|
err := log.Record(ctx, Event{
|
|
TenantSlug: "test_acme",
|
|
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, "test_acme")
|
|
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 = 'test_acme'
|
|
`).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)
|
|
}
|
|
}
|