Files
nexarch/dms/internal/auditapi/handler.go
T
patrickandClaude Sonnet 5 54e322f1e1 VER-06: audit-log-ansicht & export
audittrail.ListFiltered (Zeitraum/Akteur/Aktion) + auditapi (Liste,
gestreamter CSV-Export, Allowlist-Zugriffspruefung) als Backend-
Voraussetzung. Audit-Log-Seite im dms-browser mit Filterformular und
Export derselben gefilterten Menge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:24:59 +02:00

199 lines
6.3 KiB
Go

// Package auditapi implementiert VER-06s Backend-Voraussetzung: eine
// filterbare Ansicht und einen CSV-Export des Audit-Protokolls (VER-05).
// Ruft ausschließlich internal/audittrail auf, keine eigene Protokoll-
// Logik.
package auditapi
import (
"context"
"encoding/csv"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
)
// RoleChecker entscheidet, ob caller Zugriff auf das Audit-Protokoll hat
// (Akzeptanzkriterium 3). Schmale Schnittstelle nach demselben Muster wie
// versioningapi.PermissionChecker (VER-02) — das DMS-Modul hat noch keine
// zentrale RBAC-Anbindung (siehe VER-01/VER-05/VER-02/DOC-15-Präzedenzfall),
// eine spätere echte Rollenprüfung ersetzt dies, ohne den Aufrufvertrag zu
// ändern.
type RoleChecker interface {
IsAuditor(ctx context.Context, callerUserID string) (bool, error)
}
// AllowlistRoleChecker ist eine minimale, echte Umsetzung ohne zentrale
// RBAC-Anbindung: erlaubt Zugriff nur Nutzern aus einer fest konfigurierten
// Liste (vom Aufrufer aus einer Umgebungsvariable befüllt, siehe cmd/app).
type AllowlistRoleChecker struct {
AllowedUserIDs map[string]bool
}
func NewAllowlistRoleChecker(allowedUserIDs []string) *AllowlistRoleChecker {
set := make(map[string]bool, len(allowedUserIDs))
for _, id := range allowedUserIDs {
set[id] = true
}
return &AllowlistRoleChecker{AllowedUserIDs: set}
}
func (c *AllowlistRoleChecker) IsAuditor(ctx context.Context, callerUserID string) (bool, error) {
return c.AllowedUserIDs[callerUserID], nil
}
type Server struct {
pool *pgxpool.Pool
roles RoleChecker
}
func NewServer(pool *pgxpool.Pool, roles RoleChecker) *Server {
return &Server{pool: pool, roles: roles}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /audit-log", s.handleList)
mux.HandleFunc("GET /audit-log/export", s.handleExport)
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func requireCaller(w http.ResponseWriter, r *http.Request) (string, bool) {
caller := strings.TrimSpace(r.URL.Query().Get("caller"))
if caller == "" {
writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"caller\"")
return "", false
}
return caller, true
}
func parseFilter(r *http.Request) (audittrail.Filter, error) {
var f audittrail.Filter
if raw := r.URL.Query().Get("from"); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return f, err
}
f.From = &t
}
if raw := r.URL.Query().Get("to"); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return f, err
}
f.To = &t
}
f.Actor = r.URL.Query().Get("actor")
f.Action = r.URL.Query().Get("action")
return f, nil
}
// checkAccess prüft die Berechtigung (Akzeptanzkriterium 3) — bei
// fehlender Berechtigung wird bereits hier mit 403 geantwortet, der
// Aufrufer bekommt in KEINEM Fall Zugriff auf die eigentlichen Daten.
func (s *Server) checkAccess(w http.ResponseWriter, r *http.Request, caller string) bool {
allowed, err := s.roles.IsAuditor(r.Context(), caller)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return false
}
if !allowed {
writeError(w, http.StatusForbidden, "keine berechtigung fuer das audit-protokoll")
return false
}
return true
}
type entryDTO struct {
ID int64 `json:"id"`
OccurredAt string `json:"occurred_at"`
Actor string `json:"actor"`
Action string `json:"action"`
ObjectType string `json:"object_type"`
ObjectReference string `json:"object_reference"`
Metadata map[string]any `json:"metadata"`
}
func toDTO(e audittrail.Entry) entryDTO {
return entryDTO{
ID: e.ID, OccurredAt: e.OccurredAt.UTC().Format(time.RFC3339), Actor: e.Actor, Action: e.Action,
ObjectType: e.ObjectType, ObjectReference: e.ObjectReference, Metadata: e.Metadata,
}
}
// handleList liefert gefilterte Einträge (Akzeptanzkriterium 1: Filter nach
// Zeitraum, Akteur, Aktion).
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
caller, ok := requireCaller(w, r)
if !ok {
return
}
if !s.checkAccess(w, r, caller) {
return
}
filter, err := parseFilter(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger zeitraum-parameter: "+err.Error())
return
}
entries, err := audittrail.ListFiltered(r.Context(), s.pool, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dtos := make([]entryDTO, len(entries))
for i, e := range entries {
dtos[i] = toDTO(e)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dtos)
}
// handleExport liefert GENAU die gefilterten Einträge als CSV
// (Akzeptanzkriterium 2) — dieselbe Filterfunktion wie handleList, damit
// Ansicht und Export nie auseinanderlaufen. Schreibt Zeile für Zeile direkt
// in den Response-Writer statt den gesamten CSV-Text im Speicher
// aufzubauen, damit auch sehr große Exporte (Pflichtprüfung 1: 100.000
// Einträge) ohne wachsenden Zusatzspeicher durchlaufen.
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
caller, ok := requireCaller(w, r)
if !ok {
return
}
if !s.checkAccess(w, r, caller) {
return
}
filter, err := parseFilter(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger zeitraum-parameter: "+err.Error())
return
}
entries, err := audittrail.ListFiltered(r.Context(), s.pool, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\"audit-log.csv\"")
// Kein Content-Length gesetzt - beabsichtigt gestreamt statt im
// Speicher aufgebaut, siehe Kommentar oben.
csvWriter := csv.NewWriter(w)
_ = csvWriter.Write([]string{"id", "occurred_at", "actor", "action", "object_type", "object_reference"})
for _, e := range entries {
_ = csvWriter.Write([]string{
strconv.FormatInt(e.ID, 10), e.OccurredAt.UTC().Format(time.RFC3339), e.Actor, e.Action, e.ObjectType, e.ObjectReference,
})
}
csvWriter.Flush()
}