025ce8a367
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
905 lines
33 KiB
Markdown
905 lines
33 KiB
Markdown
# 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 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
|
||
<?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.
|