<?php
namespace App\EventSubscriber;
use App\Entity\LogsAction;
use App\Entity\User;
use App\Repository\LogsActionRepository;
use DateTime;
use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Error;
use Exception;
use ReflectionMethod;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Security;
class AuditSubscriber implements EventSubscriberInterface
{
private $requestData = [];
private $entityChanges = [];
private $shouldSkipAudit = false;
private $errorData = null;
private $preDeleteEntityData = null;
// Routes to ignore from audit logging
private const IGNORED_ROUTES = [
// Clean route patterns
'/api/discussion/list',
'/api/login_check',
// Original Symfony route patterns
'_api_/discussion/list',
'api/login_check',
'_api_/discussion/list.{_format}_get',
'api/login_check_post',
];
public function __construct(
private readonly \Doctrine\ORM\EntityManagerInterface $entityManager,
private readonly ManagerRegistry $doctrine,
private Security $security,
private readonly LogsActionRepository $logsActionRepository
) {
}
public static function getSubscribedEvents() :array
{
return [
// HTTP Kernel Events
KernelEvents::CONTROLLER => 'onKernelController',
KernelEvents::RESPONSE => 'onKernelResponse',
KernelEvents::EXCEPTION => 'onKernelException',
];
}
// Spammed routes like /api/login_check or /api/discussion/list
private function shouldIgnoreRoute(string $originalRoute = null, string $cleanRoute = null): bool
{
if (!$originalRoute && !$cleanRoute) {
return false;
}
if ($originalRoute && in_array($originalRoute, self::IGNORED_ROUTES, true)) {
return true;
}
if ($cleanRoute && in_array($cleanRoute, self::IGNORED_ROUTES, true)) {
return true;
}
foreach (self::IGNORED_ROUTES as $ignoredRoute) {
if ($originalRoute && str_starts_with($originalRoute, $ignoredRoute)) {
return true;
}
if ($originalRoute && $this->matchesRoutePattern($originalRoute, $ignoredRoute)) {
return true;
}
}
return false;
}
private function matchesRoutePattern(string $route, string $pattern): bool
{
// Remove format and method suffixes for comparison
$cleanedRoute = preg_replace('/\.\{_format\}(_\w+)?$/', '', $route);
$cleanedRoute = preg_replace('/_(?:get|post|put|patch|delete)$/', '', $cleanedRoute);
$cleanedPattern = preg_replace('/\.\{_format\}(_\w+)?$/', '', $pattern);
$cleanedPattern = preg_replace('/_(?:get|post|put|patch|delete)$/', '', $cleanedPattern);
return $cleanedRoute === $cleanedPattern;
}
private function extractEntityData($entity): array
{
try {
$data = [];
$reflection = new \ReflectionClass($entity);
foreach ($reflection->getProperties() as $property) {
try {
$value = $property->getValue($entity);
} catch (Error $e) {
// Handle uninitialized typed properties (like ID in prePersist)
if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
continue;
}
continue;
}
if ($value instanceof DateTimeInterface) {
$data[$property->getName()] = $value->format('Y-m-d H:i:s');
} elseif (is_object($value) && method_exists($value, 'getId')) {
// For relations just store the ID
try {
$data[$property->getName() . '_id'] = $value->getId();
} catch (Error $e) {
if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
$data[$property->getName() . '_id'] = 'uninitialized';
} else {
$data[$property->getName() . '_id'] = 'error';
}
}
} elseif (is_array($value) || $value instanceof Collection) {
// For collections, store count or IDs
try {
if ($value instanceof Collection) {
// Check if the collection is initialized to avoid lazy loading issues
if ($value instanceof \Doctrine\ORM\PersistentCollection && !$value->isInitialized()) {
$data[$property->getName() . '_count'] = 'uninitialized_collection';
} else {
$data[$property->getName() . '_count'] = $value->count();
}
} else {
$data[$property->getName() . '_count'] = count($value);
}
} catch (Exception $e) {
// If counting fails, just note that there's a collection
$data[$property->getName() . '_count'] = 'collection_error';
}
} elseif (is_scalar($value) || is_null($value)) {
$data[$property->getName()] = $value;
} else {
// For other objects, try to get a string representation
try {
$data[$property->getName()] = (string)$value;
} catch (Exception $e) {
$data[$property->getName()] = 'object_conversion_error';
}
}
}
return $data;
} catch (Exception $e) {
// Return basic entity info if extraction fails completely
return [
'entity_class' => get_class($entity),
'extraction_error' => $e->getMessage()
];
}
}
// This function is useless right now but in case we need to specify which HTTP actions to log later
private function shouldLogHttpAction(string $method, int $statusCode): bool
{
// Log all successful requests for certain methods
if ($statusCode >= 200 && $statusCode < 300) {
return in_array($method, ['GET', 'DELETE', 'POST', 'PUT', 'PATCH']);
}
// Log all error responses
return $statusCode >= 400;
}
private function getHttpActionType(string $method, string $route = null): string
{
if ($route && $this->isDeleteRoute($route)) {
return 'delete';
}
return match ($method) {
'GET' => 'read',
'POST' => 'create',
'PUT', 'PATCH' => 'update',
'DELETE' => 'delete',
default => 'request'
};
}
// Some Delete routes are using POST method so u need to handle them separately
private function isDeleteRoute(string $route): bool
{
$deletePatterns = [
'/delete',
'delete_',
'_delete',
'/remove',
'remove_',
'_remove',
'/destroy',
'destroy_',
'_destroy'
];
$lowerRoute = strtolower($route);
foreach ($deletePatterns as $pattern) {
if (str_contains($lowerRoute, $pattern)) {
return true;
}
}
return false;
}
private function captureEntityBeforeDelete(Request $request, string $route): void
{
try {
$entityId = $this->extractEntityIdFromRequest($request);
if (!$entityId) {
return;
}
$entityClass = $this->getEntityFromRoute($route);
if (!$entityClass) {
return;
}
$repository = $this->entityManager->getRepository($entityClass);
$entity = $repository->find($entityId);
if ($entity) {
$this->preDeleteEntityData = $this->extractEntityData($entity);
}
} catch (Exception $e) {
}
}
private function extractEntityIdFromRequest(Request $request): ?int
{
$idParams = ['id', 'entityId', 'siteId', 'userId', 'customerId'];
foreach ($idParams as $param) {
$value = $request->get($param);
if ($value && is_numeric($value)) {
return (int) $value;
}
}
$pathInfo = $request->getPathInfo();
if (preg_match('/\/(\d+)(?:\/|$)/', $pathInfo, $matches)) {
$id = (int) $matches[1];
return $id;
}
return null;
}
private function getEntityFromRoute(string $route): ?string
{
$routeToEntityMap = [
'sites' => 'App\Entity\Site',
'users' => 'App\Entity\User',
'customers' => 'App\Entity\Customer',
'evaluations' => 'App\Entity\Evaluation',
'documents' => 'App\Entity\Document',
'categories' => 'App\Entity\Category',
'sections' => 'App\Entity\Section',
'compliances' => 'App\Entity\Compliance',
//
'site' => 'App\Entity\Site',
'user' => 'App\Entity\User',
'customer' => 'App\Entity\Customer',
'evaluation' => 'App\Entity\Evaluation',
'document' => 'App\Entity\Document',
'category' => 'App\Entity\Category',
'section' => 'App\Entity\Section',
'compliance' => 'App\Entity\Compliance',
];
$lowerRoute = strtolower($route);
foreach ($routeToEntityMap as $routePattern => $entityClass) {
if (str_contains($lowerRoute, $routePattern)) {
return $entityClass;
}
}
return null;
}
private function getAuditEntityManager(): ObjectManager
{
try {
$em = $this->doctrine->resetManager();
return $this->doctrine->getManager();
} catch (Exception $e) {
return $this->entityManager;
}
}
private function getCleanRoute($request): string
{
// Try to get the actual path first
$pathInfo = $request->getPathInfo();
if ($pathInfo && $pathInfo !== '/') {
return $pathInfo;
}
// Fallback to route name, but clean it up
$route = $request->attributes->get('_route');
if (!$route) {
return 'unknown_route';
}
// Clean up common Symfony route patterns
$cleanRoute = $route;
// Remove format placeholders
$cleanRoute = preg_replace('/\.\{_format\}/', '', $cleanRoute);
// Remove method suffixes (like _post, _get, etc.)
$cleanRoute = preg_replace('/_(?:get|post|put|patch|delete)$/', '', $cleanRoute);
// Convert underscores to forward slashes for API routes
if (str_starts_with($cleanRoute, '_api_')) {
$cleanRoute = str_replace('_api_', '/api/', $cleanRoute);
$cleanRoute = str_replace('_', '/', $cleanRoute);
}
return $cleanRoute;
}
public function onKernelController(ControllerEvent $event)
{
$request = $event->getRequest();
$originalRoute = $request->attributes->get('_route');
$cleanRoute = $this->getCleanRoute($request);
if ($this->shouldIgnoreRoute($originalRoute, $cleanRoute)) {
$this->shouldSkipAudit = true;
return;
}
$this->shouldSkipAudit = false;
$requestData = [];
$requestData['query'] = $request->query->all();
$requestData['request'] = $request->request->all();
$requestData['attributes'] = $request->attributes->all();
if ($request->getContentType() === 'json' || str_contains($request->headers->get('Content-Type', ''), 'application/json')) {
$content = $request->getContent();
if ($content) {
$decodedContent = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE) {
$requestData['json_body'] = $decodedContent;
} else {
$requestData['raw_body'] = $content;
}
}
}
if ($request->files->all()) {
$requestData['files'] = array_map(function($file) {
return $file instanceof UploadedFile
? ['name' => $file->getClientOriginalName(), 'size' => $file->getSize(), 'type' => $file->getMimeType()]
: (string)$file;
}, $request->files->all());
}
$this->requestData = [
'route' => $cleanRoute,
'method' => $request->getMethod(),
'ip_address' => $this->logsActionRepository->getRealIpAddress($request),
'user_agent' => $request->headers->get('User-Agent'),
'request_data' => json_encode($requestData),
];
if ($this->isDeleteRoute($cleanRoute)) {
$this->captureEntityBeforeDelete($request, $cleanRoute);
}
}
public function preUpdate(LifecycleEventArgs $args)
{
if ($this->shouldSkipAudit) {
return;
}
try {
$entity = $args->getObject();
$entityClass = get_class($entity);
$entityId = 'unknown';
if (method_exists($entity, 'getId')) {
try {
$entityId = $entity->getId() ?? 'null_id';
} catch (\Error $e) {
if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
$entityId = 'uninitialized_id';
} else {
$entityId = 'error_getting_id';
}
}
}
$dataBefore = [];
try {
$uow = $this->entityManager->getUnitOfWork();
$changeSet = $uow->getEntityChangeSet($entity);
foreach ($changeSet as $field => $values) {
// $values[0] is the old value (before), $values[1] is the new value (after)
// This will create 2 pairs: field => old_value, field => new_value
$oldValue = $values[0];
if ($oldValue instanceof DateTimeInterface) {
$dataBefore[$field] = $oldValue->format('Y-m-d H:i:s');
} elseif (is_object($oldValue) && method_exists($oldValue, 'getId')) {
try {
$dataBefore[$field . '_id'] = $oldValue->getId();
} catch (\Error $e) {
$dataBefore[$field . '_id'] = 'uninitialized';
}
} elseif (is_scalar($oldValue) || is_null($oldValue)) {
$dataBefore[$field] = $oldValue;
} else {
try {
$dataBefore[$field] = (string)$oldValue;
} catch (Exception $e) {
$dataBefore[$field] = 'object_conversion_error';
}
}
}
if (empty($dataBefore)) {
$originalData = $uow->getOriginalEntityData($entity);
foreach ($originalData as $field => $value) {
if ($value instanceof DateTimeInterface) {
$dataBefore[$field] = $value->format('Y-m-d H:i:s');
} elseif ($value instanceof \Doctrine\ORM\PersistentCollection) {
if (!$value->isInitialized()) {
$dataBefore[$field] = 'uninitialized_collection';
} else {
$dataBefore[$field] = 'collection_count_' . $value->count();
}
} elseif ($value instanceof \Doctrine\Common\Collections\Collection) {
$dataBefore[$field] = 'collection_count_' . $value->count();
} elseif (is_array($value)) {
$dataBefore[$field] = 'array_count_' . count($value);
} elseif (is_object($value) && method_exists($value, 'getId')) {
try {
$dataBefore[$field . '_id'] = $value->getId();
} catch (Error $e) {
$dataBefore[$field . '_id'] = 'uninitialized';
}
} elseif (is_object($value)) {
if (method_exists($value, '__toString')) {
try {
$dataBefore[$field] = (string)$value;
} catch (\Exception $e) {
$dataBefore[$field] = get_class($value) . '_toString_error';
}
} else {
$dataBefore[$field] = get_class($value);
}
} elseif (is_scalar($value) || is_null($value)) {
$dataBefore[$field] = $value;
} else {
$dataBefore[$field] = 'unknown_type_' . gettype($value);
}
}
}
} catch (Exception $e) {
// If all fails, at least note the error
$dataBefore['error'] = 'Could not retrieve change set: ' . $e->getMessage();
}
$this->entityChanges[] = [
'entity' => $entityClass,
'entity_id' => $entityId,
'data_before' => $dataBefore,
'data_after' => $this->extractEntityData($entity),
'action' => 'update',
];
} catch (Exception $e) {
// Silently handle any errors to prevent disrupting the update process
}
}
public function prePersist(LifecycleEventArgs $args)
{
if ($this->shouldSkipAudit) {
return;
}
try {
$entity = $args->getObject();
$entityClass = get_class($entity);
$entityId = 'new';
if (method_exists($entity, 'getId')) {
try {
$id = $entity->getId();
$entityId = $id ?? 'new';
} catch (Error $e) {
// Handle uninitialized typed property
if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
$entityId = 'new';
} else {
$entityId = 'new_error';
}
}
}
$this->entityChanges[] = [
'entity' => $entityClass,
'entity_id' => $entityId,
'data_before' => [],
'data_after' => $this->extractEntityData($entity),
'action' => 'create',
];
} catch (Exception $e) {
}
}
// Capture entity deletions
public function preRemove(LifecycleEventArgs $args)
{
if ($this->shouldSkipAudit) {
return;
}
try {
$entity = $args->getObject();
$entityClass = get_class($entity);
// Safely get entity ID
$entityId = 'unknown';
if (method_exists($entity, 'getId')) {
try {
$entityId = $entity->getId() ?? 'null_id';
} catch (Error $e) {
if (str_contains($e->getMessage(), 'must not be accessed before initialization')) {
$entityId = 'uninitialized_id';
} else {
$entityId = 'error_getting_id';
}
}
}
$entityData = $this->extractEntityData($entity);
$this->entityChanges[] = [
'entity' => $entityClass,
'entity_id' => $entityId,
'data_before' => $entityData,
'data_after' => [], // Empty after deletion
'action' => 'delete',
'doctrine_event' => 'preRemove',
];
} catch (Exception $e) {
}
}
public function onKernelException(ExceptionEvent $event): void
{
if ($this->shouldSkipAudit) {
return;
}
$exception = $event->getThrowable();
$this->errorData = [
'error_message' => $exception->getMessage(),
'error_code' => $exception->getCode(),
'error_file' => $exception->getFile(),
'error_line' => $exception->getLine(),
'error_trace' => $exception->getTraceAsString()
];
}
public function onKernelResponse(ResponseEvent $event): void
{
if ($this->shouldSkipAudit) {
return;
}
$response = $event->getResponse() ?? null;
$user = $this->security->getUser() ?? 0;
$statusCode = $response->getStatusCode();
$method = $this->requestData['method'] ?? 'UNKNOWN';
$responseData = [];
// Clean Response Data
if ($response) {
$responseData['status_code'] = $statusCode;
$responseData['headers'] = [];
$importantHeaders = ['content-type', 'location', 'cache-control', 'content-length'];
foreach ($importantHeaders as $header) {
if ($response->headers->has($header)) {
$responseData['headers'][$header] = $response->headers->get($header);
}
}
if ($statusCode >= 200 && $statusCode < 300) {
$content = $response->getContent();
if (!empty($content)) {
// Limit response content size
if (strlen($content) > 5000) {
$responseData['content'] = substr($content, 0, 5000) . '... [truncated]';
$responseData['content_truncated'] = true;
$responseData['original_content_length'] = strlen($content);
} else {
$decodedContent = json_decode($content, true);
if (json_last_error() === JSON_ERROR_NONE) {
$responseData['content'] = $decodedContent;
} else {
$responseData['content'] = $content;
}
}
}
}
}
if ($statusCode >= 400 && empty($this->entityChanges)) {
$errorData = $this->errorData ?? ['status_code' => $statusCode];
$errorData = array_merge($errorData, $responseData);
$this->entityChanges[] = [
'entity' => 'HTTP_ERROR',
'entity_id' => 'error',
'data_before' => [],
'data_after' => $errorData,
'action' => 'error',
'response_data' => $responseData,
];
}
if (empty($this->entityChanges) && $this->shouldLogHttpAction($method, $statusCode)) {
$route = $this->requestData['route'] ?? null;
$action = $this->getHttpActionType($method, $route);
$dataBefore = [];
$dataAfter = [];
if ($action === 'delete' && $this->preDeleteEntityData !== null) {
$dataBefore = $this->preDeleteEntityData;
}
$this->entityChanges[] = [
'entity' => 'HTTP_REQUEST',
'entity_id' => $route ?? 'unknown_route',
'data_before' => $dataBefore,
'data_after' => $dataAfter, // Keep empty for HTTP requests - data goes in request_data
'action' => $action,
'response_data' => $responseData,
'http_request_data' => [ // This will be stored in request_data column
'method' => $method,
'route' => $route,
'original_route' => $this->requestData['original_route'] ?? null,
'status_code' => $statusCode,
'user_agent' => $this->requestData['user_agent'] ?? null,
'ip_address' => $this->requestData['ip_address'] ?? null,
'detected_as_delete' => $action === 'delete' && $method !== 'DELETE',
'has_pre_delete_data' => $this->preDeleteEntityData !== null,
],
];
}
if (empty($this->entityChanges)) {
return;
}
foreach ($this->entityChanges as &$change) {
if (!isset($change['response_data'])) {
$change['response_data'] = $responseData;
}
}
unset($change);
// Use a separate EntityManager for audit operations to avoid transaction conflicts
$auditEM = $this->getAuditEntityManager();
if (!$auditEM) {
return;
}
foreach ($this->entityChanges as $change) {
try {
$log = new LogsAction();
if ($user) {
$auditUser = null;
$userId = null;
if ($user instanceof User) {
$userId = $user->getId();
$auditUser = $auditEM->find(User::class, $userId);
} elseif (method_exists($user, 'getId')) {
try {
$reflection = new ReflectionMethod($user, 'getId');
$userId = $reflection->invoke($user);
if ($userId) {
$auditUser = $auditEM->find(User::class, $userId);
}
} catch (Exception $e) {
// Ignore reflection errors
}
}
if (!$auditUser && method_exists($user, 'getUserIdentifier')) {
$userRepo = $auditEM->getRepository(User::class);
$auditUser = $userRepo->findOneBy(['username' => $user->getUserIdentifier()]);
}
if ($auditUser) {
$log->setUser($auditUser);
} else {
continue;
}
} else {
$defaultUser = $auditEM->find(User::class, 0);
if (!$defaultUser) {
$userRepo = $auditEM->getRepository(User::class);
$defaultUser = $userRepo->findOneBy(['username' => 'system']);
}
if ($defaultUser) {
$log->setUser($defaultUser);
} else {
continue;
}
}
$log->setAction($change['action']);
$log->setRoute($this->requestData['route'] ?? null);
$log->setMethod($this->requestData['method'] ?? null);
$log->setIpAddress($this->requestData['ip_address'] ?? null);
$log->setUserAgent($this->requestData['user_agent'] ?? null);
if ($change['entity'] === 'HTTP_REQUEST' && isset($change['http_request_data'])) {
$log->setRequestData(json_encode($change['http_request_data']));
} else {
$log->setRequestData($this->requestData['request_data'] ?? null);
}
$log->setDataBefore(json_encode($change['data_before']));
$log->setDataAfter(json_encode($change['data_after']));
$log->setStatusCode($statusCode);
if (isset($change['response_data']) && !empty($change['response_data'])) {
$log->setResponseData(json_encode($change['response_data']));
} elseif ($this->errorData && $statusCode >= 400) {
$errorResponseData = array_merge($responseData, (array)$this->errorData);
$log->setResponseData(json_encode($errorResponseData));
} elseif (!empty($responseData)) {
$log->setResponseData(json_encode($responseData));
}
$log->setCreatedAt(new DateTime());
$log->setUpdatedAt(new DateTime());
$auditEM->persist($log);
} catch (Exception $e) {
continue;
}
}
try {
$auditEM->flush();
} catch (Exception $e) {
} finally {
$this->entityChanges = [];
$this->errorData = null;
$this->requestData = [];
$this->preDeleteEntityData = null;
}
}
}