From 3f9c2500d9aedf27b6bca2c8bc73c35f0660ec70 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 5 Aug 2026 23:56:28 -0400 Subject: [PATCH] refactor(security): type firewall policy events Signed-off-by: Sebastian Krupinski --- core/lib/Module/Module.php | 3 +- .../lib/Security/Event/DeviceBlockedEvent.php | 66 ++++++++++++++++ core/lib/Security/Event/FirewallIpEvent.php | 68 ++++++++++++++++ .../Event/FirewallSettingsUpdatedEvent.php | 76 ++++++++++++++++++ core/lib/Security/Event/IpAllowedEvent.php | 9 +++ core/lib/Security/Event/IpBlockedEvent.php | 10 +++ core/lib/Security/Event/SecurityEvent.php | 7 -- core/lib/Service/FirewallRuleManager.php | 32 ++------ core/lib/Service/FirewallService.php | 5 +- core/lib/Service/FirewallSettingsService.php | 17 ++-- .../Unit/Event/FirewallPolicyEventTest.php | 79 +++++++++++++++++++ tests/php/Unit/Event/SecurityEventTest.php | 6 +- tests/php/Unit/Module/CoreModuleTest.php | 3 +- .../Unit/Service/FirewallRuleManagerTest.php | 39 +++++++++ .../php/Unit/Service/FirewallServiceTest.php | 10 ++- .../Service/FirewallSettingsServiceTest.php | 6 +- 16 files changed, 377 insertions(+), 59 deletions(-) create mode 100644 core/lib/Security/Event/DeviceBlockedEvent.php create mode 100644 core/lib/Security/Event/FirewallIpEvent.php create mode 100644 core/lib/Security/Event/FirewallSettingsUpdatedEvent.php create mode 100644 core/lib/Security/Event/IpAllowedEvent.php create mode 100644 core/lib/Security/Event/IpBlockedEvent.php create mode 100644 tests/php/Unit/Event/FirewallPolicyEventTest.php diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 45a5633..d9b2f73 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -20,6 +20,7 @@ use KTXC\Security\Event\FirewallRuleDisabledEvent; use KTXC\Security\Event\FirewallRuleEnabledEvent; use KTXC\Security\Event\FirewallRuleExtendedEvent; use KTXC\Security\Event\FirewallRuleRemovedEvent; +use KTXC\Security\Event\FirewallSettingsUpdatedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SecurityEvent; @@ -63,7 +64,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M FirewallRuleEnabledEvent::class, FirewallRuleDisabledEvent::class, FirewallRuleRemovedEvent::class, - SecurityEvent::FIREWALL_SETTINGS_UPDATED, + FirewallSettingsUpdatedEvent::class, ] as $event) { $this->events->listen( 'core', diff --git a/core/lib/Security/Event/DeviceBlockedEvent.php b/core/lib/Security/Event/DeviceBlockedEvent.php new file mode 100644 index 0000000..724bf58 --- /dev/null +++ b/core/lib/Security/Event/DeviceBlockedEvent.php @@ -0,0 +1,66 @@ + $deviceFingerprint, 'reason' => $reason], + $tenantId, + ); + } + + public function getIpAddress(): ?string + { + return null; + } + + public function getDeviceFingerprint(): string + { + return $this->deviceFingerprint; + } + + public function getUserAgent(): ?string + { + return null; + } + + public function getRequestPath(): ?string + { + return null; + } + + public function getRequestMethod(): ?string + { + return null; + } + + public function getUserId(): ?string + { + return null; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function getSeverity(): int + { + return SecurityEvent::SEVERITY_CRITICAL; + } +} diff --git a/core/lib/Security/Event/FirewallIpEvent.php b/core/lib/Security/Event/FirewallIpEvent.php new file mode 100644 index 0000000..a155398 --- /dev/null +++ b/core/lib/Security/Event/FirewallIpEvent.php @@ -0,0 +1,68 @@ + $ipAddress, 'reason' => $reason], + $tenantId, + ); + } + + public function getIpAddress(): string + { + return $this->ipAddress; + } + + public function getDeviceFingerprint(): ?string + { + return null; + } + + public function getUserAgent(): ?string + { + return null; + } + + public function getRequestPath(): ?string + { + return null; + } + + public function getRequestMethod(): ?string + { + return null; + } + + public function getUserId(): ?string + { + return null; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function getSeverity(): int + { + return static::SEVERITY; + } +} diff --git a/core/lib/Security/Event/FirewallSettingsUpdatedEvent.php b/core/lib/Security/Event/FirewallSettingsUpdatedEvent.php new file mode 100644 index 0000000..58d897c --- /dev/null +++ b/core/lib/Security/Event/FirewallSettingsUpdatedEvent.php @@ -0,0 +1,76 @@ + $changeReason, + 'changeOrigin' => $changeOrigin, + 'previous' => $previous, + 'current' => $current, + ], + $tenantId, + $actorId, + ); + } + + public function getChangeReason(): string + { + return $this->changeReason; + } + + public function getPrevious(): array + { + return $this->previous; + } + + public function getCurrent(): array + { + return $this->current; + } + + public function getChangeOrigin(): string + { + return $this->changeOrigin; + } + + public function getUserId(): ?string + { + return null; + } + + public function getReason(): string + { + return $this->changeReason; + } + + public function getSeverity(): int + { + return SecurityEvent::SEVERITY_INFO; + } +} diff --git a/core/lib/Security/Event/IpAllowedEvent.php b/core/lib/Security/Event/IpAllowedEvent.php new file mode 100644 index 0000000..a2a08d7 --- /dev/null +++ b/core/lib/Security/Event/IpAllowedEvent.php @@ -0,0 +1,9 @@ + self::SEVERITY_WARNING, - self::IP_BLOCKED, - self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL, - default => self::SEVERITY_INFO, }; } diff --git a/core/lib/Service/FirewallRuleManager.php b/core/lib/Service/FirewallRuleManager.php index efd62ca..f5be0f7 100644 --- a/core/lib/Service/FirewallRuleManager.php +++ b/core/lib/Service/FirewallRuleManager.php @@ -11,9 +11,11 @@ use KTXC\Security\Event\FirewallRuleEnabledEvent; use KTXC\Security\Event\FirewallRuleEvent; use KTXC\Security\Event\FirewallRuleExtendedEvent; use KTXC\Security\Event\FirewallRuleRemovedEvent; +use KTXC\Security\Event\DeviceBlockedEvent; +use KTXC\Security\Event\IpAllowedEvent; +use KTXC\Security\Event\IpBlockedEvent; use KTXC\Stores\FirewallStore; use KTXF\Event\EventDispatcherInterface; -use KTXC\Security\Event\SecurityEvent; use KTXF\IpUtils; final class FirewallRuleManager @@ -192,7 +194,7 @@ final class FirewallRuleManager $origin, $metadata ); - $this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason); + $this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId)); return $rule; } @@ -215,7 +217,7 @@ final class FirewallRuleManager null, $origin ); - $this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason); + $this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId)); return $rule; } @@ -260,13 +262,7 @@ final class FirewallRuleManager $origin ); - $event = new SecurityEvent( - SecurityEvent::DEVICE_BLOCKED, - ['device' => $fingerprint, 'reason' => $reason], - tenantId: $scope->tenantId, - deviceFingerprint: $fingerprint, - reason: $reason, - ); + $event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId); $this->events->dispatch($event); return $rule; @@ -505,22 +501,6 @@ final class FirewallRuleManager return $rule && $scope->owns($rule) ? $rule : null; } - private function publishIpEvent( - string $name, - FirewallRuleScope $scope, - string $ipAddress, - ?string $reason - ): void { - $event = new SecurityEvent( - $name, - ['ip' => $ipAddress, 'reason' => $reason], - tenantId: $scope->tenantId, - ipAddress: $ipAddress, - reason: $reason, - ); - $this->events->dispatch($event); - } - /** * @param class-string $eventClass */ diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index de71afd..a1bf4dc 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -19,6 +19,7 @@ use KTXC\Security\Event\FirewallRuleDisabledEvent; use KTXC\Security\Event\FirewallRuleEnabledEvent; use KTXC\Security\Event\FirewallRuleExtendedEvent; use KTXC\Security\Event\FirewallRuleRemovedEvent; +use KTXC\Security\Event\FirewallSettingsUpdatedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SecurityEvent; @@ -299,7 +300,7 @@ class FirewallService FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED, FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED, FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED, - SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED, + FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED, default => FirewallLogObject::EVENT_ACCESS_CHECK, }; } @@ -317,7 +318,7 @@ class FirewallService FirewallRuleEnabledEvent::class, FirewallRuleDisabledEvent::class, FirewallRuleRemovedEvent::class, - SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED, + FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED, default => FirewallLogObject::RESULT_BLOCKED, }; } diff --git a/core/lib/Service/FirewallSettingsService.php b/core/lib/Service/FirewallSettingsService.php index 2fec583..9fd30d5 100644 --- a/core/lib/Service/FirewallSettingsService.php +++ b/core/lib/Service/FirewallSettingsService.php @@ -6,7 +6,7 @@ namespace KTXC\Service; use KTXC\Models\Tenant\TenantConfiguration; use KTXF\Event\EventDispatcherInterface; -use KTXC\Security\Event\SecurityEvent; +use KTXC\Security\Event\FirewallSettingsUpdatedEvent; final class FirewallSettingsService { @@ -52,16 +52,13 @@ final class FirewallSettingsService $tenant->setConfiguration($configuration); $this->tenants->deposit($tenant); - $event = new SecurityEvent( - SecurityEvent::FIREWALL_SETTINGS_UPDATED, - [ - 'changeReason' => $reason, - 'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL, - 'previous' => $previous, - 'current' => $current, - ], + $event = new FirewallSettingsUpdatedEvent( + changeReason: $reason, + previous: $previous, + current: $current, tenantId: $tenantId, - identityId: $actorId, + actorId: $actorId, + changeOrigin: FirewallRuleManager::ORIGIN_MANUAL, ); $this->events->dispatch($event); diff --git a/tests/php/Unit/Event/FirewallPolicyEventTest.php b/tests/php/Unit/Event/FirewallPolicyEventTest.php new file mode 100644 index 0000000..b8ab310 --- /dev/null +++ b/tests/php/Unit/Event/FirewallPolicyEventTest.php @@ -0,0 +1,79 @@ +getName()); + self::assertSame('203.0.113.10', $blocked->getIpAddress()); + self::assertSame('Repeated abuse', $blocked->getReason()); + self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $blocked->getSeverity()); + self::assertSame(IpAllowedEvent::class, $allowed->getName()); + self::assertSame('203.0.113.11', $allowed->getIpAddress()); + self::assertSame(SecurityEvent::SEVERITY_INFO, $allowed->getSeverity()); + self::assertSame(DeviceBlockedEvent::class, $device->getName()); + self::assertSame('device-a', $device->getDeviceFingerprint()); + self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $device->getSeverity()); + } + + #[Test] + #[TestDox('Firewall settings events capture the complete configuration transition')] + public function constructsSettingsEvent(): void + { + $event = new FirewallSettingsUpdatedEvent( + 'Tighten controls', + ['maxAuthFailures' => 5], + ['maxAuthFailures' => 3], + 'tenant-a', + 'operator-a', + ); + + self::assertSame(FirewallSettingsUpdatedEvent::class, $event->getName()); + self::assertSame('Tighten controls', $event->getChangeReason()); + self::assertSame(['maxAuthFailures' => 5], $event->getPrevious()); + self::assertSame(['maxAuthFailures' => 3], $event->getCurrent()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('operator-a', $event->getIdentityId()); + self::assertSame('manual', $event->getChangeOrigin()); + self::assertSame(SecurityEvent::SEVERITY_INFO, $event->getSeverity()); + } + + #[Test] + #[TestDox('Firewall policy events reject missing subject and ownership context')] + public function rejectsIncompletePolicyContext(): void + { + foreach ([ + static fn() => new IpBlockedEvent(''), + static fn() => new IpAllowedEvent(''), + static fn() => new DeviceBlockedEvent(''), + static fn() => new FirewallSettingsUpdatedEvent('', [], [], 'tenant-a'), + static fn() => new FirewallSettingsUpdatedEvent('Reason', [], [], ''), + ] as $construction) { + try { + $construction(); + self::fail('Incomplete firewall policy context was accepted.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Unit/Event/SecurityEventTest.php b/tests/php/Unit/Event/SecurityEventTest.php index 23789d0..47b086c 100644 --- a/tests/php/Unit/Event/SecurityEventTest.php +++ b/tests/php/Unit/Event/SecurityEventTest.php @@ -19,13 +19,9 @@ final class SecurityEventTest extends TestCase SecurityEvent::SEVERITY_WARNING, (new SecurityEvent(SecurityEvent::AUTH_LOGOUT))->getSeverity(), ); - self::assertSame( - SecurityEvent::SEVERITY_CRITICAL, - (new SecurityEvent(SecurityEvent::DEVICE_BLOCKED))->getSeverity(), - ); self::assertSame( SecurityEvent::SEVERITY_INFO, - (new SecurityEvent(SecurityEvent::IP_ALLOWED))->getSeverity(), + (new SecurityEvent(SecurityEvent::ACCESS_GRANTED))->getSeverity(), ); } diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 79ce4e0..62e84bb 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -25,6 +25,7 @@ use KTXC\Security\Event\FirewallRuleDisabledEvent; use KTXC\Security\Event\FirewallRuleEnabledEvent; use KTXC\Security\Event\FirewallRuleExtendedEvent; use KTXC\Security\Event\FirewallRuleRemovedEvent; +use KTXC\Security\Event\FirewallSettingsUpdatedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SecurityEvent; @@ -63,7 +64,7 @@ final class CoreModuleTest extends TestCase FirewallRuleDisabledEvent::class, FirewallRuleEnabledEvent::class, FirewallRuleRemovedEvent::class, - SecurityEvent::FIREWALL_SETTINGS_UPDATED, + FirewallSettingsUpdatedEvent::class, ] as $event) { $listeners = $registry->listeners($event, DeliveryMode::Deferred); self::assertCount(1, $listeners); diff --git a/tests/php/Unit/Service/FirewallRuleManagerTest.php b/tests/php/Unit/Service/FirewallRuleManagerTest.php index efb3370..80703ba 100644 --- a/tests/php/Unit/Service/FirewallRuleManagerTest.php +++ b/tests/php/Unit/Service/FirewallRuleManagerTest.php @@ -16,6 +16,9 @@ use KTXC\Security\Event\FirewallRuleDisabledEvent; use KTXC\Security\Event\FirewallRuleEnabledEvent; use KTXC\Security\Event\FirewallRuleExtendedEvent; use KTXC\Security\Event\FirewallRuleRemovedEvent; +use KTXC\Security\Event\DeviceBlockedEvent; +use KTXC\Security\Event\IpAllowedEvent; +use KTXC\Security\Event\IpBlockedEvent; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; @@ -301,6 +304,42 @@ class FirewallRuleManagerTest extends TestCase self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin')); self::assertSame('admin-a', $audit->getIdentityId()); self::assertNotNull($audit->get('expiresAt')); + self::assertInstanceOf(IpBlockedEvent::class, $events[IpBlockedEvent::class]); + self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress()); + } + + #[TestDox('IP allowance and device blocking publish dedicated policy events')] + public function testSubjectPolicyEvents(): void + { + $this->store->method('findExactIpRule')->willReturn(null); + $this->store->method('depositRule')->willReturnCallback( + static function (FirewallRuleObject $rule): FirewallRuleObject { + static $sequence = 0; + return $rule->setId('policy-rule-' . ++$sequence); + } + ); + $events = []; + $this->events->expects($this->exactly(4)) + ->method('dispatch') + ->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void { + $events[] = $event; + }); + + $this->manager->allowIp( + FirewallRuleScope::tenant('tenant-a'), + '203.0.113.11', + 'Trusted service', + 'admin-a', + ); + $this->manager->blockDevice( + FirewallRuleScope::tenant('tenant-a'), + 'device-a', + 'Compromised device', + 'admin-a', + ); + + self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof IpAllowedEvent)); + self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof DeviceBlockedEvent)); } #[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')] diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index cc8da9d..118f341 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -16,6 +16,7 @@ use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\FirewallRuleCreatedEvent; use KTXC\Security\Event\FirewallRuleDisabledEvent; +use KTXC\Security\Event\FirewallSettingsUpdatedEvent; use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Service\FirewallService; @@ -396,11 +397,12 @@ class FirewallServiceTest extends TestCase && $log->getMetadata()['changeReason'] === 'Tighten controls' )) ->willReturnArgument(0); - $event = new \KTXC\Security\Event\SecurityEvent( - \KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED, - ['changeReason' => 'Tighten controls'], + $event = new FirewallSettingsUpdatedEvent( + changeReason: 'Tighten controls', + previous: ['maxAuthFailures' => 5], + current: ['maxAuthFailures' => 3], tenantId: 'tenant-a', - identityId: 'operator', + actorId: 'operator', ); $this->service->logSecurityEvent($event); diff --git a/tests/php/Unit/Service/FirewallSettingsServiceTest.php b/tests/php/Unit/Service/FirewallSettingsServiceTest.php index 18c958a..94d6aa2 100644 --- a/tests/php/Unit/Service/FirewallSettingsServiceTest.php +++ b/tests/php/Unit/Service/FirewallSettingsServiceTest.php @@ -9,7 +9,7 @@ use KTXC\Models\Tenant\TenantObject; use KTXC\Service\FirewallSettingsService; use KTXC\Service\TenantService; use KTXF\Event\EventDispatcherInterface; -use KTXC\Security\Event\SecurityEvent; +use KTXC\Security\Event\FirewallSettingsUpdatedEvent; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -37,8 +37,8 @@ final class FirewallSettingsServiceTest extends TestCase $events = $this->createMock(EventDispatcherInterface::class); $events->expects(self::once()) ->method('dispatch') - ->with(self::callback(static fn(SecurityEvent $event): bool => - $event->getName() === SecurityEvent::FIREWALL_SETTINGS_UPDATED + ->with(self::callback(static fn(FirewallSettingsUpdatedEvent $event): bool => + $event->getName() === FirewallSettingsUpdatedEvent::class && $event->getTenantId() === 'tenant-a' && $event->getIdentityId() === 'admin-a' && $event->get('changeReason') === 'Tighten authentication controls'