570 lines
16 KiB
Go
570 lines
16 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{}
|
|
}
|
|
|
|
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,
|
|
}
|
|
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")
|
|
reason := r.URL.Query().Get("reason")
|
|
requestedBy := userFrom(r)
|
|
|
|
// Soft-delete user in users table
|
|
recordsDeleted := 0
|
|
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)
|
|
_, logErr := h.db.ExecContext(r.Context(),
|
|
`INSERT INTO gdpr_erasure_log (tenant_id, target_user, requested_by, reason, records_deleted)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
tenantID, targetUser, requestedBy, reason, recordsDeleted,
|
|
)
|
|
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{
|
|
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,
|
|
}
|
|
if s := r.URL.Query().Get("start"); s != "" {
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
q.StartTime = t
|
|
}
|
|
}
|
|
if s := r.URL.Query().Get("end"); s != "" {
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
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,
|
|
)
|
|
}
|
|
}
|