feat: Audit trail pour actions sensibles

Story 1.7 - Implémente un système complet d'audit trail pour tracer
toutes les actions sensibles (authentification, modifications de données,
exports) avec immuabilité garantie par PostgreSQL.

Fonctionnalités principales:
- Table audit_log append-only avec contraintes PostgreSQL (RULE)
- AuditLogger centralisé avec injection automatique du contexte
- Correlation ID pour traçabilité distribuée (HTTP + async)
- Handlers pour événements d'authentification
- Commande d'archivage des logs anciens
- Pas de PII dans les logs (emails/IPs hashés)

Infrastructure:
- Middlewares Messenger pour propagation du Correlation ID
- HTTP middleware pour génération/propagation du Correlation ID
- Support multi-tenant avec TenantResolver
This commit is contained in:
2026-02-04 00:11:58 +01:00
parent b823479658
commit 2ed60fdcc1
38 changed files with 4179 additions and 81 deletions

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
use App\Shared\Domain\CorrelationId;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware;
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* @see Story 1.7 - T3: Correlation ID
*/
final class AddCorrelationIdStampMiddlewareTest extends TestCase
{
private AddCorrelationIdStampMiddleware $middleware;
protected function setUp(): void
{
$this->middleware = new AddCorrelationIdStampMiddleware();
CorrelationIdHolder::clear();
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
}
public function testAddsStampWhenCorrelationIdIsSet(): void
{
$correlationId = CorrelationId::generate();
CorrelationIdHolder::set($correlationId);
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$result = $this->middleware->handle($envelope, $stack);
$stamp = $result->last(CorrelationIdStamp::class);
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
$this->assertSame($correlationId->value(), $stamp->correlationId);
}
public function testDoesNotAddStampWhenNoCorrelationId(): void
{
CorrelationIdHolder::clear();
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$result = $this->middleware->handle($envelope, $stack);
$stamp = $result->last(CorrelationIdStamp::class);
$this->assertNull($stamp);
}
public function testDoesNotOverwriteExistingStamp(): void
{
$existingId = '11111111-1111-1111-1111-111111111111';
$currentId = CorrelationId::generate();
CorrelationIdHolder::set($currentId);
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($existingId)]);
$stack = $this->createCapturingStack();
$result = $this->middleware->handle($envelope, $stack);
$stamp = $result->last(CorrelationIdStamp::class);
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
// Should keep the existing stamp, not overwrite with current
$this->assertSame($existingId, $stamp->correlationId);
}
private function createCapturingStack(): StackInterface
{
return new class implements StackInterface {
public function next(): MiddlewareInterface
{
return new class implements MiddlewareInterface {
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
return $envelope;
}
};
}
};
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
use App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware;
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use RuntimeException;
use stdClass;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* @see Story 1.7 - T3: Correlation ID
*/
final class CorrelationIdMiddlewareTest extends TestCase
{
private CorrelationIdMiddleware $middleware;
protected function setUp(): void
{
$this->middleware = new CorrelationIdMiddleware();
CorrelationIdHolder::clear();
}
protected function tearDown(): void
{
CorrelationIdHolder::clear();
}
public function testUsesCorrelationIdFromStampWhenPresent(): void
{
$expectedId = '550e8400-e29b-41d4-a716-446655440000';
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($expectedId)]);
$stack = $this->createCapturingStack();
$this->middleware->handle($envelope, $stack);
// Verify the correlation ID was set during handling
$this->assertSame($expectedId, $stack->capturedCorrelationId);
}
public function testGeneratesNewCorrelationIdWhenNoStampAndNoExistingId(): void
{
// Async worker context: no existing ID, no stamp
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$this->middleware->handle($envelope, $stack);
// Should have generated a valid UUID
$this->assertNotNull($stack->capturedCorrelationId);
$this->assertTrue(Uuid::isValid($stack->capturedCorrelationId));
}
public function testUsesExistingCorrelationIdDuringSynchronousDispatch(): void
{
// HTTP context: existing ID is set by HTTP middleware
$existingId = '99999999-9999-9999-9999-999999999999';
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
$envelope = new Envelope(new stdClass());
$stack = $this->createCapturingStack();
$this->middleware->handle($envelope, $stack);
// Should use the existing ID, not generate a new one
$this->assertSame($existingId, $stack->capturedCorrelationId);
}
public function testDoesNotClearCorrelationIdDuringSynchronousDispatch(): void
{
// HTTP context: existing ID should be preserved after dispatch
$existingId = '99999999-9999-9999-9999-999999999999';
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
$envelope = new Envelope(new stdClass());
$stack = $this->createPassthroughStack();
$this->middleware->handle($envelope, $stack);
// Should NOT be cleared - HTTP middleware handles that
$this->assertNotNull(CorrelationIdHolder::get());
$this->assertSame($existingId, CorrelationIdHolder::get()?->value());
}
public function testClearsCorrelationIdInAsyncContext(): void
{
// Async worker context: no existing ID, no stamp
$envelope = new Envelope(new stdClass());
$stack = $this->createPassthroughStack();
$this->middleware->handle($envelope, $stack);
// Should be cleared after handling in async context
$this->assertNull(CorrelationIdHolder::get());
}
public function testClearsCorrelationIdWhenStampPresent(): void
{
// Async worker receiving message from HTTP: has stamp
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
$stack = $this->createPassthroughStack();
$this->middleware->handle($envelope, $stack);
// Should be cleared after handling (async context)
$this->assertNull(CorrelationIdHolder::get());
}
public function testClearsCorrelationIdEvenOnException(): void
{
// Async worker context
$envelope = new Envelope(new stdClass());
$stack = $this->createThrowingStack();
try {
$this->middleware->handle($envelope, $stack);
$this->fail('Expected exception to be thrown');
} catch (RuntimeException) {
// Expected
}
// Should be cleared even after exception in async context
$this->assertNull(CorrelationIdHolder::get());
}
public function testDoesNotLeakBetweenMessages(): void
{
$envelope1 = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
$envelope2 = new Envelope(new stdClass(), [new CorrelationIdStamp('22222222-2222-2222-2222-222222222222')]);
$stack1 = $this->createCapturingStack();
$stack2 = $this->createCapturingStack();
$this->middleware->handle($envelope1, $stack1);
$this->middleware->handle($envelope2, $stack2);
$this->assertSame('11111111-1111-1111-1111-111111111111', $stack1->capturedCorrelationId);
$this->assertSame('22222222-2222-2222-2222-222222222222', $stack2->capturedCorrelationId);
}
private function createCapturingStack(): CapturingStack
{
return new CapturingStack();
}
private function createPassthroughStack(): StackInterface
{
return new class implements StackInterface {
public function next(): MiddlewareInterface
{
return new class implements MiddlewareInterface {
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
return $envelope;
}
};
}
};
}
private function createThrowingStack(): StackInterface
{
return new class implements StackInterface {
public function next(): MiddlewareInterface
{
return new class implements MiddlewareInterface {
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
throw new RuntimeException('Handler failed');
}
};
}
};
}
}
/**
* Stack that captures the correlation ID during handling.
*/
final class CapturingStack implements StackInterface
{
public ?string $capturedCorrelationId = null;
public function next(): MiddlewareInterface
{
return new CapturingMiddleware($this);
}
}
/**
* @internal
*/
final class CapturingMiddleware implements MiddlewareInterface
{
public function __construct(private CapturingStack $stack)
{
}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$this->stack->capturedCorrelationId = CorrelationIdHolder::get()?->value();
return $envelope;
}
}