feat: comprehensive audit log
This commit is contained in:
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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user