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>
199 lines
6.3 KiB
Go
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()
|
|
}
|