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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|