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,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;
}
}