Files
SIRH/docs/superpowers/specs/2026-06-24-audit-log-forensic-context-design.md
T
tristan 832751d1ed
Auto Tag Develop / tag (push) Successful in 9s
feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
## 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>
2026-06-24 11:56:42 +00:00

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 (avec down()).
  • 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_proxies documenté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_addressRequest::getClientIp()
  • user_agent ← header User-Agent (brut)
  • device_labelUserAgentParser::parse(userAgent)
  • device_id ← header X-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, lit localStorage['sirh-device-id'] ; si absent, génère crypto.randomUUID() et le persiste. Retourne l'ID (ou null côté serveur en SSR).
  • frontend/composables/useApi.ts, fonction request() (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 — localStorage est 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 un RequestStack peuplé d'une Request factice (IP, headers User-Agent + X-Device-Id), vérifier que l'AuditLog persisté porte bien les 4 champs ; et qu'avec une RequestStack vide (contexte CLI), les 4 champs sont null.

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_proxies n'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é.