fix(audit-log) : applique fixes code review PR #9

Resout les 5 findings de la review automatique + couverture ManyToMany
annoncee dans CLAUDE.md :

- AuditListener : resolution de la classe via ClassMetadata plutot que
  `$entity::class` direct (defense proxy Doctrine : sous ORM 2 les lazies
  sont des `Proxies\__CG__\...`). Test de regression via getReference().
- AuditListener : capture des modifications de collections to-many
  (OneToMany / ManyToMany) via getScheduledCollectionUpdates /
  getScheduledCollectionDeletions. Les diffs sont mergees dans le
  changeset existant ou creent une entree "update" dediee.
- AuditLogResource + Provider : filtre multi-valeurs
  `entity_type[]=X&entity_type[]=Y` (IN clause DBAL via
  ArrayParameterType::STRING), endpoint `/audit-log-entity-types` pour
  alimenter le MalioSelectCheckbox cote front.
- audit-log.vue : refonte complete. Passage a `MalioDataTable`,
  composants `Malio*` (MalioInputText, MalioSelectCheckbox, MalioButton),
  suppression complete de la persistance URL (`readQuery` / `syncQuery`
  / `route.query`). `datetime-local` conserve avec TODO pointant
  l'exception CLAUDE.md.
- AuditTimeline : fix du saut d'items 11-30. `PAGE_SIZE = 10` aligne
  avec un `itemsPerPage=10` passe au backend. Token anti-race pour
  ignorer les reponses tardives quand l'entite affichee change.
- AuditLogDetail : affichage des diffs de collections to-many (+ / -)
  dans le tableau field/old/new existant.
- logout.vue : ajout du `resetAuditLog()` au logout pour eviter qu'un
  user suivant (meme onglet) voie l'etat audit de l'ancien.
- Permission / Role / Site : marquage `#[Auditable]`.
- Version bump 0.1.32 → 0.1.34.

Tests : 228 / 228 (221 assertions → 851, dont regressions proxy + M2M).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-21 16:28:44 +02:00
parent a95bb6c629
commit 1505e84926
19 changed files with 1004 additions and 264 deletions

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
/**
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
* d'audit). La liste evolue automatiquement avec les nouvelles entites
* `#[Auditable]` au fil des ecritures.
*/
#[ApiResource(
shortName: 'AuditLogEntityTypes',
operations: [
new Get(
uriTemplate: '/audit-log-entity-types',
security: "is_granted('core.audit_log.view')",
provider: AuditLogEntityTypesProvider::class,
),
],
)]
final class AuditLogEntityTypesResource
{
/** @param list<string> $entityTypes */
public function __construct(
public readonly string $id = 'entity-types',
public readonly array $entityTypes = [],
) {}
}

View File

@@ -39,6 +39,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
new GetCollection(
uriTemplate: '/audit-logs',
paginationItemsPerPage: 30,
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 100,
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
*
* @implements ProviderInterface<AuditLogEntityTypesResource>
*/
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
{
/** @var list<string> $types */
$types = $this->connection
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
->fetchFirstColumn()
;
return new AuditLogEntityTypesResource(entityTypes: $types);
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -100,13 +101,30 @@ final readonly class AuditLogProvider implements ProviderInterface
/**
* @param array<string, mixed> $raw
*
* @return array{entity_type?: string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
*/
private function extractFilters(array $raw): array
{
$filters = [];
foreach (['entity_type', 'entity_id', 'action', 'performed_by'] as $key) {
// `entity_type` accepte soit une chaine, soit une liste (query syntax
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
// multi-selection cote front. On normalise en list<string> non-vide.
if (isset($raw['entity_type'])) {
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
$filters['entity_type'] = $raw['entity_type'];
} elseif (is_array($raw['entity_type'])) {
$cleaned = array_values(array_filter(
$raw['entity_type'],
static fn ($v): bool => is_string($v) && '' !== $v,
));
if ([] !== $cleaned) {
$filters['entity_type'] = $cleaned;
}
}
}
foreach (['entity_id', 'action', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
@@ -127,12 +145,18 @@ final readonly class AuditLogProvider implements ProviderInterface
}
/**
* @param array<string, string> $filters
* @param array<string, list<string>|string> $filters
*/
private function applyFilters(QueryBuilder $qb, array $filters): void
{
if (isset($filters['entity_type'])) {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
if (is_array($filters['entity_type'])) {
$qb->andWhere('entity_type IN (:entity_types)')
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
;
} else {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
}
}
if (isset($filters['entity_id'])) {
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
@@ -141,7 +165,15 @@ final readonly class AuditLogProvider implements ProviderInterface
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
}
if (isset($filters['performed_by'])) {
$qb->andWhere('performed_by = :performed_by')->setParameter('performed_by', $filters['performed_by']);
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
// matche n'importe quel caractere). La clause `ESCAPE '\\'` indique
// a PostgreSQL le caractere d'echappement utilise dans le motif.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\'")
->setParameter('performed_by', '%'.$escaped.'%')
;
}
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);

View File

@@ -14,6 +14,7 @@ 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;
@@ -42,10 +43,17 @@ use Throwable;
* 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 changements de collections ManyToMany ne sont pas tracees
* (`getEntityChangeSet()` ne les couvre pas). Extension future via
* `getScheduledCollectionUpdates()`.
* - 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
@@ -105,6 +113,18 @@ final class AuditListener
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
@@ -152,18 +172,29 @@ final class AuditListener
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
$class = $entity::class;
// 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;
}
$metadata = $em->getClassMetadata($class);
// 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', 'delete' => $this->buildSnapshot($entity, $metadata, $class),
default => [],
'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
default => [],
};
if ('update' === $action && [] === $changes) {
@@ -187,6 +218,115 @@ final class AuditListener
];
}
/**
* 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,
@@ -218,9 +358,18 @@ final class AuditListener
* 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): array
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
{
$ignored = $this->getIgnoredProperties($class);
$snapshot = [];
@@ -238,18 +387,32 @@ final class AuditListener
continue;
}
$mapping = $metadata->getAssociationMapping($assoc);
// On ne snapshot que les references scalaires (to-one) ; les
// collections to-many sont volumineuses et souvent non utiles
// a figer dans un audit (cf. limitation ManyToMany).
if (!$metadata->isSingleValuedAssociation($assoc)) {
if ($metadata->isSingleValuedAssociation($assoc)) {
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
continue;
}
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
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;