Files
nexarch/internal/audit/export_handler.go
2026-09-22 10:29:26 +02:00

105 lines
4.0 KiB
Go

package audit
import (
"context"
"log"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
)
// ExportTokenHeader ist der Header, in dem der Aufrufer ab QA-15 F-09 das
// Admin-Token uebergibt — vorher stand es als "caller"-Query-Parameter in
// der URL (Zugriffsprotokolle, Browserverlauf, Proxy-Logs). Derselbe Header
// wie die anderen internen Admin-Endpunkte (QA-15 F-01..F-03).
const ExportTokenHeader = svcauth.Header
// legacyTokenQueryParams sind die Query-Parameter, unter denen fruehere
// Versionen das Admin-Token akzeptierten. Taucht einer davon in der Anfrage
// auf, wird sie abgelehnt UND protokolliert (Akzeptanzkriterium 3) — auch
// wenn zusaetzlich ein gueltiger Header mitgeschickt wurde, damit ein
// bestehender, geteilter (und damit potenziell schon kompromittierter)
// Bookmark/Skript-Aufruf mit URL-Token sichtbar auffaellt und nicht still
// weiterfunktioniert.
var legacyTokenQueryParams = []string{"caller", "token"}
// 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) {
// QA-15 F-09: ein per URL-Query mitgeschicktes Token wird explizit
// abgewiesen UND protokolliert (Akzeptanzkriterium 3), statt es (auch
// wenn zusaetzlich ein gueltiger Header vorhanden ist) still zu
// akzeptieren — Query-Parameter landen in Zugriffsprotokollen,
// Browserverlauf und Proxy-Logs.
for _, p := range legacyTokenQueryParams {
if r.URL.Query().Get(p) != "" {
log.Printf("audit: export-aufruf mit token im query-parameter %q abgewiesen (veraltetes verfahren, siehe QA-15 F-09)", p)
http.Error(w, "token im query nicht mehr erlaubt, bitte Header "+ExportTokenHeader+" verwenden", http.StatusUnauthorized)
return
}
}
// Das Token identifiziert die anfragende Person/den Dienst fuer die
// Berechtigungspruefung — bewusst getrennt vom Filterfeld "actor" (das
// den AUDIT-Akteur meint, ueber den gefiltert wird).
caller := r.Header.Get(ExportTokenHeader)
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)
}
}
}