## Contexte Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal. ## Ce qui est ajouté (capté automatiquement, sans friction utilisateur) - **Adresse IP** de la requête - **User-Agent brut** (borné à 1024 caractères) - **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`) - **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé ## Implémentation - `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée - 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible) - Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié - Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos) - Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe) - `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy - Docs : `doc/audit-logging.md` + `CLAUDE.md` ## Hors périmètre (étapes suivantes) - **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API. - La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin. ## À noter pour le déploiement - L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté). ## Tests `OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #33 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
7.1 KiB
Contexte forensique dans le journal d'activité
Date : 2026-06-24
Branche : feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite
Problème
Le journal d'activité (audit_logs) ne stocke comme « qui » que le username. Or
certains comptes sont partagés par plusieurs personnes (ex. compte « Usine »). Sous
un compte partagé, toutes les actions apparaissent sous le même nom → impossible de
distinguer les intervenants en cas de litige. Les utilisateurs se connectent aussi
depuis des smartphones.
Objectif
Ajouter du contexte forensique automatique à chaque entrée du journal, sans rien demander à l'utilisateur. But : disposer d'assez d'indices techniques pour enquêter (IP, type d'appareil/OS/navigateur, identifiant d'appareil stable, User-Agent brut) et distinguer les appareils derrière un compte partagé.
Non-objectif (volontairement exclu) : identification explicite de la personne physique (liste de noms, PIN…). Écarté par l'utilisateur — on reste sur du signal automatique.
Périmètre
Inclus :
- Capture automatique de 4 signaux à chaque écriture d'audit.
- 4 nouvelles colonnes nullable sur
audit_logs+ migration (avecdown()). - Service
UserAgentParser(libellé appareil lisible, sans dépendance externe). - Front : identifiant d'appareil persistant (
localStorage) envoyé en header sur toutes les requêtes API. - Exposition des 4 champs dans l'API de lecture du journal (
AuditLogResource/ provider) pour que la future refonte d'écran les ait à disposition. - Config
framework.trusted_proxiesdocumentée (conservatrice, à activer selon l'infra). - Docs (
doc/audit-logging.md,documentation-content.ts,CLAUDE.md) + tests unitaires.
Exclu (étape suivante) :
- Refonte de l'écran
frontend/pages/audit-logs.vue(affichage des nouvelles colonnes, filtre par appareil). L'utilisateur prévoit de revoir cet écran séparément. On se contente d'exposer les données via l'API ; aucune modif du composant Vue dans ce lot.
Architecture
Capture — un seul point d'entrée
Toutes les écritures d'audit passent par AuditLogger::log() (src/Service/AuditLogger.php).
On y injecte RequestStack. À chaque log(), on lit la requête courante et on renseigne
les 4 champs sur l'entité AuditLog avant persistance. Aucun processor à modifier.
Extraction depuis la requête :
ip_address←Request::getClientIp()user_agent← headerUser-Agent(brut)device_label←UserAgentParser::parse(userAgent)device_id← headerX-Device-Id
Si aucune requête courante (ex. commande CLI / cron), les 4 champs restent null
(comportement « system » déjà existant pour le username).
Modèle de données — audit_logs
Ajout de 4 colonnes nullable (pas de backfill, l'existant reste valide) :
| Colonne | Type | Contenu |
|---|---|---|
ip_address |
VARCHAR(45) | IP source. 45 = longueur max IPv6 (avec mapping IPv4). |
user_agent |
TEXT | User-Agent brut, stocké tel quel. |
device_label |
VARCHAR(255) | Libellé lisible, ex. Mobile · Android · Chrome. |
device_id |
VARCHAR(64) | UUID persistant fourni par le front. |
Migration Doctrine avec down() supprimant les 4 colonnes. Pas de nouvel index dans ce
lot (le filtre par appareil étant reporté à la refonte d'écran).
Service UserAgentParser
Nouveau service src/Service/UserAgentParser.php, maison, sans dépendance.
parse(?string $userAgent): ?string → libellé court composé de :
- Type : Mobile / Tablette / Ordinateur (détecté sur tokens
Mobile,Tablet,iPad…). - OS : Android / iOS / Windows / macOS / Linux / autre.
- Navigateur : Chrome / Safari / Firefox / Edge / autre (ordre de test important : Edge avant Chrome, Chrome avant Safari, car les UA s'imbriquent).
Format : Type · OS · Navigateur (ex. Ordinateur · Windows · Firefox). Retourne null
si User-Agent vide. Heuristique volontairement simple et lisible ; suffisant pour
distinguer mobile/poste et familles d'OS. (Alternative écartée : librairie
matomo/device-detector — plus précise mais lourde et non nécessaire ici.)
Front — identifiant d'appareil persistant
- Composable
frontend/composables/useDeviceId.ts: côté client uniquement, litlocalStorage['sirh-device-id']; si absent, génèrecrypto.randomUUID()et le persiste. Retourne l'ID (ounullcôté serveur en SSR). frontend/composables/useApi.ts, fonctionrequest()(point unique de construction des headers) :headers.set('X-Device-Id', deviceId)quand l'ID est disponible. Appliqué à toutes les méthodes (GET/POST/PUT/PATCH/DELETE).- Note : l'auth est par cookie JWT (
credentials: 'include'), donc le device ID n'est pas lié à l'auth —localStorageest ici un usage non sensible, acceptable. - Limite assumée : l'ID est par navigateur/appareil, pas par personne. Sur un poste partagé (même navigateur), l'ID est identique pour tous → distingue les appareils, pas les humains. Cohérent avec l'objectif forensique.
API de lecture
Exposer ipAddress, userAgent, deviceLabel, deviceId dans la sortie de lecture du
journal (src/ApiResource/AuditLogResource.php + sérialisation dans AuditLogProvider),
ainsi que dans le DTO front frontend/services/dto/audit-log.ts. Aucune modif du
composant audit-logs.vue (refonte ultérieure). Objectif : les données sont prêtes à
être affichées par la future refonte.
Trusted proxies (IP fiable)
framework.trusted_proxies n'est pas configuré aujourd'hui. Derrière un reverse proxy
(nginx/traefik), getClientIp() renvoie l'IP du proxy. Architecture de déploiement non
confirmée → on prévoit dans config/packages/framework.yaml une entrée commentée et
documentée (avec exemple trusted_proxies réseau privé / loopback + trusted_headers),
à activer selon l'infra. En attendant, l'IP est stockée telle que renvoyée par Symfony.
Stratégie de test
tests/.../UserAgentParserTest: table de User-Agents réels (Chrome desktop, Safari iPhone, Chrome Android, Firefox, Edge, UA vide/null) → libellés attendus.tests/.../AuditLoggerTest: avec unRequestStackpeuplé d'uneRequestfactice (IP, headers User-Agent + X-Device-Id), vérifier que l'AuditLogpersisté porte bien les 4 champs ; et qu'avec uneRequestStackvide (contexte CLI), les 4 champs sontnull.
Documentation à mettre à jour (règles obligatoires CLAUDE.md)
doc/audit-logging.md: section « Données stockées par entrée » + nouveaux champs + note sur le device ID front et le caveat trusted proxies.frontend/data/documentation-content.ts: doc in-app (niveau admin) du journal.CLAUDE.md: section Audit Logging — mentionner les 4 nouveaux signaux et le point de capture unique (AuditLogger+RequestStack).
Risques / limites
- Device ID = par appareil, pas par humain (cf. ci-dessus).
- IP peu utile derrière proxy tant que
trusted_proxiesn'est pas activé. - Plusieurs personnes dans la même usine sortent souvent sur la même IP publique et peuvent avoir le même modèle de téléphone → les signaux se recoupent ; ce lot fournit des indices, pas une preuve d'identité.