From 3510d5253df68b87a264806cff23f76f353d170e Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:01:36 +0200 Subject: [PATCH 01/23] =?UTF-8?q?docs=20:=20spec=20contexte=20forensique?= =?UTF-8?q?=20journal=20d'activit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-24-audit-log-forensic-context-design.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-audit-log-forensic-context-design.md diff --git a/docs/superpowers/specs/2026-06-24-audit-log-forensic-context-design.md b/docs/superpowers/specs/2026-06-24-audit-log-forensic-context-design.md new file mode 100644 index 0000000..2f60709 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-audit-log-forensic-context-design.md @@ -0,0 +1,139 @@ +# 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_address` ← `Request::getClientIp()` +- `user_agent` ← header `User-Agent` (brut) +- `device_label` ← `UserAgentParser::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é. -- 2.39.5 From 025ce8a367cc844640b948a34de2ded982406eb2 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:09:09 +0200 Subject: [PATCH 02/23] =?UTF-8?q?docs=20:=20plan=20impl=C3=A9mentation=20c?= =?UTF-8?q?ontexte=20forensique=20journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-24-audit-log-forensic-context.md | 904 ++++++++++++++++++ 1 file changed, 904 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-audit-log-forensic-context.md diff --git a/docs/superpowers/plans/2026-06-24-audit-log-forensic-context.md b/docs/superpowers/plans/2026-06-24-audit-log-forensic-context.md new file mode 100644 index 0000000..7868a2e --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-audit-log-forensic-context.md @@ -0,0 +1,904 @@ +# Contexte forensique dans le journal d'activité — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Capter automatiquement IP, libellé appareil, User-Agent brut et identifiant d'appareil persistant sur chaque entrée du journal d'activité, et les exposer en API lecture, pour différencier les intervenants derrière un compte partagé (ex. « Usine »). + +**Architecture:** Point de capture unique côté back (`AuditLogger::log()` + `RequestStack`) → aucun processor modifié. 4 colonnes nullable ajoutées à `audit_logs`. Un service `UserAgentParser` dérive un libellé lisible. Côté front, un device ID persistant (`localStorage`) est envoyé en header `X-Device-Id` sur toutes les requêtes API. + +**Tech Stack:** Symfony 7 + API Platform + Doctrine (PostgreSQL) ; Nuxt 4 + Vue 3 + TypeScript (ofetch). + +## Global Constraints + +- Backend = source de vérité ; le front ne fait qu'envoyer le header et afficher. (CLAUDE.md) +- Toute écriture d'audit DOIT passer par `AuditLogger` — ne pas dupliquer la capture ailleurs. (CLAUDE.md, doc/audit-logging.md) +- Migrations Doctrine : toujours un `down()` fonctionnel. PostgreSQL. (CLAUDE.md) +- DTO PHP ↔ DTO TS alignés. (CLAUDE.md) +- Tout changement fonctionnel met à jour `doc/` + `frontend/data/documentation-content.ts` + `CLAUDE.md` dans la même intervention. (CLAUDE.md — règles obligatoires) +- **Ne PAS lancer `npm run build`** sauf demande explicite. (mémoire feedback utilisateur) +- Code (variables, commentaires) en anglais ; UI/libellés en français. +- Format de message de commit imposé par le hook : ` : ` (espace AVANT le `:`). Types : build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. +- Lancer un test ciblé : `make test FILES=`. Conteneur PHP : `php-sirh-fpm`. Le pre-commit hook lance déjà tout PHPUnit + php-cs-fixer. +- **Hors périmètre (étape suivante, ne PAS toucher) :** l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes, filtre par appareil). On se contente d'exposer les champs dans l'API. + +--- + +## File Structure + +**Backend** +- `src/Service/UserAgentParser.php` — *créer*. Parse un User-Agent en libellé court `Type · OS · Navigateur`. +- `tests/Service/UserAgentParserTest.php` — *créer*. +- `src/Entity/AuditLog.php` — *modifier*. +4 propriétés + accesseurs. +- `migrations/Version20260624120000.php` — *créer*. +4 colonnes nullable sur `audit_logs`. +- `src/Service/AuditLogger.php` — *modifier*. Injecte `RequestStack` + `UserAgentParser`, peuple les 4 champs. +- `tests/Service/AuditLoggerTest.php` — *créer*. +- `src/State/AuditLogProvider.php` — *modifier*. Expose les 4 champs dans le JSON. +- `tests/State/AuditLogProviderTest.php` — *créer*. +- `config/packages/framework.yaml` — *modifier*. Bloc `trusted_proxies` documenté (commenté). + +**Frontend** +- `frontend/composables/useDeviceId.ts` — *créer*. Device ID persistant. +- `frontend/composables/useApi.ts` — *modifier*. Injecte le header `X-Device-Id` (intercepteur `onRequest`). +- `frontend/services/dto/audit-log.ts` — *modifier*. +4 champs optionnels. + +**Docs** +- `doc/audit-logging.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md` — *modifier*. + +--- + +## Task 1: Service `UserAgentParser` + +**Files:** +- Create: `src/Service/UserAgentParser.php` +- Test: `tests/Service/UserAgentParserTest.php` + +**Interfaces:** +- Consumes: rien. +- Produces: `UserAgentParser::parse(?string $userAgent): ?string` → libellé `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`), ou `null` si UA vide/null. Consommé par Task 3. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Service/UserAgentParserTest.php`: + +```php +parser = new UserAgentParser(); + } + + public function testNullAndEmptyReturnNull(): void + { + self::assertNull($this->parser->parse(null)); + self::assertNull($this->parser->parse('')); + self::assertNull($this->parser->parse(' ')); + } + + public function testChromeOnWindows(): void + { + $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua)); + } + + public function testEdgeBeatsChrome(): void + { + $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'; + self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua)); + } + + public function testSafariOnIphoneIsMobileIos(): void + { + $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua)); + } + + public function testChromeOnAndroid(): void + { + $ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua)); + } + + public function testFirefoxOnLinux(): void + { + $ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'; + self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua)); + } + + public function testSafariOnMac(): void + { + $ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'; + self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua)); + } + + public function testIpadIsTablet(): void + { + $ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua)); + } + + public function testUnknownUaFallsBack(): void + { + self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0')); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test FILES=tests/Service/UserAgentParserTest.php` +Expected: FAIL — `Class "App\Service\UserAgentParser" not found`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/Service/UserAgentParser.php`: + +```php +detectType($ua), + $this->detectOs($ua), + $this->detectBrowser($ua), + ]); + } + + private function detectType(string $ua): string + { + if (1 === preg_match('/iPad|Tablet/i', $ua)) { + return 'Tablette'; + } + + if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) { + return 'Mobile'; + } + + return 'Ordinateur'; + } + + private function detectOs(string $ua): string + { + // Order matters: iOS before macOS (iOS UAs contain "Mac OS X"), + // Android before Linux (Android UAs contain "Linux"). + return match (true) { + 1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS', + 1 === preg_match('/Android/i', $ua) => 'Android', + 1 === preg_match('/Windows/i', $ua) => 'Windows', + 1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS', + 1 === preg_match('/Linux/i', $ua) => 'Linux', + default => 'Autre', + }; + } + + private function detectBrowser(string $ua): string + { + // Order matters: Edge/Opera contain "Chrome" and "Safari"; + // Chrome contains "Safari". Match the most specific first. + return match (true) { + 1 === preg_match('/Edg/i', $ua) => 'Edge', + 1 === preg_match('/OPR|Opera/i', $ua) => 'Opera', + 1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox', + 1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome', + 1 === preg_match('/Safari/i', $ua) => 'Safari', + default => 'Autre', + }; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test FILES=tests/Service/UserAgentParserTest.php` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/Service/UserAgentParser.php tests/Service/UserAgentParserTest.php +git commit -m "feat(audit) : ajoute UserAgentParser (libellé appareil lisible)" +``` + +--- + +## Task 2: Colonnes `audit_logs` + entité + +**Files:** +- Modify: `src/Entity/AuditLog.php` +- Create: `migrations/Version20260624120000.php` + +**Interfaces:** +- Produces (sur `AuditLog`) : `getIpAddress(): ?string` / `setIpAddress(?string): self` ; `getUserAgent(): ?string` / `setUserAgent(?string): self` ; `getDeviceLabel(): ?string` / `setDeviceLabel(?string): self` ; `getDeviceId(): ?string` / `setDeviceId(?string): self`. Consommés par Task 3 et Task 4. + +- [ ] **Step 1: Add the 4 mapped properties to the entity** + +In `src/Entity/AuditLog.php`, after the `affectedDate` property block (currently ends line 47, before `createdAt` declared line 49–50), insert: + +```php + #[ORM\Column(type: 'string', length: 45, nullable: true)] + private ?string $ipAddress = null; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $userAgent = null; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $deviceLabel = null; + + #[ORM\Column(type: 'string', length: 64, nullable: true)] + private ?string $deviceId = null; +``` + +- [ ] **Step 2: Add the accessors** + +In `src/Entity/AuditLog.php`, after `setAffectedDate()` (ends line 156) and before `getCreatedAt()` (line 158), insert: + +```php + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): self + { + $this->ipAddress = $ipAddress; + + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + + return $this; + } + + public function getDeviceLabel(): ?string + { + return $this->deviceLabel; + } + + public function setDeviceLabel(?string $deviceLabel): self + { + $this->deviceLabel = $deviceLabel; + + return $this; + } + + public function getDeviceId(): ?string + { + return $this->deviceId; + } + + public function setDeviceId(?string $deviceId): self + { + $this->deviceId = $deviceId; + + return $this; + } +``` + +- [ ] **Step 3: Create the migration** + +Create `migrations/Version20260624120000.php`: + +```php +addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL'); + $this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address'); + $this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent'); + $this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label'); + $this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id'); + } +} +``` + +- [ ] **Step 4: Apply the migration and verify the mapping** + +Run: `make migration-migrate` +Expected: migration `Version20260624120000` applied, no error. + +Then verify the Doctrine mapping matches the DB: +Run: `docker exec -t -u www-data php-sirh-fpm php bin/console doctrine:schema:validate` +Expected: `[OK] The mapping files are correct.` (the "database is in sync" line must also be OK for `audit_logs`). + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/AuditLog.php migrations/Version20260624120000.php +git commit -m "feat(audit) : colonnes contexte forensique sur audit_logs" +``` + +--- + +## Task 3: Capture du contexte dans `AuditLogger` + +**Files:** +- Modify: `src/Service/AuditLogger.php` +- Create: `tests/Service/AuditLoggerTest.php` + +**Interfaces:** +- Consumes: `UserAgentParser::parse()` (Task 1) ; setters de `AuditLog` (Task 2) ; `Symfony\Component\HttpFoundation\RequestStack`. +- Produces: signature publique de `AuditLogger::log()` **inchangée** (la capture est interne, automatique). Le constructeur gagne 2 dépendances autowirées. + +- [ ] **Step 1: Write the failing test** + +Create `tests/Service/AuditLoggerTest.php`: + +```php +createMock(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }); + + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); // -> username "system" + + $request = Request::create('/api/work_hours', 'POST'); + $request->server->set('REMOTE_ADDR', '203.0.113.7'); + $request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); + $request->headers->set('X-Device-Id', 'device-abc'); + + $stack = new RequestStack(); + $stack->push($request); + + $logger = new AuditLogger($em, $security, $stack, new UserAgentParser()); + $logger->log(null, 'create', 'work_hour', 1, 'desc'); + + self::assertInstanceOf(AuditLog::class, $persisted); + self::assertSame('203.0.113.7', $persisted->getIpAddress()); + self::assertSame('device-abc', $persisted->getDeviceId()); + self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel()); + self::assertNotNull($persisted->getUserAgent()); + } + + public function testTruncatesOverlongDeviceId(): void + { + $persisted = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); + + $request = Request::create('/api/work_hours', 'POST'); + $request->headers->set('X-Device-Id', str_repeat('x', 200)); + $stack = new RequestStack(); + $stack->push($request); + + $logger = new AuditLogger($em, $security, $stack, new UserAgentParser()); + $logger->log(null, 'create', 'work_hour', 1, 'desc'); + + self::assertSame(64, mb_strlen((string) $persisted->getDeviceId())); + } + + public function testNoRequestLeavesContextNull(): void + { + $persisted = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); + + $logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser()); + $logger->log(null, 'create', 'work_hour', 1, 'desc'); + + self::assertNull($persisted->getIpAddress()); + self::assertNull($persisted->getUserAgent()); + self::assertNull($persisted->getDeviceLabel()); + self::assertNull($persisted->getDeviceId()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test FILES=tests/Service/AuditLoggerTest.php` +Expected: FAIL — `AuditLogger::__construct()` expects 2 args (too few given), or `getIpAddress()` undefined if run before Task 2. + +- [ ] **Step 3: Update the service** + +Replace the full contents of `src/Service/AuditLogger.php` with: + +```php +security->getUser(); + $username = $user instanceof User ? $user->getUsername() : 'system'; + + $request = $this->requestStack->getCurrentRequest(); + $ipAddress = null; + $userAgent = null; + $deviceId = null; + + if (null !== $request) { + $ipAddress = $request->getClientIp(); + $userAgent = $request->headers->get('User-Agent'); + $deviceId = $request->headers->get('X-Device-Id'); + // The device id comes from an untrusted client header; cap it to the column width. + if (null !== $deviceId) { + $deviceId = mb_substr($deviceId, 0, 64); + } + } + + $auditLog = new AuditLog(); + $auditLog + ->setEmployee($employee) + ->setUsername($username) + ->setAction($action) + ->setEntityType($entityType) + ->setEntityId($entityId) + ->setDescription($description) + ->setChanges($changes) + ->setAffectedDate($affectedDate) + ->setIpAddress($ipAddress) + ->setUserAgent($userAgent) + ->setDeviceLabel($this->userAgentParser->parse($userAgent)) + ->setDeviceId($deviceId) + ; + + $this->entityManager->persist($auditLog); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test FILES=tests/Service/AuditLoggerTest.php` +Expected: PASS (3 tests). + +- [ ] **Step 5: Run the full backend suite (no regression)** + +Run: `make test` +Expected: OK — all tests green (existing processors that use `AuditLogger` are autowired, so the 2 new constructor args resolve automatically). + +- [ ] **Step 6: Commit** + +```bash +git add src/Service/AuditLogger.php tests/Service/AuditLoggerTest.php +git commit -m "feat(audit) : capture IP/appareil/user-agent dans AuditLogger" +``` + +--- + +## Task 4: Exposition des champs dans l'API lecture + +**Files:** +- Modify: `src/State/AuditLogProvider.php:53-64` (le tableau `$items[]`) +- Modify: `frontend/services/dto/audit-log.ts` +- Create: `tests/State/AuditLogProviderTest.php` + +**Interfaces:** +- Consumes: getters de `AuditLog` (Task 2). +- Produces: chaque item JSON du endpoint `GET /audit-logs` porte désormais `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` (string|null). Le DTO TS `AuditLog` gagne ces 4 champs optionnels. + +- [ ] **Step 1: Write the failing test** + +Create `tests/State/AuditLogProviderTest.php`: + +```php +setUsername('usine') + ->setAction('create') + ->setEntityType('work_hour') + ->setDescription('desc') + ->setIpAddress('203.0.113.7') + ->setUserAgent('UA-string') + ->setDeviceLabel('Mobile · Android · Chrome') + ->setDeviceId('device-abc') + ; + + $repo = $this->createMock(AuditLogRepository::class); + $repo->method('countByFilters')->willReturn(1); + $repo->method('findByFilters')->willReturn([$log]); + + $stack = new RequestStack(); + $stack->push(Request::create('/api/audit-logs', 'GET')); + + $provider = new AuditLogProvider($stack, $repo); + $response = $provider->provide($this->createMock(Operation::class)); + + $data = json_decode((string) $response->getContent(), true); + $item = $data['items'][0]; + + self::assertSame('203.0.113.7', $item['ipAddress']); + self::assertSame('UA-string', $item['userAgent']); + self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']); + self::assertSame('device-abc', $item['deviceId']); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test FILES=tests/State/AuditLogProviderTest.php` +Expected: FAIL — `Undefined array key "ipAddress"`. + +- [ ] **Step 3: Add the fields to the provider output** + +In `src/State/AuditLogProvider.php`, in the `$items[] = [ ... ]` block, add the 4 keys after `'affectedDate' => ...` and before `'createdAt' => ...`: + +```php + 'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'), + 'ipAddress' => $log->getIpAddress(), + 'userAgent' => $log->getUserAgent(), + 'deviceLabel' => $log->getDeviceLabel(), + 'deviceId' => $log->getDeviceId(), + 'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test FILES=tests/State/AuditLogProviderTest.php` +Expected: PASS. + +- [ ] **Step 5: Align the frontend DTO** + +Replace the full contents of `frontend/services/dto/audit-log.ts` with: + +```ts +export type AuditLog = { + id: number + employeeName: string | null + employeeId: number | null + username: string + action: string + entityType: string + description: string + changes: { old?: Record; new?: Record } | null + affectedDate: string | null + ipAddress: string | null + userAgent: string | null + deviceLabel: string | null + deviceId: string | null + createdAt: string +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/State/AuditLogProvider.php tests/State/AuditLogProviderTest.php frontend/services/dto/audit-log.ts +git commit -m "feat(audit) : expose le contexte forensique dans l'API lecture" +``` + +--- + +## Task 5: Device ID persistant côté front + +**Files:** +- Create: `frontend/composables/useDeviceId.ts` +- Modify: `frontend/composables/useApi.ts` (intercepteur `onRequest` dans `$fetch.create`, lignes 79-170) + +**Interfaces:** +- Consumes: rien. +- Produces: `useDeviceId(): string | null` (auto-importé Nuxt). Renvoie l'UUID stocké dans `localStorage['sirh-device-id']` (créé si absent), ou `null` côté serveur (SSR). `useApi` ajoute le header `X-Device-Id` sur toutes les requêtes API quand l'ID est disponible. + +- [ ] **Step 1: Create the composable** + +Create `frontend/composables/useDeviceId.ts`: + +```ts +// Stable per-device identifier used to add forensic context to audit logs. +// Persisted in localStorage so the same browser/device reuses it across sessions. +// NOTE: this identifies a device/browser, not a human — on a shared kiosk every +// user of the same browser shares one id (intended: it distinguishes devices). + +const STORAGE_KEY = 'sirh-device-id' +let cached: string | null = null + +export const useDeviceId = (): string | null => { + if (!import.meta.client) { + return null + } + if (cached) { + return cached + } + try { + let id = localStorage.getItem(STORAGE_KEY) + if (!id) { + id = crypto.randomUUID() + localStorage.setItem(STORAGE_KEY, id) + } + cached = id + return id + } catch { + // localStorage unavailable (private mode, disabled) — degrade gracefully. + return null + } +} +``` + +- [ ] **Step 2: Inject the header in the shared fetch client** + +In `frontend/composables/useApi.ts`, the client is created at line 79 with `$fetch.create({ baseURL, retry: 0, credentials: 'include', onResponse(...) {...}, onResponseError(...) {...} })`. Add an `onRequest` interceptor as the first option inside that object (right after `credentials: 'include',`): + +```ts + const client = $fetch.create({ + baseURL, + retry: 0, + credentials: 'include', + onRequest({ options }) { + const deviceId = useDeviceId() + if (deviceId) { + const headers = new Headers(options.headers as HeadersInit | undefined) + headers.set('X-Device-Id', deviceId) + options.headers = headers + } + }, + onResponse({ options, response }) { +``` + +This covers every call — both `request()` (GET/POST/PUT/PATCH/DELETE) and the `getBlob` path (`client.raw`), since both go through this single client. + +- [ ] **Step 3: Verify (no build — review only)** + +Do NOT run `npm run build` (project rule). Verify by re-reading the diff: +- `useDeviceId.ts` returns `null` on server (`import.meta.client` guard) and never throws. +- In `useApi.ts`, `onRequest` is a sibling key of `onResponse` inside `$fetch.create({...})`, the braces/commas are balanced, and `options.headers` is reassigned to the merged `Headers`. + +Expected: header `X-Device-Id` will be present on all `/api/*` requests once running. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/composables/useDeviceId.ts frontend/composables/useApi.ts +git commit -m "feat(audit) : envoie un device id persistant sur les requêtes API" +``` + +--- + +## Task 6: Config `trusted_proxies` documentée + +**Files:** +- Modify: `config/packages/framework.yaml` + +**Interfaces:** +- Consumes: rien. Produces: rien (config commentée, comportement inchangé tant que non activée). + +- [ ] **Step 1: Add the documented (commented) block** + +In `config/packages/framework.yaml`, inside the top-level `framework:` block (after `session: true`, before the `#esi: true` line), insert: + +```yaml + # Trusted proxies — REQUIRED for a correct client IP in the activity log + # when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB). + # Without this, Request::getClientIp() returns the PROXY ip, not the client's. + # Uncomment and set to the proxy network/CIDR of your deployment, e.g.: + # trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' + # trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port'] + # trusted_proxies: '%env(TRUSTED_PROXIES)%' +``` + +- [ ] **Step 2: Verify config still loads** + +Run: `docker exec -t -u www-data php-sirh-fpm php bin/console cache:clear` +Expected: cache cleared, no YAML/config error. + +- [ ] **Step 3: Commit** + +```bash +git add config/packages/framework.yaml +git commit -m "docs(audit) : documente trusted_proxies pour l'IP du journal" +``` + +--- + +## Task 7: Documentation (règles obligatoires) + +**Files:** +- Modify: `doc/audit-logging.md` +- Modify: `frontend/data/documentation-content.ts` +- Modify: `CLAUDE.md` + +**Interfaces:** N/A. + +- [ ] **Step 1: Update `doc/audit-logging.md`** + +In the "Données stockées par entrée" section, add the 4 new fields and a note. Append these lines to that list (after `affectedDate` / `createdAt`): + +```markdown +- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy +- `userAgent` : User-Agent brut de la requête +- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser` +- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes. + +Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`. +``` + +- [ ] **Step 2: Update the in-app documentation** + +In `frontend/data/documentation-content.ts`, locate the audit-log / "Journal des actions" article (admin level). Add to its content a block explaining the new forensic columns. Add this block to that article's `blocks` array (follow the existing `DocBlock` shape used in the file — typically `{ type: 'paragraph', text: '...' }` and `{ type: 'list', items: [...] }`): + +```ts + { type: 'paragraph', text: "Chaque entrée du journal enregistre aussi un contexte technique automatique pour distinguer les intervenants sur un compte partagé (ex. « Usine ») :" }, + { + type: 'list', + items: [ + "Adresse IP de la connexion", + "Appareil / système / navigateur (ex. « Mobile · Android · Chrome »)", + "Identifiant d'appareil : un même appareil garde le même identifiant entre les sessions (distingue les appareils, pas les personnes)", + ], + }, +``` + +(If the exact `DocBlock` field names differ — check `frontend/types/documentation.ts` — adapt the keys to match; keep the French copy.) + +- [ ] **Step 3: Update `CLAUDE.md`** + +In `CLAUDE.md`, in the `## Audit Logging` section, add a bullet: + +```markdown +- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`. +``` + +- [ ] **Step 4: Verify docs reference real symbols** + +Run: `grep -rn "UserAgentParser\|X-Device-Id\|sirh-device-id" doc/audit-logging.md CLAUDE.md src/ frontend/composables/` +Expected: references resolve to the files created in Tasks 1, 3, 5 (no typos). + +- [ ] **Step 5: Commit** + +```bash +git add doc/audit-logging.md frontend/data/documentation-content.ts CLAUDE.md +git commit -m "docs(audit) : documente le contexte forensique du journal" +``` + +--- + +## Self-Review (auteur du plan) + +**Spec coverage :** +- Capture 4 signaux via point unique → Task 3 ✓ +- 4 colonnes nullable + migration `down()` → Task 2 ✓ +- `UserAgentParser` maison → Task 1 ✓ +- Device id front (localStorage) + header sur toutes requêtes → Task 5 ✓ +- Exposition API lecture + DTO TS aligné → Task 4 ✓ +- `trusted_proxies` documenté/conservateur → Task 6 ✓ +- Docs (doc + in-app + CLAUDE.md) + tests → Tasks 1,3,4,7 ✓ +- Écran `audit-logs.vue` explicitement hors périmètre → respecté (aucune tâche ne le touche) ✓ + +**Placeholder scan :** aucun TBD/TODO ; tout le code est fourni. La seule souplesse explicite : Task 7 Step 2 demande d'adapter aux noms de champs réels de `DocBlock` (avec instruction de vérifier `frontend/types/documentation.ts`). + +**Type consistency :** getters/setters de Task 2 (`getIpAddress`/`getUserAgent`/`getDeviceLabel`/`getDeviceId`) réutilisés à l'identique dans Tasks 3 et 4. Clés JSON (`ipAddress`/`userAgent`/`deviceLabel`/`deviceId`) identiques entre provider (Task 4 Step 3), test (Task 4 Step 1) et DTO TS (Task 4 Step 5). Header `X-Device-Id` et clé `localStorage` `sirh-device-id` cohérents entre Task 3 (lecture back), Task 5 (écriture front) et docs. -- 2.39.5 From 3939ea75e513f4f2fbaebfa1212ff1027b266520 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:10:58 +0200 Subject: [PATCH 03/23] =?UTF-8?q?feat(audit)=20:=20ajoute=20UserAgentParse?= =?UTF-8?q?r=20(libell=C3=A9=20appareil=20lisible)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/UserAgentParser.php | 73 ++++++++++++++++++++++++++ tests/Service/UserAgentParserTest.php | 75 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/Service/UserAgentParser.php create mode 100644 tests/Service/UserAgentParserTest.php diff --git a/src/Service/UserAgentParser.php b/src/Service/UserAgentParser.php new file mode 100644 index 0000000..a087cab --- /dev/null +++ b/src/Service/UserAgentParser.php @@ -0,0 +1,73 @@ +detectType($ua), + $this->detectOs($ua), + $this->detectBrowser($ua), + ]); + } + + private function detectType(string $ua): string + { + if (1 === preg_match('/iPad|Tablet/i', $ua)) { + return 'Tablette'; + } + + if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) { + return 'Mobile'; + } + + return 'Ordinateur'; + } + + private function detectOs(string $ua): string + { + // Order matters: iOS before macOS (iOS UAs contain "Mac OS X"), + // Android before Linux (Android UAs contain "Linux"). + return match (true) { + 1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS', + 1 === preg_match('/Android/i', $ua) => 'Android', + 1 === preg_match('/Windows/i', $ua) => 'Windows', + 1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS', + 1 === preg_match('/Linux/i', $ua) => 'Linux', + default => 'Autre', + }; + } + + private function detectBrowser(string $ua): string + { + // Order matters: Edge/Opera contain "Chrome" and "Safari"; + // Chrome contains "Safari". Match the most specific first. + return match (true) { + 1 === preg_match('/Edg/i', $ua) => 'Edge', + 1 === preg_match('/OPR|Opera/i', $ua) => 'Opera', + 1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox', + 1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome', + 1 === preg_match('/Safari/i', $ua) => 'Safari', + default => 'Autre', + }; + } +} diff --git a/tests/Service/UserAgentParserTest.php b/tests/Service/UserAgentParserTest.php new file mode 100644 index 0000000..56a7deb --- /dev/null +++ b/tests/Service/UserAgentParserTest.php @@ -0,0 +1,75 @@ +parser = new UserAgentParser(); + } + + public function testNullAndEmptyReturnNull(): void + { + self::assertNull($this->parser->parse(null)); + self::assertNull($this->parser->parse('')); + self::assertNull($this->parser->parse(' ')); + } + + public function testChromeOnWindows(): void + { + $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua)); + } + + public function testEdgeBeatsChrome(): void + { + $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'; + self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua)); + } + + public function testSafariOnIphoneIsMobileIos(): void + { + $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua)); + } + + public function testChromeOnAndroid(): void + { + $ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'; + self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua)); + } + + public function testFirefoxOnLinux(): void + { + $ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'; + self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua)); + } + + public function testSafariOnMac(): void + { + $ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'; + self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua)); + } + + public function testIpadIsTablet(): void + { + $ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua)); + } + + public function testUnknownUaFallsBack(): void + { + self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0')); + } +} -- 2.39.5 From 9f0e6241380039fd9e853786bf5c19ba381cbf24 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:13:39 +0200 Subject: [PATCH 04/23] feat(audit) : colonnes contexte forensique sur audit_logs --- migrations/Version20260624120000.php | 32 +++++++++++++++ src/Entity/AuditLog.php | 60 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 migrations/Version20260624120000.php diff --git a/migrations/Version20260624120000.php b/migrations/Version20260624120000.php new file mode 100644 index 0000000..6ea4a9c --- /dev/null +++ b/migrations/Version20260624120000.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL'); + $this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address'); + $this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent'); + $this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label'); + $this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id'); + } +} diff --git a/src/Entity/AuditLog.php b/src/Entity/AuditLog.php index 730060d..c6cbee8 100644 --- a/src/Entity/AuditLog.php +++ b/src/Entity/AuditLog.php @@ -46,6 +46,18 @@ class AuditLog #[ORM\Column(type: 'date_immutable', nullable: true)] private ?DateTimeImmutable $affectedDate = null; + #[ORM\Column(type: 'string', length: 45, nullable: true)] + private ?string $ipAddress = null; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $userAgent = null; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $deviceLabel = null; + + #[ORM\Column(type: 'string', length: 64, nullable: true)] + private ?string $deviceId = null; + #[ORM\Column(type: 'datetime_immutable')] private DateTimeImmutable $createdAt; @@ -155,6 +167,54 @@ class AuditLog return $this; } + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): self + { + $this->ipAddress = $ipAddress; + + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + + return $this; + } + + public function getDeviceLabel(): ?string + { + return $this->deviceLabel; + } + + public function setDeviceLabel(?string $deviceLabel): self + { + $this->deviceLabel = $deviceLabel; + + return $this; + } + + public function getDeviceId(): ?string + { + return $this->deviceId; + } + + public function setDeviceId(?string $deviceId): self + { + $this->deviceId = $deviceId; + + return $this; + } + public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; -- 2.39.5 From 003835463ba9ea5de4a8dd9c9e0a0ca31cc22f0a Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:15:58 +0200 Subject: [PATCH 05/23] feat(audit) : capture IP/appareil/user-agent dans AuditLogger --- src/Service/AuditLogger.php | 22 ++++++++ tests/Service/AuditLoggerTest.php | 89 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/Service/AuditLoggerTest.php diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php index f0358f3..a5c638b 100644 --- a/src/Service/AuditLogger.php +++ b/src/Service/AuditLogger.php @@ -10,12 +10,15 @@ use App\Entity\User; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\RequestStack; readonly class AuditLogger { public function __construct( private EntityManagerInterface $entityManager, private Security $security, + private RequestStack $requestStack, + private UserAgentParser $userAgentParser, ) {} public function log( @@ -30,6 +33,21 @@ readonly class AuditLogger $user = $this->security->getUser(); $username = $user instanceof User ? $user->getUsername() : 'system'; + $request = $this->requestStack->getCurrentRequest(); + $ipAddress = null; + $userAgent = null; + $deviceId = null; + + if (null !== $request) { + $ipAddress = $request->getClientIp(); + $userAgent = $request->headers->get('User-Agent'); + $deviceId = $request->headers->get('X-Device-Id'); + // The device id comes from an untrusted client header; cap it to the column width. + if (null !== $deviceId) { + $deviceId = mb_substr($deviceId, 0, 64); + } + } + $auditLog = new AuditLog(); $auditLog ->setEmployee($employee) @@ -40,6 +58,10 @@ readonly class AuditLogger ->setDescription($description) ->setChanges($changes) ->setAffectedDate($affectedDate) + ->setIpAddress($ipAddress) + ->setUserAgent($userAgent) + ->setDeviceLabel($this->userAgentParser->parse($userAgent)) + ->setDeviceId($deviceId) ; $this->entityManager->persist($auditLog); diff --git a/tests/Service/AuditLoggerTest.php b/tests/Service/AuditLoggerTest.php new file mode 100644 index 0000000..97037a0 --- /dev/null +++ b/tests/Service/AuditLoggerTest.php @@ -0,0 +1,89 @@ +createMock(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }); + + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); // -> username "system" + + $request = Request::create('/api/work_hours', 'POST'); + $request->server->set('REMOTE_ADDR', '203.0.113.7'); + $request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); + $request->headers->set('X-Device-Id', 'device-abc'); + + $stack = new RequestStack(); + $stack->push($request); + + $logger = new AuditLogger($em, $security, $stack, new UserAgentParser()); + $logger->log(null, 'create', 'work_hour', 1, 'desc'); + + self::assertInstanceOf(AuditLog::class, $persisted); + self::assertSame('203.0.113.7', $persisted->getIpAddress()); + self::assertSame('device-abc', $persisted->getDeviceId()); + self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel()); + self::assertNotNull($persisted->getUserAgent()); + } + + public function testTruncatesOverlongDeviceId(): void + { + $persisted = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); + + $request = Request::create('/api/work_hours', 'POST'); + $request->headers->set('X-Device-Id', str_repeat('x', 200)); + $stack = new RequestStack(); + $stack->push($request); + + $logger = new AuditLogger($em, $security, $stack, new UserAgentParser()); + $logger->log(null, 'create', 'work_hour', 1, 'desc'); + + self::assertSame(64, mb_strlen((string) $persisted->getDeviceId())); + } + + public function testNoRequestLeavesContextNull(): void + { + $persisted = null; + $em = $this->createMock(EntityManagerInterface::class); + $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { + $persisted = $entity; + }); + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); + + $logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser()); + $logger->log(null, 'create', 'work_hour', 1, 'desc'); + + self::assertNull($persisted->getIpAddress()); + self::assertNull($persisted->getUserAgent()); + self::assertNull($persisted->getDeviceLabel()); + self::assertNull($persisted->getDeviceId()); + } +} -- 2.39.5 From 62dcae187928ceb7cb1b3d3a56466ad6583833fe Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:20:27 +0200 Subject: [PATCH 06/23] feat(audit) : expose le contexte forensique dans l'API lecture --- frontend/services/dto/audit-log.ts | 4 ++ src/Repository/AuditLogRepository.php | 3 +- .../AuditLogReadRepositoryInterface.php | 30 +++++++++++ src/State/AuditLogProvider.php | 8 ++- tests/State/AuditLogProviderTest.php | 51 +++++++++++++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/Repository/Contract/AuditLogReadRepositoryInterface.php create mode 100644 tests/State/AuditLogProviderTest.php diff --git a/frontend/services/dto/audit-log.ts b/frontend/services/dto/audit-log.ts index 5db48ae..b9ab42c 100644 --- a/frontend/services/dto/audit-log.ts +++ b/frontend/services/dto/audit-log.ts @@ -8,5 +8,9 @@ export type AuditLog = { description: string changes: { old?: Record; new?: Record } | null affectedDate: string | null + ipAddress: string | null + userAgent: string | null + deviceLabel: string | null + deviceId: string | null createdAt: string } diff --git a/src/Repository/AuditLogRepository.php b/src/Repository/AuditLogRepository.php index 8d9ea09..50215a2 100644 --- a/src/Repository/AuditLogRepository.php +++ b/src/Repository/AuditLogRepository.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Repository; use App\Entity\AuditLog; +use App\Repository\Contract\AuditLogReadRepositoryInterface; use DateTimeImmutable; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -12,7 +13,7 @@ use Doctrine\Persistence\ManagerRegistry; /** * @extends ServiceEntityRepository */ -final class AuditLogRepository extends ServiceEntityRepository +final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface { public function __construct(ManagerRegistry $registry) { diff --git a/src/Repository/Contract/AuditLogReadRepositoryInterface.php b/src/Repository/Contract/AuditLogReadRepositoryInterface.php new file mode 100644 index 0000000..82ece79 --- /dev/null +++ b/src/Repository/Contract/AuditLogReadRepositoryInterface.php @@ -0,0 +1,30 @@ + + */ + public function findByFilters( + ?int $employeeId = null, + ?DateTimeImmutable $from = null, + ?DateTimeImmutable $to = null, + ?string $entityType = null, + int $limit = 50, + int $offset = 0, + ): array; + + public function countByFilters( + ?int $employeeId = null, + ?DateTimeImmutable $from = null, + ?DateTimeImmutable $to = null, + ?string $entityType = null, + ): int; +} diff --git a/src/State/AuditLogProvider.php b/src/State/AuditLogProvider.php index bd8be04..9736030 100644 --- a/src/State/AuditLogProvider.php +++ b/src/State/AuditLogProvider.php @@ -6,7 +6,7 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use App\Repository\AuditLogRepository; +use App\Repository\Contract\AuditLogReadRepositoryInterface; use DateTimeImmutable; use DateTimeZone; use Symfony\Component\HttpFoundation\JsonResponse; @@ -18,7 +18,7 @@ class AuditLogProvider implements ProviderInterface public function __construct( private readonly RequestStack $requestStack, - private readonly AuditLogRepository $auditLogRepository, + private readonly AuditLogReadRepositoryInterface $auditLogRepository, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse @@ -60,6 +60,10 @@ class AuditLogProvider implements ProviderInterface 'description' => $log->getDescription(), 'changes' => $log->getChanges(), 'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'), + 'ipAddress' => $log->getIpAddress(), + 'userAgent' => $log->getUserAgent(), + 'deviceLabel' => $log->getDeviceLabel(), + 'deviceId' => $log->getDeviceId(), 'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'), ]; } diff --git a/tests/State/AuditLogProviderTest.php b/tests/State/AuditLogProviderTest.php new file mode 100644 index 0000000..0b9b442 --- /dev/null +++ b/tests/State/AuditLogProviderTest.php @@ -0,0 +1,51 @@ +setUsername('usine') + ->setAction('create') + ->setEntityType('work_hour') + ->setDescription('desc') + ->setIpAddress('203.0.113.7') + ->setUserAgent('UA-string') + ->setDeviceLabel('Mobile · Android · Chrome') + ->setDeviceId('device-abc') + ; + + $repo = $this->createMock(AuditLogReadRepositoryInterface::class); + $repo->method('countByFilters')->willReturn(1); + $repo->method('findByFilters')->willReturn([$log]); + + $stack = new RequestStack(); + $stack->push(Request::create('/api/audit-logs', 'GET')); + + $provider = new AuditLogProvider($stack, $repo); + $response = $provider->provide($this->createMock(Operation::class)); + + $data = json_decode((string) $response->getContent(), true); + $item = $data['items'][0]; + + self::assertSame('203.0.113.7', $item['ipAddress']); + self::assertSame('UA-string', $item['userAgent']); + self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']); + self::assertSame('device-abc', $item['deviceId']); + } +} -- 2.39.5 From cc9b50a765bcd5529364339f4fa39ba54305a041 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:25:50 +0200 Subject: [PATCH 07/23] =?UTF-8?q?feat(audit)=20:=20envoie=20un=20device=20?= =?UTF-8?q?id=20persistant=20sur=20les=20requ=C3=AAtes=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/composables/useApi.ts | 8 ++++++++ frontend/composables/useDeviceId.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 frontend/composables/useDeviceId.ts diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index ec296b4..5d9b6dc 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -80,6 +80,14 @@ export const useApi = (): ApiClient => { baseURL, retry: 0, credentials: 'include', + onRequest({ options }) { + const deviceId = useDeviceId() + if (deviceId) { + const headers = new Headers(options.headers as HeadersInit | undefined) + headers.set('X-Device-Id', deviceId) + options.headers = headers + } + }, onResponse({ options, response }) { const apiOptions = options as ApiFetchOptions<'json'> if (apiOptions?.toast === false) { diff --git a/frontend/composables/useDeviceId.ts b/frontend/composables/useDeviceId.ts new file mode 100644 index 0000000..b8d9879 --- /dev/null +++ b/frontend/composables/useDeviceId.ts @@ -0,0 +1,28 @@ +// Stable per-device identifier used to add forensic context to audit logs. +// Persisted in localStorage so the same browser/device reuses it across sessions. +// NOTE: this identifies a device/browser, not a human — on a shared kiosk every +// user of the same browser shares one id (intended: it distinguishes devices). + +const STORAGE_KEY = 'sirh-device-id' +let cached: string | null = null + +export const useDeviceId = (): string | null => { + if (!import.meta.client) { + return null + } + if (cached) { + return cached + } + try { + let id = localStorage.getItem(STORAGE_KEY) + if (!id) { + id = crypto.randomUUID() + localStorage.setItem(STORAGE_KEY, id) + } + cached = id + return id + } catch { + // localStorage unavailable (private mode, disabled) — degrade gracefully. + return null + } +} -- 2.39.5 From d44759eb1419433be03ca17a6bff564f1fd6dd17 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:27:53 +0200 Subject: [PATCH 08/23] docs(audit) : documente trusted_proxies pour l'IP du journal --- config/packages/framework.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7e1ee1f..b04bd2a 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -5,6 +5,14 @@ framework: # Note that the session will be started ONLY if you read or write from it. session: true + # Trusted proxies — REQUIRED for a correct client IP in the activity log + # when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB). + # Without this, Request::getClientIp() returns the PROXY ip, not the client's. + # Uncomment and set to the proxy network/CIDR of your deployment, e.g.: + # trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' + # trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port'] + # trusted_proxies: '%env(TRUSTED_PROXIES)%' + #esi: true #fragments: true -- 2.39.5 From 4513896112be5a95f215f398099653e92e685a63 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:29:54 +0200 Subject: [PATCH 09/23] docs(audit) : documente le contexte forensique du journal --- CLAUDE.md | 1 + doc/audit-logging.md | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3cbd374..8e346f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,7 @@ - All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions - `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically - Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB) +- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`. - Documentation: `doc/audit-logging.md` ## Backend Conventions diff --git a/doc/audit-logging.md b/doc/audit-logging.md index 971bca1..c5b8c7c 100644 --- a/doc/audit-logging.md +++ b/doc/audit-logging.md @@ -40,6 +40,12 @@ Chaque entrée contient : - **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs - **affectedDate** : date de travail ou début d'absence (pour filtrage par période) - **createdAt** : horodatage de l'action +- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy +- `userAgent` : User-Agent brut de la requête +- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser` +- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes. + +Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`. ## Filtres disponibles -- 2.39.5 From 48ee1734614d63084501278c5209d72096bf5c3a Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:34:03 +0200 Subject: [PATCH 10/23] =?UTF-8?q?refactor(audit)=20:=20alias=20explicite?= =?UTF-8?q?=20+=20borne=20le=20user-agent=20stock=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- config/services.yaml | 1 + src/Service/AuditLogger.php | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/config/services.yaml b/config/services.yaml index 9a32ff2..b11d5ed 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -44,6 +44,7 @@ services: $dataStartDate: '%env(RTT_START_DATE)%' App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository' + App\Repository\Contract\AuditLogReadRepositoryInterface: '@App\Repository\AuditLogRepository' App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository' App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository' App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository' diff --git a/src/Service/AuditLogger.php b/src/Service/AuditLogger.php index a5c638b..4256479 100644 --- a/src/Service/AuditLogger.php +++ b/src/Service/AuditLogger.php @@ -46,6 +46,10 @@ readonly class AuditLogger if (null !== $deviceId) { $deviceId = mb_substr($deviceId, 0, 64); } + // The user agent comes from an untrusted client header; cap it to prevent storage bloat. + if (null !== $userAgent) { + $userAgent = mb_substr($userAgent, 0, 1024); + } } $auditLog = new AuditLog(); -- 2.39.5 From 8c1cd6704e6733a21a3acefe059ed9e6baf796fd Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:37:12 +0200 Subject: [PATCH 11/23] test(audit) : utilise createStub pour une sortie PHPUnit propre Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Service/AuditLoggerTest.php | 12 ++++++------ tests/State/AuditLogProviderTest.php | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/Service/AuditLoggerTest.php b/tests/Service/AuditLoggerTest.php index 97037a0..d3e2e48 100644 --- a/tests/Service/AuditLoggerTest.php +++ b/tests/Service/AuditLoggerTest.php @@ -21,12 +21,12 @@ final class AuditLoggerTest extends TestCase public function testCapturesRequestContext(): void { $persisted = null; - $em = $this->createMock(EntityManagerInterface::class); + $em = $this->createStub(EntityManagerInterface::class); $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { $persisted = $entity; }); - $security = $this->createMock(Security::class); + $security = $this->createStub(Security::class); $security->method('getUser')->willReturn(null); // -> username "system" $request = Request::create('/api/work_hours', 'POST'); @@ -50,11 +50,11 @@ final class AuditLoggerTest extends TestCase public function testTruncatesOverlongDeviceId(): void { $persisted = null; - $em = $this->createMock(EntityManagerInterface::class); + $em = $this->createStub(EntityManagerInterface::class); $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { $persisted = $entity; }); - $security = $this->createMock(Security::class); + $security = $this->createStub(Security::class); $security->method('getUser')->willReturn(null); $request = Request::create('/api/work_hours', 'POST'); @@ -71,11 +71,11 @@ final class AuditLoggerTest extends TestCase public function testNoRequestLeavesContextNull(): void { $persisted = null; - $em = $this->createMock(EntityManagerInterface::class); + $em = $this->createStub(EntityManagerInterface::class); $em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void { $persisted = $entity; }); - $security = $this->createMock(Security::class); + $security = $this->createStub(Security::class); $security->method('getUser')->willReturn(null); $logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser()); diff --git a/tests/State/AuditLogProviderTest.php b/tests/State/AuditLogProviderTest.php index 0b9b442..d699f01 100644 --- a/tests/State/AuditLogProviderTest.php +++ b/tests/State/AuditLogProviderTest.php @@ -30,7 +30,7 @@ final class AuditLogProviderTest extends TestCase ->setDeviceId('device-abc') ; - $repo = $this->createMock(AuditLogReadRepositoryInterface::class); + $repo = $this->createStub(AuditLogReadRepositoryInterface::class); $repo->method('countByFilters')->willReturn(1); $repo->method('findByFilters')->willReturn([$log]); @@ -38,7 +38,7 @@ final class AuditLogProviderTest extends TestCase $stack->push(Request::create('/api/audit-logs', 'GET')); $provider = new AuditLogProvider($stack, $repo); - $response = $provider->provide($this->createMock(Operation::class)); + $response = $provider->provide($this->createStub(Operation::class)); $data = json_decode((string) $response->getContent(), true); $item = $data['items'][0]; -- 2.39.5 From 95bf8c4c0a53448fca70445d058bfacb00ce8c33 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:49:04 +0200 Subject: [PATCH 12/23] =?UTF-8?q?fix(audit)=20:=20autorise=20l'en-t=C3=AAt?= =?UTF-8?q?e=20X-Device-Id=20en=20CORS=20(d=C3=A9bloque=20le=20front)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le front envoie X-Device-Id sur toutes les requêtes (cross-origin :3001 -> :8081). Sans l'ajouter à allow_headers, le préflight CORS échoue et le navigateur bloque toutes les requêtes API. Vérifié : préflight OPTIONS passe de 400 à 200. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/packages/nelmio_cors.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml index 2717d60..a95a715 100644 --- a/config/packages/nelmio_cors.yaml +++ b/config/packages/nelmio_cors.yaml @@ -3,7 +3,7 @@ nelmio_cors: origin_regex: true allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] - allow_headers: ['Content-Type', 'Authorization'] + allow_headers: ['Content-Type', 'Authorization', 'X-Device-Id'] allow_credentials: true expose_headers: ['Link', 'Content-Disposition'] max_age: 3600 -- 2.39.5 From 39cdfd7428c03c0050a2f23e85fd1c4d94912751 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:50:03 +0200 Subject: [PATCH 13/23] =?UTF-8?q?docs(audit)=20:=20note=20la=20d=C3=A9pend?= =?UTF-8?q?ance=20CORS=20de=20X-Device-Id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- doc/audit-logging.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e346f3..a1c5296 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,7 @@ - All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions - `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically - Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB) -- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`. +- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. **CORS** : `X-Device-Id` doit rester dans `nelmio_cors.allow_headers` (front/API cross-origin → préflight, sinon le navigateur bloque toutes les requêtes). Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`. - Documentation: `doc/audit-logging.md` ## Backend Conventions diff --git a/doc/audit-logging.md b/doc/audit-logging.md index c5b8c7c..e4d43c0 100644 --- a/doc/audit-logging.md +++ b/doc/audit-logging.md @@ -47,6 +47,8 @@ Chaque entrée contient : Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`. +> ⚠️ **CORS** : le front et l'API sont sur des origines distinctes ; le header `X-Device-Id` ajouté à chaque requête déclenche un préflight CORS. Il **doit** figurer dans `nelmio_cors.allow_headers` (`config/packages/nelmio_cors.yaml`), sinon le navigateur bloque toutes les requêtes API. + ## Filtres disponibles - Par employé -- 2.39.5 From c9fd973da3f7365562562160ee0ea663a641a729 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 11:03:36 +0200 Subject: [PATCH 14/23] =?UTF-8?q?docs=20:=20spec=20refonte=20=C3=A9cran=20?= =?UTF-8?q?journal=20(MalioDataTable=20+=20drawer=20filtre)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-06-24-audit-log-screen-rework-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-audit-log-screen-rework-design.md diff --git a/docs/superpowers/specs/2026-06-24-audit-log-screen-rework-design.md b/docs/superpowers/specs/2026-06-24-audit-log-screen-rework-design.md new file mode 100644 index 0000000..efd2fb8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-audit-log-screen-rework-design.md @@ -0,0 +1,207 @@ +# Refonte de l'écran Journal d'activité (MalioDataTable + drawer de filtre) + +Date : 2026-06-24 +Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite` + +## Problème / objectif + +L'écran `frontend/pages/audit-logs.vue` (journal d'activité, `ROLE_SUPER_ADMIN`) est aujourd'hui +fait main : `` natifs, tableau en grille CSS, lignes dépliables affichant le diff +JSON brut, pagination « précédent/suivant » figée à 50/page. Il faut le **moderniser** : + +1. Passer le tableau en **`MalioDataTable`** (1er usage dans SIRH). +2. Mettre les filtres dans un **drawer**, sur le **même principe que STARSEED** (les écrans de liste + `modules/.../pages/.../index.vue` : `MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, + footer Réinitialiser/Appliquer, badge de compteur de filtres actifs). +3. Passer **tous** les composants de l'écran en composants **Malio** quand l'équivalent existe. +4. Exploiter les nouvelles données forensiques (IP, appareil, User-Agent, device id) déjà captées + par le backend. + +## Référence de pattern + +- STARSEED, écran canonique : `/home/m-tristan/workspace/Starseed/frontend/modules/commercial/pages/clients/index.vue` + (drawer de filtre, `MalioAccordion`, brouillon→appliqué, `MalioDataTable`, badge compteur). +- Adaptations SIRH : **libellés en français en dur** (convention des drawers SIRH existants — + `employees/index.vue`, `sites.vue` — pas d'i18n comme STARSEED) ; **filtres non persistés en URL** + (comme STARSEED et l'écran actuel). +- Malio `@malio/layer-ui` 1.7.15 (doc `node_modules/@malio/layer-ui/COMPONENTS.md`). + +## Périmètre + +**Inclus :** refonte complète de `audit-logs.vue` (tableau, filtres, détail) + évolutions backend +nécessaires (perPage + nouveaux filtres) + DTO TS + docs. + +**Exclus :** toute autre page ; l'audit reste `ROLE_SUPER_ADMIN` ; pas de doc in-app (outil caché, +aucun article existant — décision déjà prise au lot précédent). + +--- + +## A. Tableau — `MalioDataTable` + +API (1.7.15) : `:columns` (`{key,label}[]`), `:items`, `:total-items`, `v-model:page`, +`v-model:per-page`, `:per-page-options`, `row-clickable`, événements `row-click` / +`update:page` / `update:per-page`, slots `#cell-{key}` et `#empty`. + +Colonnes : + +| key | label | rendu | +|---|---|---| +| `createdAt` | Date action | `JJ/MM/AAAA HH:MM` (déjà formaté par le provider) | +| `username` | Utilisateur | texte brut | +| `action` | Action | badge couleur via `#cell-action` (create=vert, update=bleu, delete=rouge, validate=violet, site_validate=indigo, défaut=neutre) | +| `entityType` | Type | libellé FR via `#cell-entityType` (work_hour→Heures, absence→Absence, employee→Employé, contract_suspension→Suspension, rtt_payment→RTT, fractioned_days→Fract., paid_leave_days→Congés payés, week_comment→Commentaire) | +| `employeeName` | Employé | nom ou `—` | +| `deviceLabel` | Appareil | `deviceLabel` ou `—` | +| `description` | Description | tronqué (`truncate` + `title`) via `#cell-description` | + +- `:per-page-options="[25, 50, 100]"`, `perPage` par défaut 50. +- `@row-click` → ouvre le drawer de détail avec la ligne cliquée. +- `:items` = directement les `AuditLog` de la page courante (le DTO porte déjà toutes les clés ; + les `key` de colonnes correspondent aux champs). + +## B. Drawer de détail (clic ligne) + +`MalioDrawer` (droite, `drawer-class="max-w-xl"`), titre `#header` = « Détail de l'action ». +Contenu (lecture seule, sections) : + +- **Méta** : Utilisateur, Employé, Date action, Date affectée, Action (badge), Type (libellé). +- **Contexte technique** : IP (`ipAddress`), Appareil (`deviceLabel`), User-Agent brut + (`userAgent`, en `break-all`/petite police), Device id (`deviceId`). Champs nuls → `—`. +- **Changements** : si `changes` non nul, rendu lisible — pour chaque clé présente dans + `old`/`new`, une ligne `clé : ancienne → nouvelle` (au lieu du double bloc JSON brut actuel). + Helper front `formatChanges(changes)` qui fusionne les clés de `old` et `new`. Si `changes` nul → + « Aucun détail de modification ». + +État : `selectedLog: AuditLog | null` + `detailOpen: boolean`. Fermeture standard MalioDrawer. + +## C. Drawer de filtre (principe STARSEED) + +Bouton **« Filtrer »** (`MalioButton variant="tertiary" icon-name="mdi:tune"`) dans la barre de titre ; +son label porte le **compteur de filtres actifs** (`Filtrer (N)` si N>0). + +`MalioDrawer` (`drawer-class="max-w-[450px]"`, `body-class="p-0"`, +`footer-class="justify-between border-t border-black p-6"`), titre `#header` = « Filtres ». +Corps en `MalioAccordion` (un `MalioAccordionItem` par section) : + +| Section | Composant | Champ filtre | +|---|---|---| +| Période | `MalioDateRange` (`v-model` = `{start,end}` ISO) | `from`/`to` sur `affectedDate` (sémantique actuelle conservée) | +| Employé | `MalioSelect` (options = employés chargés au mount) | `employeeId` (valeur unique) | +| Type d'entité | liste de `MalioCheckbox` (multi) | `entityType[]` | +| Action | liste de `MalioCheckbox` (multi) | `action[]` | +| Utilisateur / compte | `MalioInputText` (`icon mdi:magnify`) | `username` (ILIKE partiel) | +| IP | `MalioInputText` | `ip` (ILIKE partiel) | +| Appareil | `MalioInputText` | `device` (ILIKE partiel sur `device_label` OU `device_id`) | + +Footer : `MalioButton variant="tertiary"` **Réinitialiser** (gauche) + `MalioButton variant="primary"` +**Appliquer** (droite). + +**État brouillon → appliqué** (pattern STARSEED) : +- `draft*` refs (éditées dans le drawer) et `applied*` refs (pilotent le fetch). +- `openFilters()` : copie `applied*` → `draft*` puis ouvre. +- `applyFilters()` : copie `draft*` → `applied*`, remet `page=1`, refetch, ferme le drawer. +- `resetFilters()` : vide `draft*` **et** `applied*`, remet `page=1`, refetch, **laisse le drawer ouvert**. +- `activeFilterCount` (computed sur `applied*`) → badge bouton. +- Helpers `toggle(arrayRef, value, selected)` pour les multi-select. +- Options Type d'entité / Action = listes statiques (mêmes codes que le provider) ; options Employé + chargées une fois au `onMounted` (réutiliser le chargement employés déjà fait par l'écran actuel). + +## D. Composable `useAuditLogsList` + +Composable **spécifique à l'écran** (`frontend/composables/useAuditLogsList.ts`) — pas de +`usePaginatedList` générique (un seul consommateur → YAGNI). Expose : + +- état : `items`, `total`, `page`, `perPage`, `loading`, les `draft*`/`applied*`, `activeFilterCount`, + `employeeOptions`. +- actions : `load()` (fetch avec filtres appliqués + page/perPage), `goToPage(n)`, `setPerPage(n)`, + `openFilters()`, `applyFilters()`, `resetFilters()`, `loadEmployeeOptions()`. +- `load()` doit ignorer les réponses périmées (garde anti-race : compteur de requête, on jette + les réponses dont l'index n'est pas le dernier émis). + +La page `audit-logs.vue` se réduit à : barre de titre (titre + bouton Filtrer), `MalioDataTable`, +drawer filtre, drawer détail — toute la logique vit dans le composable. + +## E. Backend + +### `frontend/services/dto/audit-log.ts` (`AuditLogFilters`) +Étendre : +```ts +export type AuditLogFilters = { + employeeId?: number + from?: string + to?: string + entityType?: string[] + action?: string[] + username?: string + ip?: string + device?: string + page?: number + perPage?: number +} +``` +`fetchAuditLogs` sérialise les tableaux en `entityType[]`/`action[]` (syntaxe PHP) et n'inclut que +les filtres non vides. + +### `src/ApiResource/AuditLogResource.php` +Ajouter les `QueryParameter` : `perPage`, `username`, `ip`, `device`, `action` (`entityType` existe +déjà). (Les `QueryParameter` sont surtout documentaires : le provider lit `$request->query`.) + +### `src/State/AuditLogProvider.php` +- Lire `perPage` (défaut 50, clampé à un ensemble autorisé `[25,50,100]`, fallback 50 ; borne dure). +- Lire `username`, `ip`, `device` (chaînes, `null` si vide). +- Lire `entityType` et `action` en **tableaux** (`$request->query->all('entityType')` / + `->all('action')`), `null`/`[]` si absent. Conserver la rétro-compat : si `entityType` arrive en + scalaire, le normaliser en tableau à un élément. +- Passer le tout au repository ; `perPage` remplace la constante `PER_PAGE`. La réponse renvoie + `perPage` réel. + +### `src/Repository/Contract/AuditLogReadRepositoryInterface.php` + `AuditLogRepository.php` +Faire évoluer `findByFilters` / `countByFilters` : +```php +findByFilters( + ?int $employeeId, + ?DateTimeImmutable $from, + ?DateTimeImmutable $to, + ?array $entityTypes, // list|null + ?array $actions, // list|null + ?string $username, + ?string $ip, + ?string $device, + int $limit, + int $offset, +): array +countByFilters(... mêmes filtres ...): int +``` +Clauses : `employeeId` =, dates BETWEEN sur `affectedDate` (inchangé), `entityTypes`/`actions` +`IN (:...)` si non vides, `username`/`ip` `ILIKE %v%` (paramètre échappé), `device` → +`(device_label ILIKE :d OR device_id ILIKE :d)`. Tri inchangé (`createdAt DESC`). +Mutualiser la construction des critères entre les deux méthodes (méthode privée +`applyFilters(QueryBuilder, ...)`) pour rester DRY. + +## Tests + +- Backend : `AuditLogProviderTest` étendu — vérifier que `perPage`, `username`, `ip`, `device`, + `entityType[]`, `action[]` sont lus et transmis au repository (repo stubbé, on asserte les + arguments via un spy), et que `perPage` hors liste retombe sur 50. +- Backend : test repository des nouvelles clauses si un test repository existe ; sinon couvrir via le + provider (le repo réel n'est pas unit-testé aujourd'hui — ne pas introduire d'intégration DB). +- Front : pas de test auto (convention SIRH, pas de build) — revue de diff. Le composable + `useAuditLogsList` reste pur/réactif et testable manuellement. + +## Documentation + +- `doc/audit-logging.md` : section « Filtres disponibles » mise à jour (employé, période, type[], + action[], utilisateur, IP, appareil ; pagination perPage) + mention du drawer et du drawer de + détail. +- `CLAUDE.md` : compléter la puce « Contexte forensique » / journal pour noter l'écran refondu + (`MalioDataTable`, drawer de filtre façon STARSEED, drawer de détail, filtres back + username/ip/device/action[]/entityType[]/perPage). + +## Risques / notes + +- 1er `MalioDataTable` de SIRH : valider le rendu (le composant gère sa propre pagination/markup ; + ne pas réappliquer le gabarit grille maison du CLAUDE.md à ce tableau). +- `MalioDateRange` filtre `affectedDate` (cohérent avec l'existant) ; ne pas confondre avec + `createdAt` (date d'action affichée en colonne). +- Évolution de signature de `AuditLogReadRepositoryInterface` : mettre à jour l'implémentation et le + provider dans le même lot (ils sont les seuls consommateurs). -- 2.39.5 From 2d284b897baf4a691b57512f2a8ea2e5b2f62d3a Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 11:14:40 +0200 Subject: [PATCH 15/23] =?UTF-8?q?docs=20:=20plan=20refonte=20=C3=A9cran=20?= =?UTF-8?q?journal=20(MalioDataTable=20+=20drawers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-24-audit-log-screen-rework.md | 1118 +++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-audit-log-screen-rework.md diff --git a/docs/superpowers/plans/2026-06-24-audit-log-screen-rework.md b/docs/superpowers/plans/2026-06-24-audit-log-screen-rework.md new file mode 100644 index 0000000..ca715da --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-audit-log-screen-rework.md @@ -0,0 +1,1118 @@ +# Refonte écran Journal d'activité — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refondre l'écran `audit-logs.vue` avec un `MalioDataTable`, un drawer de filtre façon STARSEED et un drawer de détail, en exploitant les champs forensiques (IP, appareil, User-Agent, device id) et en enrichissant le backend (perPage + nouveaux filtres). + +**Architecture:** Backend — `AuditLogProvider`/repository/interface gagnent `perPage` + filtres `username`/`ip`/`device` (LIKE insensible à la casse) et `entityType[]`/`action[]` (IN). Frontend — un composable dédié `useAuditLogsList` porte l'état brouillon/appliqué + pagination ; la page se réduit à une toolbar, un `MalioDataTable` et deux `MalioDrawer` (filtre + détail). + +**Tech Stack:** Symfony 7 + API Platform + Doctrine (PostgreSQL) ; Nuxt 4 + Vue 3 + TS + `@malio/layer-ui` 1.7.15. + +## Global Constraints + +- Écran réservé `ROLE_SUPER_ADMIN` (inchangé). (spec) +- Libellés UI en **français en dur** (convention drawers SIRH `employees/index.vue`/`sites.vue`), PAS d'i18n. (spec) +- **Filtres non persistés en URL** ; état local uniquement. (spec) +- Tous les ``/`