Files
SIRH/docs/superpowers/plans/2026-06-24-audit-log-forensic-context.md
T
tristan 832751d1ed
Auto Tag Develop / tag (push) Successful in 9s
feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
## Contexte

Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal.

## Ce qui est ajouté (capté automatiquement, sans friction utilisateur)

- **Adresse IP** de la requête
- **User-Agent brut** (borné à 1024 caractères)
- **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`)
- **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé

## Implémentation

- `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée
- 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible)
- Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié
- Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos)
- Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe)
- `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy
- Docs : `doc/audit-logging.md` + `CLAUDE.md`

## Hors périmètre (étapes suivantes)

- **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API.
- La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin.

## À noter pour le déploiement

- L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté).

## Tests

`OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 11:56:42 +00:00

33 KiB
Raw Blame History

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.phpcréer. Parse un User-Agent en libellé court Type · OS · Navigateur.
  • tests/Service/UserAgentParserTest.phpcréer.
  • src/Entity/AuditLog.phpmodifier. +4 propriétés + accesseurs.
  • migrations/Version20260624120000.phpcréer. +4 colonnes nullable sur audit_logs.
  • src/Service/AuditLogger.phpmodifier. Injecte RequestStack + UserAgentParser, peuple les 4 champs.
  • tests/Service/AuditLoggerTest.phpcréer.
  • src/State/AuditLogProvider.phpmodifier. Expose les 4 champs dans le JSON.
  • tests/State/AuditLogProviderTest.phpcréer.
  • config/packages/framework.yamlmodifier. Bloc trusted_proxies documenté (commenté).

Frontend

  • frontend/composables/useDeviceId.tscréer. Device ID persistant.
  • frontend/composables/useApi.tsmodifier. Injecte le header X-Device-Id (intercepteur onRequest).
  • frontend/services/dto/audit-log.tsmodifier. +4 champs optionnels.

Docs

  • doc/audit-logging.md, frontend/data/documentation-content.ts, CLAUDE.mdmodifier.

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

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

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
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:

    #[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:

    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

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
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

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

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
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

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' => ...:

                '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:

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
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:

// 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',):

  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
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:

    # 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
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):

- `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: [...] }):

        { 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:

- **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
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.