From 90b8ca15cdeb01202ccce31860b877ac4e392848 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 19 Jun 2026 21:09:55 +0200 Subject: [PATCH] feat(core) : expose read-only audit-logs api with dbal provider and pagination --- .../Core/Application/DTO/AuditLogOutput.php | 30 +++ src/Module/Core/CoreModule.php | 1 + .../ApiPlatform/Pagination/DbalPaginator.php | 74 ++++++ .../Resource/AuditLogEntityTypesResource.php | 34 +++ .../ApiPlatform/Resource/AuditLogResource.php | 54 ++++ .../Provider/AuditLogEntityTypesProvider.php | 35 +++ .../State/Provider/AuditLogProvider.php | 247 ++++++++++++++++++ .../Module/Core/AuditLogApiTest.php | 140 ++++++++++ tests/Unit/Module/Core/CoreModuleTest.php | 8 + 9 files changed, 623 insertions(+) create mode 100644 src/Module/Core/Application/DTO/AuditLogOutput.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php create mode 100644 tests/Functional/Module/Core/AuditLogApiTest.php diff --git a/src/Module/Core/Application/DTO/AuditLogOutput.php b/src/Module/Core/Application/DTO/AuditLogOutput.php new file mode 100644 index 0000000..d84ad5a --- /dev/null +++ b/src/Module/Core/Application/DTO/AuditLogOutput.php @@ -0,0 +1,30 @@ + */ + public array $changes, + public string $performedBy, + public DateTimeImmutable $performedAt, + public ?string $ipAddress, + public ?string $requestId, + ) {} +} diff --git a/src/Module/Core/CoreModule.php b/src/Module/Core/CoreModule.php index 587b3db..f6db729 100644 --- a/src/Module/Core/CoreModule.php +++ b/src/Module/Core/CoreModule.php @@ -36,6 +36,7 @@ final class CoreModule implements ModuleInterface ['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'], ['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'], ['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'], + ['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'], ]; } } diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php b/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php new file mode 100644 index 0000000..adda23c --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php @@ -0,0 +1,74 @@ + + */ +final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate +{ + /** + * @param list $items Items deja decoupes sur la page courante + * @param int $currentPage Page courante (1-indexee) + * @param int $itemsPerPage Limite appliquee a la requete SQL + * @param int $totalItems Resultat du COUNT(*) sans limite + */ + public function __construct( + private array $items, + private int $currentPage, + private int $itemsPerPage, + private int $totalItems, + ) {} + + public function getCurrentPage(): float + { + return (float) $this->currentPage; + } + + public function getLastPage(): float + { + if ($this->itemsPerPage <= 0) { + return 1.0; + } + + return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage)); + } + + public function getItemsPerPage(): float + { + return (float) $this->itemsPerPage; + } + + public function getTotalItems(): float + { + return (float) $this->totalItems; + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} 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 new file mode 100644 index 0000000..4879894 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php @@ -0,0 +1,54 @@ + 405) + * conformement au caractere append-only de la table `audit_log`. + * + * La resource est un simple porteur de metadonnees #[ApiResource] ; le + * provider lit via DBAL et retourne directement des instances du DTO + * `AuditLogOutput` (declare via `output:`). La table n'est pas geree par + * l'ORM : aucune entite Doctrine n'est necessaire ici. + * + * Filtres query-param supportes par le provider : + * ?entity_type=core.User + * ?entity_id=42 + * ?action=update + * ?performed_by=admin + * ?performed_at[after]=2026-04-01T00:00:00Z + * ?performed_at[before]=2026-04-30T23:59:59Z + * + * La pagination herite du standard global (10 items / page, max 50, cf. + * `config/packages/api_platform.yaml`). Elle est materialisee par le + * DbalPaginator du provider qui implemente PaginatorInterface — API Platform + * genere automatiquement hydra:view sans construction manuelle. + */ +#[ApiResource( + shortName: 'AuditLog', + operations: [ + new GetCollection( + uriTemplate: '/audit-logs', + security: "is_granted('core.audit_log.view')", + provider: AuditLogProvider::class, + ), + new Get( + uriTemplate: '/audit-logs/{id}', + requirements: ['id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'], + security: "is_granted('core.audit_log.view')", + provider: AuditLogProvider::class, + ), + ], + output: AuditLogOutput::class, +)] +final class AuditLogResource {} 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 new file mode 100644 index 0000000..7cc3126 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php @@ -0,0 +1,247 @@ +provideItem((string) $uriVariables['id']); + } + + return $this->provideCollection($operation, $context); + } + + private function provideItem(string $id): ?AuditLogOutput + { + /** @var array|false $row */ + $row = $this->connection->fetchAssociative( + 'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id + FROM audit_log WHERE id = :id', + ['id' => $id], + ); + + if (false === $row) { + return null; + } + + return $this->hydrate($row); + } + + /** + * @param array $context + */ + private function provideCollection(Operation $operation, array $context): DbalPaginator + { + // Contrairement aux ressources ORM (cf. CategoryProvider), ce provider + // ne gere PAS l'echappatoire `?pagination=false` : la pagination y est + // toujours forcee. `audit_log` est une table append-only a croissance + // infinie — la dumper entierement saturerait memoire/reseau et n'a aucun + // usage front (pas de