<?php
declare(strict_types=1);
namespace WnsSecurityComplianceSuite\Logging\Subscriber;
use Psr\Log\LoggerInterface;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityDeletedEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence;
use Shopware\Core\Framework\Event\NestedEventCollection;
use Shopware\Core\PlatformRequest;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\EventDispatcher\Event;
use WnsSecurityComplianceSuite\Logging\DTO\Log;
use WnsSecurityComplianceSuite\Logging\DTO\LogSource;
use WnsSecurityComplianceSuite\Logging\Provider\EntityInformationProvider;
use WnsSecurityComplianceSuite\Logging\Provider\MonitoredEntityProvider;
use WnsSecurityComplianceSuite\Logging\Provider\SourceInfoProvider;
use WnsSecurityComplianceSuite\Logging\Service\ActionLogger;
use WnsSecurityComplianceSuite\Logging\Service\DataSanitizer;
use WnsSecurityComplianceSuite\Logging\Service\RequestInterpreter;
final class CrudSubscriber implements EventSubscriberInterface
{
private RequestStack $requestStack;
private EntityInformationProvider $entityInformationProvider;
private MonitoredEntityProvider $monitoredEntityProvider;
private ActionLogger $actionLogger;
private SourceInfoProvider $sourceInfoProvider;
private DataSanitizer $dataSanitizer;
private LoggerInterface $wscsLogger;
private RequestInterpreter $requestInterpreter;
public function __construct(
RequestStack $requestStack,
EntityInformationProvider $entityInformationProvider,
MonitoredEntityProvider $monitoredEntityProvider,
ActionLogger $actionLogger,
SourceInfoProvider $sourceInfoProvider,
DataSanitizer $dataSanitizer,
LoggerInterface $wscsLogger,
RequestInterpreter $requestInterpreter
) {
$this->requestStack = $requestStack;
$this->entityInformationProvider = $entityInformationProvider;
$this->monitoredEntityProvider = $monitoredEntityProvider;
$this->actionLogger = $actionLogger;
$this->sourceInfoProvider = $sourceInfoProvider;
$this->dataSanitizer = $dataSanitizer;
$this->wscsLogger = $wscsLogger;
$this->requestInterpreter = $requestInterpreter;
}
public static function getSubscribedEvents()
{
return [
EntityWrittenContainerEvent::class => 'onEntityWritten',
KernelEvents::REQUEST => 'checkForDeleteRequest',
];
}
public function checkForDeleteRequest(RequestEvent $event): void
{
$request = $event->getRequest();
try {
$entities = $this->requestInterpreter->getRequestedEntityDeletions($request);
if (!empty($entities)) {
$this->entityInformationProvider->preloadEntityData($entities);
}
} catch (\Throwable $e) {
$this->wscsLogger->error('Error getting entity data for request: ' . $e->getMessage(), ['request' => $request->attributes->all()]);
}
}
public function onEntityWritten(EntityWrittenContainerEvent $event): void
{
$salesChannelId = null;
$source = $this->getSource($event);
$eventCollection = $event->getEvents() ?? new NestedEventCollection();
if ($event->getEvents() === null) {
return;
}
/** @var EntityWrittenEvent $innerEvent */
foreach ($eventCollection as $innerEvent) {
$payloads = $innerEvent->getPayloads();
if (empty($payloads) || [[]] === $payloads) {
continue;
}
$entityName = $innerEvent->getEntityName();
if ($this->entityInformationProvider->isTranslationEntity($entityName) === true) {
// Do not log translation deletions
continue;
}
$eventType = $innerEvent->getName();
if (!$this->monitoredEntityProvider->entityMonitored($entityName)) {
if (!$this->monitoredEntityProvider->entityIgnored($entityName)) {
$this->actionLogger->logEntityDebug($entityName, $innerEvent->getPayloads());
}
continue;
}
if ($innerEvent instanceof EntityDeletedEvent) {
// Assume there is only one entity per event. It is not an unreasonable assumption, because Shopware by default creates 1 event per 1 entity deleted, even if many are removed by a single request
$writeResult = $innerEvent->getWriteResults()[0];
$primaryKeys = $writeResult->getExistence() instanceof EntityExistence ? $writeResult->getExistence()->getPrimaryKey() : [];
$entityData = $this->entityInformationProvider->retrieveEntityData($entityName, $primaryKeys);
$detail = [
'data' => $entityData ?? $writeResult->getPayload(),
];
} else {
// Regular upsert
$payloads = $this->filterMeaningfulData($entityName, $payloads);
if (empty($payloads)) {
continue;
}
$detail = [
'data' => $payloads,
];
}
$log = new Log(
$eventType,
$source,
$this->dataSanitizer->sanitize($detail),
new \DateTimeImmutable()
);
$this->actionLogger->log($log, $salesChannelId);
}
}
/**
* This function filters out data entries that consist only of primary keys and/or createdAt/updatedAt fields
* This is a relatively frequent occurrence, particularly with translations. We do not need to log these if no data is modified.
*
* @param array<mixed> $entities
*
* @return array<mixed>
*/
private function filterMeaningfulData(string $entityName, array $entities): array
{
$primaryKeys = $this->entityInformationProvider->getEntityPrimaryKeys($entityName);
$ignoredKeys = array_merge(['createdAt', 'updatedAt'], $primaryKeys);
$filtered = [];
foreach ($entities as $key => $entity) {
if (isset($entity['versionId']) && $entity['versionId'] !== Defaults::LIVE_VERSION) {
// Do not do anything with non-live versions
continue;
}
$entityKeys = array_keys($entity);
$meaningfulKeys = array_diff($entityKeys, $ignoredKeys);
if (\count($meaningfulKeys) > 0) {
$filtered[$key] = $entity;
}
}
return $filtered;
}
private function getSource(EntityWrittenContainerEvent $event): LogSource
{
/** @var Request|null $request */
$request = $this->requestStack->getMainRequest();
try {
$context = isset($request) && $request->get(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT) !== null ?
$request->get(PlatformRequest::ATTRIBUTE_CONTEXT_OBJECT)
: $event->getContext()
;
$source = $this->sourceInfoProvider->getSourceDTO($context->getSource());
} catch (\Throwable $e) {
$this->wscsLogger->error('Error getting scope: ' . $e->getMessage(), ['request' => $request]);
throw $e;
}
return $source;
}
}