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() }