feat: comprehensive audit log

This commit is contained in:
fromme committed 2026-09-18 11:16:57 +02:00
1 parent 867bd413c8
commit 02e0d672bd
20 files changed
+932 -82

No files matched your search

@@ -9,8 +9,8 @@ use App\Enum\ResourceType;
use App\Form\Admin\ResourceFormType;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -22,7 +22,7 @@ final class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -43,12 +43,7 @@ final class CreateController extends AbstractController
$this->em->persist($resource);
$this->em->flush();
$this->logger->info('Admin created a resource.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'resource_id' => $resource->getId(),
'type' => $type->value,
'label' => $resource->getLabel(),
]);
$this->audit->resourceCreated($resource);
$this->addFlash('success', \sprintf('%s angelegt.', $resource->getLabel()));
return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
@@ -12,8 +12,8 @@ use App\Htmx\HxRedirectResponse;
use App\Repository\BookingRepository;
use App\Security\Role;
use Doctrine\DBAL\LockMode;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -38,7 +38,7 @@ final class DeleteController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -61,8 +61,10 @@ final class DeleteController extends AbstractController
$this->denyUnlessCsrfValid($request, $tokenId);
$id = $resource->getId();
// Snapshotted before the delete: afterwards the entity no longer knows its own id.
$id = (int) $resource->getId();
$label = $resource->getLabel();
$type = $resource->getType();
$deleted = $this->em->wrapInTransaction(function () use ($resource): int {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
@@ -87,12 +89,7 @@ final class DeleteController extends AbstractController
return (int) $deleted;
});
$this->logger->info('Admin deleted a resource.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'resource_id' => $id,
'label' => $label,
'bookings_deleted' => $deleted,
]);
$this->audit->resourceDeleted($id, $label, $type, $deleted);
$this->addFlash('success', $deleted > 0
? \sprintf('%s und %d Buchungen gelöscht.', $label, $deleted)
@@ -9,8 +9,8 @@ use App\Form\Admin\ResourceFormType;
use App\Htmx\HxRedirectResponse;
use App\Repository\BookingRepository;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -31,7 +31,7 @@ final class EditController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -49,13 +49,7 @@ final class EditController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->logger->info('Admin edited a resource.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'resource_id' => $resource->getId(),
'label' => $resource->getLabel(),
'group' => $resource->getGroup()?->getLabel(),
'active' => $resource->isActive(),
]);
$this->audit->resourceUpdated($resource, $wasActive);
$remaining = $wasActive && !$resource->isActive()
? $this->bookings->countFromDate($resource, new \DateTimeImmutable('today'))
@@ -10,8 +10,8 @@ use App\Form\Admin\ResourceGroupFormType;
use App\Htmx\HxRedirectResponse;
use App\Repository\ResourceGroupRepository;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -24,7 +24,7 @@ final class CreateController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ResourceGroupRepository $groups,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -45,12 +45,7 @@ final class CreateController extends AbstractController
$this->em->persist($group);
$this->em->flush();
$this->logger->info('Admin created a resource group.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'group_id' => $group->getId(),
'type' => $type->value,
'label' => $group->getLabel(),
]);
$this->audit->resourceGroupCreated($group);
$this->addFlash('success', \sprintf('%s angelegt.', $group->getLabel()));
return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
@@ -9,8 +9,8 @@ use App\Entity\ResourceGroup;
use App\Htmx\HxRedirectResponse;
use App\Repository\ResourceRepository;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -31,7 +31,7 @@ final class DeleteController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ResourceRepository $resources,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -51,11 +51,7 @@ final class DeleteController extends AbstractController
$this->denyUnlessCsrfValid($request, 'delete-resource-group-'.$group->getId());
$this->logger->info('Admin deleted a resource group.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'group_id' => $group->getId(),
'label' => $group->getLabel(),
]);
$this->audit->resourceGroupDeleted((int) $group->getId(), $group->getLabel(), $group->getType());
$label = $group->getLabel();
$this->em->remove($group);
@@ -8,8 +8,8 @@ use App\Entity\ResourceGroup;
use App\Form\Admin\ResourceGroupFormType;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -21,7 +21,7 @@ final class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -36,12 +36,7 @@ final class EditController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->logger->info('Admin edited a resource group.', [
'admin' => $this->getUser()?->getUserIdentifier(),
'group_id' => $group->getId(),
'label' => $group->getLabel(),
'position' => $group->getPosition(),
]);
$this->audit->resourceGroupUpdated($group);
$this->addFlash('success', \sprintf('%s gespeichert.', $group->getLabel()));
return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
+3 -14
View File
@@ -12,7 +12,6 @@ use App\Htmx\HxTrait;
use App\Security\Role;
use App\Service\BoardContextFactory;
use App\Service\BookingManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -28,7 +27,6 @@ final class ReleaseController extends AbstractController
public function __construct(
private readonly BoardContextFactory $boardContext,
private readonly BookingManager $manager,
private readonly LoggerInterface $logger,
) {
}
@@ -57,18 +55,9 @@ final class ReleaseController extends AbstractController
$holder = $booking->getUser();
$onBehalf = !$booking->belongsTo($this->currentUser());
if ($onBehalf) {
// The row is about to be deleted, so without this line nothing records that an
// administrator freed somebody else's desk.
$this->logger->info('Admin released a booking on behalf of another user.', [
'admin' => $this->currentUser()->getEmail(),
'holder' => $holder->getEmail(),
'resource' => $label,
'date' => $date->format('Y-m-d'),
'time' => (string) $booking->getTimeRange(),
]);
}
// The release itself is audited from BookingManager, which is also where the row still
// exists to be described. An administrator freeing somebody else's desk needs nothing
// extra here: the record carries the holder, and the actor comes from the token.
$this->manager->release($booking);
$this->addFlash('success', $onBehalf
+2 -11
View File
@@ -10,7 +10,6 @@ use App\Entity\BookingSeries;
use App\Htmx\HxTrait;
use App\Security\Role;
use App\Service\BookingManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -24,7 +23,6 @@ final class EndController extends AbstractController
public function __construct(
private readonly BookingManager $manager,
private readonly LoggerInterface $logger,
) {
}
@@ -45,17 +43,10 @@ final class EndController extends AbstractController
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
// Only the flash still cares who the series belongs to; the audit record is written
// from BookingManager and names holder and actor by itself.
$onBehalf = $series->getUser()->getId() !== $this->currentUser()->getId();
if ($onBehalf) {
$this->logger->info('Admin ended a series on behalf of another user.', [
'admin' => $this->currentUser()->getEmail(),
'holder' => $series->getUser()->getEmail(),
'resource' => $series->getResource()->getLabel(),
'series' => $series->getId(),
]);
}
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('today'));
$this->addFlash('success', $onBehalf
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\User;
use App\Service\AuditLogger;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\Event\LogoutEvent;
/**
* The two ends of a session, audited from the firewall rather than from a controller.
*
* Neither end has a controller to hang this on: the callback route is intercepted by
* EpAuthenticator and /logout by the firewall's logout listener, and both controllers do nothing
* but throw. The events are also the only hook that would still fire if a second authenticator
* were ever added.
*
* Refused sign-ins are NOT here. They are audited from EpAuthenticator, which is the only place
* that knows *why* one was refused -- by the time a failure event is dispatched the reason has
* been flattened into a German message meant for the login page.
*/
final readonly class AuditSessionListener
{
public function __construct(private AuditLogger $audit)
{
}
#[AsEventListener(event: LoginSuccessEvent::class)]
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$user = $event->getUser();
if ($user instanceof User) {
$this->audit->signedIn($user);
}
}
/**
* The token is still in place while this event is dispatched -- LogoutListener clears it
* afterwards -- but the account is read off the event rather than off the token store all
* the same, so the record stays complete if that order ever changes.
*/
#[AsEventListener(event: LogoutEvent::class)]
public function onLogout(LogoutEvent $event): void
{
$user = $event->getToken()?->getUser();
if ($user instanceof User) {
$this->audit->signedOut($user);
}
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Logger;
use App\Entity\User;
use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Stamps every audit record with who caused it.
*
* The actor lives in `extra` rather than in the context each call site passes, because it is the
* one field that is true of every audit record and that no call site should be able to forget or
* get wrong -- it is read from the token, not from an argument. What a call site does pass is the
* *subject*: whose booking, which resource. When those two differ, the record shows an
* administrator acting on somebody else's behalf without anybody having to say so.
*
* Bound to the audit channel alone: the framework log has no use for it, and enriching every
* Doctrine record with a security lookup would be a needless cost.
*/
#[AsMonologProcessor(channel: 'audit')]
final readonly class AuditActorProcessor
{
/** Console commands and the message worker have no token; they still write bookings. */
public const SYSTEM = 'system';
/** Reachable only before the firewall has a user -- login, and errors on public routes. */
public const ANONYMOUS = 'anonymous';
public function __construct(private Security $security)
{
}
public function __invoke(LogRecord $record): LogRecord
{
$record->extra['actor'] = $this->actor();
return $record;
}
private function actor(): string
{
$user = $this->security->getUser();
if ($user instanceof User) {
return $user->getEmail();
}
// The user is asked for first and the SAPI only afterwards, so that a console command
// running with a token -- a future import, say -- is still attributed to whoever it runs
// as, rather than swallowed by a blanket "cli means system" check.
if (null !== $user) {
return $user->getUserIdentifier();
}
return 'cli' === \PHP_SAPI ? self::SYSTEM : self::ANONYMOUS;
}
}
+23
View File
@@ -9,6 +9,7 @@ use App\Repository\UserRepository;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use App\Security\OAuth2\UserinfoUnavailableException;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Log\LoggerInterface;
@@ -42,6 +43,7 @@ class EpAuthenticator extends AbstractAuthenticator
private readonly EntityManagerInterface $em,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
private readonly AuditLogger $audit,
) {
}
@@ -56,6 +58,10 @@ class EpAuthenticator extends AbstractAuthenticator
$accessToken = $this->client->fetchAccessToken($request);
} catch (AuthorizationRequestException|IdentityProviderException $e) {
$this->logger->error('Login via myep failed: no access token', ['exception' => $e]);
// Audited here rather than from a failure event: this is the only point that still
// knows why the sign-in did not happen. Downstream there is only the German message
// the login page shows.
$this->audit->signInRefused('no_access_token');
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
}
@@ -64,6 +70,7 @@ class EpAuthenticator extends AbstractAuthenticator
$userinfo = $this->client->fetchUserinfo($accessToken);
} catch (UserinfoUnavailableException $e) {
$this->logger->error('Login via myep failed: userinfo unreadable', ['exception' => $e]);
$this->audit->signInRefused('userinfo_unreadable');
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
}
@@ -74,6 +81,7 @@ class EpAuthenticator extends AbstractAuthenticator
$email = $userinfo['email'] ?? null;
if (!\is_string($email) || '' === $email) {
$this->logger->error('Login via myep failed: userinfo carried no email claim');
$this->audit->signInRefused('no_email_claim');
throw new CustomUserMessageAuthenticationException('Anmeldung über myep fehlgeschlagen.');
}
@@ -81,6 +89,7 @@ class EpAuthenticator extends AbstractAuthenticator
$roles = Role::eligibleOnly($this->claimedRoles($userinfo));
if ([] === $roles) {
$this->logger->info('Login via myep refused: no eligible role', ['email' => $email]);
$this->audit->signInRefused('no_eligible_role', $email);
throw new CustomUserMessageAuthenticationException(
'Für E&P Platz ist eine Mitarbeiter- oder Administrator-Rolle erforderlich.',
@@ -154,6 +163,7 @@ class EpAuthenticator extends AbstractAuthenticator
private function createOrUpdate(string $email, string $name, array $roles): User
{
$user = $this->users->findOneBy(['email' => $email]);
$isNew = null === $user;
if (null === $user) {
$user = new User($email, $name);
@@ -162,11 +172,24 @@ class EpAuthenticator extends AbstractAuthenticator
$user->setName($name);
}
// Filtered the same way the claim was, so the comparison below is between two sets of
// the same shape -- getRoles() would add ROLE_USER to one side and not the other.
$previousRoles = Role::eligibleOnly($user->getRoles());
// myep leads: a role it no longer reports is withdrawn here, not kept because it was
// granted once.
$user->setRoles($roles);
$this->em->flush();
// After the flush, so nothing is recorded that a failing write would have undone. Both
// records show "anonymous" as their actor, correctly: they happen while the person is
// being authenticated and before any token exists, which is why they name the account.
if ($isNew) {
$this->audit->accountProvisioned($user);
} elseif ($previousRoles !== $roles) {
$this->audit->rolesChanged($user, $previousRoles, $roles);
}
return $user;
}
}
+269
View File
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\User;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Model\TimeRange;
use Psr\Log\LoggerInterface;
/**
* The audit trail: one method per thing that can happen to a booking, a resource, or a session.
*
* Why a service rather than a LoggerInterface in every controller. The records are only worth
* keeping if they are uniform -- the same wording and the same context keys for the same event,
* whichever route reached it -- and that is not something a dozen scattered `$logger->info()`
* calls hold on to. Naming the events here also makes the list of what is audited readable in
* one place, so a new write that nobody logged is a visible gap rather than an invisible one.
*
* Messages are English, like every other developer-facing string in this project: an audit log
* is read by whoever is investigating, not by the person who was booking a desk.
*
* The acting user is NOT passed in. AuditActorProcessor takes it from the security token and
* puts it in `extra.actor`, so no call site can log the wrong one. What the context carries is
* the subject -- `holder` for a booking -- and a record whose holder is not its actor is an
* administrator having acted for somebody else.
*
* Level is info throughout: an audit record is a fact, not a problem. Something that *is* a
* problem belongs in the framework log, which is what the default channel is for -- a refused
* sign-in is audited here *and* logged with its exception there, because the two readers want
* different things from it.
*/
final readonly class AuditLogger
{
public function __construct(private LoggerInterface $logger)
{
}
public function bookingCreated(Booking $booking): void
{
$this->logger->info('Booking created.', $this->booking($booking));
}
/**
* One record for the series, not one per occurrence: the occurrences are the same act, and
* a year of weekly bookings would otherwise bury everything else in the file. The individual
* days stay recoverable -- the series id is on every booking record that follows.
*/
public function seriesCreated(SeriesResult $result): void
{
$series = $result->series;
$this->logger->info('Booking series created.', [
'series_id' => $series->getId(),
'holder' => $series->getUser()->getEmail(),
'resource' => $series->getResource()->getLabel(),
'resource_id' => $series->getResource()->getId(),
'rule' => $series->getRule()->value,
'start_date' => $series->getStartDate()->format('Y-m-d'),
'end_date' => $series->getEndDate()->format('Y-m-d'),
'time' => self::clock($series->getTimeRange()),
'bookings_created' => $result->createdCount(),
// The days the series could not have, so a later "why is Tuesday missing" has an
// answer without reconstructing what was booked at the time.
'skipped_dates' => array_keys($result->skipped),
]);
}
public function bookingRescheduled(Booking $booking, TimeRange $previous): void
{
$this->logger->info('Booking rescheduled.', $this->booking($booking) + [
'previous_time' => self::clock($previous),
]);
}
/**
* Called before the row goes, since afterwards there is nothing left to describe it.
*/
public function bookingReleased(Booking $booking): void
{
$this->logger->info('Booking released.', $this->booking($booking));
}
/**
* The id is passed rather than read off the series, because ending one can delete it and
* Doctrine nulls the identifier of a deleted entity (UnitOfWork::executeDeletions) -- asking
* the series for its own id afterwards yields null exactly on the records that matter most.
*/
public function seriesEnded(BookingSeries $series, ?int $seriesId, \DateTimeImmutable $from, int $removed): void
{
$this->logger->info('Booking series ended.', [
'series_id' => $seriesId,
'holder' => $series->getUser()->getEmail(),
'resource' => $series->getResource()->getLabel(),
'resource_id' => $series->getResource()->getId(),
'ended_from' => $from->format('Y-m-d'),
'bookings_removed' => $removed,
]);
}
public function signedIn(User $user): void
{
$this->logger->info('User signed in.', [
'account' => $user->getEmail(),
'roles' => $user->getRoles(),
]);
}
/**
* The account is named separately from the actor because a refused sign-in has no token
* behind it -- the actor of these records is always "anonymous", and on the paths that fail
* before the userinfo response is read there is not even an email to name.
*
* @param string $reason a short English tag, not a message: these are counted and filtered,
* and the user-facing German explanation says nothing to whoever reads
* the trail. The exception detail stays in the framework log.
*/
public function signInRefused(string $reason, ?string $email = null): void
{
$this->logger->info('Sign-in refused.', [
'account' => $email,
'reason' => $reason,
]);
}
public function signedOut(User $user): void
{
$this->logger->info('User signed out.', [
'account' => $user->getEmail(),
]);
}
/**
* First sign-in: myep authenticates the person, and this is the moment ephub gains a row for
* them. Audited because it is the only place an account comes into existence -- nobody
* creates one by hand.
*/
public function accountProvisioned(User $user): void
{
$this->logger->info('User account provisioned.', [
'account' => $user->getEmail(),
'roles' => $user->getRoles(),
]);
}
/**
* Roles follow myep on every sign-in, so a person can gain or lose administrative access
* without anybody here doing anything. That is worth a record of its own: it is the one
* change to an account that alters what its holder may do.
*
* @param list<string> $previous
* @param list<string> $current
*/
public function rolesChanged(User $user, array $previous, array $current): void
{
$this->logger->info('User roles changed.', [
'account' => $user->getEmail(),
'previous_roles' => $previous,
'roles' => $current,
]);
}
public function resourceCreated(Resource $resource): void
{
$this->logger->info('Resource created.', $this->resource($resource));
}
public function resourceUpdated(Resource $resource, bool $wasActive): void
{
$this->logger->info('Resource updated.', $this->resource($resource) + [
// Retiring and reactivating are ordinary edits to the form and to the database, but
// not to the people whose bookings they affect, so the transition is spelled out
// rather than left to be diffed out of two consecutive records.
'was_active' => $wasActive,
]);
}
/**
* Takes a snapshot rather than the entity: by the time a deletion is worth recording, the
* entity no longer has an id to read.
*/
public function resourceDeleted(int $id, string $label, ResourceType $type, int $bookingsDeleted): void
{
$this->logger->info('Resource deleted.', [
'resource' => $label,
'resource_id' => $id,
'type' => $type->value,
'bookings_deleted' => $bookingsDeleted,
]);
}
public function resourceGroupCreated(ResourceGroup $group): void
{
$this->logger->info('Resource group created.', $this->group($group));
}
public function resourceGroupUpdated(ResourceGroup $group): void
{
$this->logger->info('Resource group updated.', $this->group($group));
}
public function resourceGroupDeleted(int $id, string $label, ResourceType $type): void
{
$this->logger->info('Resource group deleted.', [
'group' => $label,
'group_id' => $id,
'type' => $type->value,
]);
}
/**
* @return array<string, mixed>
*/
private function booking(Booking $booking): array
{
return [
'booking_id' => $booking->getId(),
'holder' => $booking->getUser()->getEmail(),
'resource' => $booking->getResource()->getLabel(),
'resource_id' => $booking->getResource()->getId(),
'date' => $booking->getDate()->format('Y-m-d'),
'time' => self::clock($booking->getTimeRange()),
'series_id' => $booking->getSeries()?->getId(),
];
}
/**
* Clock times rather than (string) $time: TimeRange's own string form is the German one the
* UI shows ("ganztägig"), and an audit record is read by whoever is investigating. This also
* keeps every record's time in one shape, which "all day" would not be.
*/
private static function clock(TimeRange $time): string
{
return $time->start().'-'.$time->end();
}
/**
* @return array<string, mixed>
*/
private function resource(Resource $resource): array
{
return [
'resource' => $resource->getLabel(),
'resource_id' => $resource->getId(),
'type' => $resource->getType()->value,
'group' => $resource->getGroup()?->getLabel(),
'active' => $resource->isActive(),
];
}
/**
* @return array<string, mixed>
*/
private function group(ResourceGroup $group): array
{
return [
'group' => $group->getLabel(),
'group_id' => $group->getId(),
'type' => $group->getType()->value,
'position' => $group->getPosition(),
];
}
}
+30 -3
View File
@@ -43,6 +43,7 @@ final readonly class BookingManager
public function __construct(
private EntityManagerInterface $em,
private BookingRepository $bookings,
private AuditLogger $audit,
) {
}
@@ -54,7 +55,7 @@ final readonly class BookingManager
// All day is the default, as it was before ranges existed.
$time ??= TimeRange::wholeDay();
return $this->em->wrapInTransaction(function () use ($user, $resource, $date, $time): Booking {
$booking = $this->em->wrapInTransaction(function () use ($user, $resource, $date, $time): Booking {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$this->assertFree($resource, $date, $time);
@@ -65,6 +66,13 @@ final readonly class BookingManager
return $booking;
});
// Audited outside the transaction, here and below: a record of a write that was rolled
// back afterwards would be worse than no record, and a refused booking throws before
// ever reaching this line.
$this->audit->bookingCreated($booking);
return $booking;
}
/**
@@ -76,7 +84,7 @@ final readonly class BookingManager
*/
public function bookSeries(BookingSeries $series): SeriesResult
{
return $this->em->wrapInTransaction(function () use ($series): SeriesResult {
$result = $this->em->wrapInTransaction(function () use ($series): SeriesResult {
$resource = $series->getResource();
$user = $series->getUser();
$time = $series->getTimeRange();
@@ -108,6 +116,10 @@ final readonly class BookingManager
return new SeriesResult($series, $created, $skipped);
});
$this->audit->seriesCreated($result);
return $result;
}
/**
@@ -115,6 +127,8 @@ final readonly class BookingManager
*/
public function reschedule(Booking $booking, TimeRange $time): void
{
$previous = $booking->getTimeRange();
$this->em->wrapInTransaction(function () use ($booking, $time): void {
$resource = $booking->getResource();
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
@@ -124,10 +138,15 @@ final readonly class BookingManager
$booking->changeTime($time);
$this->em->flush();
});
$this->audit->bookingRescheduled($booking, $previous);
}
public function release(Booking $booking): void
{
// Before the flush, while the booking still knows its own id.
$this->audit->bookingReleased($booking);
$this->em->remove($booking);
$this->em->flush();
}
@@ -140,7 +159,11 @@ final readonly class BookingManager
*/
public function endSeries(BookingSeries $series, \DateTimeImmutable $from): int
{
return $this->em->wrapInTransaction(function () use ($series, $from): int {
// Read before the transaction: ending a series can delete it, and a deleted entity no
// longer knows its own id.
$seriesId = $series->getId();
$removed = $this->em->wrapInTransaction(function () use ($series, $from): int {
$from = $from->setTime(0, 0);
$removed = 0;
@@ -159,6 +182,10 @@ final readonly class BookingManager
return $removed;
});
$this->audit->seriesEnded($series, $seriesId, $from, $removed);
return $removed;
}
/**