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:
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user