diff --git a/config/version.yaml b/config/version.yaml index 96e21a4..d8ae3c7 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.32' + app.version: '0.1.34' diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 7527977..f85270b 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -88,13 +88,15 @@ "date_to": "Au", "entity_type": "Type d'entité", "user": "Utilisateur", - "action": "Action" + "action": "Action", + "all_actions": "Toutes les actions" }, "detail": { "field": "Champ", "old_value": "Ancienne valeur", "new_value": "Nouvelle valeur" - } + }, + "detail_title": "Détail de l'entrée" }, "success": { "auth": { diff --git a/frontend/modules/core/pages/admin/audit-log.vue b/frontend/modules/core/pages/admin/audit-log.vue index bbc6afe..1f84216 100644 --- a/frontend/modules/core/pages/admin/audit-log.vue +++ b/frontend/modules/core/pages/admin/audit-log.vue @@ -8,260 +8,271 @@
-
+ +
+
-
+
-
-
-
+
-
- + />
-
- - - - - - - - - - - - - - - - - - - - -
- {{ t('admin.auditLog.table.performedAt') }} - - {{ t('admin.auditLog.table.performedBy') }} - - {{ t('admin.auditLog.table.entityType') }} - - {{ t('admin.auditLog.table.entityId') }} - - {{ t('admin.auditLog.table.action') }} - - {{ t('admin.auditLog.table.summary') }} -
- {{ isFiltered ? t('audit.no_results') : t('audit.empty') }} -
- {{ t('common.loading') }} -
-
+ + + + + + - - + diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue index b1962ca..c25c9ad 100644 --- a/frontend/modules/core/pages/logout.vue +++ b/frontend/modules/core/pages/logout.vue @@ -11,6 +11,7 @@ const auth = useAuthStore() const { resetSidebar } = useSidebar() const { resetModules } = useModules() const { resetCurrentSite } = useCurrentSite() +const { resetAuditLog } = useAuditLog() onMounted(async () => { try { @@ -18,13 +19,14 @@ onMounted(async () => { } finally { // Les resets sont garantis meme si auth.logout() rejette : eviter // qu'un user suivant (connecte sur le meme onglet) voie l'etat de - // l'ancien. Les trois fonctions reset sont synchrones et ne + // l'ancien. Toutes les fonctions reset sont synchrones et ne // peuvent pas throw (juste des assignations reactives). // navigateTo est dans le finally pour garantir la redirection // meme si auth.logout() lance une exception (ex: reseau coupé). resetSidebar() resetModules() resetCurrentSite() + resetAuditLog() await navigateTo('/login') } }) diff --git a/frontend/shared/components/audit/AuditLogDetail.vue b/frontend/shared/components/audit/AuditLogDetail.vue index e444347..7058a7c 100644 --- a/frontend/shared/components/audit/AuditLogDetail.vue +++ b/frontend/shared/components/audit/AuditLogDetail.vue @@ -10,6 +10,9 @@

+ @@ -24,6 +27,20 @@ + + + + + +
{{ formatValue(diff.old) }} {{ formatValue(diff.new) }}
{{ field }} + − {{ diff.removed.join(', ') }} + + + + {{ diff.added.join(', ') }} + +
@@ -45,7 +62,7 @@ const props = defineProps<{ entry: AuditLogEntry }>() const { t } = useI18n() -// Extrait les entrees au shape { old, new } pour les updates. +// Extrait les entrees au shape { old, new } pour les updates scalaires. const updateDiff = computed>(() => { const out: Record = {} for (const [key, value] of Object.entries(props.entry.changes)) { @@ -56,6 +73,22 @@ const updateDiff = computed>(() = return out }) +// Extrait les entrees au shape { added, removed } pour les modifications +// de collections to-many (cf. AuditListener::captureCollectionChange). +const collectionDiff = computed>(() => { + const out: Record = {} + for (const [key, value] of Object.entries(props.entry.changes)) { + if (value && typeof value === 'object' && 'added' in value && 'removed' in value) { + const diff = value as { added: unknown; removed: unknown } + out[key] = { + added: Array.isArray(diff.added) ? diff.added : [], + removed: Array.isArray(diff.removed) ? diff.removed : [], + } + } + } + return out +}) + function formatValue(value: unknown): string { if (value === null || value === undefined) return '∅' if (typeof value === 'boolean') return value ? 'oui' : 'non' diff --git a/frontend/shared/components/audit/AuditTimeline.vue b/frontend/shared/components/audit/AuditTimeline.vue index 9f360f9..a4368f4 100644 --- a/frontend/shared/components/audit/AuditTimeline.vue +++ b/frontend/shared/components/audit/AuditTimeline.vue @@ -51,6 +51,13 @@ {{ formatValue(diff.new) }} + +
+ {{ field }} : + −{{ diff.removed.join(', ') }} + + +{{ diff.added.join(', ') }} +
{{ snapshotSummary(entry) }} @@ -104,29 +111,41 @@ const page = ref(1) const totalItems = ref(0) const loading = ref(false) -// Lazy loading : 10 items max par page visible cote UX. Le back fixe la -// limite a 30 (paginationItemsPerPage de AuditLogResource) ; on coupe a 10 -// dans le composant pour ne pas saturer le flux visuel, et on laisse -// l'utilisateur demander plus via "Voir plus". -const INITIAL_LIMIT = 10 +// Lazy loading : 10 items par page cote UX. On aligne la pagination backend +// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de +// slicer cote client — sinon les items 11-30 de chaque page etaient ignores. +const PAGE_SIZE = 10 + +// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre +// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs +// dont le premier repond en retard et ecrase l'etat de la seconde timeline. +// On incremente un token a chaque fetch ; seule la derniere requete ecrit le +// resultat. loadMore() est aussi protege : une reponse tardive append sur +// une timeline dont l'entite a deja change serait visuellement confuse. +let requestToken = 0 const hasMore = computed(() => entries.value.length < totalItems.value) async function loadPage(targetPage: number, append: boolean): Promise { if (!canView.value) return + const token = ++requestToken loading.value = true try { - const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage) - const slice = (data.member ?? []).slice(0, append ? undefined : INITIAL_LIMIT) - entries.value = append ? [...entries.value, ...slice] : slice + const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE) + if (token !== requestToken) return + const items = data.member ?? [] + entries.value = append ? [...entries.value, ...items] : items totalItems.value = data.totalItems ?? entries.value.length page.value = targetPage } catch { + if (token !== requestToken) return // Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false. entries.value = append ? entries.value : [] } finally { - loading.value = false + if (token === requestToken) { + loading.value = false + } } } @@ -179,6 +198,22 @@ function updateDiff(entry: AuditLogEntry): Record { + // Format to-many : { champ: { added: [ids], removed: [ids] } } produit + // par AuditListener::captureCollectionChange. + const out: Record = {} + for (const [key, value] of Object.entries(entry.changes)) { + if (value && typeof value === 'object' && 'added' in value && 'removed' in value) { + const diff = value as { added: unknown; removed: unknown } + out[key] = { + added: Array.isArray(diff.added) ? diff.added : [], + removed: Array.isArray(diff.removed) ? diff.removed : [], + } + } + } + return out +} + function snapshotSummary(entry: AuditLogEntry): string { const keys = Object.keys(entry.changes) if (keys.length === 0) return '—' diff --git a/frontend/shared/composables/useAuditLog.ts b/frontend/shared/composables/useAuditLog.ts index ba695e7..faac0d7 100644 --- a/frontend/shared/composables/useAuditLog.ts +++ b/frontend/shared/composables/useAuditLog.ts @@ -1,5 +1,5 @@ import { ref } from 'vue' -import type { AuditLogEntry, AuditLogFilters } from '~/shared/types' +import type { AuditLogEntityTypes, AuditLogEntry, AuditLogFilters } from '~/shared/types' import type { HydraCollection } from '~/shared/utils/api' import { onAuthSessionCleared } from '~/shared/stores/auth' @@ -29,17 +29,25 @@ onAuthSessionCleared(resetAuditLog) * * @returns objet plat directement consommable par `useApi().get(url, query)`. */ -function buildQuery(filters: AuditLogFilters | undefined): Record { - const query: Record = {} +function buildQuery(filters: AuditLogFilters | undefined): Record { + const query: Record = {} if (!filters) return query - if (filters.entityType) query.entity_type = filters.entityType + // `entity_type` : chaine simple ou liste pour un filtre multi-selection. + // Cote PHP, la syntaxe `entity_type[]=X&entity_type[]=Y` est requise pour + // que $_GET['entity_type'] soit un tableau (sinon "last wins"). + if (Array.isArray(filters.entityType)) { + if (filters.entityType.length > 0) query['entity_type[]'] = filters.entityType + } else if (filters.entityType) { + query.entity_type = filters.entityType + } if (filters.entityId) query.entity_id = filters.entityId if (filters.action) query.action = filters.action if (filters.performedBy) query.performed_by = filters.performedBy if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore if (filters.page) query.page = filters.page + if (filters.itemsPerPage) query.itemsPerPage = filters.itemsPerPage return query } @@ -84,18 +92,39 @@ export function useAuditLog() { return api.get(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS }) } + /** + * Liste des valeurs distinctes de `entity_type` pour alimenter le filtre + * multi-selection. Alimente par un endpoint DBAL, aucune cache cote front + * (la liste peut evoluer a chaque nouvelle ecriture d'audit). + */ + async function fetchEntityTypes(): Promise { + const data = await api.get( + '/audit-log-entity-types', + {}, + { toast: false, headers: JSONLD_HEADERS }, + ) + return data.entityTypes ?? [] + } + async function fetchEntityLogs( entityType: string, entityId: string | number, page: number = 1, + itemsPerPage: number = 10, ): Promise> { // Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser // `lastCollection` — la timeline peut etre rendue simultanement a // la page globale et doit rester independante. + // + // Le backend pagine a 30 par defaut (paginationItemsPerPage) ; on + // passe explicitement itemsPerPage ici pour que la taille de page + // soit alignee avec l'UX timeline (10 items + bouton "Voir plus"). + // Sans ce param, le client slice a 10 et rate 20 entrees par page. return fetchLogs({ entityType, entityId: String(entityId), page, + itemsPerPage, }) } @@ -104,6 +133,7 @@ export function useAuditLog() { fetchLogs: fetchLogsCached, fetchLogById, fetchEntityLogs, + fetchEntityTypes, resetAuditLog, } } diff --git a/frontend/shared/types/index.ts b/frontend/shared/types/index.ts index e7e9367..9658c9d 100644 --- a/frontend/shared/types/index.ts +++ b/frontend/shared/types/index.ts @@ -35,11 +35,18 @@ export interface AuditLogEntry { * `performed_at[before]`. */ export interface AuditLogFilters { - entityType?: string + /** Chaine pour un seul type, liste pour un filtre multi-selection. */ + entityType?: string | string[] entityId?: string action?: string performedBy?: string performedAtAfter?: string performedAtBefore?: string page?: number + itemsPerPage?: number +} + +export interface AuditLogEntityTypes { + id: string + entityTypes: string[] } diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index e780ff3..9825a05 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository; +use App\Shared\Domain\Attribute\Auditable; use Doctrine\ORM\Mapping as ORM; use InvalidArgumentException; use Symfony\Component\Serializer\Attribute\Groups; @@ -31,6 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiFilter(BooleanFilter::class, properties: ['orphan'])] #[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)] #[ORM\Table(name: 'permission')] +#[Auditable] #[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])] #[ORM\Index(name: 'idx_permission_module', columns: ['module'])] #[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])] diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 1f84615..a6b4bcf 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Post; use App\Module\Core\Domain\Exception\SystemRoleDeletionException; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor; use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository; +use App\Shared\Domain\Attribute\Auditable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; @@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiFilter(BooleanFilter::class, properties: ['isSystem'])] #[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] +#[Auditable] #[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])] #[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] #[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')] diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php new file mode 100644 index 0000000..5892f6c --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php @@ -0,0 +1,34 @@ + $entityTypes */ + public function __construct( + public readonly string $id = 'entity-types', + public readonly array $entityTypes = [], + ) {} +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php index 387fb08..1f428b9 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php @@ -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, ), diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php new file mode 100644 index 0000000..eee33e5 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php @@ -0,0 +1,35 @@ + + */ +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 $types */ + $types = $this->connection + ->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC') + ->fetchFirstColumn() + ; + + return new AuditLogEntityTypesResource(entityTypes: $types); + } +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php index 7a0848a..f7d6137 100644 --- a/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php @@ -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 $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, 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 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 $filters + * @param array|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']); diff --git a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php index 3011e49..98b302f 100644 --- a/src/Module/Core/Infrastructure/Doctrine/AuditListener.php +++ b/src/Module/Core/Infrastructure/Doctrine/AuditListener.php @@ -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 */ - 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; diff --git a/src/Module/Sites/Domain/Entity/Site.php b/src/Module/Sites/Domain/Entity/Site.php index 71a2f3a..79f384b 100644 --- a/src/Module/Sites/Domain/Entity/Site.php +++ b/src/Module/Sites/Domain/Entity/Site.php @@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Module\Core\Domain\Entity\User; use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository; +use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\SiteInterface; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert; )] #[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)] #[ORM\Table(name: 'site')] +#[Auditable] #[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])] #[ORM\HasLifecycleCallbacks] #[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')] diff --git a/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php b/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php index 13ea032..756a2d7 100644 --- a/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php +++ b/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php @@ -34,7 +34,7 @@ class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepo */ public function findAllOrderedByName(): array { - /** @var list $sites */ + // @var list $sites return $this->findBy([], ['name' => 'ASC']); } diff --git a/tests/Module/Core/Api/AuditLogApiTest.php b/tests/Module/Core/Api/AuditLogApiTest.php index 609ea8b..32e66b5 100644 --- a/tests/Module/Core/Api/AuditLogApiTest.php +++ b/tests/Module/Core/Api/AuditLogApiTest.php @@ -95,13 +95,6 @@ final class AuditLogApiTest extends AbstractApiTestCase self::assertArrayHasKey('totalItems', $data); } - /** - * Le frontend force `Accept: application/ld+json` dans `useAuditLog` pour - * recuperer les cles prefixees `hydra:*` (et `hydra:view` pour la - * pagination). Ce test verrouille ce contrat : sans lui, un changement - * de configuration API Platform cassant le JSON-LD passerait inaperçu - * et le tableau admin apparaitrait silencieusement vide en production. - */ /** * Le frontend demande explicitement `application/ld+json` dans `useAuditLog` * pour obtenir l'objet Hydra complet (`member`, `totalItems`, `view`). Sous @@ -206,6 +199,139 @@ final class AuditLogApiTest extends AbstractApiTestCase self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)'); } + /** + * Filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` : l'union des + * deux types est retournee. On seed 2 types differents (core.User et + * core.Role) et on verifie que les deux apparaissent sous notre runTag, + * et qu'une valeur non existante (`core.Nonexistent`) n'ajoute rien. + * + * On interroge avec itemsPerPage=100 pour englober nos 5 lignes quel + * que soit le bruit de lignes preexistantes dans audit_log. + */ + public function testFilterByMultipleEntityTypes(): void + { + // Seed 2 lignes supplementaires avec un autre entity_type. + $this->seedExtraRow('core.Role', '1001', 'create'); + $this->seedExtraRow('core.Role', '1002', 'update'); + + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs?'.http_build_query([ + 'entity_type' => ['core.User', 'core.Role', 'core.Nonexistent'], + 'itemsPerPage' => 100, + ])); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + + // Filtre sur notre runTag pour isoler nos 5 lignes (3 User + 2 Role) + // independamment des entrees pre-existantes de la table. + $ours = array_values(array_filter( + $data['member'], + fn (array $m) => ($m['requestId'] ?? null) === $this->runTag, + )); + self::assertCount(5, $ours, 'Les 3 lignes core.User + 2 lignes core.Role doivent etre retournees.'); + + $types = array_unique(array_map(fn (array $m) => $m['entityType'], $ours)); + sort($types); + self::assertSame(['core.Role', 'core.User'], $types); + + // Verifier qu'aucune ligne hors filtre n'apparait dans la reponse. + foreach ($data['member'] as $member) { + self::assertContains($member['entityType'], ['core.User', 'core.Role']); + } + } + + /** + * Recherche partielle insensible a la casse sur `performed_by` via ILIKE. + * Le seed utilise `performed_by=admin` ; on cherche `ADM` pour tester + * a la fois la casse et le wildcard contains. + */ + public function testFilterByPerformedByPartialMatch(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-logs?performed_by=ADM&entity_id=999'); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); + self::assertGreaterThan(0, count($ours), 'La recherche ILIKE doit matcher "ADM" -> "admin".'); + } + + /** + * Les caracteres wildcard PostgreSQL (`%`, `_`) saisis par l'utilisateur + * doivent etre echappes et traites comme caracteres litteraux, pas comme + * des metacaracteres LIKE. Idem pour le backslash qui doit etre double + * pour ne pas interferer avec la clause ESCAPE. + */ + public function testFilterByPerformedByEscapesWildcards(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + + // `%` seul doit matcher 0 ligne (personne n'a `%` dans performed_by). + $response = $client->request('GET', '/api/audit-logs?performed_by=%25&entity_id=999'); + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); + self::assertCount(0, $ours, '% doit etre traite comme literal, pas wildcard.'); + + // `_` seul (wildcard single-char en LIKE) doit aussi matcher 0 ligne. + $response = $client->request('GET', '/api/audit-logs?performed_by=_&entity_id=999'); + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + $ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag); + self::assertCount(0, $ours, '_ doit etre traite comme literal, pas wildcard single-char.'); + + // `\` (backslash) dans le motif ne doit pas casser la clause ESCAPE : + // on attend une reponse 200 (pas 500), meme si le resultat est vide. + $response = $client->request('GET', '/api/audit-logs?performed_by=%5C&entity_id=999'); + self::assertSame(200, $response->getStatusCode(), 'Un backslash dans le filtre ne doit pas produire de 500.'); + } + + /** + * L'endpoint `/api/audit-log-entity-types` retourne la liste des valeurs + * distinctes de `entity_type` presentes dans la table. La presence du + * seed runTag garantit au moins `core.User`. + */ + public function testEntityTypesEndpointReturnsDistinctTypes(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $response = $client->request('GET', '/api/audit-log-entity-types'); + + self::assertSame(200, $response->getStatusCode()); + $data = $response->toArray(); + self::assertArrayHasKey('entityTypes', $data); + self::assertIsArray($data['entityTypes']); + self::assertContains('core.User', $data['entityTypes']); + } + + public function testEntityTypesEndpointRequiresPermission(): void + { + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + $response = $client->request('GET', '/api/audit-log-entity-types'); + + self::assertSame(403, $response->getStatusCode()); + } + + /** + * Helper interne pour seeder une ligne additionnelle avec un entity_type + * arbitraire, taggee runTag pour nettoyage en tearDown. + */ + private function seedExtraRow(string $entityType, string $entityId, string $action): void + { + $this->auditConnection->insert('audit_log', [ + 'id' => Uuid::v7()->toRfc4122(), + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'action' => $action, + 'changes' => json_encode(['field' => ['old' => 1, 'new' => 2]], JSON_THROW_ON_ERROR), + 'performed_by' => 'admin', + 'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format('Y-m-d H:i:sO'), + 'ip_address' => null, + 'request_id' => $this->runTag, + ]); + } + /** * Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur. */ @@ -220,7 +346,10 @@ final class AuditLogApiTest extends AbstractApiTestCase 'action' => 'update', 'changes' => ['isAdmin' => ['old' => false, 'new' => true]], 'performed_by' => 'admin', - 'performed_at' => $now->modify('-2 hours'), + // Offsets faibles (secondes) : garantit que les 3 lignes + // restent parmi les plus recentes de audit_log meme quand la + // table contient plusieurs centaines de lignes historiques. + 'performed_at' => $now->modify('-2 seconds'), ], [ 'entity_type' => 'core.User', @@ -228,7 +357,7 @@ final class AuditLogApiTest extends AbstractApiTestCase 'action' => 'update', 'changes' => ['username' => ['old' => 'x', 'new' => 'y']], 'performed_by' => 'admin', - 'performed_at' => $now->modify('-1 hour'), + 'performed_at' => $now->modify('-1 second'), ], [ 'entity_type' => 'core.User', diff --git a/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php index 24b6841..8f16cce 100644 --- a/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php +++ b/tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Tests\Module\Core\Infrastructure\Doctrine; +use App\Module\Core\Domain\Entity\Permission; +use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; @@ -32,6 +34,9 @@ final class AuditListenerTest extends KernelTestCase /** @var list IDs de users crees par le test (nettoyage en tearDown) */ private array $createdUserIds = []; + /** @var list IDs de roles crees par le test (nettoyage en tearDown) */ + private array $createdRoleIds = []; + private string $testRunTag; protected function setUp(): void @@ -66,6 +71,24 @@ final class AuditListenerTest extends KernelTestCase $this->em->flush(); } + if ([] !== $this->createdRoleIds) { + foreach ($this->createdRoleIds as $id) { + $role = $this->em->find(Role::class, $id); + if (null !== $role) { + $this->em->remove($role); + } + } + $this->em->flush(); + // Nettoie egalement les lignes audit de ces roles (entity_id est + // une colonne text, on delete en boucle pour simplifier le binding). + foreach ($this->createdRoleIds as $id) { + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $id], + ); + } + } + $this->auditConnection->executeStatement( "DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag", ['tag' => $this->testRunTag.'%'], @@ -154,6 +177,157 @@ final class AuditListenerTest extends KernelTestCase ); } + /** + * Regression test : une entite recuperee via `getReference()` (proxy / + * ghost object lazy) doit etre auditee avec le FQCN canonique. Sur + * Doctrine ORM 3 + PHP 8.4, les lazy ghosts preservent `::class` reel + * — mais sous Doctrine 2 ou en cas de retour a un `__CG__\` proxy, + * l'audit doit toujours resoudre la classe via `ClassMetadata` et + * jamais aboutir a un `entity_type` de type `Proxies\__CG__\...\User`. + */ + public function testLogsUpdateOnProxyEntity(): void + { + $user = $this->makeUser(); + $this->em->persist($user); + $this->em->flush(); + $userId = (int) $user->getId(); + $this->createdUserIds[] = $userId; + + // Detache puis recupere via getReference : sur Doctrine 2, renvoie + // un `Proxies\__CG__\...\User` ; sur Doctrine 3 + PHP 8.4 le ghost + // object reste instance de la classe reelle — dans tous les cas la + // resolution via ClassMetadata doit produire un audit correct. + $this->em->clear(); + + $proxy = $this->em->getReference(User::class, $userId); + self::assertNotNull($proxy); + + // Reset de la baseline : on ne garde que la ligne update du proxy. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'', + ['id' => (string) $userId], + ); + + $proxy->setIsAdmin(true); + $this->em->flush(); + + $rows = $this->fetchAuditRows($userId); + self::assertCount(1, $rows, 'La mutation sur un proxy doit etre auditee.'); + self::assertSame('update', $rows[0]['action']); + // L'entity_type doit etre le FQCN canonique, pas celui du proxy. + self::assertSame('core.User', $rows[0]['entity_type']); + } + + /** + * Verifie que l'ajout d'une permission a un role est bien audite sous + * la forme `{permissions: {added: [id], removed: []}}`. Regression test + * pour le bug "ManyToMany collections ignorees par getEntityChangeSet". + */ + public function testLogsManyToManyCollectionAddition(): void + { + $roleCode = 'audittest_'.bin2hex(random_bytes(3)); + $role = new Role($roleCode, 'Test role '.$roleCode); + $this->em->persist($role); + $this->em->flush(); + $roleId = (int) $role->getId(); + $this->createdRoleIds[] = $roleId; + + // Reset baseline : on ne veut que le log de l'update de collection. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $roleId], + ); + + // Recupere une permission existante (fixtures garantissent core.users.view). + $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertNotNull($permission, 'Fixture core.users.view manquante.'); + + $role->addPermission($permission); + $this->em->flush(); + + $rows = $this->fetchRoleAuditRows($roleId); + self::assertCount(1, $rows, 'Une ligne update attendue pour l\'ajout de permission.'); + self::assertSame('update', $rows[0]['action']); + + $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR); + self::assertArrayHasKey('permissions', $changes, 'Le changeset doit contenir le champ "permissions".'); + self::assertSame([], $changes['permissions']['removed']); + self::assertSame([(int) $permission->getId()], $changes['permissions']['added']); + } + + /** + * Symetrique : retirer une permission d'un role est audite sous + * `{permissions: {added: [], removed: [id]}}`. + */ + public function testLogsManyToManyCollectionRemoval(): void + { + $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertNotNull($permission); + + $roleCode = 'audittest_'.bin2hex(random_bytes(3)); + $role = new Role($roleCode, 'Test role '.$roleCode); + $role->addPermission($permission); + $this->em->persist($role); + $this->em->flush(); + $roleId = (int) $role->getId(); + $this->createdRoleIds[] = $roleId; + + // Reset baseline. + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $roleId], + ); + + $role->removePermission($permission); + $this->em->flush(); + + $rows = $this->fetchRoleAuditRows($roleId); + self::assertCount(1, $rows); + $changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame([], $changes['permissions']['added']); + self::assertSame([(int) $permission->getId()], $changes['permissions']['removed']); + } + + /** + * Regression test : supprimer un role avec des permissions attachees doit + * preserver la liste des permissions dans le snapshot delete. C'etait le + * trou principal du fix ManyToMany initial (reviewer Codex round 2). + */ + public function testDeleteSnapshotIncludesManyToManyIds(): void + { + $permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']); + self::assertNotNull($permission); + + $roleCode = 'audittest_'.bin2hex(random_bytes(3)); + $role = new Role($roleCode, 'Delete test '.$roleCode); + $role->addPermission($permission); + $this->em->persist($role); + $this->em->flush(); + $roleId = (int) $role->getId(); + + $this->em->remove($role); + $this->em->flush(); + + $rows = $this->fetchRoleAuditRows($roleId); + // create + update (permission ajoutee) + delete attendus. + $actions = array_column($rows, 'action'); + self::assertContains('delete', $actions); + + $deleteRow = $rows[array_search('delete', $actions, true)]; + $changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR); + + // Le snapshot delete doit contenir la liste des IDs de permissions + // attachees au role au moment de la suppression. + self::assertArrayHasKey('permissions', $changes); + self::assertSame([(int) $permission->getId()], $changes['permissions']); + + // Nettoyage manuel (le role est deja supprime, on ne peut plus passer par $this->em). + $this->auditConnection->executeStatement( + 'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id', + ['id' => (string) $roleId], + ); + } + /** * @return list */ @@ -178,4 +352,16 @@ final class AuditListenerTest extends KernelTestCase return $user; } + + /** + * @return list + */ + private function fetchRoleAuditRows(int $roleId): array + { + // @var list $rows + return $this->auditConnection->fetchAllAssociative( + 'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC', + ['type' => 'core.Role', 'id' => (string) $roleId], + ); + } }