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

@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Messaging;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Administration\Domain\Event\ConnexionReussie;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Enregistre les événements de connexion dans l'audit log.
*
* Important: Les IP sont hashées pour respecter NFR-S3 (pas de PII dans les logs).
*
* @see Story 1.4 - T5.5: Tracer dans audit log
* @see AC3: Événement tracé dans audit log
*/
final readonly class AuditLoginEventsHandler
{
public function __construct(
private LoggerInterface $auditLogger,
private string $appSecret,
) {
}
#[AsMessageHandler]
public function handleConnexionReussie(ConnexionReussie $event): void
{
$this->auditLogger->info('login.success', [
'user_id' => $event->userId,
'tenant_id' => (string) $event->tenantId,
'ip_hash' => $this->hashIp($event->ipAddress),
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
'occurred_on' => $event->occurredOn->format('c'),
]);
}
#[AsMessageHandler]
public function handleConnexionEchouee(ConnexionEchouee $event): void
{
$this->auditLogger->warning('login.failure', [
'email_hash' => $this->hashEmail($event->email),
'reason' => $event->reason,
'ip_hash' => $this->hashIp($event->ipAddress),
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
'occurred_on' => $event->occurredOn->format('c'),
]);
}
/**
* Hash l'IP pour éviter de stocker des PII.
* Le hash permet toujours de corréler les événements d'une même IP.
*/
private function hashIp(string $ip): string
{
return hash('sha256', $ip . $this->appSecret);
}
/**
* Hash l'email pour éviter de stocker des PII.
*/
private function hashEmail(string $email): string
{
return hash('sha256', strtolower($email) . $this->appSecret);
}
/**
* Hash le User-Agent (généralement pas PII mais peut être très long).
*/
private function hashUserAgent(string $userAgent): string
{
return hash('sha256', $userAgent);
}
}

View File

@@ -7,8 +7,10 @@ namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Event\CompteBloqueTemporairement;
use App\Administration\Domain\Event\ConnexionEchouee;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
use App\Shared\Infrastructure\Tenant\TenantResolver;
use function is_array;
use function is_string;
@@ -20,12 +22,16 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Throwable;
/**
* Handles login failures: Fibonacci rate limiting, audit, user-friendly messages.
*
* Important: Never reveal whether the email exists or not (AC2).
*
* Note: /api/login is excluded from TenantMiddleware, so we resolve tenant
* directly from host header using TenantResolver.
*
* @see Story 1.4 - T5: Backend Login Endpoint
*/
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
@@ -34,6 +40,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
private LoginRateLimiterInterface $rateLimiter,
private MessageBusInterface $eventBus,
private Clock $clock,
private TenantResolver $tenantResolver,
) {
}
@@ -49,9 +56,12 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
// Record the failure and get the new state
$result = $this->rateLimiter->recordFailure($request, $email);
// Dispatch the failure event
// Resolve tenant from host header (TenantMiddleware skips /api/login)
$tenantId = $this->resolveTenantFromHost($request);
$this->eventBus->dispatch(new ConnexionEchouee(
email: $email,
tenantId: $tenantId,
ipAddress: $ipAddress,
userAgent: $userAgent,
reason: 'invalid_credentials',
@@ -62,6 +72,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
if ($result->ipBlocked) {
$this->eventBus->dispatch(new CompteBloqueTemporairement(
email: $email,
tenantId: $tenantId,
ipAddress: $ipAddress,
userAgent: $userAgent,
blockedForSeconds: $result->retryAfter ?? LoginRateLimiterInterface::IP_BLOCK_DURATION,
@@ -125,4 +136,26 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
return $response;
}
/**
* Resolve tenant from request host header.
*
* Since /api/login is excluded from TenantMiddleware, we must resolve
* the tenant ourselves to properly scope audit events.
*
* Returns null if tenant cannot be resolved (unknown domain, database issues, etc.)
* to ensure login failure handling never breaks due to tenant resolution.
*/
private function resolveTenantFromHost(Request $request): ?TenantId
{
try {
$config = $this->tenantResolver->resolve($request->getHost());
return $config->tenantId;
} catch (Throwable) {
// Login attempt on unknown domain or tenant resolution failed
// Don't let tenant resolution break the login failure handling
return null;
}
}
}