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 <noreply@anthropic.com>
123 lines
3.7 KiB
Go
123 lines
3.7 KiB
Go
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()
|
|
}
|