diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php index 75a4cc1..375301a 100644 --- a/core/lib/Models/Firewall/FirewallLogObject.php +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -13,6 +13,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable { public const RESULT_ALLOWED = 'allowed'; public const RESULT_BLOCKED = 'blocked'; + public const RESULT_RECORDED = 'recorded'; public const EVENT_AUTH_FAILURE = 'auth_failure'; public const EVENT_RATE_LIMIT = 'rate_limit'; @@ -20,6 +21,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable public const EVENT_SUSPICIOUS = 'suspicious'; public const EVENT_RULE_MATCH = 'rule_match'; public const EVENT_ACCESS_CHECK = 'access_check'; + public const EVENT_RULE_CREATED = 'rule_created'; + public const EVENT_RULE_DISABLED = 'rule_disabled'; + public const EVENT_RULE_REMOVED = 'rule_removed'; private ?string $id = null; private ?string $tenantId = null; diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 813c8a7..8862b0b 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -41,6 +41,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M SecurityEvent::BRUTE_FORCE_DETECTED, SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY, + SecurityEvent::FIREWALL_RULE_CREATED, + SecurityEvent::FIREWALL_RULE_DISABLED, + SecurityEvent::FIREWALL_RULE_REMOVED, ] as $event) { $this->events->listen( 'core', diff --git a/core/lib/Service/FirewallRuleManager.php b/core/lib/Service/FirewallRuleManager.php index adb2b77..96a40a0 100644 --- a/core/lib/Service/FirewallRuleManager.php +++ b/core/lib/Service/FirewallRuleManager.php @@ -11,6 +11,9 @@ use KTXF\Event\SecurityEvent; final class FirewallRuleManager { + public const ORIGIN_MANUAL = 'manual'; + public const ORIGIN_AUTOMATIC = 'automatic'; + public function __construct( private readonly FirewallStore $store, private readonly FirewallRuleCache $cache, @@ -30,7 +33,8 @@ final class FirewallRuleManager string $ipAddress, ?string $reason, ?string $createdBy, - ?int $durationSeconds = null + ?int $durationSeconds = null, + string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); FirewallRuleValidator::duration($durationSeconds); @@ -52,7 +56,8 @@ final class FirewallRuleManager $ipAddress, $reason ?? 'Blocked by administrator', $createdBy, - $durationSeconds + $durationSeconds, + $origin ); $this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason); @@ -63,7 +68,8 @@ final class FirewallRuleManager FirewallRuleScope $scope, string $ipAddress, ?string $reason, - ?string $createdBy + ?string $createdBy, + string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); $rule = $this->create( @@ -72,7 +78,9 @@ final class FirewallRuleManager FirewallRuleObject::ACTION_ALLOW, $ipAddress, $reason ?? 'Allowed by administrator', - $createdBy + $createdBy, + null, + $origin ); $this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason); @@ -83,7 +91,8 @@ final class FirewallRuleManager FirewallRuleScope $scope, string $cidr, ?string $reason, - ?string $createdBy + ?string $createdBy, + string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { return $this->create( $scope, @@ -91,7 +100,9 @@ final class FirewallRuleManager FirewallRuleObject::ACTION_BLOCK, FirewallRuleValidator::cidr($cidr), $reason ?? 'Range blocked by administrator', - $createdBy + $createdBy, + null, + $origin ); } @@ -100,7 +111,8 @@ final class FirewallRuleManager string $fingerprint, ?string $reason, ?string $createdBy, - ?int $durationSeconds = null + ?int $durationSeconds = null, + string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { FirewallRuleValidator::duration($durationSeconds); $fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint); @@ -111,7 +123,8 @@ final class FirewallRuleManager $fingerprint, $reason ?? 'Device blocked by administrator', $createdBy, - $durationSeconds + $durationSeconds, + $origin ); $event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]); @@ -121,7 +134,7 @@ final class FirewallRuleManager return $rule; } - public function disable(FirewallRuleScope $scope, string $ruleId): bool + public function disable(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool { $rule = $this->ownedRule($scope, $ruleId); if (!$rule) { @@ -131,11 +144,12 @@ final class FirewallRuleManager $rule->setEnabled(false); $this->store->depositRule($rule); $this->cache->invalidate(); + $this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_DISABLED, $rule, $actorId); return true; } - public function remove(FirewallRuleScope $scope, string $ruleId): bool + public function remove(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool { $rule = $this->ownedRule($scope, $ruleId); if (!$rule) { @@ -144,6 +158,7 @@ final class FirewallRuleManager $this->store->destroyRule($rule); $this->cache->invalidate(); + $this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_REMOVED, $rule, $actorId); return true; } @@ -155,8 +170,13 @@ final class FirewallRuleManager string $value, string $reason, ?string $createdBy, - ?int $durationSeconds = null + ?int $durationSeconds = null, + string $origin = self::ORIGIN_MANUAL ): FirewallRuleObject { + if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) { + throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}"); + } + $rule = (new FirewallRuleObject()) ->setScope($scope->scope) ->setTenantId($scope->tenantId) @@ -166,6 +186,7 @@ final class FirewallRuleManager ->setReason($reason) ->setCreatedBy($createdBy) ->setCreatedAt(new \DateTimeImmutable()) + ->setMetadata(['origin' => $origin]) ->setEnabled(true); if ($durationSeconds !== null) { @@ -174,6 +195,7 @@ final class FirewallRuleManager $this->store->depositRule($rule); $this->cache->invalidate(); + $this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule); return $rule; } @@ -195,4 +217,25 @@ final class FirewallRuleManager $event->setIpAddress($ipAddress)->setReason($reason)->setTenantId($scope->tenantId); $this->events->dispatch($event); } + + private function publishLifecycleEvent( + string $name, + FirewallRuleObject $rule, + ?string $actorId = null + ): void + { + $event = new SecurityEvent($name, [ + 'ruleId' => $rule->getId(), + 'ruleScope' => $rule->getScope(), + 'ruleType' => $rule->getType(), + 'ruleAction' => $rule->getAction(), + 'ruleValue' => $rule->getValue(), + 'reason' => $rule->getReason(), + 'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL, + 'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM), + ]); + $event->setTenantId($rule->getTenantId()) + ->setIdentityId($actorId ?? $rule->getCreatedBy()); + $this->events->dispatch($event); + } } diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 6ade478..24d381e 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -191,7 +191,8 @@ class FirewallService $ipAddress, sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), null, // System-created - $blockDuration + $blockDuration, + FirewallRuleManager::ORIGIN_AUTOMATIC ); } @@ -214,10 +215,10 @@ class FirewallService ->setRequestPath($event->getRequestPath()) ->setRequestMethod($event->getRequestMethod()) ->setEventType($this->mapEventToLogType($event->getName())) - ->setResult($this->mapEventToResult($event->getName())) + ->setResult($this->mapEventToResult($event)) ->setRuleId($event->get('ruleId')) ->setRuleScope($ruleScope) - ->setIdentityId($event->getUserId()) + ->setIdentityId($event->getUserId() ?? $event->getIdentityId()) ->setTimestamp(new \DateTimeImmutable()) ->setMetadata($event->getData()); @@ -236,6 +237,9 @@ class FirewallService SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT, SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH, SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, + SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED, + SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED, + SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED, default => FirewallLogObject::EVENT_ACCESS_CHECK, }; } @@ -243,11 +247,14 @@ class FirewallService /** * Map security event to result */ - private function mapEventToResult(string $eventName): string + private function mapEventToResult(SecurityEvent $event): string { - return match ($eventName) { + return match ($event->getName()) { SecurityEvent::AUTH_SUCCESS, SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED, + SecurityEvent::FIREWALL_RULE_CREATED, + SecurityEvent::FIREWALL_RULE_DISABLED, + SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED, default => FirewallLogObject::RESULT_BLOCKED, }; } diff --git a/core/lib/Service/SystemFirewallRuleService.php b/core/lib/Service/SystemFirewallRuleService.php index d63e4dd..6c9cb66 100644 --- a/core/lib/Service/SystemFirewallRuleService.php +++ b/core/lib/Service/SystemFirewallRuleService.php @@ -66,13 +66,21 @@ final class SystemFirewallRuleService public function disableRule(string $ruleId): bool { $this->requirePermission(self::PERMISSION_MANAGE); - return $this->rules->disable(FirewallRuleScope::system(), $ruleId); + return $this->rules->disable( + FirewallRuleScope::system(), + $ruleId, + $this->identity->identifier() + ); } public function removeRule(string $ruleId): bool { $this->requirePermission(self::PERMISSION_MANAGE); - return $this->rules->remove(FirewallRuleScope::system(), $ruleId); + return $this->rules->remove( + FirewallRuleScope::system(), + $ruleId, + $this->identity->identifier() + ); } private function requirePermission(string $permission): void diff --git a/core/lib/Service/TenantFirewallRuleService.php b/core/lib/Service/TenantFirewallRuleService.php index c17dc07..1a08e0d 100644 --- a/core/lib/Service/TenantFirewallRuleService.php +++ b/core/lib/Service/TenantFirewallRuleService.php @@ -60,13 +60,13 @@ final class TenantFirewallRuleService public function disableRule(string $ruleId): bool { $this->requirePermission(self::PERMISSION_MANAGE); - return $this->rules->disable($this->scope(), $ruleId); + return $this->rules->disable($this->scope(), $ruleId, $this->identity->identifier()); } public function removeRule(string $ruleId): bool { $this->requirePermission(self::PERMISSION_MANAGE); - return $this->rules->remove($this->scope(), $ruleId); + return $this->rules->remove($this->scope(), $ruleId, $this->identity->identifier()); } private function scope(): FirewallRuleScope diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php index c499b13..42c7370 100644 --- a/shared/lib/Event/SecurityEvent.php +++ b/shared/lib/Event/SecurityEvent.php @@ -26,6 +26,9 @@ class SecurityEvent extends Event public const IP_BLOCKED = 'security.ip.blocked'; public const IP_ALLOWED = 'security.ip.allowed'; public const DEVICE_BLOCKED = 'security.device.blocked'; + public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created'; + public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled'; + public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed'; private ?string $ipAddress = null; private ?string $deviceFingerprint = null; diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 59140f0..d436a99 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -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, diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 00dd4a2..45317d5 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -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); diff --git a/tests/php/Unit/Service/FirewallRuleManagerTest.php b/tests/php/Unit/Service/FirewallRuleManagerTest.php index c8f0966..24e1d64 100644 --- a/tests/php/Unit/Service/FirewallRuleManagerTest.php +++ b/tests/php/Unit/Service/FirewallRuleManagerTest.php @@ -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()); + } } diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index fa24121..326298e 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -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')]