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, ) } }