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

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Signing out through the real firewall, rather than by calling the listener.
*
* The listener itself is unit-tested; what this pins down is the wiring -- that the listener is
* reached at all on a firewall-dispatched event, which no unit test can show. A TestHandler is
* pushed onto the audit channel so the assertion reads the records instead of the rotating file.
*/
class LogoutAuditTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
private TestHandler $auditRecords;
protected function setUp(): void
{
$this->client = self::createClient(server: ['HTTPS' => true]);
$this->client->disableReboot();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
$this->auditRecords = new TestHandler();
$audit = self::getContainer()->get('monolog.logger.audit');
\assert($audit instanceof Logger);
$audit->pushHandler($this->auditRecords);
}
protected function tearDown(): void
{
$this->em->getConnection()->rollBack();
parent::tearDown();
}
public function testSigningOutIsAudited(): void
{
$user = new User('[email protected]', 'Anna');
$user->setRoles(['ROLE_EMPLOYEE']);
$this->em->persist($user);
$this->em->flush();
$this->client->loginUser($user);
$this->client->request('GET', '/logout');
self::assertResponseRedirects();
$records = array_values(array_filter(
$this->auditRecords->getRecords(),
static fn ($record): bool => 'User signed out.' === $record->message,
));
self::assertCount(1, $records);
self::assertSame('[email protected]', $records[0]->context['account']);
self::assertSame('[email protected]', $records[0]->extra['actor']);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Entity\User;
use App\EventListener\AuditSessionListener;
use App\Service\AuditLogger;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Http\Event\LogoutEvent;
/**
* Signing out has no controller behind it -- the firewall intercepts /logout -- so the record
* has to come off the event, and it has to survive the token being on its way out.
*/
class AuditSessionListenerTest extends TestCase
{
public function testSigningOutIsRecordedAgainstTheAccountThatHeldTheSession(): void
{
$handler = new TestHandler();
$listener = new AuditSessionListener(new AuditLogger(new Logger('audit', [$handler])));
$user = new User('[email protected]', 'Anna');
$listener->onLogout(new LogoutEvent(new Request(), new UsernamePasswordToken($user, 'main', $user->getRoles())));
$record = $handler->getRecords()[0];
self::assertSame('User signed out.', $record->message);
self::assertSame('[email protected]', $record->context['account']);
}
/**
* /logout is public, so it can be reached without a session at all -- that is not an event
* worth a record, and it must not be an error either.
*/
public function testSigningOutWithoutATokenRecordsNothing(): void
{
$handler = new TestHandler();
$listener = new AuditSessionListener(new AuditLogger(new Logger('audit', [$handler])));
$listener->onLogout(new LogoutEvent(new Request(), null));
self::assertSame([], $handler->getRecords());
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Tests\Logger;
use App\Entity\User;
use App\Logger\AuditActorProcessor;
use Monolog\Level;
use Monolog\LogRecord;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* The actor is the one field an audit record cannot be allowed to get wrong, so it is taken from
* the token rather than from any call site -- which is what these cover.
*/
class AuditActorProcessorTest extends TestCase
{
public function testTheActorIsTheSignedInUsersEmailAddress(): void
{
$record = $this->process(new User('[email protected]', 'Admin'));
self::assertSame('[email protected]', $record->extra['actor']);
}
/**
* PHPUnit runs under the CLI SAPI, which is the same context a console command or the
* message worker writes from -- so this is the system branch, not the anonymous one.
*/
public function testAWriteWithNoUserBehindItIsAttributedToTheSystem(): void
{
$record = $this->process(null);
self::assertSame(AuditActorProcessor::SYSTEM, $record->extra['actor']);
}
private function process(?User $user): LogRecord
{
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
$processor = new AuditActorProcessor($security);
return $processor(new LogRecord(
new \DateTimeImmutable(),
'audit',
Level::Info,
'Booking created.',
));
}
}
+93
View File
@@ -11,8 +11,12 @@ use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\EpClient;
use App\Security\OAuth2\UserinfoUnavailableException;
use App\Security\Role;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Token\AccessToken;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Monolog\LogRecord;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
@@ -29,12 +33,14 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class EpAuthenticatorTest extends KernelTestCase
{
private EntityManagerInterface $em;
private TestHandler $auditRecords;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
$this->auditRecords = new TestHandler();
}
protected function tearDown(): void
@@ -189,6 +195,79 @@ class EpAuthenticatorTest extends KernelTestCase
->authenticate(new Request());
}
public function testAFirstSignInIsAudited(): void
{
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
$record = $this->audited('User account provisioned.');
self::assertSame('[email protected]', $record->context['account']);
self::assertContains(Role::EMPLOYEE, $record->context['roles']);
}
/**
* Roles follow myep on every sign-in, so somebody can lose administrative access without
* anybody here doing anything -- which is exactly the kind of change the trail is for.
*/
public function testAWithdrawnRoleIsAudited(): void
{
$existing = new User('[email protected]', 'Chef');
$existing->setRoles([Role::EMPLOYEE, Role::ADMIN]);
$this->em->persist($existing);
$this->em->flush();
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
$record = $this->audited('User roles changed.');
self::assertSame([Role::EMPLOYEE, Role::ADMIN], $record->context['previous_roles']);
self::assertSame([Role::EMPLOYEE], $record->context['roles']);
}
public function testASignInThatChangesNothingIsNotAuditedAsARoleChange(): void
{
$existing = new User('[email protected]', 'Anna');
$existing->setRoles([Role::EMPLOYEE]);
$this->em->persist($existing);
$this->em->flush();
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_EMPLOYEE']]);
self::assertFalse($this->auditRecords->hasInfoThatContains('User roles changed.'));
self::assertFalse($this->auditRecords->hasInfoThatContains('User account provisioned.'));
}
/**
* The reason is the point: by the time the failure reaches the login page it is one German
* sentence, whatever went wrong.
*/
public function testARefusedSignInIsAuditedWithWhyItWasRefused(): void
{
try {
$this->authenticate(['email' => '[email protected]', 'roles' => ['ROLE_CUSTOMER']]);
} catch (CustomUserMessageAuthenticationException) {
}
$record = $this->audited('Sign-in refused.');
self::assertSame('no_eligible_role', $record->context['reason']);
self::assertSame('[email protected]', $record->context['account']);
}
public function testAFailureBeforeTheUserIsKnownIsAuditedWithoutAnAccount(): void
{
try {
$this->authenticator(throwOnToken: new AuthorizationRequestException('state mismatch'))
->authenticate(new Request());
} catch (CustomUserMessageAuthenticationException) {
}
$record = $this->audited('Sign-in refused.');
self::assertSame('no_access_token', $record->context['reason']);
self::assertNull($record->context['account']);
}
public function testItOnlyFiresOnTheCallbackRoute(): void
{
$authenticator = $this->authenticator();
@@ -246,6 +325,20 @@ class EpAuthenticatorTest extends KernelTestCase
$this->em,
self::getContainer()->get(UrlGeneratorInterface::class),
new NullLogger(),
new AuditLogger(new Logger('audit', [$this->auditRecords])),
);
}
/** The one audit record carrying this message. */
private function audited(string $message): LogRecord
{
$records = array_values(array_filter(
$this->auditRecords->getRecords(),
static fn (LogRecord $record): bool => $message === $record->message,
));
self::assertCount(1, $records, \sprintf('Expected exactly one "%s" audit record.', $message));
return $records[0];
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceGroup;
use App\Entity\User;
use App\Enum\RecurrenceRule;
use App\Enum\ResourceType;
use App\Model\SeriesResult;
use App\Model\TimeRange;
use App\Service\AuditLogger;
use Monolog\Handler\TestHandler;
use Monolog\LogRecord;
use Monolog\Logger;
use PHPUnit\Framework\TestCase;
/**
* What the trail has to say, rather than that it says something: an audit record is only useful
* if it names the holder and the resource, since the actor beside it is what makes an
* administrator acting for somebody else visible at all.
*/
class AuditLoggerTest extends TestCase
{
private TestHandler $handler;
private AuditLogger $audit;
protected function setUp(): void
{
$this->handler = new TestHandler();
$this->audit = new AuditLogger(new Logger('audit', [$this->handler]));
}
public function testACreatedBookingIsRecordedWithItsHolderAndResource(): void
{
$this->audit->bookingCreated($this->booking());
$record = $this->record();
self::assertSame('Booking created.', $record->message);
self::assertSame('[email protected]', $record->context['holder']);
self::assertSame('D1', $record->context['resource']);
self::assertSame('2026-09-18', $record->context['date']);
self::assertSame('09:00-13:00', $record->context['time']);
}
public function testARescheduledBookingKeepsTheSpanItCameFrom(): void
{
$booking = $this->booking();
$previous = $booking->getTimeRange();
$booking->changeTime(TimeRange::fromMinutes(600, 900));
$this->audit->bookingRescheduled($booking, $previous);
$record = $this->record();
self::assertSame('Booking rescheduled.', $record->message);
self::assertSame('09:00-13:00', $record->context['previous_time']);
self::assertSame('10:00-15:00', $record->context['time']);
}
/**
* A series is one record, not one per occurrence -- otherwise a year of weekly bookings
* buries every other event in the file.
*/
public function testASeriesIsRecordedOnceWithTheDaysItCouldNotHave(): void
{
$series = new BookingSeries(
$this->user(),
$this->resource(),
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-09-18'),
new \DateTimeImmutable('2026-10-02'),
TimeRange::fromMinutes(540, 780),
);
$this->audit->seriesCreated(new SeriesResult($series, [$this->booking()], ['2026-09-25' => 'taken']));
$records = $this->handler->getRecords();
self::assertCount(1, $records);
self::assertSame('Booking series created.', $records[0]->message);
self::assertSame(1, $records[0]->context['bookings_created']);
self::assertSame(['2026-09-25'], $records[0]->context['skipped_dates']);
}
/**
* Ending a series can delete it, and Doctrine nulls the id of a deleted entity -- so the id
* is passed in, and the record must use the one it was given.
*/
public function testAnEndedSeriesIsRecordedWithTheIdItHadBeforeItWasDeleted(): void
{
$series = new BookingSeries(
$this->user(),
$this->resource(),
RecurrenceRule::WEEKLY,
new \DateTimeImmutable('2026-09-18'),
new \DateTimeImmutable('2026-10-02'),
TimeRange::fromMinutes(540, 780),
);
$this->audit->seriesEnded($series, 42, new \DateTimeImmutable('2026-09-25'), 2);
$record = $this->record();
self::assertSame(42, $record->context['series_id']);
self::assertSame('2026-09-25', $record->context['ended_from']);
self::assertSame(2, $record->context['bookings_removed']);
}
public function testRetiringAResourceIsRecordedAsTheTransitionItIs(): void
{
$resource = $this->resource();
$resource->setGroup(new ResourceGroup(ResourceType::DESK, 'Room 1'));
$resource->setActive(false);
$this->audit->resourceUpdated($resource, true);
$record = $this->record();
self::assertSame('Resource updated.', $record->message);
self::assertTrue($record->context['was_active']);
self::assertFalse($record->context['active']);
self::assertSame('Room 1', $record->context['group']);
}
public function testADeletedResourceIsRecordedWithHowManyBookingsWentWithIt(): void
{
$this->audit->resourceDeleted(7, 'D1', ResourceType::DESK, 3);
$record = $this->record();
self::assertSame('Resource deleted.', $record->message);
self::assertSame(7, $record->context['resource_id']);
self::assertSame(3, $record->context['bookings_deleted']);
}
public function testASignInAndASignOutAreBothRecordedAgainstTheAccount(): void
{
$this->audit->signedIn($this->user());
$this->audit->signedOut($this->user());
$records = $this->handler->getRecords();
self::assertSame('User signed in.', $records[0]->message);
self::assertSame('[email protected]', $records[0]->context['account']);
self::assertSame('User signed out.', $records[1]->message);
self::assertSame('[email protected]', $records[1]->context['account']);
}
private function record(): LogRecord
{
return $this->handler->getRecords()[0];
}
private function booking(): Booking
{
return new Booking(
$this->user(),
$this->resource(),
new \DateTimeImmutable('2026-09-18'),
TimeRange::fromMinutes(540, 780),
);
}
private function user(): User
{
return new User('[email protected]', 'Holder');
}
private function resource(): Resource
{
return new Resource(ResourceType::DESK, 'D1');
}
}