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