Files
SIRH/docs/superpowers/plans/2026-06-24-audit-log-forensic-context.md
T
2026-06-24 10:09:09 +02:00

905 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 : `<type> : <message>` (espace AVANT le `:`). Types : build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.
- Lancer un test ciblé : `make test FILES=<chemin>`. 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
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\UserAgentParser;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class UserAgentParserTest extends TestCase
{
private UserAgentParser $parser;
protected function setUp(): void
{
$this->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
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
* User-Agent string, used to add forensic context to audit log entries.
* Heuristic on purpose — enough to tell a phone from a desktop and identify
* OS/browser families on shared accounts.
*/
class UserAgentParser
{
public function parse(?string $userAgent): ?string
{
if (null === $userAgent) {
return null;
}
$ua = trim($userAgent);
if ('' === $ua) {
return null;
}
return implode(' · ', [
$this->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 4950), 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
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260624120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
}
public function up(Schema $schema): void
{
$this->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
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\AuditLog;
use App\Service\AuditLogger;
use App\Service\UserAgentParser;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @internal
*/
final class AuditLoggerTest extends TestCase
{
public function testCapturesRequestContext(): 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); // -> 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
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\AuditLog;
use App\Entity\Employee;
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(
?Employee $employee,
string $action,
string $entityType,
?int $entityId,
string $description,
?array $changes = null,
?DateTimeImmutable $affectedDate = null,
): void {
$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)
->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
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Operation;
use App\Entity\AuditLog;
use App\Repository\AuditLogRepository;
use App\State\AuditLogProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* @internal
*/
final class AuditLogProviderTest extends TestCase
{
public function testProvideExposesForensicFields(): void
{
$log = (new AuditLog())
->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<string, unknown>; new?: Record<string, unknown> } | 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.