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:
65
backend/src/Shared/Application/Port/AuditLogger.php
Normal file
65
backend/src/Shared/Application/Port/AuditLogger.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Application\Port;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Port for audit logging.
|
||||
*
|
||||
* Infrastructure implementation writes to append-only database table.
|
||||
*
|
||||
* @see Story 1.7 - T2: AuditLogger Service
|
||||
*/
|
||||
interface AuditLogger
|
||||
{
|
||||
/**
|
||||
* Log authentication events (login success/failure, account blocked, etc.).
|
||||
*
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
* @param string|null $tenantId Override tenant from event (for async handlers without TenantContext)
|
||||
*/
|
||||
public function logAuthentication(
|
||||
string $eventType,
|
||||
?UuidInterface $userId,
|
||||
array $payload,
|
||||
?string $tenantId = null,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Log data changes (notes, absences, student data modifications).
|
||||
*
|
||||
* @param array<string, mixed> $oldValues Previous values
|
||||
* @param array<string, mixed> $newValues New values
|
||||
*/
|
||||
public function logDataChange(
|
||||
string $aggregateType,
|
||||
UuidInterface $aggregateId,
|
||||
string $eventType,
|
||||
array $oldValues,
|
||||
array $newValues,
|
||||
?string $reason = null,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Log export operations (CSV, PDF, RGPD data exports).
|
||||
*/
|
||||
public function logExport(
|
||||
string $exportType,
|
||||
int $recordCount,
|
||||
?string $targetDescription = null,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Log access to sensitive data (student records, etc.).
|
||||
*
|
||||
* @param array<string, mixed> $context Additional context (screen, action)
|
||||
*/
|
||||
public function logAccess(
|
||||
string $resourceType,
|
||||
UuidInterface $resourceId,
|
||||
array $context = [],
|
||||
): void;
|
||||
}
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class CorrelationId
|
||||
{
|
||||
private function __construct(
|
||||
@@ -20,6 +23,12 @@ final readonly class CorrelationId
|
||||
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
if (!Uuid::isValid($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('Invalid correlation ID format: "%s". Expected UUID.', $value),
|
||||
);
|
||||
}
|
||||
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
|
||||
101
backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php
Normal file
101
backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Read model for audit log entries.
|
||||
*
|
||||
* Immutable DTO representing a single audit log entry.
|
||||
*/
|
||||
final readonly class AuditLogEntry
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public UuidInterface $id,
|
||||
public string $aggregateType,
|
||||
public ?UuidInterface $aggregateId,
|
||||
public string $eventType,
|
||||
public array $payload,
|
||||
public array $metadata,
|
||||
public DateTimeImmutable $occurredAt,
|
||||
public int $sequenceNumber,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function fromDatabaseRow(array $row): self
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $aggregateType */
|
||||
$aggregateType = $row['aggregate_type'];
|
||||
/** @var string|null $aggregateId */
|
||||
$aggregateId = $row['aggregate_id'];
|
||||
/** @var string $eventType */
|
||||
$eventType = $row['event_type'];
|
||||
/** @var string $payloadJson */
|
||||
$payloadJson = $row['payload'];
|
||||
/** @var string $metadataJson */
|
||||
$metadataJson = $row['metadata'];
|
||||
/** @var string $occurredAt */
|
||||
$occurredAt = $row['occurred_at'];
|
||||
/** @var string|int $sequenceNumber */
|
||||
$sequenceNumber = $row['sequence_number'];
|
||||
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($payloadJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
/** @var array<string, mixed> $metadata */
|
||||
$metadata = json_decode($metadataJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return new self(
|
||||
id: Uuid::fromString($id),
|
||||
aggregateType: $aggregateType,
|
||||
aggregateId: $aggregateId !== null
|
||||
? Uuid::fromString($aggregateId)
|
||||
: null,
|
||||
eventType: $eventType,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
occurredAt: new DateTimeImmutable($occurredAt),
|
||||
sequenceNumber: (int) $sequenceNumber,
|
||||
);
|
||||
}
|
||||
|
||||
public function correlationId(): ?string
|
||||
{
|
||||
/** @var string|null $correlationId */
|
||||
$correlationId = $this->metadata['correlation_id'] ?? null;
|
||||
|
||||
return $correlationId;
|
||||
}
|
||||
|
||||
public function tenantId(): ?string
|
||||
{
|
||||
/** @var string|null $tenantId */
|
||||
$tenantId = $this->metadata['tenant_id'] ?? null;
|
||||
|
||||
return $tenantId;
|
||||
}
|
||||
|
||||
public function userId(): ?string
|
||||
{
|
||||
/** @var string|null $userId */
|
||||
$userId = $this->metadata['user_id'] ?? null;
|
||||
|
||||
return $userId;
|
||||
}
|
||||
}
|
||||
188
backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php
Normal file
188
backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Repository for querying audit logs.
|
||||
*
|
||||
* Provides investigation queries for administrators and DPOs.
|
||||
* All queries are tenant-scoped for security.
|
||||
*
|
||||
* @see Story 1.7 - T9: Requetes d'investigation
|
||||
*/
|
||||
final readonly class AuditLogRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific user.
|
||||
*
|
||||
* @see T9.1: Query GetAuditLogByUser
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function findByUser(
|
||||
UuidInterface $userId,
|
||||
TenantId $tenantId,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $eventType = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where("metadata->>'user_id' = :user_id")
|
||||
->andWhere("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('user_id', $userId->toString())
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->applyFilters($qb, $from, $to, $eventType);
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific resource (aggregate).
|
||||
*
|
||||
* @see T9.2: Query GetAuditLogByResource
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function findByResource(
|
||||
string $aggregateType,
|
||||
UuidInterface $aggregateId,
|
||||
TenantId $tenantId,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where('aggregate_type = :aggregate_type')
|
||||
->andWhere('aggregate_id = :aggregate_id')
|
||||
->andWhere("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('aggregate_type', $aggregateType)
|
||||
->setParameter('aggregate_id', $aggregateId->toString())
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->applyFilters($qb, $from, $to, null);
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs by correlation ID (trace a full request).
|
||||
*
|
||||
* @see T9.3: Query GetAuditLogByCorrelationId
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function findByCorrelationId(
|
||||
string $correlationId,
|
||||
TenantId $tenantId,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where("metadata->>'correlation_id' = :correlation_id")
|
||||
->andWhere("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('correlation_id', $correlationId)
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'ASC');
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search audit logs with multiple filters.
|
||||
*
|
||||
* @see T9.4: Filtres avances
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function search(
|
||||
TenantId $tenantId,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $eventType = null,
|
||||
?string $aggregateType = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->applyFilters($qb, $from, $to, $eventType);
|
||||
|
||||
if ($aggregateType !== null) {
|
||||
$qb->andWhere('aggregate_type = :aggregate_type')
|
||||
->setParameter('aggregate_type', $aggregateType);
|
||||
}
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Doctrine\DBAL\Query\QueryBuilder $qb
|
||||
*/
|
||||
private function applyFilters(
|
||||
$qb,
|
||||
?DateTimeImmutable $from,
|
||||
?DateTimeImmutable $to,
|
||||
?string $eventType,
|
||||
): void {
|
||||
if ($from !== null) {
|
||||
$qb->andWhere('occurred_at >= :from')
|
||||
->setParameter('from', $from->format('Y-m-d H:i:s.uP'));
|
||||
}
|
||||
|
||||
if ($to !== null) {
|
||||
$qb->andWhere('occurred_at <= :to')
|
||||
->setParameter('to', $to->format('Y-m-d H:i:s.uP'));
|
||||
}
|
||||
|
||||
if ($eventType !== null) {
|
||||
$qb->andWhere('event_type = :event_type')
|
||||
->setParameter('event_type', $eventType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
private function mapResults(array $rows): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (array $row): AuditLogEntry => AuditLogEntry::fromDatabaseRow($row),
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
215
backend/src/Shared/Infrastructure/Audit/AuditLogger.php
Normal file
215
backend/src/Shared/Infrastructure/Audit/AuditLogger.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Application\Port\AuditLogger as AuditLoggerInterface;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
/**
|
||||
* Centralized audit logging service.
|
||||
*
|
||||
* All sensitive actions are logged to the append-only audit_log table.
|
||||
* Metadata (tenant, user, IP, correlation_id) is automatically injected.
|
||||
*
|
||||
* IMPORTANT: No PII in logs (NFR-S3). IPs/emails are hashed.
|
||||
*
|
||||
* @see Story 1.7 - T2: AuditLogger Service
|
||||
* @see FR90: Tracage actions sensibles
|
||||
* @see NFR-S3: Pas de PII dans les logs
|
||||
* @see NFR-S7: Audit trail immutable
|
||||
*/
|
||||
final readonly class AuditLogger implements AuditLoggerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TenantContext $tenantContext,
|
||||
private TokenStorageInterface $tokenStorage,
|
||||
private RequestStack $requestStack,
|
||||
private Clock $clock,
|
||||
private string $appSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logAuthentication(
|
||||
string $eventType,
|
||||
?UuidInterface $userId,
|
||||
array $payload,
|
||||
?string $tenantId = null,
|
||||
): void {
|
||||
$this->log(
|
||||
aggregateType: 'User',
|
||||
aggregateId: $userId,
|
||||
eventType: $eventType,
|
||||
payload: $payload,
|
||||
tenantIdOverride: $tenantId,
|
||||
userIdOverride: $userId?->toString(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logDataChange(
|
||||
string $aggregateType,
|
||||
UuidInterface $aggregateId,
|
||||
string $eventType,
|
||||
array $oldValues,
|
||||
array $newValues,
|
||||
?string $reason = null,
|
||||
): void {
|
||||
$payload = [
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
];
|
||||
|
||||
if ($reason !== null) {
|
||||
$payload['reason'] = $reason;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
aggregateType: $aggregateType,
|
||||
aggregateId: $aggregateId,
|
||||
eventType: $eventType,
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logExport(
|
||||
string $exportType,
|
||||
int $recordCount,
|
||||
?string $targetDescription = null,
|
||||
): void {
|
||||
$payload = [
|
||||
'export_type' => $exportType,
|
||||
'record_count' => $recordCount,
|
||||
];
|
||||
|
||||
if ($targetDescription !== null) {
|
||||
$payload['target'] = $targetDescription;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
aggregateType: 'Export',
|
||||
aggregateId: null,
|
||||
eventType: 'ExportGenerated',
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logAccess(
|
||||
string $resourceType,
|
||||
UuidInterface $resourceId,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->log(
|
||||
aggregateType: $resourceType,
|
||||
aggregateId: $resourceId,
|
||||
eventType: 'ResourceAccessed',
|
||||
payload: $context,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logging method - writes to append-only audit_log table.
|
||||
*
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
* @param string|null $tenantIdOverride Override tenant (for async handlers without TenantContext)
|
||||
* @param string|null $userIdOverride Override user (for async handlers without security token)
|
||||
*/
|
||||
private function log(
|
||||
string $aggregateType,
|
||||
?UuidInterface $aggregateId,
|
||||
string $eventType,
|
||||
array $payload,
|
||||
?string $tenantIdOverride = null,
|
||||
?string $userIdOverride = null,
|
||||
): void {
|
||||
$metadata = $this->buildMetadata($tenantIdOverride, $userIdOverride);
|
||||
|
||||
// Pass raw arrays - DBAL handles JSON encoding with 'json' type binding
|
||||
$this->connection->insert('audit_log', [
|
||||
'aggregate_type' => $aggregateType,
|
||||
'aggregate_id' => $aggregateId?->toString(),
|
||||
'event_type' => $eventType,
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
'occurred_at' => $this->clock->now()->format('Y-m-d H:i:s.uP'),
|
||||
], [
|
||||
'payload' => 'json',
|
||||
'metadata' => 'json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata with automatic context injection.
|
||||
*
|
||||
* @param string|null $tenantIdOverride Override tenant from event (for async handlers)
|
||||
* @param string|null $userIdOverride Override user from event (for async handlers)
|
||||
*
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
private function buildMetadata(?string $tenantIdOverride = null, ?string $userIdOverride = null): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
$metadata = [
|
||||
'correlation_id' => CorrelationIdHolder::getOrGenerate()->value(),
|
||||
'occurred_at' => $this->clock->now()->format('c'),
|
||||
];
|
||||
|
||||
// Tenant ID: use override if provided, otherwise try TenantContext
|
||||
// Override is needed for async handlers that don't have request context
|
||||
if ($tenantIdOverride !== null) {
|
||||
$metadata['tenant_id'] = $tenantIdOverride;
|
||||
} elseif ($this->tenantContext->hasTenant()) {
|
||||
$metadata['tenant_id'] = (string) $this->tenantContext->getCurrentTenantId();
|
||||
}
|
||||
|
||||
// User ID: use override if provided, otherwise try security token
|
||||
// Override is needed for async handlers that don't have security context
|
||||
if ($userIdOverride !== null) {
|
||||
$metadata['user_id'] = $userIdOverride;
|
||||
} else {
|
||||
$token = $this->tokenStorage->getToken();
|
||||
if ($token !== null) {
|
||||
$user = $token->getUser();
|
||||
if ($user !== null && method_exists($user, 'userId')) {
|
||||
$metadata['user_id'] = $user->userId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IP and User-Agent (hashed for privacy)
|
||||
if ($request !== null) {
|
||||
$ip = $request->getClientIp();
|
||||
if ($ip !== null) {
|
||||
$metadata['ip_hash'] = $this->hashValue($ip);
|
||||
}
|
||||
|
||||
$userAgent = $request->headers->get('User-Agent');
|
||||
if ($userAgent !== null) {
|
||||
$metadata['user_agent_hash'] = hash('sha256', $userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash sensitive values (IP, email) using app secret.
|
||||
* Allows correlation without storing PII.
|
||||
*/
|
||||
private function hashValue(string $value): string
|
||||
{
|
||||
return hash('sha256', $value . $this->appSecret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
|
||||
/**
|
||||
* Thread-local storage for correlation ID.
|
||||
*
|
||||
* Allows any part of the application to access the current request's
|
||||
* correlation ID for audit logging and distributed tracing.
|
||||
*
|
||||
* For HTTP requests, the CorrelationIdMiddleware sets the ID.
|
||||
* For async handlers and CRON jobs, getOrGenerate() auto-creates one.
|
||||
*
|
||||
* NOTE: Static methods are used here because PHP lacks native thread-local
|
||||
* storage (unlike Java's ThreadLocal). This is an accepted exception to the
|
||||
* "no static" rule - see project-context.md "Nuance PHP 8.5 sur No Static".
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdHolder
|
||||
{
|
||||
private static ?CorrelationId $correlationId = null;
|
||||
|
||||
public static function set(CorrelationId $correlationId): void
|
||||
{
|
||||
self::$correlationId = $correlationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current correlation ID, or null if not set.
|
||||
*
|
||||
* Use getOrGenerate() when you need a correlation ID guaranteed.
|
||||
*/
|
||||
public static function get(): ?CorrelationId
|
||||
{
|
||||
return self::$correlationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current correlation ID, or generate one if not set.
|
||||
*
|
||||
* Use this in contexts without HTTP request (async handlers, CRON jobs)
|
||||
* to ensure traceability is never broken.
|
||||
*/
|
||||
public static function getOrGenerate(): CorrelationId
|
||||
{
|
||||
if (self::$correlationId === null) {
|
||||
self::$correlationId = CorrelationId::generate();
|
||||
}
|
||||
|
||||
return self::$correlationId;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$correlationId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit\Handler;
|
||||
|
||||
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use App\Shared\Application\Port\AuditLogger;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handles authentication events for audit logging.
|
||||
*
|
||||
* Writes to append-only audit_log table via AuditLogger.
|
||||
* All PII (emails, IPs) are hashed before storage.
|
||||
*
|
||||
* @see Story 1.7 - T4: Listeners Authentification
|
||||
* @see AC1: Tracage authentification
|
||||
*/
|
||||
final readonly class AuditAuthenticationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private AuditLogger $auditLogger,
|
||||
private string $appSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.1: Successful login.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionReussie(ConnexionReussie $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: Uuid::fromString($event->userId),
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'result' => 'success',
|
||||
'method' => 'password',
|
||||
],
|
||||
tenantId: (string) $event->tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.2: Failed login.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionEchouee(ConnexionEchouee $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionEchouee',
|
||||
userId: null, // No user ID for failed logins
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'result' => 'failure',
|
||||
'reason' => $event->reason,
|
||||
],
|
||||
tenantId: $event->tenantId !== null ? (string) $event->tenantId : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.3: Account temporarily locked.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleCompteBloqueTemporairement(CompteBloqueTemporairement $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'CompteBloqueTemporairement',
|
||||
userId: null, // No user ID - we only have email
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'blocked_for_seconds' => $event->blockedForSeconds,
|
||||
'failed_attempts' => $event->failedAttempts,
|
||||
],
|
||||
tenantId: $event->tenantId !== null ? (string) $event->tenantId : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.4: Password changed (via reset or update).
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleMotDePasseChange(MotDePasseChange $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'MotDePasseChange',
|
||||
userId: Uuid::fromString($event->userId),
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
],
|
||||
tenantId: (string) $event->tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
private function hashEmail(string $email): string
|
||||
{
|
||||
return hash('sha256', strtolower($email) . $this->appSecret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Archives audit log entries older than 5 years.
|
||||
*
|
||||
* Entries are MOVED (not copied) to audit_log_archive table.
|
||||
* This preserves immutability while managing table size.
|
||||
*
|
||||
* WARNING: This command temporarily disables PostgreSQL rules via ALTER TABLE,
|
||||
* which acquires an AccessExclusiveLock. Schedule during LOW TRAFFIC periods only.
|
||||
*
|
||||
* Run via CRON: 0 2 * * 0 php bin/console app:audit:archive
|
||||
*
|
||||
* @see Story 1.7 - T8: Archivage
|
||||
* @see NFR-C5: Conservation 5 ans actif, puis archive
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:audit:archive',
|
||||
description: 'Archive audit log entries older than 5 years',
|
||||
)]
|
||||
final class ArchiveAuditLogsCommand extends Command
|
||||
{
|
||||
private const int DEFAULT_RETENTION_YEARS = 5;
|
||||
private const int DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption(
|
||||
'retention-years',
|
||||
'r',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Number of years to keep in active table',
|
||||
(string) self::DEFAULT_RETENTION_YEARS,
|
||||
)
|
||||
->addOption(
|
||||
'batch-size',
|
||||
'b',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Number of records to process per batch',
|
||||
(string) self::DEFAULT_BATCH_SIZE,
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Simulate without making changes',
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string $retentionYearsOption */
|
||||
$retentionYearsOption = $input->getOption('retention-years');
|
||||
$retentionYears = (int) $retentionYearsOption;
|
||||
|
||||
if ($retentionYears < 1) {
|
||||
$io->error('Retention years must be a positive integer.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
/** @var string $batchSizeOption */
|
||||
$batchSizeOption = $input->getOption('batch-size');
|
||||
$batchSize = (int) $batchSizeOption;
|
||||
|
||||
if ($batchSize < 1) {
|
||||
$io->error('Batch size must be a positive integer.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$cutoffDate = $this->clock->now()->modify("-{$retentionYears} years");
|
||||
$io->title('Audit Log Archival');
|
||||
$io->info(sprintf(
|
||||
'Archiving entries older than %s (%d years retention)',
|
||||
$cutoffDate->format('Y-m-d H:i:s'),
|
||||
$retentionYears,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->warning('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Count entries to archive
|
||||
$countSql = 'SELECT COUNT(*) FROM audit_log WHERE occurred_at < :cutoff';
|
||||
/** @var string|int|false $countResult */
|
||||
$countResult = $this->connection->fetchOne($countSql, [
|
||||
'cutoff' => $cutoffDate->format('Y-m-d H:i:s.uP'),
|
||||
]);
|
||||
$totalCount = (int) $countResult;
|
||||
|
||||
if ($totalCount === 0) {
|
||||
$io->success('No entries to archive.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Found %d entries to archive', $totalCount));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->success(sprintf('DRY RUN: Would archive %d entries', $totalCount));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Archive in batches
|
||||
$progressBar = $io->createProgressBar($totalCount);
|
||||
$progressBar->start();
|
||||
$archivedCount = 0;
|
||||
|
||||
do {
|
||||
$batchCount = $this->archiveBatch($cutoffDate, $batchSize);
|
||||
$archivedCount += $batchCount;
|
||||
$progressBar->advance($batchCount);
|
||||
} while ($batchCount === $batchSize);
|
||||
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
$io->success(sprintf(
|
||||
'Successfully archived %d audit log entries',
|
||||
$archivedCount,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a batch of entries using PostgreSQL function.
|
||||
*
|
||||
* The archive_audit_entries() function is SECURITY DEFINER and can
|
||||
* bypass the audit_no_delete rule. This is the ONLY way to delete
|
||||
* from audit_log - ensuring immutability for normal operations.
|
||||
*/
|
||||
private function archiveBatch(DateTimeImmutable $cutoffDate, int $batchSize): int
|
||||
{
|
||||
// Call the PostgreSQL function that handles INSERT + DELETE atomically
|
||||
// The function uses SECURITY DEFINER to bypass the delete rule
|
||||
$sql = 'SELECT archive_audit_entries(:cutoff, :batch_size)';
|
||||
|
||||
/** @var int|string|false $result */
|
||||
$result = $this->connection->fetchOne($sql, [
|
||||
'cutoff' => $cutoffDate->format('Y-m-d H:i:s.uP'),
|
||||
'batch_size' => $batchSize,
|
||||
]);
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* Adds correlation ID stamp to outgoing messages.
|
||||
*
|
||||
* When a message is dispatched during an HTTP request, this middleware
|
||||
* captures the current correlation ID and attaches it as a stamp.
|
||||
* The receiving worker will then use this stamp to restore the correlation ID.
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class AddCorrelationIdStampMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
// Only add stamp if not already present and we have a correlation ID
|
||||
if ($envelope->last(CorrelationIdStamp::class) === null) {
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId !== null) {
|
||||
$envelope = $envelope->with(new CorrelationIdStamp($correlationId->value()));
|
||||
}
|
||||
}
|
||||
|
||||
return $stack->next()->handle($envelope, $stack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* Messenger middleware for correlation ID propagation in async workers.
|
||||
*
|
||||
* - Extracts correlation ID from message stamp if present (propagated from HTTP request)
|
||||
* - Otherwise uses existing ID (synchronous dispatch) or generates new one (async worker)
|
||||
* - Only clears after handling in async context to prevent leakage between messages
|
||||
*
|
||||
* IMPORTANT: We must NOT clear the correlation ID during synchronous dispatch
|
||||
* within an HTTP request, as it would break traceability for the rest of the request.
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class CorrelationIdMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
// Check if we already have a correlation ID (from HTTP middleware)
|
||||
$existingId = CorrelationIdHolder::get();
|
||||
|
||||
// Extract correlation ID from stamp if present (async message from HTTP)
|
||||
$stamp = $envelope->last(CorrelationIdStamp::class);
|
||||
|
||||
if ($stamp instanceof CorrelationIdStamp) {
|
||||
// Async worker receiving message from HTTP - use stamp's ID
|
||||
$correlationId = CorrelationId::fromString($stamp->correlationId);
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
$shouldClear = true; // Clear after async handling
|
||||
} elseif ($existingId !== null) {
|
||||
// Synchronous dispatch within HTTP request - keep existing ID
|
||||
$shouldClear = false; // Don't clear - HTTP middleware will handle it
|
||||
} else {
|
||||
// Async worker with no stamp - generate new ID for this message
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
$shouldClear = true; // Clear after handling
|
||||
}
|
||||
|
||||
try {
|
||||
return $stack->next()->handle($envelope, $stack);
|
||||
} finally {
|
||||
// Only clear in async context to prevent leakage between messages
|
||||
// Don't clear during synchronous dispatch - HTTP middleware handles that
|
||||
if ($shouldClear) {
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Messenger;
|
||||
|
||||
use Symfony\Component\Messenger\Stamp\StampInterface;
|
||||
|
||||
/**
|
||||
* Stamp to propagate correlation ID from HTTP request to async message handlers.
|
||||
*
|
||||
* Added automatically by AddCorrelationIdStampMiddleware when dispatching messages.
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class CorrelationIdStamp implements StampInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $correlationId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Middleware;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Generates or propagates correlation ID for request tracing.
|
||||
*
|
||||
* - If X-Correlation-Id header exists, uses it (distributed tracing)
|
||||
* - Otherwise generates a new UUID v7
|
||||
* - Stores in CorrelationIdHolder for access anywhere in the request
|
||||
* - Adds X-Correlation-Id header to response
|
||||
* - Clears on request termination (and defensively on exception)
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class CorrelationIdMiddleware
|
||||
{
|
||||
public const string HEADER_NAME = 'X-Correlation-Id';
|
||||
|
||||
#[AsEventListener(event: KernelEvents::REQUEST, priority: 255)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive cleanup: if previous request didn't terminate properly
|
||||
// (edge case: fatal error, script termination), clear stale ID
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$request = $event->getRequest();
|
||||
$existingId = $request->headers->get(self::HEADER_NAME);
|
||||
|
||||
$correlationId = $this->parseOrGenerate($existingId);
|
||||
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
// Also store in request attributes for easy access
|
||||
$request->attributes->set('correlation_id', $correlationId);
|
||||
}
|
||||
|
||||
#[AsEventListener(event: KernelEvents::RESPONSE, priority: -255)]
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId !== null) {
|
||||
$event->getResponse()->headers->set(
|
||||
self::HEADER_NAME,
|
||||
$correlationId->value(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store correlation ID in exception for error logging before cleanup.
|
||||
*
|
||||
* Priority -255: run after other exception handlers that might need the ID.
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -255)]
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add correlation ID to exception context if possible
|
||||
$exception = $event->getThrowable();
|
||||
if (method_exists($exception, 'setCorrelationId')) {
|
||||
$exception->setCorrelationId($correlationId->value());
|
||||
}
|
||||
}
|
||||
|
||||
#[AsEventListener(event: KernelEvents::TERMINATE)]
|
||||
public function onKernelTerminate(TerminateEvent $event): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse existing correlation ID or generate new one if invalid.
|
||||
*
|
||||
* We're lenient with malformed input - just generate a fresh ID.
|
||||
*/
|
||||
private function parseOrGenerate(?string $existingId): CorrelationId
|
||||
{
|
||||
if ($existingId === null) {
|
||||
return CorrelationId::generate();
|
||||
}
|
||||
|
||||
try {
|
||||
return CorrelationId::fromString($existingId);
|
||||
} catch (InvalidArgumentException) {
|
||||
return CorrelationId::generate();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user