Rôle : Infrastructure, sécurité, conformité — tables techniques invisibles des utilisateurs finaux. Journal audit (ISO 27001 / RGPD), codes 2FA, config k/v, monitoring (snapshots 2 min / alertes), smoke tests, audit sécurité quotidien, intégration LTI et registre des violations (Art. 33/34 RGPD).
⚠️ Points clés :
app_settings utilise key TEXT PK (pas id) — SELECT value FROM sys.app_settings WHERE key=$1 ·
monitoring_snapshots capturés toutes les 2 min, rétention 90 jours ·
two_fa_codes.code stocké en clair mais purgé toutes les 2h (scheduler) — ne jamais logger ·
audit_logs.details est TEXT (JSON sérialisé côté app, pas JSONB) ·
5 tables avec id INTEGER SERIAL (breach_register, monitoring_alerts, monitoring_snapshots, security_audits, smoke_test_results) — les autres en TEXT UUID ·
breach_register utilise BOOLEAN natif (cnil_notified, persons_notified) — seule table sys à le faire.
📜 audit_logs — Journal d'audit ISO 27001 / RGPD (9 colonnes)
Colonnes
| id | TEXT PK | UUID |
| user_id | TEXT | user_id auteur (nullable pour actions système) |
| user_email | TEXT | Dénormalisation pour audit lisible même si user supprimé |
| action | TEXT NOT NULL | login · login_failed · logout · create · update · delete · quiz_submit · rgpd_export · rgpd_erase… |
| target_type | TEXT | Type d'entité ciblée (user, certification, session…) |
| target_id | TEXT | id de l'entité |
| details | TEXT | JSON sérialisé (PAS JSONB) — contexte libre. Parser avec ::jsonb ou JSON.parse |
| ip_address | TEXT | Anonymisée RGPD après 12 mois (cron purge) |
| user_agent | TEXT | Idem |
| created_at | TIMESTAMPTZ | Défaut NOW() |
📝 Exemple — tentatives de login échouées 24h
SELECT user_email, ip_address, details::jsonb->>'reason' AS raison,
COUNT(*) AS nb
FROM sys.audit_logs
WHERE action = 'login_failed'
AND created_at > NOW() - INTERVAL '24 hours'
GROUP BY user_email, ip_address, raison
ORDER BY nb DESC;
Obligation ISO 27001 : INSERT à chaque action sensible. details en TEXT (pas JSONB natif) → parser explicitement. Purge RGPD des IP/UA à 12 mois via scheduler (2h nuit).
🚨 breach_register — Registre violations RGPD (Art. 33/34) (16 colonnes) ★ NEW doc
Colonnes
| id | INTEGER SERIAL PK | ⚠️ INTEGER (pas UUID) |
| detected_at | TIMESTAMPTZ NOT NULL | Date/heure de détection (défaut NOW()) |
| description | TEXT NOT NULL | Description du fait |
| data_types | TEXT | Types de données concernées (JSON ou liste libre) |
| nb_persons_affected | INTEGER | Nombre estimé |
| severity | TEXT | Défaut moderate · low · moderate · high · critical |
| cnil_notified | BOOLEAN | ⚠️ BOOLEAN natif (pas INTEGER 0/1) — défaut false |
| cnil_notification_at | TIMESTAMPTZ | Horodatage notif CNIL (obligatoire sous 72h) |
| cnil_reference | TEXT | Numéro de dossier CNIL |
| persons_notified | BOOLEAN | BOOLEAN — défaut false (Art. 34 RGPD) |
| persons_notification_at | TIMESTAMPTZ | Horodatage notif individuelle |
| root_cause | TEXT | Cause racine après investigation |
| corrective_actions | TEXT | Actions correctives mises en œuvre |
| resolved_at | TIMESTAMPTZ | Date de clôture |
| reported_by | TEXT | user_id ou email du rapporteur |
| status | TEXT | Défaut open · investigating · notified · closed |
| created_at | TIMESTAMPTZ | Défaut NOW() |
📝 Exemple — violations non notifiées dans les 72h
SELECT id, detected_at, severity, description
FROM sys.breach_register
WHERE cnil_notified = false
AND detected_at < NOW() - INTERVAL '72 hours'
AND status <> 'closed';
Art. 33 RGPD : notification CNIL obligatoire sous 72h après détection si risque pour les droits/libertés. Art. 34 : notification individuelle si risque élevé. Seule table sys utilisant BOOLEAN natif — filtrer avec = true / = false (PAS = 1).
🛡️ security_audits — Audit sécurité quotidien (6 colonnes)
Colonnes
| id | INTEGER SERIAL PK | |
| audited_at | TIMESTAMPTZ | Défaut NOW() — exécuté à 7h chaque jour |
| score | REAL NOT NULL | Score obtenu (ex 9.8) |
| max_score | REAL NOT NULL | Défaut 10 |
| grade | TEXT NOT NULL | A (≥9) · B (≥7) · C (≥5) · D (<5) |
| results_json | TEXT NOT NULL | JSON détaillé des 16+ checks (P01→P16 — SSH, JWT, CORS, Helmet, CSP, SQLi, etc.) |
Score actuel V1.19 : 9.8/10 Grade A. Page Diagnostique (carte "🔐 Audit sécurité") appelle cette table pour le dernier run + trigger manuel via POST /admin/diagnostics/trigger/audit.
🔑 two_fa_codes — Codes 2FA temporaires (6 colonnes)
Colonnes
| id | TEXT PK | UUID |
| user_id | TEXT NOT NULL | FK logique → public.users(id) |
| code | TEXT NOT NULL | ⚠️ Code 6 chiffres stocké en clair — ne jamais logger |
| expires_at | TIMESTAMPTZ NOT NULL | +15 min à la génération |
| used | INTEGER 0/1 | Défaut 0 — marqué 1 après validation |
| created_at | TIMESTAMPTZ | Défaut NOW() |
Usage : login admin/user client (obligatoire), login viewer (exempt), quiz entreprise (cookie preQuizAuth 2h). Purge auto par scheduler (cron 2h) des codes expires_at < NOW() ou used=1 — le code clair disparaît très vite. Email via Brevo SMTP.
📊 monitoring_snapshots — Snapshots métriques 2 min (3 colonnes)
Colonnes
| id | INTEGER SERIAL PK | |
| captured_at | TIMESTAMPTZ | Défaut NOW() |
| metrics | TEXT NOT NULL | JSON (stocké TEXT) : {pool_total, pool_active, pool_idle, pool_waiting, pool_max, heap_mb, rss_mb, cpu_pct, load_avg, ram_pct} |
📝 Exemple — charge moyenne 1h
SELECT
AVG((metrics::jsonb->>'cpu_pct')::real) AS cpu_avg,
MAX((metrics::jsonb->>'pool_active')::int) AS pool_peak
FROM sys.monitoring_snapshots
WHERE captured_at > NOW() - INTERVAL '1 hour';
V1.18+ : cron */2 * * * * (toutes les 2 min, était 5), rétention 90 jours (était 7), purge auto à chaque snapshot. Alimente les 4 sparkline (pool PG / CPU / RAM / Heap) de la carte "📈 Historique de charge" sur Diagnostique.
🔔 monitoring_alerts — Alertes seuil dépassé (10 colonnes)
Colonnes
| id | INTEGER SERIAL PK | |
| triggered_at | TIMESTAMPTZ | Défaut NOW() |
| alert_type | TEXT NOT NULL | cpu_high · disk_full · pool_saturation · backup_failed · smoke_fail… |
| severity | TEXT NOT NULL | Défaut warning · critical |
| message | TEXT NOT NULL | Message humain |
| value | REAL | Valeur mesurée |
| threshold_value | REAL | Seuil déclencheur |
| resolved_at | TEXT | ⚠️ TEXT (pas TIMESTAMPTZ) — à caster avec ::timestamptz si comparaison |
| notified_email | INTEGER 0/1 | Défaut 0 → 1 après envoi Brevo |
| notified_telegram | INTEGER 0/1 | Défaut 0 → 1 après envoi Telegram bot |
Notifications duales : email admin + Telegram (bot @FormaleoMonitorBot). Double flag pour éviter les spams si retry. resolved_at stocké TEXT par inadvertance historique — prévoir le cast.
🧪 smoke_test_results — Résultats smoke tests (8 colonnes)
Colonnes
| id | INTEGER SERIAL PK | |
| run_at | TIMESTAMPTZ | Défaut NOW() — cron toutes les 6h |
| total_tests | INTEGER NOT NULL | 30 tests |
| passed | INTEGER NOT NULL | |
| failed | INTEGER NOT NULL | |
| duration_ms | INTEGER NOT NULL | ~730 ms actuellement (V1.17 après fix PG) |
| details | TEXT | JSON sérialisé — liste {name, status, duration_ms, error?} |
| status | TEXT NOT NULL | Défaut ok · warning · error |
Comptes dédiés smoke-test-client@formaleo.fr + smoke-test-agency@formaleo.fr (rôle viewer, sans 2FA). Ne pas supprimer — le scheduler échoue sinon. Trigger manuel : POST /admin/diagnostics/trigger/smoke.
⚙️ app_settings — Configuration k/v (3 colonnes)
Colonnes
| key | TEXT PK | ⚠️ La PK est key (PAS id) |
| value | TEXT NOT NULL | Toujours TEXT — caster selon usage |
| updated_at | TIMESTAMPTZ | Défaut NOW() |
Clés utilisées actuellement
cgv_version | Version CGV en vigueur (ex 2026-04-15) |
cgu_version | Version CGU (V1.24+) |
last_backup_at | ISO — affiché sur diagnostique |
last_backup_status | OK / PARTIEL (S3=1 Strasbourg=0) |
last_backup_size | Ex 38M |
📝 Exemple — UPSERT paramètre
INSERT INTO sys.app_settings (key, value, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
updated_at = NOW();
Toujours utiliser UPSERT ON CONFLICT (key). Ne pas créer d'autre table de config : centraliser ici.
🔗 lti_tools — Outils LTI 1.3 configurés (10 colonnes)
Colonnes
| id | TEXT PK | UUID |
| name | TEXT NOT NULL | Ex Memoforma |
| client_id | TEXT NOT NULL | OAuth 2.0 client_id |
| deployment_id | TEXT | LTI deployment ID |
| oidc_initiation_url | TEXT NOT NULL | URL de lancement OIDC côté LMS |
| launch_url | TEXT NOT NULL | URL cible après auth |
| jwks_url | TEXT NOT NULL | JWKS (clés publiques LMS) |
| is_active | INTEGER 0/1 | Défaut 1 |
| notes | TEXT | |
| created_at | TIMESTAMPTZ | Défaut NOW() |
FK ← content.elearning_modules.lti_tool_id. Pas de secret stocké : LTI 1.3 utilise des paires de clés JWT (public via jwks_url, privée côté LMS).
🎫 lti_states — Nonces LTI anti-CSRF (7 colonnes)
Colonnes
| state | TEXT PK | ⚠️ La PK est state (pas id) |
| user_id | TEXT NOT NULL | Qui initie le launch |
| tool_id | TEXT NOT NULL | FK logique → lti_tools(id) |
| module_id | TEXT | FK logique → content.elearning_modules(id) |
| resource_link_id | TEXT | ID ressource côté LMS |
| nonce | TEXT NOT NULL | Anti-replay OIDC |
| expires_at | BIGINT NOT NULL | ⚠️ BIGINT (timestamp Unix ms) — pas TIMESTAMPTZ |
Durée de vie 10 min. Purge à chaque launch. Cast explicite nécessaire pour comparer expires_at à NOW() : EXTRACT(EPOCH FROM NOW())*1000 > expires_at.
💡 Page Diagnostique agrège ces tables : monitoring_snapshots (courbes 2 min), monitoring_alerts (dernières), smoke_test_results (dernier run), security_audits (dernier grade), audit_logs (volumétrie), app_settings (backup + versions docs légaux).