From b12d53f46972addceb8dc37874e0f7894976716a Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 19:22:50 +0200 Subject: [PATCH 1/5] AUD-01: zentrales-audit-log-modell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go.mod | 9 ++ go.sum | 28 ++++++ internal/audit/audit.go | 96 +++++++++++++++++++ internal/audit/audit_test.go | 132 ++++++++++++++++++++++++++ migrations/0004_audit_events.down.sql | 1 + migrations/0004_audit_events.up.sql | 17 ++++ scripts/reset-test-env.sh | 24 +++++ scripts/run-checks.sh | 24 +++++ 8 files changed, 331 insertions(+) create mode 100644 go.sum create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/audit_test.go create mode 100644 migrations/0004_audit_events.down.sql create mode 100644 migrations/0004_audit_events.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..b012d98 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,96 @@ +// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen +// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und +// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant). +// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03 +// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen +// Schreibpfad (Akzeptanzkriterium 3). +package audit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende +// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein +// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2). +const SystemTenant = "system" + +var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein") +var ErrMissingActor = errors.New("audit: actor darf nicht leer sein") +var ErrMissingAction = errors.New("audit: action darf nicht leer sein") + +// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur, +// Aktion, Zielobjekt, Zeitpunkt, Tenant). +type Event struct { + TenantSlug string + Actor string + Action string + Target string + Metadata map[string]any + OccurredAt time.Time +} + +// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt +// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich +// direkt in audit_events schreibt und dabei die Validierung umgeht +// (Akzeptanzkriterium 3). +type Log struct { + pool *pgxpool.Pool +} + +func NewLog(pool *pgxpool.Pool) *Log { + return &Log{pool: pool} +} + +// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird +// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im +// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die +// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2). +func (l *Log) Record(ctx context.Context, e Event) error { + if e.TenantSlug == "" { + return ErrMissingTenant + } + if e.Actor == "" { + return ErrMissingActor + } + if e.Action == "" { + return ErrMissingAction + } + if e.Metadata == nil { + e.Metadata = map[string]any{} + } + metadataJSON, err := json.Marshal(e.Metadata) + if err != nil { + return fmt.Errorf("metadaten serialisieren: %w", err) + } + if e.OccurredAt.IsZero() { + e.OccurredAt = time.Now() + } + + _, err = l.pool.Exec(ctx, ` + INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + `, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON) + if err != nil { + return fmt.Errorf("audit-ereignis schreiben: %w", err) + } + return nil +} + +// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die +// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen. +func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) { + var n int + if err := l.pool.QueryRow(ctx, ` + SELECT count(*) FROM audit_events WHERE tenant_slug = $1 + `, tenantSlug).Scan(&n); err != nil { + return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err) + } + return n, nil +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..ad856b3 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,132 @@ +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) + } +} diff --git a/migrations/0004_audit_events.down.sql b/migrations/0004_audit_events.down.sql new file mode 100644 index 0000000..3713ba9 --- /dev/null +++ b/migrations/0004_audit_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_events; diff --git a/migrations/0004_audit_events.up.sql b/migrations/0004_audit_events.up.sql new file mode 100644 index 0000000..2f2d942 --- /dev/null +++ b/migrations/0004_audit_events.up.sql @@ -0,0 +1,17 @@ +-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md). +-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene +-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework. +-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 / +-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten +-- Wert 'system', niemals NULL oder leeren String. +CREATE TABLE 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 INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..0eee269 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte +# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle +# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches +# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische +# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +ROLE="nexarch_test" + +export PGPASSWORD="$PASS" + +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;" + +dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") +for db in $dbs; do + psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";" +done + +echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..83f26b6 --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost: +# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch. +# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf +# dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +cd "$(dirname "$0")/.." + +NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh + +export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable" + +echo "== go build ==" +go build ./... + +echo "== go vet ==" +go vet ./... + +echo "== go test (-p 1) ==" +go test ./... -p 1 -count=1 From c9e8edbbf08373a4f9be4dc5d49ecde21e547822 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 21:02:56 +0200 Subject: [PATCH 2/5] AUD-03: audit-log-export-filter-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/audit/export.go: StreamCSV/StreamJSON filtern nach Tenant, Akteur, Aktion und Zeitraum (Akzeptanzkriterium 1) und schreiben Zeile fuer Zeile ueber rows.Next() DIREKT auf den uebergebenen io.Writer — zu keinem Zeitpunkt wird das komplette Ergebnis im Speicher aufgebaut (Akzeptanz- kriterium 3). JSON-Export als JSON Lines statt einem grossen Array, um Streaming ohne Sonderbehandlung von Klammern/Kommas zu ermoeglichen. ExportHandler (Akzeptanzkriterium 2) schreibt direkt auf http.ResponseWriter — derselbe Streaming-Pfad wie in Tests, kein Zwischenpuffer nur fuer HTTP. Authorize ist eine schmale Schnittstelle (Vorbild: AUD-05 RetentionRegistrar- Muster), da die eigentliche Rollenpruefung RBAC-02 (Policy-Enforcement) ist und nicht Teil dieser Kachel — der Handler kennt nur "darf dieser Aufrufer exportieren", nicht wie das entschieden wird. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Export mit hoher Eintragszahl ohne uebermaessigen Speicherverbrauch — TestExport_StreamsLargeResultWithoutExcessiveMemory: 20.000 Eintraege, Heap-Wachstum waehrend Export nur ~1.8KB (Schwelle 3MB). PASS. 2. Filterkombinationen automatisiert gegen erwartete Ergebnismengen — TestExport_FilterCombinations (Tenant/Actor/Action einzeln und kombiniert) und TestExport_TimeRangeFilter (innerhalb/ausserhalb Zeitraum). PASS. 3. Zugriff ohne passende Berechtigung abgewiesen — TestExportHandler_RejectsWithoutAuthorization: fehlender/falscher caller -> 403, berechtigter caller -> 200. PASS. Co-Authored-By: Claude Sonnet 5 --- internal/audit/export.go | 122 +++++++++++++++++ internal/audit/export_handler.go | 73 ++++++++++ internal/audit/export_test.go | 222 +++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 internal/audit/export.go create mode 100644 internal/audit/export_handler.go create mode 100644 internal/audit/export_test.go diff --git a/internal/audit/export.go b/internal/audit/export.go new file mode 100644 index 0000000..3f39c8f --- /dev/null +++ b/internal/audit/export.go @@ -0,0 +1,122 @@ +package audit + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "time" +) + +// Filter grenzt einen Export ein (Akzeptanzkriterium 1). Leere/Nil-Felder +// bedeuten "kein Filter auf diesem Feld". +type Filter struct { + TenantSlug string + Actor string + Action string + From *time.Time + To *time.Time +} + +func buildFilterQuery(f Filter) (string, []any) { + query := `SELECT occurred_at, tenant_slug, actor, action, target, metadata FROM audit_events WHERE 1=1` + var args []any + + if f.TenantSlug != "" { + args = append(args, f.TenantSlug) + query += fmt.Sprintf(" AND tenant_slug = $%d", len(args)) + } + if f.Actor != "" { + args = append(args, f.Actor) + query += fmt.Sprintf(" AND actor = $%d", len(args)) + } + if f.Action != "" { + args = append(args, f.Action) + query += fmt.Sprintf(" AND action = $%d", len(args)) + } + if f.From != nil { + args = append(args, *f.From) + query += fmt.Sprintf(" AND occurred_at >= $%d", len(args)) + } + if f.To != nil { + args = append(args, *f.To) + query += fmt.Sprintf(" AND occurred_at <= $%d", len(args)) + } + query += " ORDER BY occurred_at" + return query, args +} + +// StreamCSV schreibt gefilterte Audit-Eintraege direkt als CSV in w, Zeile +// fuer Zeile ueber rows.Next() — es wird zu keinem Zeitpunkt das gesamte +// Ergebnis im Speicher aufgebaut (Akzeptanzkriterium 3 / Pruefung 1). +func (l *Log) StreamCSV(ctx context.Context, filter Filter, w io.Writer) error { + query, args := buildFilterQuery(filter) + rows, err := l.pool.Query(ctx, query, args...) + if err != nil { + return fmt.Errorf("export abfragen: %w", err) + } + defer rows.Close() + + cw := csv.NewWriter(w) + if err := cw.Write([]string{"occurred_at", "tenant_slug", "actor", "action", "target", "metadata"}); err != nil { + return fmt.Errorf("csv-header schreiben: %w", err) + } + + for rows.Next() { + var occurredAt time.Time + var tenantSlug, actor, action, target string + var metadataJSON []byte + if err := rows.Scan(&occurredAt, &tenantSlug, &actor, &action, &target, &metadataJSON); err != nil { + return fmt.Errorf("zeile lesen: %w", err) + } + if err := cw.Write([]string{ + occurredAt.Format(time.RFC3339), tenantSlug, actor, action, target, string(metadataJSON), + }); err != nil { + return fmt.Errorf("csv-zeile schreiben: %w", err) + } + } + cw.Flush() + if err := rows.Err(); err != nil { + return fmt.Errorf("export lesen: %w", err) + } + return cw.Error() +} + +// exportRecord ist die JSON-Repraesentation einer exportierten Zeile. +type exportRecord struct { + OccurredAt time.Time `json:"occurred_at"` + TenantSlug string `json:"tenant_slug"` + Actor string `json:"actor"` + Action string `json:"action"` + Target string `json:"target"` + Metadata json.RawMessage `json:"metadata"` +} + +// StreamJSON schreibt gefilterte Audit-Eintraege als JSON Lines (ein +// JSON-Objekt pro Zeile) — bewusst kein einzelnes grosses JSON-Array, da +// dessen korrektes Streaming (Kommas/Klammern ohne Zwischenpufferung) +// unnoetige Komplexitaet fuer denselben Zweck waere. Wie StreamCSV +// zeilenweise ueber rows.Next(), kein Aufbau im Speicher. +func (l *Log) StreamJSON(ctx context.Context, filter Filter, w io.Writer) error { + query, args := buildFilterQuery(filter) + rows, err := l.pool.Query(ctx, query, args...) + if err != nil { + return fmt.Errorf("export abfragen: %w", err) + } + defer rows.Close() + + enc := json.NewEncoder(w) + for rows.Next() { + var rec exportRecord + var metadataJSON []byte + if err := rows.Scan(&rec.OccurredAt, &rec.TenantSlug, &rec.Actor, &rec.Action, &rec.Target, &metadataJSON); err != nil { + return fmt.Errorf("zeile lesen: %w", err) + } + rec.Metadata = metadataJSON + if err := enc.Encode(rec); err != nil { + return fmt.Errorf("json-zeile schreiben: %w", err) + } + } + return rows.Err() +} diff --git a/internal/audit/export_handler.go b/internal/audit/export_handler.go new file mode 100644 index 0000000..fe03371 --- /dev/null +++ b/internal/audit/export_handler.go @@ -0,0 +1,73 @@ +package audit + +import ( + "context" + "net/http" + "time" +) + +// Authorize entscheidet, ob caller den Export ausfuehren darf. Die +// eigentliche Rollen-/Rechtepruefung (RBAC-02 Policy-Enforcement) ist nicht +// Teil dieser Kachel — ExportHandler kennt nur diese schmale Schnittstelle, +// analog zum RetentionRegistrar-Muster aus AUD-05. +type Authorize func(ctx context.Context, caller string) bool + +// ExportHandler stellt den Export als HTTP-Endpunkt bereit +// (Akzeptanzkriterium 2: fuer berechtigte Rollen verfuegbar). +type ExportHandler struct { + log *Log + authorize Authorize +} + +func NewExportHandler(log *Log, authorize Authorize) *ExportHandler { + return &ExportHandler{log: log, authorize: authorize} +} + +// Export liest Filter-Query-Parameter (tenant, actor, action, from, to, +// format) und schreibt DIREKT auf den ResponseWriter (io.Writer) — dieselbe +// Streaming-Funktion wie in export.go, kein zusaetzlicher Pufferungsschritt. +func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) { + // "caller" identifiziert die anfragende Person fuer die Berechtigungs- + // pruefung — bewusst getrennt vom Filterfeld "actor" (das den + // AUDIT-Akteur meint, ueber den gefiltert wird). + caller := r.URL.Query().Get("caller") + if caller == "" || !h.authorize(r.Context(), caller) { + http.Error(w, "keine berechtigung fuer audit-log-export", http.StatusForbidden) + return + } + + filter := Filter{ + TenantSlug: r.URL.Query().Get("tenant"), + Actor: r.URL.Query().Get("actor"), + Action: r.URL.Query().Get("action"), + } + if from := r.URL.Query().Get("from"); from != "" { + t, err := time.Parse(time.RFC3339, from) + if err != nil { + http.Error(w, "ungueltiges from-datum, erwartet RFC3339", http.StatusBadRequest) + return + } + filter.From = &t + } + if to := r.URL.Query().Get("to"); to != "" { + t, err := time.Parse(time.RFC3339, to) + if err != nil { + http.Error(w, "ungueltiges to-datum, erwartet RFC3339", http.StatusBadRequest) + return + } + filter.To = &t + } + + switch r.URL.Query().Get("format") { + case "json": + w.Header().Set("Content-Type", "application/x-ndjson") + if err := h.log.StreamJSON(r.Context(), filter, w); err != nil { + http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError) + } + default: + w.Header().Set("Content-Type", "text/csv") + if err := h.log.StreamCSV(r.Context(), filter, w); err != nil { + http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError) + } + } +} diff --git a/internal/audit/export_test.go b/internal/audit/export_test.go new file mode 100644 index 0000000..279bf80 --- /dev/null +++ b/internal/audit/export_test.go @@ -0,0 +1,222 @@ +package audit + +import ( + "bytes" + "context" + "encoding/csv" + "fmt" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupExportTest(t *testing.T) (*Log, 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.Close() } + return NewLog(pool), cleanup +} + +// Akzeptanzkriterium 1 + Pruefung 2: Filterkombinationen liefern korrekte +// Teilmengen. +func TestExport_FilterCombinations(t *testing.T) { + log, cleanup := setupExportTest(t) + defer cleanup() + ctx := context.Background() + + run := fmt.Sprintf("_%d", time.Now().UnixNano()) + tenantA, tenantB := "test_fa"+run, "test_fb"+run + alice, bob := "alice"+run, "bob"+run + + events := []Event{ + {TenantSlug: tenantA, Actor: alice, Action: "login", Target: "x"}, + {TenantSlug: tenantA, Actor: bob, Action: "login", Target: "x"}, + {TenantSlug: tenantA, Actor: alice, Action: "logout", Target: "x"}, + {TenantSlug: tenantB, Actor: alice, Action: "login", Target: "x"}, + } + for _, e := range events { + if err := log.Record(ctx, e); err != nil { + t.Fatalf("record: %v", err) + } + } + + cases := []struct { + name string + filter Filter + wantLen int + }{ + {"nach tenant", Filter{TenantSlug: tenantA}, 3}, + {"nach tenant+actor", Filter{TenantSlug: tenantA, Actor: alice}, 2}, + {"nach tenant+actor+action", Filter{TenantSlug: tenantA, Actor: alice, Action: "login"}, 1}, + {"nach actor ueber beide tenants", Filter{Actor: alice, Action: "login"}, 2}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var buf bytes.Buffer + if err := log.StreamCSV(ctx, c.filter, &buf); err != nil { + t.Fatalf("stream: %v", err) + } + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatalf("csv parsen: %v", err) + } + got := len(rows) - 1 // Header abziehen + if got != c.wantLen { + t.Fatalf("erwartet %d zeilen, habe %d", c.wantLen, got) + } + }) + } +} + +// Akzeptanzkriterium 1 + Pruefung 2: Zeitraum-Filter. +func TestExport_TimeRangeFilter(t *testing.T) { + log, cleanup := setupExportTest(t) + defer cleanup() + ctx := context.Background() + + tenant := fmt.Sprintf("test_tr_%d", time.Now().UnixNano()) + past := time.Now().Add(-48 * time.Hour) + future := time.Now().Add(48 * time.Hour) + + if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "a", Action: "x", Target: "t", OccurredAt: time.Now()}); err != nil { + t.Fatalf("record: %v", err) + } + + var buf bytes.Buffer + if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &past, To: &future}, &buf); err != nil { + t.Fatalf("stream (innerhalb range): %v", err) + } + if got := countLines(buf.String()) - 1; got != 1 { + t.Fatalf("erwartet 1 eintrag innerhalb des zeitraums, habe %d", got) + } + + farPast := time.Now().Add(-96 * time.Hour) + buf.Reset() + if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &farPast, To: &past}, &buf); err != nil { + t.Fatalf("stream (ausserhalb range): %v", err) + } + if got := countLines(buf.String()) - 1; got != 0 { + t.Fatalf("erwartet 0 eintraege ausserhalb des zeitraums, habe %d", got) + } +} + +func countLines(s string) int { + s = strings.TrimRight(s, "\n") + if s == "" { + return 0 + } + return len(strings.Split(s, "\n")) +} + +// Akzeptanzkriterium 3 + Pruefung 1: Export mit hoher Eintragszahl ohne +// uebermaessigen Speicherverbrauch — Stichprobe per runtime.MemStats. +func TestExport_StreamsLargeResultWithoutExcessiveMemory(t *testing.T) { + log, cleanup := setupExportTest(t) + defer cleanup() + ctx := context.Background() + + tenant := fmt.Sprintf("test_large_%d", time.Now().UnixNano()) + const n = 20000 + for i := 0; i < n; i++ { + if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "bulk", Action: "test.bulk", Target: fmt.Sprintf("obj-%d", i)}); err != nil { + t.Fatalf("record %d: %v", i, err) + } + } + + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + + lineCount := 0 + cw := &countingWriter{onWrite: func(p []byte) { lineCount += strings.Count(string(p), "\n") }} + if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant}, cw); err != nil { + t.Fatalf("stream: %v", err) + } + + runtime.GC() + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if lineCount != n+1 { // +1 Header + t.Fatalf("erwartet %d zeilen (inkl. header), habe %d", n+1, lineCount) + } + + // Grobe Stichprobe: ein NICHT streamender Export haette hier locker + // mehrere MB an einmal gehaltenen Zeilen/Strings erzeugt. Grosszuegige + // Schwelle, da Go-Heap-Messungen naturgemaess rauschen. + const maxAcceptableGrowth = 3 * 1024 * 1024 // 3 MB + growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) + t.Logf("heap-wachstum waehrend export von %d zeilen: %d bytes (schwelle: %d)", n, growth, maxAcceptableGrowth) + if growth > maxAcceptableGrowth { + t.Fatalf("heap ist um %d bytes gewachsen, erwartet unter %d (hinweis auf vollstaendige pufferung statt streaming)", growth, maxAcceptableGrowth) + } +} + +type countingWriter struct { + onWrite func(p []byte) +} + +func (w *countingWriter) Write(p []byte) (int, error) { + w.onWrite(p) + return len(p), nil +} + +// Akzeptanzkriterium 2 + Pruefung 3: Zugriff ohne passende Berechtigung wird abgewiesen. +func TestExportHandler_RejectsWithoutAuthorization(t *testing.T) { + log, cleanup := setupExportTest(t) + defer cleanup() + + handler := NewExportHandler(log, func(ctx context.Context, caller string) bool { + return caller == "berechtigte-person@example.com" + }) + + req := httptest.NewRequest(http.MethodGet, "/audit/export?caller=unberechtigt@example.com", nil) + rec := httptest.NewRecorder() + handler.Export(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("unberechtigt: status = %d, want 403", rec.Code) + } + + reqNoCaller := httptest.NewRequest(http.MethodGet, "/audit/export", nil) + recNoCaller := httptest.NewRecorder() + handler.Export(recNoCaller, reqNoCaller) + if recNoCaller.Code != http.StatusForbidden { + t.Fatalf("ohne caller: status = %d, want 403", recNoCaller.Code) + } + + reqOK := httptest.NewRequest(http.MethodGet, "/audit/export?caller=berechtigte-person@example.com", nil) + recOK := httptest.NewRecorder() + handler.Export(recOK, reqOK) + if recOK.Code != http.StatusOK { + t.Fatalf("berechtigt: status = %d, want 200", recOK.Code) + } +} From 3b96d8ef41c2f889a7f00ca89f5aff441d8c0459 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 00:10:48 +0200 Subject: [PATCH 3/5] AUD-04: backend-authorizer + dev-server + next.js audit-log-ansicht --- cmd/auditlog-devserver/main.go | 69 +++++++++++ internal/auditadmin/authorize.go | 23 ++++ internal/auditadmin/authorize_test.go | 33 ++++++ web/audit-log/app/layout.tsx | 17 +++ web/audit-log/app/page.tsx | 159 ++++++++++++++++++++++++++ web/audit-log/lib/api.ts | 63 ++++++++++ web/audit-log/next.config.mjs | 3 + web/audit-log/package.json | 21 ++++ web/audit-log/tsconfig.json | 21 ++++ 9 files changed, 409 insertions(+) create mode 100644 cmd/auditlog-devserver/main.go create mode 100644 internal/auditadmin/authorize.go create mode 100644 internal/auditadmin/authorize_test.go create mode 100644 web/audit-log/app/layout.tsx create mode 100644 web/audit-log/app/page.tsx create mode 100644 web/audit-log/lib/api.ts create mode 100644 web/audit-log/next.config.mjs create mode 100644 web/audit-log/package.json create mode 100644 web/audit-log/tsconfig.json diff --git a/cmd/auditlog-devserver/main.go b/cmd/auditlog-devserver/main.go new file mode 100644 index 0000000..c7c94be --- /dev/null +++ b/cmd/auditlog-devserver/main.go @@ -0,0 +1,69 @@ +// auditlog-devserver stellt AUD-03s ExportHandler (internal/audit) fuer die +// Next.js-Audit-Log-Ansicht (AUD-04) bereit. Getrennt von cmd/core aus +// demselben Grund wie die anderen *-devserver (siehe LIC-04/TEN-05): echte +// Auth/RBAC ist noch nicht in die zentrale Server-Topologie verdrahtet. +// +// Authorize wird hier mit einem geteilten Admin-Token ueber +// crypto/subtle.ConstantTimeCompare umgesetzt — demselben Timing-safe-Muster +// wie internal/audit.timingsafe (AUD-02), NICHT ueber eine neue +// Rollen-/Rechteschicht, da diese Kachel ausdruecklich nur von AUD-03 +// abhaengt und keine Rechteverwaltung duplizieren soll. +package main + +import ( + "context" + "log" + "net/http" + "os" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" + "gitea.perlbach24.de/scripte/nexarch/internal/auditadmin" + "gitea.perlbach24.de/scripte/nexarch/internal/db" +) + +func main() { + dsn := os.Getenv("NEXARCH_REGISTRY_DSN") + if dsn == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + adminToken := os.Getenv("NEXARCH_AUDIT_ADMIN_TOKEN") + if adminToken == "" { + log.Fatal("NEXARCH_AUDIT_ADMIN_TOKEN nicht gesetzt") + } + addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR") + if addr == "" { + addr = ":8083" + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + auditLog := audit.NewLog(pool) + tokenAuthorize := auditadmin.NewTokenAuthorizer(adminToken) + authorize := func(_ context.Context, caller string) bool { return tokenAuthorize(caller) } + handler := audit.NewExportHandler(auditLog, authorize) + + mux := http.NewServeMux() + mux.HandleFunc("/audit/export", withCORS(handler.Export)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("auditlog-devserver listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} diff --git a/internal/auditadmin/authorize.go b/internal/auditadmin/authorize.go new file mode 100644 index 0000000..c0eb59c --- /dev/null +++ b/internal/auditadmin/authorize.go @@ -0,0 +1,23 @@ +// Package auditadmin implementiert Core AUD-04: die Berechtigungspruefung +// fuer die Audit-Log-Ansicht. Enthaelt bewusst KEINE eigene Filter-/ +// Export-Logik — die Ansicht ist "reiner Konsument der Export-API" (AUD-03, +// internal/audit.ExportHandler); dieses Paket liefert nur die +// audit.Authorize-Implementierung, die der Entwicklungs-/Testserver +// (cmd/auditlog-devserver) einhaengt. +package auditadmin + +import "crypto/subtle" + +// NewTokenAuthorizer liefert eine audit.Authorize-Funktion, die den +// aufrufenden "caller"-Wert timing-safe gegen ein geteiltes Admin-Token +// vergleicht — dasselbe Muster wie internal/audit.timingsafe (AUD-02), +// NICHT ueber eine neue Rollen-/Rechteschicht, da AUD-04 ausdruecklich nur +// von AUD-03 abhaengt. +func NewTokenAuthorizer(adminToken string) func(caller string) bool { + return func(caller string) bool { + if caller == "" || adminToken == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(caller), []byte(adminToken)) == 1 + } +} diff --git a/internal/auditadmin/authorize_test.go b/internal/auditadmin/authorize_test.go new file mode 100644 index 0000000..b1f5e91 --- /dev/null +++ b/internal/auditadmin/authorize_test.go @@ -0,0 +1,33 @@ +package auditadmin + +import "testing" + +// Grundlage fuer Akzeptanzkriterium 1/2: die Oberflaeche darf Daten und den +// Export nur bei korrektem Admin-Token abrufen. +func TestNewTokenAuthorizer_AcceptsCorrectToken(t *testing.T) { + authorize := NewTokenAuthorizer("geheimes-token") + if !authorize("geheimes-token") { + t.Fatal("erwartet true fuer korrektes token") + } +} + +func TestNewTokenAuthorizer_RejectsWrongToken(t *testing.T) { + authorize := NewTokenAuthorizer("geheimes-token") + if authorize("falsches-token") { + t.Fatal("erwartet false fuer falsches token") + } +} + +func TestNewTokenAuthorizer_RejectsEmptyCaller(t *testing.T) { + authorize := NewTokenAuthorizer("geheimes-token") + if authorize("") { + t.Fatal("erwartet false fuer leeren aufrufer") + } +} + +func TestNewTokenAuthorizer_RejectsWhenNoTokenConfigured(t *testing.T) { + authorize := NewTokenAuthorizer("") + if authorize("irgendwas") { + t.Fatal("erwartet false, wenn kein admin-token konfiguriert ist (fail-safe-default)") + } +} diff --git a/web/audit-log/app/layout.tsx b/web/audit-log/app/layout.tsx new file mode 100644 index 0000000..817c017 --- /dev/null +++ b/web/audit-log/app/layout.tsx @@ -0,0 +1,17 @@ +export const metadata = { + title: "NEXARCH Audit-Log", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/audit-log/app/page.tsx b/web/audit-log/app/page.tsx new file mode 100644 index 0000000..8eb7625 --- /dev/null +++ b/web/audit-log/app/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { fetchRecords, exportURL, type AuditRecord, type AuditFilter } from "@/lib/api"; + +const EMPTY_FILTER: AuditFilter = { tenant: "", actor: "", action: "", from: "", to: "" }; + +export default function Page() { + const [caller, setCaller] = useState(""); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [records, setRecords] = useState(null); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + + async function onSearch() { + setError(null); + setSelected(null); + try { + const data = await fetchRecords(caller.trim(), filter); + setRecords(data); + } catch (e: any) { + setError(e.message ?? "Unbekannter Fehler"); + setRecords(null); + } + } + + function onExport(format: "csv" | "json") { + const url = exportURL(caller.trim(), filter, format); + // Echter Datei-Download ueber Browser-Navigation, KEIN erneutes Parsen + // im Frontend (Akzeptanzkriterium 2: Export aus AUD-03 direkt ausloesbar). + window.open(url, "_blank"); + } + + return ( +
+

Audit-Log

+ +
+ setCaller(e.target.value)} + placeholder="Admin-Token (Berechtigung)" + style={{ padding: "0.5rem", gridColumn: "span 3" }} + /> + setFilter({ ...filter, tenant: e.target.value })} + placeholder="Tenant" + style={{ padding: "0.5rem" }} + /> + setFilter({ ...filter, actor: e.target.value })} + placeholder="Akteur" + style={{ padding: "0.5rem" }} + /> + setFilter({ ...filter, action: e.target.value })} + placeholder="Aktion" + style={{ padding: "0.5rem" }} + /> + + +
+ +
+ + + +
+ + {error && ( +

+ Fehler: {error} +

+ )} + + {records && ( + + + + + + + + + + + + {records.map((r, i) => ( + + + + + + + + ))} + {records.length === 0 && ( + + + + )} + +
ZeitpunktTenantAkteurAktion
{new Date(r.occurred_at).toLocaleString("de-DE")}{r.tenant_slug}{r.actor}{r.action} + +
+ Keine Einträge gefunden. +
+ )} + + {selected && ( +
+

Eintrag-Details

+
+
Zeitpunkt
+
{new Date(selected.occurred_at).toLocaleString("de-DE")}
+
Tenant
+
{selected.tenant_slug}
+
Akteur
+
{selected.actor}
+
Aktion
+
{selected.action}
+
Ziel
+
{selected.target}
+
Metadaten
+
+
+                {JSON.stringify(selected.metadata, null, 2)}
+              
+
+
+ +
+ )} +
+ ); +} diff --git a/web/audit-log/lib/api.ts b/web/audit-log/lib/api.ts new file mode 100644 index 0000000..bef57c6 --- /dev/null +++ b/web/audit-log/lib/api.ts @@ -0,0 +1,63 @@ +// Duenner Client der AUD-03-Export-API (internal/audit.ExportHandler) — +// keine eigene Aggregations-/Filterlogik im Frontend (Ticket-Vorgabe: +// "reiner Konsument der Export-API"). Die JSON-Lines-Antwort wird nur +// dekodiert, nicht neu berechnet oder gefiltert. +export type AuditRecord = { + occurred_at: string; + tenant_slug: string; + actor: string; + action: string; + target: string; + metadata: unknown; +}; + +export type AuditFilter = { + tenant: string; + actor: string; + action: string; + from: string; // RFC3339, leer = kein Filter + to: string; +}; + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_AUDITLOG_API_URL; + if (!base) { + throw new Error( + "NEXT_PUBLIC_AUDITLOG_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)" + ); + } + return base; +} + +function buildParams(caller: string, filter: AuditFilter, format: "json" | "csv"): URLSearchParams { + const params = new URLSearchParams({ caller, format }); + if (filter.tenant) params.set("tenant", filter.tenant); + if (filter.actor) params.set("actor", filter.actor); + if (filter.action) params.set("action", filter.action); + if (filter.from) params.set("from", filter.from); + if (filter.to) params.set("to", filter.to); + return params; +} + +// exportURL liefert die Adresse desselben Endpunkts, den auch die +// Export-Datei-Funktion (Akzeptanzkriterium 2) verwendet — Liste und Export +// sind bewusst DERSELBE API-Aufruf mit unterschiedlichem "format". +export function exportURL(caller: string, filter: AuditFilter, format: "json" | "csv"): string { + return `${apiBase()}/audit/export?${buildParams(caller, filter, format)}`; +} + +// fetchRecords laedt die Liste als JSON-Lines und dekodiert Zeile fuer +// Zeile — dieselben Filterparameter wie ein direkter API-Aufruf +// (Akzeptanzkriterium 1 / Pruefung 1). +export async function fetchRecords(caller: string, filter: AuditFilter): Promise { + const res = await fetch(exportURL(caller, filter, "json"), { cache: "no-store" }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Anfrage fehlgeschlagen (${res.status})`); + } + const text = await res.text(); + return text + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as AuditRecord); +} diff --git a/web/audit-log/next.config.mjs b/web/audit-log/next.config.mjs new file mode 100644 index 0000000..f26ac37 --- /dev/null +++ b/web/audit-log/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +export default nextConfig; diff --git a/web/audit-log/package.json b/web/audit-log/package.json new file mode 100644 index 0000000..94d14f3 --- /dev/null +++ b/web/audit-log/package.json @@ -0,0 +1,21 @@ +{ + "name": "nexarch-audit-log", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/audit-log/tsconfig.json b/web/audit-log/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/audit-log/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From f344f793261c85b7806b231f21963b28fc11fd65 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 21:47:23 +0200 Subject: [PATCH 4/5] AUD-04: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) --- web/audit-log/app/layout.tsx | 17 +++++++++++++++-- web/audit-log/package.json | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/web/audit-log/app/layout.tsx b/web/audit-log/app/layout.tsx index 817c017..b78312e 100644 --- a/web/audit-log/app/layout.tsx +++ b/web/audit-log/app/layout.tsx @@ -1,3 +1,5 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + export const metadata = { title: "NEXARCH Audit-Log", }; @@ -9,8 +11,19 @@ export default function RootLayout({ }) { return ( - - {children} + + + + {children} + + ); diff --git a/web/audit-log/package.json b/web/audit-log/package.json index 94d14f3..5798042 100644 --- a/web/audit-log/package.json +++ b/web/audit-log/package.json @@ -8,6 +8,7 @@ "lint": "next lint" }, "dependencies": { + "@nexarch/shl": "file:../shl", "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" From 554c9aae6676648c026dccb830995dc3f20d1790 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 21:56:27 +0200 Subject: [PATCH 5/5] =?UTF-8?q?AUD-04:=20fix=20=E2=80=94=20transpilePackag?= =?UTF-8?q?es=20fuer=20@nexarch/shl=20(Next.js=20transpiliert=20node=5Fmod?= =?UTF-8?q?ules=20sonst=20nicht,=20Build=20brach=20ab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/audit-log/next.config.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/audit-log/next.config.mjs b/web/audit-log/next.config.mjs index f26ac37..fa7f635 100644 --- a/web/audit-log/next.config.mjs +++ b/web/audit-log/next.config.mjs @@ -1,3 +1,7 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + // @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules — + // Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md. + transpilePackages: ["@nexarch/shl"], +}; export default nextConfig;