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/go.sum b/go.sum index d2b07a5..e86010c 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,13 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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= <<<<<<< HEAD +<<<<<<< HEAD github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= ======= >>>>>>> feature/lic-04-lizenz-modul-verwaltungsoberflaeche +======= +>>>>>>> feature/aud-04-audit-log-ansicht 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= 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/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) + } +} 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/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 index dea12d2..7c09465 100755 --- a/scripts/reset-test-env.sh +++ b/scripts/reset-test-env.sh @@ -35,6 +35,7 @@ psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EX psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS usage_quotas CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS usage_counters CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_licenses 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 diff --git a/web/audit-log/app/layout.tsx b/web/audit-log/app/layout.tsx new file mode 100644 index 0000000..b78312e --- /dev/null +++ b/web/audit-log/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +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..fa7f635 --- /dev/null +++ b/web/audit-log/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').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; diff --git a/web/audit-log/package.json b/web/audit-log/package.json new file mode 100644 index 0000000..5798042 --- /dev/null +++ b/web/audit-log/package.json @@ -0,0 +1,22 @@ +{ + "name": "nexarch-audit-log", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "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"] +}