feat: comprehensive audit log
This commit is contained in:
1 parent
867bd413c8
commit
02e0d672bd
20 files changed
+932
-82
No files matched your search
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user