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

531 lines
18 KiB
Go

package compliance
import (
"bytes"
"fmt"
"io"
"strings"
"time"
"github.com/go-pdf/fpdf"
)
// ─── colour palette ───────────────────────────────────────────────────────────
var (
colNavy = [3]int{30, 58, 95}
colBlack = [3]int{30, 30, 30}
colGray = [3]int{100, 100, 100}
colLightBg = [3]int{245, 247, 250}
colRed = [3]int{220, 38, 38}
colOrange = [3]int{234, 88, 12}
colAmber = [3]int{180, 110, 10}
colGreen = [3]int{21, 128, 61}
)
func riskColor(risk string) [3]int {
switch risk {
case "forbidden":
return colRed
case "high":
return colOrange
case "limited":
return colAmber
case "minimal":
return colGreen
default:
return colGray
}
}
// ─── helpers ─────────────────────────────────────────────────────────────────
func newPDF() *fpdf.Fpdf {
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetMargins(20, 20, 20)
pdf.SetAutoPageBreak(true, 20)
return pdf
}
func setFont(pdf *fpdf.Fpdf, style string, size float64, col [3]int) {
pdf.SetFont("Helvetica", style, size)
pdf.SetTextColor(col[0], col[1], col[2])
}
func sectionHeader(pdf *fpdf.Fpdf, title string) {
pdf.Ln(6)
pdf.SetFillColor(colNavy[0], colNavy[1], colNavy[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Helvetica", "B", 10)
pdf.CellFormat(0, 8, " "+title, "", 1, "L", true, 0, "")
pdf.SetTextColor(colBlack[0], colBlack[1], colBlack[2])
pdf.Ln(2)
}
func labelValue(pdf *fpdf.Fpdf, label, value string) {
if value == "" {
value = "—"
}
setFont(pdf, "B", 9, colGray)
pdf.CellFormat(55, 6, label+":", "", 0, "L", false, 0, "")
setFont(pdf, "", 9, colBlack)
pdf.MultiCell(0, 6, value, "", "L", false)
}
func tableRow(pdf *fpdf.Fpdf, cols []string, widths []float64, fill bool) {
if fill {
pdf.SetFillColor(colLightBg[0], colLightBg[1], colLightBg[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
for i, col := range cols {
pdf.CellFormat(widths[i], 6, col, "1", 0, "L", fill, 0, "")
}
pdf.Ln(-1)
}
func footer(pdf *fpdf.Fpdf) {
pdf.SetFooterFunc(func() {
pdf.SetY(-15)
setFont(pdf, "I", 8, colGray)
pdf.CellFormat(0, 10,
fmt.Sprintf("Généré par Veylant IA · %s · Page %d/{nb}",
time.Now().Format("02/01/2006"),
pdf.PageNo(),
),
"", 0, "C", false, 0, "")
})
pdf.AliasNbPages("{nb}")
}
func covePage(pdf *fpdf.Fpdf, title, subtitle, tenantName string) {
pdf.AddPage()
pdf.Ln(30)
// Title block
pdf.SetFillColor(colNavy[0], colNavy[1], colNavy[2])
pdf.SetTextColor(255, 255, 255)
pdf.SetFont("Helvetica", "B", 22)
pdf.CellFormat(0, 18, title, "", 1, "C", true, 0, "")
pdf.SetFont("Helvetica", "", 13)
pdf.CellFormat(0, 10, subtitle, "", 1, "C", true, 0, "")
pdf.Ln(6)
// Tenant + date
pdf.SetTextColor(colBlack[0], colBlack[1], colBlack[2])
pdf.SetFont("Helvetica", "", 11)
pdf.CellFormat(0, 8, "Organisation : "+tenantName, "", 1, "C", false, 0, "")
pdf.CellFormat(0, 8, "Date de génération : "+time.Now().Format("02 janvier 2006 à 15:04"), "", 1, "C", false, 0, "")
pdf.Ln(10)
// Confidential stamp
pdf.SetFont("Helvetica", "B", 14)
pdf.SetTextColor(colRed[0], colRed[1], colRed[2])
pdf.CellFormat(0, 10, "⚠ DOCUMENT CONFIDENTIEL", "", 1, "C", false, 0, "")
}
// ─── GenerateArticle30 ────────────────────────────────────────────────────────
// GenerateArticle30 generates a GDPR Article 30 processing registry PDF.
func GenerateArticle30(entries []ProcessingEntry, tenantName string, w io.Writer) error {
if tenantName == "" {
tenantName = "Organisation"
}
pdf := newPDF()
footer(pdf)
covePage(pdf, "Registre des Activités de Traitement",
"Conformément à l'Article 30 du Règlement (UE) 2016/679 (RGPD)", tenantName)
// Section 1 — Responsable de traitement
pdf.AddPage()
sectionHeader(pdf, "1. Identification du Responsable de Traitement")
pdf.Ln(2)
labelValue(pdf, "Organisation", tenantName)
labelValue(pdf, "Plateforme IA", "Veylant IA — Proxy IA multi-fournisseurs")
labelValue(pdf, "DPO / Contact", "dpo@"+strings.ToLower(strings.ReplaceAll(tenantName, " ", ""))+".fr")
labelValue(pdf, "Cadre réglementaire", "RGPD (UE) 2016/679, Loi Informatique et Libertés")
// Section 2 — Tableau des traitements
sectionHeader(pdf, "2. Activités de Traitement")
pdf.Ln(2)
if len(entries) == 0 {
setFont(pdf, "I", 9, colGray)
pdf.CellFormat(0, 8, "Aucun traitement enregistré.", "", 1, "L", false, 0, "")
} else {
widths := []float64{55, 40, 30, 40}
headers := []string{"Cas d'usage", "Finalité", "Base légale", "Catégories de données"}
setFont(pdf, "B", 9, colBlack)
tableRow(pdf, headers, widths, true)
for i, e := range entries {
cats := strings.Join(e.DataCategories, ", ")
if len(cats) > 35 {
cats = cats[:32] + "..."
}
purpose := e.Purpose
if len(purpose) > 38 {
purpose = purpose[:35] + "..."
}
legalLabel := LegalBasisLabels[e.LegalBasis]
if legalLabel == "" {
legalLabel = e.LegalBasis
}
setFont(pdf, "", 8, colBlack)
tableRow(pdf, []string{e.UseCaseName, purpose, legalLabel, cats}, widths, i%2 == 0)
}
}
// Section 3 — Sous-traitants
sectionHeader(pdf, "3. Destinataires et Sous-Traitants (Fournisseurs LLM)")
pdf.Ln(2)
allProcessors := map[string]bool{}
for _, e := range entries {
for _, p := range e.Processors {
allProcessors[p] = true
}
for _, r := range e.Recipients {
allProcessors[r] = true
}
}
if len(allProcessors) == 0 {
setFont(pdf, "I", 9, colGray)
pdf.CellFormat(0, 6, "Aucun sous-traitant déclaré.", "", 1, "L", false, 0, "")
} else {
for proc := range allProcessors {
setFont(pdf, "", 9, colBlack)
pdf.CellFormat(5, 6, "•", "", 0, "L", false, 0, "")
pdf.CellFormat(0, 6, proc+" — fournisseur LLM (sous-traitant au sens de l'Art. 28 RGPD)", "", 1, "L", false, 0, "")
}
}
// Section 4 — Durées de conservation
sectionHeader(pdf, "4. Durées de Conservation")
pdf.Ln(2)
if len(entries) > 0 {
widths := []float64{85, 80}
headers := []string{"Cas d'usage", "Durée de conservation"}
setFont(pdf, "B", 9, colBlack)
tableRow(pdf, headers, widths, true)
for i, e := range entries {
setFont(pdf, "", 8, colBlack)
tableRow(pdf, []string{e.UseCaseName, e.RetentionPeriod}, widths, i%2 == 0)
}
}
pdf.Ln(3)
setFont(pdf, "I", 8, colGray)
pdf.MultiCell(0, 5,
"Architecture Veylant IA : journaux chauds 90 jours (ClickHouse), archives tièdes 1 an, archives froides 5 ans (TTL automatique).",
"", "L", false)
// Section 5 — Mesures de sécurité
sectionHeader(pdf, "5. Mesures de Sécurité Techniques et Organisationnelles")
pdf.Ln(2)
measures := []string{
"Chiffrement AES-256-GCM des prompts avant stockage",
"Pseudonymisation automatique des données personnelles (PII) avant transmission aux LLM",
"Contrôle d'accès RBAC (Admin, Manager, Utilisateur, Auditeur)",
"Authentification forte via Keycloak (OIDC/SAML 2.0 / MFA)",
"Journaux d'audit immuables (ClickHouse append-only, TTL uniquement)",
"TLS 1.3 pour toutes les communications externes",
"Circuit breaker pour la résilience des fournisseurs",
"Séparation logique multi-locataires (Row-Level Security PostgreSQL)",
}
for _, m := range measures {
setFont(pdf, "", 9, colBlack)
pdf.CellFormat(5, 6, "✓", "", 0, "L", false, 0, "")
pdf.MultiCell(0, 6, m, "", "L", false)
}
// Section 6 — Droits des personnes
sectionHeader(pdf, "6. Droits des Personnes Concernées")
pdf.Ln(2)
rights := []struct{ art, desc string }{
{"Art. 15", "Droit d'accès — Endpoint GET /v1/admin/compliance/gdpr/access/{user_id}"},
{"Art. 16", "Droit de rectification — via l'interface d'administration"},
{"Art. 17", "Droit à l'effacement — Endpoint DELETE /v1/admin/compliance/gdpr/erase/{user_id}"},
{"Art. 18", "Droit à la limitation — contact DPO"},
{"Art. 20", "Droit à la portabilité — export JSON/CSV disponible"},
{"Art. 21", "Droit d'opposition — contact DPO"},
{"Art. 22", "Droit à ne pas faire l'objet d'une décision automatisée — supervision humaine obligatoire"},
}
widths := []float64{20, 145}
setFont(pdf, "B", 9, colBlack)
tableRow(pdf, []string{"Article", "Description"}, widths, true)
for i, r := range rights {
setFont(pdf, "", 8, colBlack)
tableRow(pdf, []string{r.art, r.desc}, widths, i%2 == 0)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return fmt.Errorf("pdf output: %w", err)
}
_, err := w.Write(buf.Bytes())
return err
}
// ─── GenerateAiActReport ──────────────────────────────────────────────────────
// GenerateAiActReport generates an EU AI Act risk classification report PDF.
func GenerateAiActReport(entries []ProcessingEntry, tenantName string, w io.Writer) error {
if tenantName == "" {
tenantName = "Organisation"
}
pdf := newPDF()
footer(pdf)
covePage(pdf, "Rapport de Classification AI Act",
"Conformément au Règlement (UE) 2024/1689 sur l'Intelligence Artificielle", tenantName)
pdf.AddPage()
// Summary
sectionHeader(pdf, "Synthèse de la Classification")
pdf.Ln(2)
counts := map[string]int{"forbidden": 0, "high": 0, "limited": 0, "minimal": 0, "": 0}
for _, e := range entries {
counts[e.RiskLevel]++
}
widths := []float64{50, 30, 85}
setFont(pdf, "B", 9, colBlack)
tableRow(pdf, []string{"Niveau de risque", "Nb systèmes", "Obligations réglementaires"}, widths, true)
obligations := map[string]string{
"forbidden": "INTERDIT — blocage automatique requis",
"high": "DPIA obligatoire · supervision humaine · journalisation renforcée",
"limited": "Obligation de transparence (Art. 50) · mention IA requise",
"minimal": "Journalisation standard uniquement",
"": "Non classifié — questionnaire à compléter",
}
riskOrder := []string{"forbidden", "high", "limited", "minimal", ""}
for i, risk := range riskOrder {
label := RiskLabels[risk]
if label == "" {
label = "Non classifié"
}
col := riskColor(risk)
pdf.SetTextColor(col[0], col[1], col[2])
pdf.SetFont("Helvetica", "B", 8)
fill := i%2 == 0
if fill {
pdf.SetFillColor(colLightBg[0], colLightBg[1], colLightBg[2])
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.CellFormat(widths[0], 6, label, "1", 0, "L", fill, 0, "")
setFont(pdf, "", 8, colBlack)
pdf.CellFormat(widths[1], 6, fmt.Sprintf("%d", counts[risk]), "1", 0, "C", fill, 0, "")
pdf.CellFormat(widths[2], 6, obligations[risk], "1", 1, "L", fill, 0, "")
}
// Per-system detail
if len(entries) > 0 {
sectionHeader(pdf, "Détail par Système IA")
pdf.Ln(2)
for _, e := range entries {
col := riskColor(e.RiskLevel)
riskLabel := RiskLabels[e.RiskLevel]
if riskLabel == "" {
riskLabel = "Non classifié"
}
// System header
pdf.SetFillColor(colLightBg[0], colLightBg[1], colLightBg[2])
pdf.SetFont("Helvetica", "B", 10)
pdf.SetTextColor(colNavy[0], colNavy[1], colNavy[2])
pdf.CellFormat(0, 8, " "+e.UseCaseName, "LRT", 1, "L", true, 0, "")
// Risk badge
pdf.SetFont("Helvetica", "B", 9)
pdf.SetTextColor(col[0], col[1], col[2])
pdf.CellFormat(40, 6, " Niveau : "+riskLabel, "LB", 0, "L", true, 0, "")
setFont(pdf, "", 9, colBlack)
pdf.CellFormat(0, 6, " Base légale : "+LegalBasisLabels[e.LegalBasis], "RB", 1, "L", true, 0, "")
// Details
pdf.Ln(1)
labelValue(pdf, "Finalité", e.Purpose)
labelValue(pdf, "Données traitées", strings.Join(e.DataCategories, ", "))
labelValue(pdf, "Durée conservation", e.RetentionPeriod)
if len(e.AiActAnswers) > 0 {
yesItems := []string{}
for _, q := range AiActQuestions {
if e.AiActAnswers[q.Key] {
yesItems = append(yesItems, "• "+q.Label)
}
}
if len(yesItems) > 0 {
setFont(pdf, "B", 9, colGray)
pdf.CellFormat(55, 6, "Critères AI Act :", "", 1, "L", false, 0, "")
setFont(pdf, "", 8, colBlack)
for _, yi := range yesItems {
pdf.MultiCell(0, 5, " "+yi, "", "L", false)
}
}
}
pdf.Ln(4)
}
}
// Regulatory note
sectionHeader(pdf, "Note Réglementaire")
pdf.Ln(2)
setFont(pdf, "", 9, colBlack)
pdf.MultiCell(0, 6,
"Ce rapport est généré conformément au Règlement (UE) 2024/1689 sur l'Intelligence Artificielle (AI Act), "+
"entré en vigueur le 1er août 2024. Les systèmes classifiés \"Haut risque\" sont soumis à une évaluation "+
"de conformité avant déploiement. Les systèmes \"Interdits\" ne peuvent être mis en service sur le territoire "+
"de l'Union Européenne. Ce document doit être mis à jour à chaque modification substantielle d'un système IA.",
"", "L", false)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return fmt.Errorf("pdf output: %w", err)
}
_, err := w.Write(buf.Bytes())
return err
}
// ─── GenerateDPIA ─────────────────────────────────────────────────────────────
// GenerateDPIA generates a pre-filled DPIA template for a processing entry (Art. 35 GDPR).
func GenerateDPIA(entry ProcessingEntry, tenantName string, w io.Writer) error {
if tenantName == "" {
tenantName = "Organisation"
}
pdf := newPDF()
footer(pdf)
covePage(pdf, "Analyse d'Impact relative à la Protection des Données",
"Data Protection Impact Assessment (DPIA) — Article 35 RGPD", tenantName)
pdf.AddPage()
// Section 1 — Description
sectionHeader(pdf, "1. Description du Traitement")
pdf.Ln(2)
labelValue(pdf, "Cas d'usage", entry.UseCaseName)
labelValue(pdf, "Finalité", entry.Purpose)
labelValue(pdf, "Base légale", LegalBasisLabels[entry.LegalBasis])
labelValue(pdf, "Catégories de données", strings.Join(entry.DataCategories, ", "))
labelValue(pdf, "Destinataires", strings.Join(entry.Recipients, ", "))
labelValue(pdf, "Sous-traitants LLM", strings.Join(entry.Processors, ", "))
labelValue(pdf, "Durée de conservation", entry.RetentionPeriod)
labelValue(pdf, "Classification AI Act", RiskLabels[entry.RiskLevel])
// Section 2 — Nécessité et proportionnalité
sectionHeader(pdf, "2. Nécessité et Proportionnalité")
pdf.Ln(2)
setFont(pdf, "", 9, colBlack)
pdf.MultiCell(0, 6,
"Le traitement est nécessaire pour atteindre la finalité identifiée. "+
"La pseudonymisation automatique des données personnelles par Veylant IA "+
"(avant transmission aux fournisseurs LLM) constitue une mesure de minimisation des données "+
"conforme à l'Art. 5(1)(c) RGPD. "+
"Seules les catégories de données strictement nécessaires sont traitées.",
"", "L", false)
// Section 3 — Risques
sectionHeader(pdf, "3. Évaluation des Risques")
pdf.Ln(2)
risks := []struct{ risk, proba, impact, mitigation string }{
{
"Accès non autorisé aux données",
"Faible",
"Élevé",
"RBAC strict, MFA, TLS 1.3, chiffrement AES-256-GCM",
},
{
"Fuite de données vers fournisseur LLM",
"Très faible",
"Élevé",
"Pseudonymisation PII avant envoi, contrats DPA avec fournisseurs (Art. 28)",
},
{
"Rétention excessive des données",
"Faible",
"Moyen",
"TTL automatique ClickHouse, politique de rétention définie (" + entry.RetentionPeriod + ")",
},
{
"Décision automatisée non supervisée",
"Moyen",
"Élevé",
"Supervision humaine obligatoire pour décisions à impact légal",
},
{
"Indisponibilité du service",
"Faible",
"Moyen",
"Circuit breaker, failover multi-fournisseurs, monitoring Prometheus",
},
}
widths := []float64{60, 22, 22, 61}
setFont(pdf, "B", 9, colBlack)
tableRow(pdf, []string{"Risque", "Probabilité", "Impact", "Mesure d'atténuation"}, widths, true)
for i, r := range risks {
setFont(pdf, "", 8, colBlack)
tableRow(pdf, []string{r.risk, r.proba, r.impact, r.mitigation}, widths, i%2 == 0)
}
// Section 4 — Mesures d'atténuation
sectionHeader(pdf, "4. Mesures d'Atténuation Implémentées")
pdf.Ln(2)
if entry.SecurityMeasures != "" {
labelValue(pdf, "Mesures spécifiques", entry.SecurityMeasures)
}
genericMeasures := []string{
"Pseudonymisation automatique des PII (regex + NER + validation LLM)",
"Chiffrement AES-256-GCM au repos et TLS 1.3 en transit",
"RBAC avec 4 niveaux (Admin, Manager, Utilisateur, Auditeur)",
"Journaux d'audit immuables avec conservation " + entry.RetentionPeriod,
"Tests de sécurité SAST/DAST en pipeline CI/CD",
"Contrats de sous-traitance (DPA) avec chaque fournisseur LLM",
}
for _, m := range genericMeasures {
setFont(pdf, "", 9, colBlack)
pdf.CellFormat(5, 6, "✓", "", 0, "L", false, 0, "")
pdf.MultiCell(0, 6, m, "", "L", false)
}
// Section 5 — Risque résiduel
sectionHeader(pdf, "5. Risque Résiduel et Conclusion")
pdf.Ln(2)
setFont(pdf, "", 9, colBlack)
pdf.MultiCell(0, 6,
"Après application des mesures d'atténuation identifiées, le risque résiduel est évalué comme "+
"ACCEPTABLE. Ce traitement peut être mis en œuvre sous réserve du respect continu des mesures "+
"de sécurité décrites. Une réévaluation annuelle ou lors de toute modification substantielle "+
"du traitement est recommandée.",
"", "L", false)
// Section 6 — Signatures
sectionHeader(pdf, "6. Approbation")
pdf.Ln(4)
col1 := 85.0
col2 := 85.0
setFont(pdf, "B", 9, colBlack)
pdf.CellFormat(col1, 6, "Responsable de traitement", "", 0, "C", false, 0, "")
pdf.CellFormat(col2, 6, "Délégué à la Protection des Données", "", 1, "C", false, 0, "")
pdf.Ln(10)
setFont(pdf, "", 9, colGray)
pdf.CellFormat(col1, 6, "Signature : ________________________", "", 0, "C", false, 0, "")
pdf.CellFormat(col2, 6, "Signature : ________________________", "", 1, "C", false, 0, "")
pdf.Ln(3)
pdf.CellFormat(col1, 6, "Date : ____/____/________", "", 0, "C", false, 0, "")
pdf.CellFormat(col2, 6, "Date : ____/____/________", "", 1, "C", false, 0, "")
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return fmt.Errorf("pdf output: %w", err)
}
_, err := w.Write(buf.Bytes())
return err
}