Add audit system core with event handling and subject conversion

- Implemented `AuditEvent` class to represent audit events.
- Added `AuditEvent2Trail` for converting events to audit trails.
- Introduced `Subject` and `SubjectConverterManager` for subject conversion.
- Created contracts like `SubjectConverterInterface` and `AuditEvent2TrailInterface`.
- Developed `AuditEventSubscriber` to persist audit events using `AuditTrailPersister`.
- Included test classes for core audit services and components.
This commit is contained in:
2026-01-23 17:33:48 +01:00
parent ed41224d7a
commit 6bbdc858bd
12 changed files with 485 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Symfony\Contracts\Translation\TranslatableInterface;
readonly class AuditEvent
{
public function __construct(
public string $action,
/**
* @var list<object>
*/
public array $subjects = [],
public string|TranslatableInterface $description = '',
public array $metadata = [],
) {}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Chill\MainBundle\Entity\AuditTrail;
use Chill\MainBundle\Entity\User;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class AuditEvent2Trail implements AuditEvent2TrailInterface
{
public function __construct(
private TranslatorInterface $translator,
private SubjectConverterManagerInterface $subjectConverterManager,
private ClockInterface $clock,
private Security $security,
) {}
public function convertToTrail(AuditEvent $event): AuditTrail
{
$description = $event->description instanceof TranslatableInterface ?
$event->description->trans($this->translator)
: $event->description;
$targets = array_map(fn (mixed $subject) => $this->subjectConverterManager->convertEntityToSubjects($subject), $event->subjects);
$user = $this->security->getUser();
return new AuditTrail(
Uuid::uuid7(),
$event->action,
$this->clock->now(),
$user instanceof User ? $user : null,
$description,
$targets,
$event->metadata,
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Chill\MainBundle\Entity\AuditTrail;
interface AuditEvent2TrailInterface
{
public function convertToTrail(AuditEvent $event): AuditTrail;
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final readonly class AuditEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private AuditTrailPersister $auditTrailPersister,
private AuditEvent2TrailInterface $auditEvent2Trail,
) {}
public static function getSubscribedEvents(): array
{
return [
AuditEvent::class => 'onAuditEvent',
];
}
public function onAuditEvent(AuditEvent $event): void
{
$this->auditTrailPersister->persistAuditTrail($this->auditEvent2Trail->convertToTrail($event));
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Chill\MainBundle\Entity\AuditTrail;
class AuditTrailPersister
{
public function persistAuditTrail(AuditTrail $auditTrail): void {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit\Exception;
class ConvertSubjectException extends \LogicException
{
public function __construct(mixed $subject, ?Throwable $previous = null)
{
parent::__construct(
sprintf('Could not convert object of type %s to an acceptable audit representation', is_object($subject) ? get_class($subject) : gettype($subject)),
previous: $previous
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
class Subject
{
public function __construct(
public string $type,
/**
* @var array<string, string|number|\Stringable>
*/
public array $identifiers,
) {}
public function asArray(): array
{
return [...$this->identifiers, 't' => $this->type];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
interface SubjectConverterInterface
{
/**
* @return Subject|list<Subject>
*/
public function convert(mixed $subject): Subject|array;
public function supportsConvert(mixed $subject): bool;
public static function getDefaultPriority(): int;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
use Chill\MainBundle\Audit\Exception\ConvertSubjectException;
final readonly class SubjectConverterManager implements SubjectConverterManagerInterface
{
public function __construct(
/**
* @var iterable<SubjectConverterInterface>
*/
private iterable $converters,
) {}
/**
* @return list<array>
*
* @throws ConvertSubjectException
*/
public function convertEntityToSubjects(mixed $subject): array
{
foreach ($this->converters as $converter) {
if ($converter->supportsConvert($subject)) {
$subjects = $converter->convert($subject);
if ($subjects instanceof Subject) {
return [$subjects->asArray()];
}
return array_map(static fn (Subject $subject) => $subject->asArray(), $subjects);
}
}
throw new ConvertSubjectException($subject);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Audit;
interface SubjectConverterManagerInterface
{
/**
* @return list<array>
*/
public function convertEntityToSubjects(mixed $subject): array;
}