veylant/internal/compliance/handler.go
2026-03-13 12:43:20 +01:00

605 lines
18 KiB
Go

package compliance
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/auditlog"
"github.com/veylant/ia-gateway/internal/middleware"
)
// Handler provides HTTP endpoints for the compliance module.
type Handler struct {
store ComplianceStore
auditLog auditlog.Logger // nil → 501 for GDPR and export endpoints
db *sql.DB // nil → 501 for Art. 17 erasure log
tenantName string
logger *zap.Logger
}
// New creates a compliance Handler.
func New(store ComplianceStore, logger *zap.Logger) *Handler {
return &Handler{store: store, logger: logger, tenantName: "Organisation"}
}
// WithAudit attaches an audit logger (required for GDPR access/erase + CSV export).
func (h *Handler) WithAudit(al auditlog.Logger) *Handler {
h.auditLog = al
return h
}
// WithDB attaches a database connection (required for Art. 17 erasure log).
func (h *Handler) WithDB(db *sql.DB) *Handler {
h.db = db
return h
}
// WithTenantName sets the tenant display name used in PDF headers.
func (h *Handler) WithTenantName(name string) *Handler {
if name != "" {
h.tenantName = name
}
return h
}
// Routes registers all compliance endpoints on r.
// Callers must mount r under an authenticated prefix.
func (h *Handler) Routes(r chi.Router) {
// Processing registry CRUD (E9-01)
r.Get("/entries", h.listEntries)
r.Post("/entries", h.createEntry)
r.Get("/entries/{id}", h.getEntry)
r.Put("/entries/{id}", h.updateEntry)
r.Delete("/entries/{id}", h.deleteEntry)
// AI Act classification (E9-02)
r.Post("/entries/{id}/classify", h.classifyEntry)
// PDF reports (E9-03, E9-04, E9-07)
r.Get("/report/article30", h.reportArticle30)
r.Get("/report/aiact", h.reportAiAct)
r.Get("/dpia/{id}", h.reportDPIA)
// GDPR rights (E9-05, E9-06)
r.Get("/gdpr/access/{user_id}", h.gdprAccess)
r.Delete("/gdpr/erase/{user_id}", h.gdprErase)
// CSV export (E7-10)
r.Get("/export/logs", h.exportLogsCSV)
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func tenantFrom(w http.ResponseWriter, r *http.Request) (string, bool) {
claims, ok := middleware.ClaimsFromContext(r.Context())
if !ok || claims.TenantID == "" {
apierror.WriteError(w, apierror.NewAuthError("missing authentication"))
return "", false
}
return claims.TenantID, true
}
func userFrom(r *http.Request) string {
if claims, ok := middleware.ClaimsFromContext(r.Context()); ok {
return claims.UserID
}
return "unknown"
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeStoreError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) {
apierror.WriteError(w, &apierror.APIError{
Type: "not_found_error", Message: "entry not found", HTTPStatus: http.StatusNotFound,
})
return
}
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
}
// ─── CRUD ────────────────────────────────────────────────────────────────────
func (h *Handler) listEntries(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
entries, err := h.store.List(r.Context(), tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to list entries: "+err.Error()))
return
}
if entries == nil {
entries = []ProcessingEntry{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"data": entries})
}
type entryRequest struct {
UseCaseName string `json:"use_case_name"`
LegalBasis string `json:"legal_basis"`
Purpose string `json:"purpose"`
DataCategories []string `json:"data_categories"`
Recipients []string `json:"recipients"`
Processors []string `json:"processors"`
RetentionPeriod string `json:"retention_period"`
SecurityMeasures string `json:"security_measures"`
ControllerName string `json:"controller_name"`
}
func validateEntry(req entryRequest) error {
if req.UseCaseName == "" {
return fmt.Errorf("use_case_name is required")
}
if req.LegalBasis == "" {
return fmt.Errorf("legal_basis is required")
}
if req.Purpose == "" {
return fmt.Errorf("purpose is required")
}
if req.RetentionPeriod == "" {
return fmt.Errorf("retention_period is required")
}
return nil
}
func (h *Handler) createEntry(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
var req entryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
return
}
if err := validateEntry(req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError(err.Error()))
return
}
if req.DataCategories == nil {
req.DataCategories = []string{}
}
if req.Recipients == nil {
req.Recipients = []string{}
}
if req.Processors == nil {
req.Processors = []string{}
}
entry := ProcessingEntry{
TenantID: tenantID,
UseCaseName: req.UseCaseName,
LegalBasis: req.LegalBasis,
Purpose: req.Purpose,
DataCategories: req.DataCategories,
Recipients: req.Recipients,
Processors: req.Processors,
RetentionPeriod: req.RetentionPeriod,
SecurityMeasures: req.SecurityMeasures,
ControllerName: req.ControllerName,
IsActive: true,
}
created, err := h.store.Create(r.Context(), entry)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to create entry: "+err.Error()))
return
}
h.logger.Info("compliance entry created",
zap.String("id", created.ID),
zap.String("tenant_id", tenantID),
)
writeJSON(w, http.StatusCreated, created)
}
func (h *Handler) getEntry(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
entry, err := h.store.Get(r.Context(), id, tenantID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, entry)
}
func (h *Handler) updateEntry(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
var req entryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
return
}
if err := validateEntry(req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError(err.Error()))
return
}
if req.DataCategories == nil {
req.DataCategories = []string{}
}
if req.Recipients == nil {
req.Recipients = []string{}
}
if req.Processors == nil {
req.Processors = []string{}
}
// Fetch existing entry to preserve AI Act classification (risk_level + ai_act_answers).
// Without this, every Registre edit would null out a previously saved classification.
existing, fetchErr := h.store.Get(r.Context(), id, tenantID)
if fetchErr != nil {
writeStoreError(w, fetchErr)
return
}
entry := ProcessingEntry{
ID: id,
TenantID: tenantID,
UseCaseName: req.UseCaseName,
LegalBasis: req.LegalBasis,
Purpose: req.Purpose,
DataCategories: req.DataCategories,
Recipients: req.Recipients,
Processors: req.Processors,
RetentionPeriod: req.RetentionPeriod,
SecurityMeasures: req.SecurityMeasures,
ControllerName: req.ControllerName,
IsActive: true,
RiskLevel: existing.RiskLevel,
AiActAnswers: existing.AiActAnswers,
}
updated, err := h.store.Update(r.Context(), entry)
if err != nil {
writeStoreError(w, err)
return
}
h.logger.Info("compliance entry updated",
zap.String("id", id),
zap.String("tenant_id", tenantID),
)
writeJSON(w, http.StatusOK, updated)
}
func (h *Handler) deleteEntry(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
if err := h.store.Delete(r.Context(), id, tenantID); err != nil {
writeStoreError(w, err)
return
}
h.logger.Info("compliance entry deleted",
zap.String("id", id),
zap.String("tenant_id", tenantID),
)
w.WriteHeader(http.StatusNoContent)
}
// ─── AI Act classification (E9-02) ───────────────────────────────────────────
type classifyRequest struct {
Answers map[string]bool `json:"answers"`
}
func (h *Handler) classifyEntry(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
var req classifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
return
}
if len(req.Answers) == 0 {
apierror.WriteError(w, apierror.NewBadRequestError("answers is required"))
return
}
// Fetch current entry
entry, err := h.store.Get(r.Context(), id, tenantID)
if err != nil {
writeStoreError(w, err)
return
}
// Compute risk level
entry.RiskLevel = ScoreRisk(req.Answers)
entry.AiActAnswers = req.Answers
updated, err := h.store.Update(r.Context(), entry)
if err != nil {
writeStoreError(w, err)
return
}
h.logger.Info("AI Act classification updated",
zap.String("id", id),
zap.String("risk_level", updated.RiskLevel),
zap.String("tenant_id", tenantID),
)
writeJSON(w, http.StatusOK, updated)
}
// ─── PDF reports ─────────────────────────────────────────────────────────────
func (h *Handler) reportArticle30(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
entries, err := h.store.List(r.Context(), tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to load entries: "+err.Error()))
return
}
format := r.URL.Query().Get("format")
if format == "json" {
writeJSON(w, http.StatusOK, map[string]interface{}{"data": entries})
return
}
filename := fmt.Sprintf("article30_rgpd_%s.pdf", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if err := GenerateArticle30(entries, h.tenantName, w); err != nil {
h.logger.Error("Article 30 PDF generation failed", zap.Error(err))
}
}
func (h *Handler) reportAiAct(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
entries, err := h.store.List(r.Context(), tenantID)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to load entries: "+err.Error()))
return
}
format := r.URL.Query().Get("format")
if format == "json" {
writeJSON(w, http.StatusOK, map[string]interface{}{"data": entries})
return
}
filename := fmt.Sprintf("aiact_report_%s.pdf", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if err := GenerateAiActReport(entries, h.tenantName, w); err != nil {
h.logger.Error("AI Act PDF generation failed", zap.Error(err))
}
}
func (h *Handler) reportDPIA(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
entry, err := h.store.Get(r.Context(), id, tenantID)
if err != nil {
writeStoreError(w, err)
return
}
filename := fmt.Sprintf("dpia_%s_%s.pdf", id[:8], time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
if err := GenerateDPIA(entry, h.tenantName, w); err != nil {
h.logger.Error("DPIA PDF generation failed", zap.Error(err))
}
}
// ─── GDPR Art. 15 — right of access ──────────────────────────────────────────
func (h *Handler) gdprAccess(w http.ResponseWriter, r *http.Request) {
if h.auditLog == nil {
apierror.WriteError(w, &apierror.APIError{
Type: "not_implemented", Message: "audit logging not enabled", HTTPStatus: http.StatusNotImplemented,
})
return
}
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
targetUser := chi.URLParam(r, "user_id")
q := auditlog.AuditQuery{
TenantID: tenantID,
UserID: targetUser,
Limit: 1000,
}
result, err := h.auditLog.Query(r.Context(), q)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to query logs: "+err.Error()))
return
}
h.logger.Info("GDPR Art. 15 access request",
zap.String("target_user", targetUser),
zap.String("requested_by", userFrom(r)),
zap.Int("records", result.Total),
)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": targetUser,
"generated_at": time.Now().Format(time.RFC3339),
"total": result.Total,
"records": result.Data,
})
}
// ─── GDPR Art. 17 — right to erasure ─────────────────────────────────────────
func (h *Handler) gdprErase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
targetUser := chi.URLParam(r, "user_id")
requestedBy := userFrom(r)
// reason is sent as JSON body by the frontend; fall back to query param for API clients.
var reason string
var bodyReq struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&bodyReq); err == nil && bodyReq.Reason != "" {
reason = bodyReq.Reason
} else {
reason = r.URL.Query().Get("reason")
}
// Soft-delete user in users table
recordsDeleted := 0
erasureID := ""
if h.db != nil {
res, err := h.db.ExecContext(r.Context(),
`UPDATE users SET is_active=FALSE, updated_at=NOW() WHERE email=$1 AND tenant_id=$2`,
targetUser, tenantID,
)
if err != nil {
h.logger.Warn("GDPR erase: users table update failed", zap.Error(err))
} else {
n, _ := res.RowsAffected()
recordsDeleted = int(n)
}
// Log erasure (immutable) — read back generated UUID for the response.
logErr := h.db.QueryRowContext(r.Context(),
`INSERT INTO gdpr_erasure_log (tenant_id, target_user, requested_by, reason, records_deleted)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
tenantID, targetUser, requestedBy, reason, recordsDeleted,
).Scan(&erasureID)
if logErr != nil {
h.logger.Error("GDPR erase: failed to write erasure log", zap.Error(logErr))
}
}
h.logger.Info("GDPR Art. 17 erasure",
zap.String("target_user", targetUser),
zap.String("requested_by", requestedBy),
zap.Int("records_deleted", recordsDeleted),
)
writeJSON(w, http.StatusOK, ErasureRecord{
ID: erasureID,
TenantID: tenantID,
TargetUser: targetUser,
RequestedBy: requestedBy,
Reason: reason,
RecordsDeleted: recordsDeleted,
Status: "completed",
CreatedAt: time.Now(),
})
}
// ─── CSV export (E7-10) ───────────────────────────────────────────────────────
func (h *Handler) exportLogsCSV(w http.ResponseWriter, r *http.Request) {
if h.auditLog == nil {
apierror.WriteError(w, &apierror.APIError{
Type: "not_implemented", Message: "audit logging not enabled", HTTPStatus: http.StatusNotImplemented,
})
return
}
tenantID, ok := tenantFrom(w, r)
if !ok {
return
}
q := auditlog.AuditQuery{
TenantID: tenantID,
Provider: r.URL.Query().Get("provider"),
Limit: 10000,
}
// Accept both RFC3339 (API clients) and date-only YYYY-MM-DD (HTML date input from frontend).
parseDate := func(s string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t
}
}
return time.Time{}
}
if s := r.URL.Query().Get("start"); s != "" {
if t := parseDate(s); !t.IsZero() {
q.StartTime = t
}
}
if s := r.URL.Query().Get("end"); s != "" {
if t := parseDate(s); !t.IsZero() {
// For date-only end, include the full day (end at 23:59:59).
if len(s) == 10 {
t = t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
q.EndTime = t
}
}
result, err := h.auditLog.Query(r.Context(), q)
if err != nil {
apierror.WriteError(w, apierror.NewUpstreamError("failed to query logs: "+err.Error()))
return
}
filename := fmt.Sprintf("audit_logs_%s_%s.csv", tenantID[:8], time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
// Write CSV header
fmt.Fprintln(w, "request_id,timestamp,user_id,tenant_id,provider,model_requested,model_used,department,user_role,sensitivity_level,token_input,token_output,token_total,cost_usd,latency_ms,status,error_type,pii_entity_count,stream")
for _, e := range result.Data {
fmt.Fprintf(w, "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%d,%d,%d,%.6f,%d,%s,%s,%d,%t\n",
e.RequestID,
e.Timestamp.Format(time.RFC3339),
e.UserID,
e.TenantID,
e.Provider,
e.ModelRequested,
e.ModelUsed,
e.Department,
e.UserRole,
e.SensitivityLevel,
e.TokenInput,
e.TokenOutput,
e.TokenTotal,
e.CostUSD,
e.LatencyMs,
e.Status,
e.ErrorType,
e.PIIEntityCount,
e.Stream,
)
}
}