feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
Auto Tag Develop / tag (push) Successful in 9s

## 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>
This commit was merged in pull request #33.
This commit is contained in:
2026-06-24 11:56:42 +00:00
committed by Autin
parent c119db0b02
commit 832751d1ed
26 changed files with 3467 additions and 308 deletions
+7
View File
@@ -16,9 +16,16 @@ use App\State\AuditLogProvider;
provider: AuditLogProvider::class,
parameters: [
new QueryParameter(key: 'employeeId'),
new QueryParameter(key: 'employee'),
new QueryParameter(key: 'from'),
new QueryParameter(key: 'to'),
new QueryParameter(key: 'entityType'),
new QueryParameter(key: 'action'),
new QueryParameter(key: 'username'),
new QueryParameter(key: 'ip'),
new QueryParameter(key: 'device'),
new QueryParameter(key: 'page'),
new QueryParameter(key: 'perPage'),
],
security: "is_granted('ROLE_SUPER_ADMIN')"
),
+60
View File
@@ -46,6 +46,18 @@ class AuditLog
#[ORM\Column(type: 'date_immutable', nullable: true)]
private ?DateTimeImmutable $affectedDate = null;
#[ORM\Column(type: 'string', length: 45, nullable: true)]
private ?string $ipAddress = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $userAgent = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $deviceLabel = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $deviceId = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -155,6 +167,54 @@ class AuditLog
return $this;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(?string $userAgent): self
{
$this->userAgent = $userAgent;
return $this;
}
public function getDeviceLabel(): ?string
{
return $this->deviceLabel;
}
public function setDeviceLabel(?string $deviceLabel): self
{
$this->deviceLabel = $deviceLabel;
return $this;
}
public function getDeviceId(): ?string
{
return $this->deviceId;
}
public function setDeviceId(?string $deviceId): self
{
$this->deviceId = $deviceId;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
+68 -57
View File
@@ -5,28 +5,32 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\AuditLog;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AuditLog>
*/
final class AuditLogRepository extends ServiceEntityRepository
final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AuditLog::class);
}
/**
* @return list<AuditLog>
*/
public function findByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?string $entityType = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
int $limit = 50,
int $offset = 0,
): array {
@@ -35,30 +39,7 @@ final class AuditLogRepository extends ServiceEntityRepository
->setMaxResults($limit)
->setFirstResult($offset)
;
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')
->setParameter('employeeId', $employeeId)
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')
->setParameter('from', $from)
;
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')
->setParameter('to', $to)
;
}
if (null !== $entityType) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $entityType)
;
}
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
return $qb->getQuery()->getResult();
}
@@ -67,36 +48,66 @@ final class AuditLogRepository extends ServiceEntityRepository
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?string $entityType = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
): int {
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
;
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')
->setParameter('employeeId', $employeeId)
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')
->setParameter('from', $from)
;
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')
->setParameter('to', $to)
;
}
if (null !== $entityType) {
$qb->andWhere('a.entityType = :entityType')
->setParameter('entityType', $entityType)
;
}
$qb = $this->createQueryBuilder('a')->select('COUNT(a.id)');
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* @param null|list<string> $entityTypes
* @param null|list<string> $actions
*/
private function applyFilters(
QueryBuilder $qb,
?int $employeeId,
?DateTimeImmutable $from,
?DateTimeImmutable $to,
?array $entityTypes,
?array $actions,
?string $username,
?string $ip,
?string $device,
?string $employeeName = null,
): void {
if (null !== $employeeId) {
$qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId);
}
if (null !== $employeeName && '' !== $employeeName) {
$qb->join('a.employee', 'e')
->andWhere('LOWER(e.lastName) LIKE :employeeName OR LOWER(e.firstName) LIKE :employeeName')
->setParameter('employeeName', '%'.mb_strtolower($employeeName).'%')
;
}
if (null !== $from) {
$qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from);
}
if (null !== $to) {
$qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to);
}
if (null !== $entityTypes && [] !== $entityTypes) {
$qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes);
}
if (null !== $actions && [] !== $actions) {
$qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions);
}
if (null !== $username && '' !== $username) {
$qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%');
}
if (null !== $ip && '' !== $ip) {
$qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%');
}
if (null !== $device && '' !== $device) {
$qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)')
->setParameter('device', '%'.mb_strtolower($device).'%')
;
}
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\AuditLog;
use DateTimeImmutable;
interface AuditLogReadRepositoryInterface
{
/**
* @param null|list<string> $entityTypes
* @param null|list<string> $actions
*
* @return list<AuditLog>
*/
public function findByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
int $limit = 50,
int $offset = 0,
): array;
/**
* @param null|list<string> $entityTypes
* @param null|list<string> $actions
*/
public function countByFilters(
?int $employeeId = null,
?DateTimeImmutable $from = null,
?DateTimeImmutable $to = null,
?array $entityTypes = null,
?array $actions = null,
?string $username = null,
?string $ip = null,
?string $device = null,
?string $employeeName = null,
): int;
}
+26
View File
@@ -10,12 +10,15 @@ use App\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
readonly class AuditLogger
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private UserAgentParser $userAgentParser,
) {}
public function log(
@@ -30,6 +33,25 @@ readonly class AuditLogger
$user = $this->security->getUser();
$username = $user instanceof User ? $user->getUsername() : 'system';
$request = $this->requestStack->getCurrentRequest();
$ipAddress = null;
$userAgent = null;
$deviceId = null;
if (null !== $request) {
$ipAddress = $request->getClientIp();
$userAgent = $request->headers->get('User-Agent');
$deviceId = $request->headers->get('X-Device-Id');
// The device id comes from an untrusted client header; cap it to the column width.
if (null !== $deviceId) {
$deviceId = mb_substr($deviceId, 0, 64);
}
// The user agent comes from an untrusted client header; cap it to prevent storage bloat.
if (null !== $userAgent) {
$userAgent = mb_substr($userAgent, 0, 1024);
}
}
$auditLog = new AuditLog();
$auditLog
->setEmployee($employee)
@@ -40,6 +62,10 @@ readonly class AuditLogger
->setDescription($description)
->setChanges($changes)
->setAffectedDate($affectedDate)
->setIpAddress($ipAddress)
->setUserAgent($userAgent)
->setDeviceLabel($this->userAgentParser->parse($userAgent))
->setDeviceId($deviceId)
;
$this->entityManager->persist($auditLog);
+73
View File
@@ -0,0 +1,73 @@
<?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',
};
}
}
+53 -15
View File
@@ -6,7 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\AuditLogRepository;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -14,11 +14,12 @@ use Symfony\Component\HttpFoundation\RequestStack;
class AuditLogProvider implements ProviderInterface
{
private const PER_PAGE = 50;
private const DEFAULT_PER_PAGE = 10;
private const ALLOWED_PER_PAGE = [10, 25, 50, 100];
public function __construct(
private readonly RequestStack $requestStack,
private readonly AuditLogRepository $auditLogRepository,
private readonly AuditLogReadRepositoryInterface $auditLogRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
@@ -28,20 +29,33 @@ class AuditLogProvider implements ProviderInterface
return new JsonResponse(['items' => [], 'total' => 0]);
}
$employeeId = $request->query->get('employeeId');
$from = $request->query->get('from');
$to = $request->query->get('to');
$entityType = $request->query->get('entityType');
$page = max(1, (int) $request->query->get('page', '1'));
$query = $request->query;
$all = $query->all();
$employeeId = $query->get('employeeId');
$from = $query->get('from');
$to = $query->get('to');
$page = max(1, (int) $query->get('page', '1'));
$perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE);
if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) {
$perPage = self::DEFAULT_PER_PAGE;
}
$entityTypes = $this->normalizeList($all['entityType'] ?? null);
$actions = $this->normalizeList($all['action'] ?? null);
$username = $this->normalizeString($query->get('username'));
$ip = $this->normalizeString($query->get('ip'));
$device = $this->normalizeString($query->get('device'));
$employee = $this->normalizeString($query->get('employee'));
$empId = $employeeId ? (int) $employeeId : null;
$fromDt = $from ? new DateTimeImmutable($from) : null;
$toDt = $to ? new DateTimeImmutable($to) : null;
$type = $entityType ?: null;
$offset = ($page - 1) * self::PER_PAGE;
$fromDt = $from ? new DateTimeImmutable((string) $from) : null;
$toDt = $to ? new DateTimeImmutable((string) $to) : null;
$offset = ($page - 1) * $perPage;
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee);
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee, $perPage, $offset);
$items = [];
foreach ($logs as $log) {
@@ -60,6 +74,10 @@ class AuditLogProvider implements ProviderInterface
'description' => $log->getDescription(),
'changes' => $log->getChanges(),
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
'ipAddress' => $log->getIpAddress(),
'userAgent' => $log->getUserAgent(),
'deviceLabel' => $log->getDeviceLabel(),
'deviceId' => $log->getDeviceId(),
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
];
}
@@ -68,7 +86,27 @@ class AuditLogProvider implements ProviderInterface
'items' => $items,
'total' => $total,
'page' => $page,
'perPage' => self::PER_PAGE,
'perPage' => $perPage,
]);
}
/**
* @return null|list<string>
*/
private function normalizeList(mixed $value): ?array
{
$list = array_values(array_filter(
(array) ($value ?? []),
static fn ($v): bool => is_string($v) && '' !== trim($v),
));
return [] === $list ? null : $list;
}
private function normalizeString(mixed $value): ?string
{
$trimmed = trim((string) ($value ?? ''));
return '' === $trimmed ? null : $trimmed;
}
}