Files
patrickandClaude Sonnet 5 bf670ef4a6
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CMP-05: compliance-berichts-oberfläche (Backend-API + Next.js-Oberfläche)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 00:14:50 +02:00

176 lines
5.8 KiB
Go

package complianceapi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/dpreport"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/gobddoc"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient"
)
// compliancePermission ist DAS bei Core RBAC-06 geprüfte Recht für alle
// CMP-05-Endpunkte. Ein einzelnes Recht statt getrennter Rechte für
// Erstellen/Einsehen/Herunterladen — GoBD- und DSGVO-Berichte sind
// beides Prüfer-/Verwaltungswerkzeug, keine Selbstbedienung für
// betroffene Personen (Akzeptanzkriterium 2: das schließt normale
// Nutzer aus, ohne eine zweite Rechteabstufung einzuführen, die das
// Ticket nicht verlangt).
const compliancePermission = "compliance.reports"
// requestedRoleHeader wie retentionapi (RET-08): die Rolle wird
// behauptet, Core RBAC-06 (POST /authorize) prüft sie tatsächlich.
const requestedRoleHeader = "X-User-Role"
// Mount registriert alle CMP-05-Endpunkte.
func Mount(mux *http.ServeMux, pool *pgxpool.Pool, rbac *rbacclient.Client) {
mux.HandleFunc("POST /compliance/reports", requireRBAC(rbac, requestHandler(pool)))
mux.HandleFunc("GET /compliance/reports", requireRBAC(rbac, listHandler(pool)))
mux.HandleFunc("GET /compliance/reports/{id}/download", requireRBAC(rbac, downloadHandler(pool)))
}
func requireRBAC(rbac *rbacclient.Client, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role := r.Header.Get(requestedRoleHeader)
if role == "" {
http.Error(w, "zugriff verweigert: header "+requestedRoleHeader+" erforderlich", http.StatusForbidden)
return
}
allowed, err := rbac.Authorize(r.Context(), role, compliancePermission)
if err != nil || !allowed {
// fail-closed: Transportfehler und ausdrückliche Ablehnung
// führen beide zur Verweigerung (Muster wie retentionapi).
http.Error(w, "zugriff verweigert", http.StatusForbidden)
return
}
next(w, r)
}
}
type requestBody struct {
ReportType string `json:"report_type"`
DataSubjectRef string `json:"data_subject_ref"`
}
type requestResponse struct {
ID string `json:"id"`
Filename string `json:"filename"`
DownloadToken string `json:"download_token"`
ExpiresAt string `json:"expires_at"`
}
// requestHandler löst den Export aus (Akzeptanzkriterium 1, Pflicht-
// prüfung 1) und legt ihn als herunterladbaren Bericht ab.
func requestHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req requestBody
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
requestedBy := r.Header.Get(requestedRoleHeader)
var content []byte
var contentType, filename string
now := time.Now().UTC()
switch req.ReportType {
case "gobd":
doc, err := gobddoc.Build(r.Context(), pool, now)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
content = doc.PDF
contentType = "application/pdf"
filename = fmt.Sprintf("gobd-verfahrensdokumentation-%s.pdf", now.Format("2006-01-02"))
case "dsgvo":
if req.DataSubjectRef == "" {
http.Error(w, "data_subject_ref ist bei report_type=dsgvo pflicht", http.StatusBadRequest)
return
}
records, err := dpreport.SubjectReport(r.Context(), pool, req.DataSubjectRef)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := dpreport.WriteSubjectReportCSV(&buf, records); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
content = buf.Bytes()
contentType = "text/csv"
filename = fmt.Sprintf("dsgvo-auskunftsbericht-%s.csv", now.Format("2006-01-02"))
default:
http.Error(w, "report_type muss gobd oder dsgvo sein", http.StatusBadRequest)
return
}
report, token, err := insertReport(r.Context(), pool, req.ReportType, req.DataSubjectRef, requestedBy, filename, contentType, content)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(requestResponse{
ID: report.ID,
Filename: report.Filename,
DownloadToken: token,
ExpiresAt: report.ExpiresAt.Format(time.RFC3339),
})
}
}
// listHandler liefert die Historie (Akzeptanzkriterium 3).
func listHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
reports, err := listReports(r.Context(), pool)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(reports)
}
}
// downloadHandler liefert den gespeicherten Bericht aus, wenn Token und
// Ablauf passen (Pflichtprüfung 3: abgelaufene Links werden abgewiesen).
func downloadHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "token fehlt", http.StatusForbidden)
return
}
stored, err := loadReportContent(r.Context(), pool, id)
if err != nil {
http.Error(w, "bericht nicht gefunden", http.StatusNotFound)
return
}
if !tokenMatches(token, stored.tokenHash) {
http.Error(w, "token ungültig", http.StatusForbidden)
return
}
if time.Now().UTC().After(stored.expiresAt) {
http.Error(w, "downloadlink ist abgelaufen", http.StatusGone)
return
}
w.Header().Set("Content-Type", stored.contentType)
w.Header().Set("Content-Disposition", "attachment; filename=\""+stored.filename+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
_, _ = w.Write(stored.content)
}
}