feat : add audit log (table, writer, listener, API, admin UI, timeline)

Implemente le journal d'audit append-only sur toutes les mutations Doctrine
des entites portant #[Auditable]. Couvre les 5 tickets de doc/audit-log.md :

1. Table PG audit_log (uuid PK, jsonb changes, index entity/time/performer)
   + AuditLogWriter (DBAL connexion dediee audit, blacklist defense-in-depth
   sur password/plainPassword/token/secret) + RequestIdProvider (UUID v4 par
   requete HTTP principale).
2. Attributs Auditable / AuditIgnore dans Shared/Domain/Attribute/
   + AuditListener (onFlush capture + postFlush ecriture hors transaction ORM,
   pattern swap-and-clear, erreurs loguees jamais propagees). User annote.
3. API Platform read-only /api/audit-logs (permission core.audit_log.view)
   avec filtres entity_type / entity_id / action / performed_by / plage
   performed_at + DbalPaginator implementant PaginatorInterface (hydra:view
   genere automatiquement).
4. Page admin /admin/audit-log : tableau pagine, filtres persistes en query
   params, row expandable (diff + timeline de l'entite), entree sidebar avec
   permission. Composable useAuditLog avec resetAuditLog() auto-enregistre
   sur onAuthSessionCleared.
5. Composant AuditTimeline reutilisable : garde permission, lazy loading,
   dates relatives FR, skeleton loader.

Fix connexe : phpunit.dist.xml forcait APP_ENV=dev via <env> ce qui cablait
framework.test=false et rendait test.service_container indisponible ; le
JWT_PASSPHRASE ne matchait pas non plus les cles dev. Corrige en meme temps
pour debloquer la suite de tests.
This commit is contained in:
2026-04-20 20:51:10 +02:00
parent 140dca9061
commit de39fe6a3e
31 changed files with 2754 additions and 6 deletions

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Symfony\Component\Uid\Uuid;
/**
* Tests fonctionnels de l'API `/api/audit-logs`.
*
* Invariants testes :
* - 401 sans authentification ;
* - 403 pour un user authentifie sans permission `core.audit_log.view` ;
* - 200 + JSON-LD pagine pour admin et user avec la permission ;
* - filtres `entity_type`, `action` operants ;
* - ordre `performed_at DESC` ;
* - aucune operation d'ecriture exposee (POST -> 405).
*
* Seed : on insere 3 lignes temoins directement via DBAL (pas via l'ORM)
* pour eviter la recursion du listener. Les lignes sont supprimees en
* tearDown par le request_id tag specifique au run.
*
* @internal
*/
final class AuditLogApiTest extends AbstractApiTestCase
{
private Connection $auditConnection;
private string $runTag;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
$this->runTag = 'api_audit_'.bin2hex(random_bytes(4));
$this->seedAuditLog();
}
protected function tearDown(): void
{
$this->auditConnection->executeStatement(
'DELETE FROM audit_log WHERE request_id = :tag',
['tag' => $this->runTag],
);
parent::tearDown();
}
public function testUnauthenticatedRequestGets401(): void
{
$client = self::createClient();
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(401, $response->getStatusCode());
}
public function testAuthenticatedUserWithoutPermissionGets403(): void
{
// Utilise `core.users.view` comme permission non-liee (l'user n'a pas audit_log.view).
$credentials = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(403, $response->getStatusCode());
}
public function testAuthenticatedUserWithPermissionGets200(): void
{
$credentials = $this->createUserWithPermission('core.audit_log.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('member', $data);
self::assertArrayHasKey('totalItems', $data);
}
public function testAdminGets200(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(200, $response->getStatusCode());
}
public function testFilterByEntityType(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?entity_type=core.User&action=update');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
$members = $data['member'];
// On verifie qu'il n'y a que des lignes matching nos filtres dans les resultats de notre run
// (d'autres lignes antérieures au run peuvent exister, mais le filtre doit etre respecte).
foreach ($members as $member) {
self::assertSame('core.User', $member['entityType']);
self::assertSame('update', $member['action']);
}
}
public function testOrderedByPerformedAtDesc(): void
{
$client = $this->authenticatedClient('admin', 'admin');
// On cible les 3 lignes seedees via le filtre `entity_id=999` (unique a ce test).
$response = $client->request('GET', '/api/audit-logs?'.http_build_query(['entity_type' => 'core.User', 'entity_id' => '999']));
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
$members = array_values(array_filter(
$data['member'],
fn (array $m) => ($m['requestId'] ?? null) === $this->runTag,
));
self::assertCount(3, $members, 'Les 3 lignes seedees doivent etre visibles');
// Tri DESC : le plus recent d'abord.
$timestamps = array_map(fn (array $m) => strtotime((string) $m['performedAt']), $members);
$sortedDesc = $timestamps;
rsort($sortedDesc);
self::assertSame($sortedDesc, $timestamps, 'Les lignes doivent etre triees par performedAt DESC');
}
public function testItemEndpointReturns200WithPermission(): void
{
$row = $this->auditConnection->fetchAssociative(
'SELECT id FROM audit_log WHERE request_id = :tag LIMIT 1',
['tag' => $this->runTag],
);
self::assertIsArray($row);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs/'.$row['id']);
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertSame($row['id'], $data['id']);
}
public function testPostIsNotAllowed(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/audit-logs', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['entityType' => 'core.User', 'entityId' => '1', 'action' => 'create', 'changes' => []],
]);
self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)');
}
/**
* Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur.
*/
private function seedAuditLog(): void
{
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$fixtures = [
[
'entity_type' => 'core.User',
'entity_id' => '999',
'action' => 'update',
'changes' => ['isAdmin' => ['old' => false, 'new' => true]],
'performed_by' => 'admin',
'performed_at' => $now->modify('-2 hours'),
],
[
'entity_type' => 'core.User',
'entity_id' => '999',
'action' => 'update',
'changes' => ['username' => ['old' => 'x', 'new' => 'y']],
'performed_by' => 'admin',
'performed_at' => $now->modify('-1 hour'),
],
[
'entity_type' => 'core.User',
'entity_id' => '999',
'action' => 'delete',
'changes' => ['username' => 'y'],
'performed_by' => 'admin',
'performed_at' => $now,
],
];
foreach ($fixtures as $row) {
$this->auditConnection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $row['entity_type'],
'entity_id' => $row['entity_id'],
'action' => $row['action'],
'changes' => json_encode($row['changes'], JSON_THROW_ON_ERROR),
'performed_by' => $row['performed_by'],
'performed_at' => $row['performed_at']->format('Y-m-d H:i:sO'),
'ip_address' => null,
'request_id' => $this->runTag,
]);
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\Audit;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Module\Core\Infrastructure\Audit\RequestIdProvider;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* Tests unitaires de l'AuditLogWriter.
*
* Verifie les invariants critiques :
* - filtrage des cles sensibles (defense-in-depth par rapport a #[AuditIgnore]) ;
* - utilisation du username courant ou "system" en CLI ;
* - captation IP + request_id si requete HTTP presente ;
* - generation d'un UUID v7 (tri chronologique implicite en PK).
*
* Aucune BDD : la connexion DBAL est mockee pour capturer l'insert.
*
* @internal
*/
#[AllowMockObjectsWithoutExpectations]
final class AuditLogWriterTest extends TestCase
{
/**
* @var null|array{0: string, 1: array<string, mixed>, 2: array<string, mixed>}
*
* Capture de l'appel `insert()` : [$table, $data, $types]
*/
private ?array $capturedInsert = null;
private Connection $connection;
private RequestStack $requestStack;
private RequestIdProvider $requestIdProvider;
protected function setUp(): void
{
$this->capturedInsert = null;
$this->connection = $this->createMock(Connection::class);
$this->connection
->method('insert')
->willReturnCallback(function (string $table, array $data, array $types = []): int {
$this->capturedInsert = [$table, $data, $types];
return 1;
})
;
$this->requestStack = new RequestStack();
$this->requestIdProvider = new RequestIdProvider();
}
public function testLogsCreateWithAuthenticatedUser(): void
{
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '42', 'create', ['username' => 'alice']);
$this->assertNotNull($this->capturedInsert);
[$table, $data] = $this->capturedInsert;
$this->assertSame('audit_log', $table);
$this->assertSame('core.User', $data['entity_type']);
$this->assertSame('42', $data['entity_id']);
$this->assertSame('create', $data['action']);
$this->assertSame(['username' => 'alice'], $data['changes']);
$this->assertSame('alice', $data['performed_by']);
}
public function testUsesSystemWhenNoAuthenticatedUser(): void
{
$security = $this->buildSecurityWithUser(null);
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'update', ['isAdmin' => ['old' => false, 'new' => true]]);
$this->assertSame('system', $this->capturedInsert[1]['performed_by']);
}
public function testStripsSensitiveKeys(): void
{
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', [
'username' => 'bob',
'password' => 'topsecrethash',
'plainPassword' => 'clear',
'token' => 'abc',
'secret' => 'xyz',
'email' => 'bob@example.com',
]);
$changes = $this->capturedInsert[1]['changes'];
$this->assertArrayNotHasKey('password', $changes);
$this->assertArrayNotHasKey('plainPassword', $changes);
$this->assertArrayNotHasKey('token', $changes);
$this->assertArrayNotHasKey('secret', $changes);
$this->assertSame('bob', $changes['username']);
$this->assertSame('bob@example.com', $changes['email']);
}
public function testCapturesIpAddressWhenRequestPresent(): void
{
$request = Request::create('/api/users', 'POST');
$request->server->set('REMOTE_ADDR', '203.0.113.42');
$this->requestStack->push($request);
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', []);
$this->assertSame('203.0.113.42', $this->capturedInsert[1]['ip_address']);
}
public function testIpAddressNullInCli(): void
{
$security = $this->buildSecurityWithUser(null);
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', []);
$this->assertNull($this->capturedInsert[1]['ip_address']);
$this->assertNull($this->capturedInsert[1]['request_id']);
}
public function testGeneratesUuidV7PrimaryKey(): void
{
$security = $this->buildSecurityWithUser('alice');
$writer = new AuditLogWriter($this->connection, $security, $this->requestStack, $this->requestIdProvider);
$writer->log('core.User', '1', 'create', []);
$id = $this->capturedInsert[1]['id'];
// UUID v7 : le 13e caractere (apres les tirets) vaut "7".
// Format : xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
$id
);
}
private function buildSecurityWithUser(?string $username): Security
{
$security = $this->createMock(Security::class);
$user = null !== $username ? new InMemoryUser($username, 'pwd') : null;
$security->method('getUser')->willReturn($user);
return $security;
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Tests d'integration de l'AuditListener.
*
* Contrairement aux tests unitaires du writer, on fait tourner le kernel
* complet pour verifier que le listener est bien cable et que les attributs
* #[Auditable] / #[AuditIgnore] sur User sont respectes jusqu'a l'insert
* final dans audit_log.
*
* Strategie de nettoyage : chaque test supprime ses fixtures dans tearDown
* (pas de rollback transactionnel DAMA sur ce projet).
*
* @internal
*/
final class AuditListenerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $auditConnection;
/** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
private array $createdUserIds = [];
private string $testRunTag;
protected function setUp(): void
{
self::bootKernel();
/** @var EntityManagerInterface $em */
$em = self::getContainer()->get('doctrine')->getManager();
$this->em = $em;
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
// Tag unique par run pour filtrer les lignes audit_log produites
// exclusivement par ce test (la table n'a ni truncate ni rollback).
$this->testRunTag = 'audit_test_'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
// Suppression explicite des users crees (cascade sur user_role /
// user_site via les ORM mappings) + nettoyage des lignes audit
// correspondantes pour ne pas polluer les runs suivants.
if ([] !== $this->createdUserIds) {
foreach ($this->createdUserIds as $id) {
$user = $this->em->find(User::class, $id);
if (null !== $user) {
$this->em->remove($user);
}
}
$this->em->flush();
}
$this->auditConnection->executeStatement(
"DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
['tag' => $this->testRunTag.'%'],
);
parent::tearDown();
}
public function testLogsCreateOnUserInsertion(): void
{
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$this->createdUserIds[] = $user->getId();
$rows = $this->fetchAuditRows($user->getId());
$this->assertCount(1, $rows, 'Une ligne audit attendue a la creation');
$row = $rows[0];
$this->assertSame('core.User', $row['entity_type']);
$this->assertSame('create', $row['action']);
$this->assertSame((string) $user->getId(), $row['entity_id']);
$changes = json_decode($row['changes'], true, 512, JSON_THROW_ON_ERROR);
$this->assertArrayHasKey('username', $changes);
$this->assertArrayNotHasKey('password', $changes, 'password doit etre #[AuditIgnore]');
$this->assertArrayNotHasKey('plainPassword', $changes, 'plainPassword doit etre #[AuditIgnore]');
}
public function testLogsUpdateWithDiff(): void
{
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$this->createdUserIds[] = $user->getId();
// Reset de la baseline : on ne garde que la ligne update.
$this->auditConnection->executeStatement(
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
['id' => (string) $user->getId()],
);
$user->setIsAdmin(true);
$this->em->flush();
$rows = $this->fetchAuditRows($user->getId());
$this->assertCount(1, $rows);
$this->assertSame('update', $rows[0]['action']);
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
$this->assertArrayHasKey('isAdmin', $changes);
$this->assertSame(false, $changes['isAdmin']['old']);
$this->assertSame(true, $changes['isAdmin']['new']);
}
public function testLogsDeleteSnapshot(): void
{
$user = $this->makeUser();
$this->em->persist($user);
$this->em->flush();
$userId = $user->getId();
$this->em->remove($user);
$this->em->flush();
$rows = $this->fetchAuditRows($userId);
// Deux lignes : la creation + la suppression.
$actions = array_column($rows, 'action');
$this->assertContains('delete', $actions);
$deleteRow = $rows[array_search('delete', $actions, true)];
$changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
$this->assertArrayHasKey('username', $changes);
$this->assertArrayNotHasKey('password', $changes);
// On nettoie a la main les lignes restantes (user deja delete).
$this->auditConnection->executeStatement(
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
['id' => (string) $userId],
);
}
/**
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
*/
private function fetchAuditRows(int $userId): array
{
/** @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $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.User', 'id' => (string) $userId],
);
}
private function makeUser(): User
{
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setUsername($this->testRunTag.'_'.bin2hex(random_bytes(2)));
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, 'testpass'));
return $user;
}
}