feat(firewall): audit rule lifecycle changes

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-30 22:57:25 -04:00
parent 5ada4c0c45
commit 7aa8a27b1b
11 changed files with 218 additions and 25 deletions
@@ -196,6 +196,29 @@ class FirewallStoreTest extends TestCase
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $logs[0]->getRuleScope());
}
#[TestDox('Rule lifecycle audit persistence retains actor and origin')]
public function testLifecycleAuditPersistence(): void
{
$log = (new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_RULE_CREATED)
->setResult(FirewallLogObject::RESULT_RECORDED)
->setRuleId('rule-456')
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
->setIdentityId('admin-a')
->setTimestamp(new \DateTimeImmutable())
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->createLog($log);
$logs = $this->store->listLogs('tenant-a');
self::assertCount(1, $logs);
self::assertSame(FirewallLogObject::EVENT_RULE_CREATED, $logs[0]->getEventType());
self::assertSame(FirewallLogObject::RESULT_RECORDED, $logs[0]->getResult());
self::assertSame('admin-a', $logs[0]->getIdentityId());
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $logs[0]->getMetadata()['origin']);
}
private function rule(
string $reason,
string $scope,
+8 -2
View File
@@ -28,13 +28,19 @@ final class CoreModuleTest extends TestCase
$module->boot();
$definitions = $registry->definitions();
self::assertCount(7, $definitions);
self::assertCount(10, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame(
FirewallService::class,
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
);
foreach ([SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY] as $event) {
foreach ([
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
] as $event) {
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
self::assertCount(1, $listeners);
self::assertSame(FirewallService::class, $listeners[0]->service);
@@ -10,6 +10,7 @@ use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallRuleScope;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
@@ -106,4 +107,64 @@ class FirewallRuleManagerTest extends TestCase
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
self::assertSame([], $cache->tenant('tenant-a'));
}
#[TestDox('Rule creation emits complete lifecycle audit context')]
public function testCreationAudit(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnCallback(
static function (FirewallRuleObject $rule): FirewallRuleObject {
return $rule->setId('rule-123');
}
);
$events = [];
$this->events->expects($this->exactly(2))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[$event->getName()] = $event;
});
$this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
'Repeated abuse',
'admin-a',
300
);
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED];
self::assertSame('rule-123', $audit->get('ruleId'));
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
self::assertSame('admin-a', $audit->getIdentityId());
self::assertNotNull($audit->get('expiresAt'));
}
#[TestDox('Disable and remove audits identify the acting administrator')]
public function testLifecycleActors(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setTenantId(null)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setCreatedBy('creator');
$this->store->method('fetchRule')->willReturn($rule);
$events = [];
$this->events->expects($this->exactly(2))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[$event->getName()] = $event;
});
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
}
}
+38 -3
View File
@@ -309,6 +309,33 @@ class FirewallServiceTest extends TestCase
$this->service->logSecurityEvent($event);
}
#[TestDox('Rule lifecycle events map to recorded audit entries')]
public function testRuleLifecycleAudit(): void
{
$this->currentTenant = null;
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getEventType() === FirewallLogObject::EVENT_RULE_DISABLED
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
&& $log->getRuleId() === 'rule-123'
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
&& $log->getIdentityId() === 'operator';
}))
->willReturnArgument(0);
$event = new \KTXF\Event\SecurityEvent(
\KTXF\Event\SecurityEvent::FIREWALL_RULE_DISABLED,
[
'ruleId' => 'rule-123',
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
'origin' => FirewallRuleManager::ORIGIN_MANUAL,
]
);
$event->setIdentityId('operator');
$this->service->logSecurityEvent($event);
}
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
public function testFirewallConfiguration(): void
{
@@ -380,17 +407,25 @@ class FirewallServiceTest extends TestCase
->willReturnArgument(0);
$publishedTenants = [];
$this->events->expects($this->exactly(2))
$lifecycleOrigin = null;
$this->events->expects($this->exactly(3))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$publishedTenants): void {
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
&$publishedTenants,
&$lifecycleOrigin
): void {
$publishedTenants[] = $event->getTenantId();
if ($event->getName() === \KTXF\Event\SecurityEvent::FIREWALL_RULE_CREATED) {
$lifecycleOrigin = $event->get('origin');
}
});
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-event');
$this->service->handleAuthFailure($event);
self::assertSame(['tenant-event', 'tenant-event'], $publishedTenants);
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
}
#[TestDox('Authentication events without a tenant use the current tenant')]