feat : audit log (table + writer + listener + API + admin UI + timeline) (#9)
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 9s

## Résumé

Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.

## Ce qui change

### Audit log — cœur de la PR

**Backend**

- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.

**Frontend**

- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.

### Fixes embarqués

- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.

### E2E Playwright (nouveau, commit `4603ab2`)

- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.

## Décisions techniques

- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.

## Test plan

- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.

## Points d'attention pour le review

- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: MALIO-DEV/Coltura#9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #9.
This commit is contained in:
2026-05-13 08:29:30 +00:00
parent dce189d982
commit e6c8381b3c
92 changed files with 8543 additions and 2309 deletions
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;
/**
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
* l'attribut #[Auditable].
*
* Pipeline en deux temps :
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
* on capture les changements en memoire. Aucune ecriture SQL cote audit
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
*
* Pattern swap-and-clear dans postFlush :
* - on copie localement la liste des evenements ;
* - on vide la propriete pendingLogs immediatement ;
* - on itere la copie.
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
* pas de double insertion, pas de boucle infinie.
*
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
* de garantie forte (dead-letter queue, retry).
*
* Collections (OneToMany / ManyToMany) :
* - Les modifications de collections sont tracees via
* `getScheduledCollectionUpdates()` et reportees comme un changement
* `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
* l'entite proprietaire.
* - Si l'entite proprietaire est deja scheduled pour insertion, la diff
* est merge dans le snapshot create (en tant que liste d'IDs initiaux).
* - Si l'entite proprietaire est scheduled pour deletion, les collections
* associees sont ignorees (deja couvertes par le snapshot delete).
*
* Limitations connues :
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
* - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
* bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
* operation de purge/nettoyage qui doit etre auditee doit passer par
* `EntityManager::remove()` + `flush()`. Si un futur batch (ex: commande
* "purger users inactifs") utilise du DQL bulk, les suppressions ne
* seront pas dans `audit_log` — choix d'architecture explicite a faire.
*/
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class AuditListener
{
/**
* Cache par FQCN : true si la classe porte #[Auditable], false sinon.
* Evite une ReflectionClass par entite a chaque flush.
*
* @var array<class-string, bool>
*/
private array $auditableCache = [];
/**
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
*
* @var array<class-string, list<string>>
*/
private array $ignoredPropertiesCache = [];
/**
* Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
*
* Pour les inserts, l'ID est assignee DURANT le flush : on capture la
* reference de l'entite et on resout l'ID au moment du postFlush.
*
* @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
*/
private array $pendingLogs = [];
public function __construct(
private readonly AuditLogWriter $writer,
private readonly LoggerInterface $logger,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
/** @var EntityManagerInterface $em */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
// Reset defensif en debut de cycle : si un flush precedent a leve une
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
// rempli avec des changements jamais committes. Sans ce reset, un
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
// ce reset ne le fragilise donc pas.
$this->pendingLogs = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'update');
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'delete');
}
// Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()`
// ne les expose pas, il faut interroger `UnitOfWork` separement. On
// merge la diff dans le log de l'entite proprietaire si elle est deja
// scheduled, sinon on cree une entree "update" dediee.
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->captureCollectionChange($collection, $em, cleared: false);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->captureCollectionChange($collection, $em, cleared: true);
}
}
public function postFlush(PostFlushEventArgs $args): void
{
// Swap-and-clear : protege d'un flush re-entrant (aucune double
// insertion meme si un callback utilisateur re-declenche un flush).
$logs = $this->pendingLogs;
$this->pendingLogs = [];
foreach ($logs as $log) {
// Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
// on la resout maintenant (Doctrine l'a hydratee pendant le flush).
$entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);
if (null === $entityId) {
$this->logger->warning(
'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
['entityType' => $log['entityType'], 'action' => $log['action']]
);
continue;
}
try {
$this->writer->log(
$log['entityType'],
$entityId,
$log['action'],
$log['changes'],
);
} catch (Throwable $e) {
// Erreur audit : logue mais ne crashe jamais le flux metier.
$this->logger->error(
'Echec d\'ecriture audit_log',
[
'exception' => $e,
'entityType' => $log['entityType'],
'entityId' => $entityId,
'action' => $log['action'],
]
);
}
}
}
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
// Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
// proxy Doctrine pour une entite chargee en lazy (ex:
// `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
// le verrait comme non-auditable car `#[Auditable]` n'est declare que
// sur la classe parente.
$metadata = $em->getClassMetadata($entity::class);
$class = $metadata->getName();
if (!$this->isAuditable($class)) {
return;
}
// Sur `delete`, on inclut aussi les collections to-many dans le
// snapshot : c'est la derniere occasion de capturer l'etat complet
// (ex: quelles permissions etaient rattachees au role supprime).
// Sur `create`, les collections initiales sont rapportees via
// captureCollectionChange quand l'entite est scheduled avec un
// collection update dans le meme flush.
$changes = match ($action) {
'update' => $this->buildUpdateChanges($entity, $uow, $class),
'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
default => [],
};
if ('update' === $action && [] === $changes) {
// Flush sans changement reel sur une entite auditable : on n'emet pas.
return;
}
// Pour delete/update, l'ID est deja set en onFlush — on la capture
// maintenant (apres postFlush, l'entite detachee peut perdre sa ref
// dans l'identity map). Pour create (IDENTITY), l'ID est generee par
// le flush — on differe a postFlush.
$capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);
$this->pendingLogs[] = [
'entity' => $entity,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => $action,
'changes' => $changes,
'capturedId' => $capturedId,
];
}
/**
* Capture la modification d'une collection to-many.
*
* Strategie de merge :
* - Si l'entite proprietaire est deja scheduled pour `delete` → ignore
* (redondant avec le snapshot delete deja produit).
* - Si l'entite est deja scheduled pour `create` → on ajoute le champ
* collection au snapshot initial, sous forme de liste d'IDs ajoutes.
* - Si l'entite est deja scheduled pour `update` → on merge la diff
* {added, removed} dans le changeset existant.
* - Sinon → on cree une nouvelle entree `update` dediee pour l'entite
* proprietaire (cas d'une collection modifiee sans autre changement
* sur l'entite elle-meme, ex : ajout d'une permission a un role).
*
* @param bool $cleared true si la collection entiere est supprimee
* (getScheduledCollectionDeletions) — tous les
* items du snapshot sont consideres comme retires
*/
private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void
{
$owner = $collection->getOwner();
if (null === $owner) {
return;
}
// Voir capturePendingLog : meme contournement proxy Doctrine.
$class = $em->getClassMetadata($owner::class)->getName();
if (!$this->isAuditable($class)) {
return;
}
$fieldName = $collection->getMapping()->fieldName;
if (in_array($fieldName, $this->getIgnoredProperties($class), true)) {
return;
}
if ($cleared) {
$added = [];
$removed = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getSnapshot(),
);
} else {
$added = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getInsertDiff(),
);
$removed = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getDeleteDiff(),
);
}
if ([] === $added && [] === $removed) {
return;
}
// Chercher un log deja en attente pour cette entite, pour merger la
// diff au lieu de creer une entree d'audit redondante.
foreach ($this->pendingLogs as $idx => $log) {
if ($log['entity'] !== $owner) {
continue;
}
if ('delete' === $log['action']) {
// Deletion de l'entite : la collection suit mecaniquement,
// pas d'entree dediee (le snapshot delete contient deja
// l'etat a supprimer).
return;
}
if ('create' === $log['action']) {
// Insertion : le snapshot create ne contient pas les
// collections (buildSnapshot ignore les to-many). On ajoute
// donc la liste des items initiaux comme IDs, pour avoir
// une trace complete de l'etat a la creation. array_values
// garantit un array JSON (pas un objet) si les cles du diff
// ne sont pas sequentielles.
$this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added);
return;
}
// Update : on merge dans le changeset existant.
$this->pendingLogs[$idx]['changes'][$fieldName] = [
'added' => array_values($added),
'removed' => array_values($removed),
];
return;
}
// Aucun log existant : l'entite n'a eu QUE des changements de
// collection. On cree une entree update minimale.
$metadata = $em->getClassMetadata($class);
$this->pendingLogs[] = [
'entity' => $owner,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => 'update',
'changes' => [$fieldName => [
'added' => array_values($added),
'removed' => array_values($removed),
]],
'capturedId' => $this->resolveEntityId($owner, $metadata),
];
}
/**
* Build du changeset "update" : {champ: {old, new}} a partir de
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
* null-safe via `?->getId()`.
*
* @return array<string, array{old: mixed, new: mixed}>
*/
private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
{
$changeSet = $uow->getEntityChangeSet($entity);
$ignored = $this->getIgnoredProperties($class);
$filteredChanges = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if (in_array($field, $ignored, true)) {
continue;
}
$filteredChanges[$field] = [
'old' => $this->normalizeValue($oldValue),
'new' => $this->normalizeValue($newValue),
];
}
return $filteredChanges;
}
/**
* Build d'un snapshot complet (create / delete) : lit toutes les
* proprietes non-ignorees via Reflection.
*
* @param bool $includeCollections si true, les associations to-many sont
* aussi snapshotees (liste d'IDs). Utilise
* uniquement sur `delete` pour preserver
* l'etat des relations au moment de la
* suppression. En create, on laisse
* captureCollectionChange enrichir le
* snapshot si une collection est modifiee
* dans le meme flush.
*
* @return array<string, mixed>
*/
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
{
$ignored = $this->getIgnoredProperties($class);
$snapshot = [];
foreach ($metadata->getFieldNames() as $field) {
if (in_array($field, $ignored, true)) {
continue;
}
$snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
}
foreach ($metadata->getAssociationNames() as $assoc) {
if (in_array($assoc, $ignored, true)) {
continue;
}
if ($metadata->isSingleValuedAssociation($assoc)) {
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
continue;
}
if (!$includeCollections) {
continue;
}
// Collection to-many : snapshot = liste d'IDs. On itere la
// Collection (PersistentCollection ou ArrayCollection) pour
// obtenir les elements. Pour un delete, la collection est deja
// chargee (Doctrine en a besoin pour les cascades).
$collection = $metadata->getFieldValue($entity, $assoc);
if (!is_iterable($collection)) {
continue;
}
$ids = [];
foreach ($collection as $item) {
$ids[] = $this->normalizeValue($item);
}
$snapshot[$assoc] = $ids;
}
return $snapshot;
}
private function isAuditable(string $class): bool
{
if (array_key_exists($class, $this->auditableCache)) {
return $this->auditableCache[$class];
}
$reflection = new ReflectionClass($class);
$isAuditable = [] !== $reflection->getAttributes(Auditable::class);
$this->auditableCache[$class] = $isAuditable;
return $isAuditable;
}
/**
* @return list<string>
*/
private function getIgnoredProperties(string $class): array
{
if (array_key_exists($class, $this->ignoredPropertiesCache)) {
return $this->ignoredPropertiesCache[$class];
}
$ignored = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
if ([] !== $property->getAttributes(AuditIgnore::class)) {
$ignored[] = $property->getName();
}
}
$this->ignoredPropertiesCache[$class] = $ignored;
return $ignored;
}
/**
* Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
*
* Format `module.Entity` pour eviter les collisions inter-modules.
*/
private function formatEntityType(string $class): string
{
if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
return strtolower($matches['module']).'.'.$matches['entity'];
}
// Fallback : on retourne le FQCN complet si la regex ne matche pas
// (entite hors structure modulaire — ne devrait pas arriver).
return $class;
}
private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
{
$identifier = $metadata->getIdentifierValues($entity);
if ([] === $identifier) {
return null;
}
// Cle composee : on concatene les valeurs. Cas rare sur le projet.
return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
}
/**
* Normalise une valeur pour encodage JSON stable.
*/
private function normalizeValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if (is_object($value)) {
// Relation to-one non parsee par buildSnapshot (cas update sur
// un champ qui devient un objet) : on tente getId() si possible.
if (method_exists($value, 'getId')) {
return $value->getId();
}
return (string) $value;
}
return $value;
}
}