feat(core) : add audit log writer and request id provider
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Low-level service responsible for writing into the `audit_log` table.
|
||||
*
|
||||
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
|
||||
* outside the ORM transaction: audit rows survive an application-side
|
||||
* rollback and avoid transactional entanglement in batch (fixtures).
|
||||
*
|
||||
* Sensitive keys are stripped in defense-in-depth even when entities already
|
||||
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
|
||||
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
|
||||
* business flow.
|
||||
*/
|
||||
final class AuditLogWriter
|
||||
{
|
||||
/** @var list<string> keys always stripped from the `changes` payload */
|
||||
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.audit_connection')]
|
||||
private readonly Connection $connection,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly RequestIdProvider $requestIdProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $entityType Format "module.Entity" (e.g. "core.User")
|
||||
* @param string $entityId Entity id (int or serialized UUID)
|
||||
* @param string $action create|update|delete
|
||||
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
|
||||
*/
|
||||
public function log(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $action,
|
||||
array $changes,
|
||||
): void {
|
||||
$filteredChanges = $this->stripSensitive($changes);
|
||||
|
||||
$this->connection->insert('audit_log', [
|
||||
'id' => Uuid::v7()->toRfc4122(),
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'action' => $action,
|
||||
'changes' => $filteredChanges,
|
||||
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
|
||||
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
|
||||
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
|
||||
'request_id' => $this->requestIdProvider->getRequestId(),
|
||||
], [
|
||||
'id' => Types::GUID,
|
||||
'changes' => Types::JSON,
|
||||
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively removes sensitive keys from the payload.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function stripSensitive(array $data): array
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
||||
unset($data[$key]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->stripSensitive($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Provides an HTTP request identifier (UUID v4) shared by every audit row
|
||||
* produced during a single main request. Null in CLI (fixtures, batch).
|
||||
*/
|
||||
final class RequestIdProvider
|
||||
{
|
||||
private ?string $requestId = null;
|
||||
|
||||
#[AsEventListener(event: 'kernel.request')]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requestId = Uuid::v4()->toRfc4122();
|
||||
}
|
||||
|
||||
public function getRequestId(): ?string
|
||||
{
|
||||
return $this->requestId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user