diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 5219e89..849721a 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -11,6 +11,7 @@ use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXC\Service\TenantFirewallStatusService; +use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\SecurityEvent; @@ -45,7 +46,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M foreach ([ SecurityEvent::AUTH_SUCCESS, - SecurityEvent::ACCESS_DENIED, + AccessDeniedEvent::class, BruteForceDetectedEvent::class, SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY, diff --git a/core/lib/Security/Event/AccessDeniedEvent.php b/core/lib/Security/Event/AccessDeniedEvent.php new file mode 100644 index 0000000..f4cc81f --- /dev/null +++ b/core/lib/Security/Event/AccessDeniedEvent.php @@ -0,0 +1,88 @@ + $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason], + $tenantId, + $identityId, + ); + } + + public function getIpAddress(): string + { + return $this->ipAddress; + } + + public function getRuleId(): string + { + return $this->ruleId; + } + + public function getRuleScope(): string + { + return $this->ruleScope; + } + + 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_WARNING; + } +} diff --git a/core/lib/Security/Event/SecurityEvent.php b/core/lib/Security/Event/SecurityEvent.php index d2de9ae..78fa612 100644 --- a/core/lib/Security/Event/SecurityEvent.php +++ b/core/lib/Security/Event/SecurityEvent.php @@ -17,7 +17,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface public const TOKEN_REFRESH = 'security.token.refresh'; public const TOKEN_REVOKED = 'security.token.revoked'; - public const ACCESS_DENIED = 'security.access.denied'; public const ACCESS_GRANTED = 'security.access.granted'; public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded'; @@ -137,29 +136,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface ); } - /** - * Create an access denied event - */ - public static function accessDenied( - string $ipAddress, - ?string $deviceFingerprint = null, - ?string $ruleId = null, - ?string $ruleScope = null, - ?string $reason = null, - ?string $tenantId = null, - ?string $identityId = null, - ): self { - return self::create( - self::ACCESS_DENIED, - $ipAddress, - $deviceFingerprint, - ['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason], - $tenantId, - $identityId, - reason: $reason, - ); - } - /** * Get default severity for event types */ @@ -170,7 +146,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface self::ACCESS_GRANTED, self::TOKEN_REFRESH => self::SEVERITY_INFO, - self::ACCESS_DENIED, self::AUTH_LOGOUT, self::TOKEN_REVOKED => self::SEVERITY_WARNING, diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 773e730..fee5942 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -10,6 +10,7 @@ use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Stores\FirewallStore; use KTXC\Context\TenantContextInterface; +use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\SecurityEvent; @@ -283,7 +284,7 @@ class FirewallService SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK, BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT, - SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH, + AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED, @@ -321,13 +322,13 @@ class FirewallService ?string $deviceFingerprint, FirewallRuleObject $rule ): void { - $event = SecurityEvent::accessDenied( - $ipAddress, - $deviceFingerprint, - $rule->getId(), - $rule->getScope(), - $rule->getReason(), - $this->tenantContext->identifier(), + $event = new AccessDeniedEvent( + ipAddress: $ipAddress, + ruleId: $rule->getId(), + ruleScope: $rule->getScope(), + deviceFingerprint: $deviceFingerprint, + reason: $rule->getReason(), + tenantId: $this->tenantContext->identifier(), ); $this->events->dispatch($event); } diff --git a/tests/php/Unit/Event/AccessDeniedEventTest.php b/tests/php/Unit/Event/AccessDeniedEventTest.php new file mode 100644 index 0000000..d4111c3 --- /dev/null +++ b/tests/php/Unit/Event/AccessDeniedEventTest.php @@ -0,0 +1,58 @@ +getName()); + self::assertSame('203.0.113.10', $event->getIpAddress()); + self::assertSame('device-a', $event->getDeviceFingerprint()); + self::assertSame('rule-a', $event->getRuleId()); + self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope()); + self::assertSame('Blocked by policy', $event->getReason()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('identity-a', $event->getIdentityId()); + self::assertSame(SecurityEvent::SEVERITY_WARNING, $event->getSeverity()); + } + + #[Test] + #[TestDox('Access denial rejects incomplete rule context')] + public function rejectsIncompleteRuleContext(): void + { + foreach ([ + ['', 'rule-a', FirewallRuleObject::SCOPE_TENANT], + ['203.0.113.10', '', FirewallRuleObject::SCOPE_TENANT], + ['203.0.113.10', 'rule-a', 'unknown'], + ] as $arguments) { + try { + new AccessDeniedEvent(...$arguments); + self::fail('Incomplete access-denial context was accepted.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 09fff81..4afe41f 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -16,6 +16,7 @@ use KTXC\Service\TenantFirewallStatusService; use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallRuleService; use KTXC\Event\EventListenerRegistry; +use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\SecurityEvent; @@ -44,6 +45,7 @@ final class CoreModuleTest extends TestCase ); self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred)); foreach ([ + AccessDeniedEvent::class, BruteForceDetectedEvent::class, SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY, diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 5d0c99b..e91e177 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -10,6 +10,7 @@ use KTXC\Http\Request\RequestContext; use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Tenant\TenantConfiguration; +use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Service\FirewallService; @@ -86,7 +87,15 @@ class FirewallServiceTest extends TestCase $this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]); $this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]); - $this->events->expects($this->once())->method('dispatch'); + $this->events->expects($this->once()) + ->method('dispatch') + ->with(self::callback(static function ($event): bool { + return $event instanceof AccessDeniedEvent + && $event->getIpAddress() === '203.0.113.10' + && $event->getRuleId() === 'system-block' + && $event->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM + && $event->getReason() === 'system-block'; + })); $result = $this->service->analyze('203.0.113.10'); @@ -226,13 +235,12 @@ class FirewallServiceTest extends TestCase && $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH; })) ->willReturnArgument(0); - $event = \KTXC\Security\Event\SecurityEvent::accessDenied( - '203.0.113.10', - null, - 'tenant-rule', - FirewallRuleObject::SCOPE_TENANT, - 'Tenant block', - 'tenant-a', + $event = new AccessDeniedEvent( + ipAddress: '203.0.113.10', + ruleId: 'tenant-rule', + ruleScope: FirewallRuleObject::SCOPE_TENANT, + reason: 'Tenant block', + tenantId: 'tenant-a', ); $this->service->logSecurityEvent($event); @@ -250,12 +258,11 @@ class FirewallServiceTest extends TestCase && $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM; })) ->willReturnArgument(0); - $event = \KTXC\Security\Event\SecurityEvent::accessDenied( - '203.0.113.10', - null, - 'system-rule', - FirewallRuleObject::SCOPE_SYSTEM, - 'System block' + $event = new AccessDeniedEvent( + ipAddress: '203.0.113.10', + ruleId: 'system-rule', + ruleScope: FirewallRuleObject::SCOPE_SYSTEM, + reason: 'System block', ); $this->service->logSecurityEvent($event);