Implementing an audit trail log for eZ Platform

Audit logging in IT means storing information from a system to a specific log. An audit trail log i useful for providing a retrospective view to a security incident, for example. In certain areas, such as financial systems, audit logs are critical but many enterprise grade content management systems also implement audit logging.

Defining what data should be stored in an audit log is not always straightforward. For example, in the case of a telephone network you can define that the audit log entry for each call should contain: source, target, timestamp and duration. But in the case of a mobile phone, should you also store all the cell towers the phone has been connected to?

A log-everything policy could lead to collect irrelevant data, and might even be in violation of privacy laws such as GDPR. It is also worth noting that just metadata can be useful in drawing conclusions, such as telephone call metadata used by authorities: Information on who called who when is telling, even without knowledge of what was discussed.

Version 3.0 eZ Platform does not include an audit log as a feature. While you could argue this as a missing feature, I think that the requirements of audit logging for a Digital Experience Platform (DXP) project implementations are so flexible that any out-of-the-box functionality will probably end up missing the mark. Instead of a feature, what eZ Platform provides is robust tooling to cost-effectively implement the level of auditing your project needs.

The building blocks of an audit trail log

One of the cornerstones of eZ Platform is its enterprise grade content engine. This feature handles the complexities of storing multilingual content with versioning by default. Developers are also able to extend and integrate the repository. One key extension point is the events system, often used to integrate to external tools like a computer vision system.

Events are also used heavily for internal integration between components of eZ Platform. Our built-in search engine communicates with the core repository exclusively with events: An update to the repository triggers and updates to the search index.

We give access to our native APIs so that developers can take advantage of the same exact mechanism to populate an audit log that we use internally to populate our search engine. In addition to the repository events that provide data for changes such as content creation, modification and deletions you will need to store all the collected data in a log.

Logging is one area where we stand on the shoulders of giants, in this case we use Symfony framework and the Monolog library. The framework natively integrates Monolog, which we can then easily use to log messages into a log (or many logs). Monolog in turn can integrate to various logging backends. The default backend is a log file, which is drop-dead simple.

For larger volumes you can configure Monolog to use a log handler that uses a specialized services such as Logstash or Datadog as a backend. These will not only allow you handle data scale, but also offer tooling for accessing and analyzing the collected data. When using an external service, it is (even more) important to pay attention to what data is stored and where, to make sure you comply with personally identifiable information (PII) legislation.

Putting the pieces together

Next we'll walk through creating an Event Handler that collects any content changes made to content objects as well as moving the location in the content tree. This is partially overlapping with the content versioning, but the log is a more manageable archiving format. We will also use the Version Comparison API that shipped in eZ Platform EE 3.0.

First create a new event listener in src/EventListener/AuditListener.php:

<?php

namespace App\EventListener;

use eZ\Publish\API\Repository\Events\Content\UpdateContentEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;


class AuditListener implements EventSubscriberInterface
{

    public static function getSubscribedEvents()
    {
        return [
            UpdateContentEvent::class => 'onUpdateContent'
        ];
    }

    public function onUpdateContent(UpdateContentEvent $event)
    {
        dump($event);
        die();
    }
}

In the default configuration of eZ Platform 3 and Symfony 5 this will be picked automatically. The next time you publish content in the eZ Platform admin you will be presented with a dump of the UpdateContentEvent instance.

To make things easier to keep track, we will want to log our audit messages in a separate log file. First, we need to define the configuration in config/packages/monolog.yaml:

monolog:
    channels: ['audit']
    handlers:
        audit:
            level:    debug
            type:     stream
            path:     '%kernel.logs_dir%/audit.log'
            channels: [audit]

The next step is to make our AuditListener to use this handler. This can be done without a separate service configuration using a new feature available in MonologBundle 3.5 and higher by naming the injected parameter based on your handler name:

public function __construct(LoggerInterface $auditLogger)
{
    $this->logger = $auditLogger;
}

Now all logging calls will be written to a separate log file: var/log/audit.log

To access eZ Platform services we inject them with constructor injection, similar to what we did for the logger. With the needed services injected our class is as follows:

use Psr\Log\LoggerInterface;
use eZ\Publish\API\Repository\PermissionResolver;
use eZ\Publish\API\Repository\UserService;
use eZ\Publish\API\Repository\ContentService;
use EzSystems\EzPlatformVersionComparison\Service\VersionComparisonService;
use eZ\Publish\API\Repository\LocationService;

class AuditListener implements EventSubscriberInterface
{

    private $logger;
    private $permissionResolver;
    private $userService;
    private $contentService;
    private $comparisonService;
    private $locationService;

    public function __construct(
        LoggerInterface $auditLogger,
        PermissionResolver $permissionResolver,
        UserService $userService,
        ContentService $contentService,
        VersionComparisonService $comparisonService,
        LocationService $locationService
    )
    {
        $this->logger = $auditLogger;
        $this->permissionResolver = $permissionResolver;
        $this->userService = $userService;
        $this->contentService = $contentService;
        $this->comparisonService = $comparisonService;
        $this->locationService = $locationService;
    }

With the core services we can generate some rough logging logic in our onUpdateContent method. The example below constructs a buffer with user details, content information and a crude dump of the output of the Version Compare API. This is buffer is then logged.

public function onUpdateContent(UpdateContentEvent $event)
{
    $content = $event->getContent();
    $contentName = $content->getName();
    $currentUserId = $this->permissionResolver
                          ->getCurrentUserReference()->getUserId();
    $currentUser = $this->userService->loadUser($currentUserId);

    $buffer = <<< BUFFER_TEMPLATE
    User with login "$currentUser->login" 
    edited object "$contentName" (id: $content->id)
    BUFFER_TEMPLATE;

    $publishedVersionNo = $content->versionInfo->versionNo;

    $versionFrom = $this->contentService->loadVersionInfo(
        $content->versionInfo->contentInfo,
        $publishedVersionNo
    );
    $versionTo = $this->contentService->loadVersionInfo(
        $content->versionInfo->contentInfo,
        $publishedVersionNo-1
    );

    foreach($content->getFields() as $field){
        $comparison = $this->comparisonService
            ->compare($versionFrom, $versionTo)
            ->getFieldValueDiffByIdentifier($field->fieldDefIdentifier)
            ->getComparisonResult();
        if($comparison->isChanged()){
            $buffer .= var_export($comparison,true) . PHP_EOL . PHP_EOL;
        }
    }

    $this->logger->info($buffer);

}

To implement the location logging using the same handler, add a second subscription:

public static function getSubscribedEvents()
{
    return [
        UpdateContentEvent::class => 'onUpdateContent',
        MoveSubtreeEvent::class => 'moveSubtreeEvent'
    ];
}

The MoveSubtreeEvent is triggered upon moving parent. Below is the simple logging logic:

public function moveSubtreeEvent(MoveSubtreeEvent $event)
{

    $currentUserId = $this->permissionResolver
        ->getCurrentUserReference()->getUserId();
    $currentUser = $this->userService->loadUser($currentUserId);

    $location = $event->getLocation();
    $locationName = $location->getContent()->getName();

    $newParentLocation = $event->getNewParentLocation();
    $newParentLocationName = $newParentLocation->getContent()->getName();

    $buffer = <<< BUFFER_TEMPLATE
    User with login "$currentUser->login"
    moved location "$locationName" (id: $location->id) 
    under location "$newParentLocationName" (id: $newParentLocation->id)
    BUFFER_TEMPLATE;

    $this->logger->info($buffer);

}

With all of the above implemented we now get details on content and location changes logged into a separate log file. The code is available on GitHub: ezplatform-audit-log

Conclusion

As you can see it is straightforward to hook into the different events sent by the eZ Platform core. The exact logging details are left up to your development team, including defining if you want to collect PII data such as IP addresses or unanonymized user details. One strategy could be to collect all events and then filter on an external logging platform.

In addition to showing how to implement a basic audit trail logging, the completed code is a showcase of some of the latest capabilities our product team has added to eZ Platform, including the very latest features available in the Symfony 5 framework.

Learn more about the new features and technical improvements our recent blog post: Discover eZ Platform v3.0

Insights and News