From da81f1ddf1b3339b12877a4a3a5ded0a70421ea3 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 30 Jul 2026 22:35:24 -0400 Subject: [PATCH] refactor(firewall): separate enforcement from rule management Signed-off-by: Sebastian Krupinski --- core/lib/Module/Module.php | 24 +- core/lib/Service/FirewallRuleCache.php | 29 ++ core/lib/Service/FirewallRuleManager.php | 198 ++++++++++ core/lib/Service/FirewallRuleScope.php | 35 ++ core/lib/Service/FirewallRuleValidator.php | 63 ++++ core/lib/Service/FirewallService.php | 352 +----------------- .../lib/Service/SystemFirewallRuleService.php | 84 +++++ .../lib/Service/TenantFirewallRuleService.php | 83 +++++ core/lib/Stores/FirewallStore.php | 20 + .../Integration/Stores/FirewallStoreTest.php | 42 ++- tests/php/Unit/Module/CoreModuleTest.php | 14 + .../Unit/Service/FirewallRuleManagerTest.php | 109 ++++++ .../Unit/Service/FirewallRuleServicesTest.php | 106 ++++++ .../php/Unit/Service/FirewallServiceTest.php | 91 +---- 14 files changed, 814 insertions(+), 436 deletions(-) create mode 100644 core/lib/Service/FirewallRuleCache.php create mode 100644 core/lib/Service/FirewallRuleManager.php create mode 100644 core/lib/Service/FirewallRuleScope.php create mode 100644 core/lib/Service/FirewallRuleValidator.php create mode 100644 core/lib/Service/SystemFirewallRuleService.php create mode 100644 core/lib/Service/TenantFirewallRuleService.php create mode 100644 tests/php/Unit/Service/FirewallRuleManagerTest.php create mode 100644 tests/php/Unit/Service/FirewallRuleServicesTest.php diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 0733768..9fa59b8 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -3,6 +3,8 @@ namespace KTXC\Module; use KTXC\Service\FirewallService; +use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\TenantFirewallRuleService; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistry; use KTXF\Event\SecurityEvent; @@ -115,7 +117,27 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'group' => 'Module Management' ], - // System Administration + // Firewall Management + TenantFirewallRuleService::PERMISSION_READ => [ + 'label' => 'View Tenant Firewall Rules', + 'description' => 'View firewall rules owned by the current tenant', + 'group' => 'Firewall Management' + ], + TenantFirewallRuleService::PERMISSION_MANAGE => [ + 'label' => 'Manage Tenant Firewall Rules', + 'description' => 'Create, disable, and remove firewall rules owned by the current tenant', + 'group' => 'Firewall Management' + ], + SystemFirewallRuleService::PERMISSION_READ => [ + 'label' => 'View System Firewall Rules', + 'description' => 'View firewall rules that apply to every tenant', + 'group' => 'System Administration' + ], + SystemFirewallRuleService::PERMISSION_MANAGE => [ + 'label' => 'Manage System Firewall Rules', + 'description' => 'Create, disable, and remove firewall rules that apply to every tenant', + 'group' => 'System Administration' + ], 'system.admin' => [ 'label' => 'System Administrator', 'description' => 'Full system access (superuser)', diff --git a/core/lib/Service/FirewallRuleCache.php b/core/lib/Service/FirewallRuleCache.php new file mode 100644 index 0000000..9948d6d --- /dev/null +++ b/core/lib/Service/FirewallRuleCache.php @@ -0,0 +1,29 @@ + */ + private array $rules = []; + + public function __construct(private readonly FirewallStore $store) + { + } + + /** @return FirewallRuleObject[] */ + public function applicable(string $tenantId): array + { + return $this->rules[$tenantId] ??= $this->store->listApplicableRules($tenantId); + } + + public function invalidate(): void + { + $this->rules = []; + } +} diff --git a/core/lib/Service/FirewallRuleManager.php b/core/lib/Service/FirewallRuleManager.php new file mode 100644 index 0000000..adb2b77 --- /dev/null +++ b/core/lib/Service/FirewallRuleManager.php @@ -0,0 +1,198 @@ +scope === FirewallRuleObject::SCOPE_SYSTEM + ? $this->store->listSystemRules($activeOnly) + : $this->store->listRules($scope->tenantId, $activeOnly); + } + + public function blockIp( + FirewallRuleScope $scope, + string $ipAddress, + ?string $reason, + ?string $createdBy, + ?int $durationSeconds = null + ): FirewallRuleObject { + $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); + FirewallRuleValidator::duration($durationSeconds); + + $existing = $this->store->findExactIpRule( + $scope->tenantId, + $ipAddress, + FirewallRuleObject::ACTION_BLOCK, + $scope->scope + ); + if ($existing) { + return $existing; + } + + $rule = $this->create( + $scope, + FirewallRuleObject::TYPE_IP, + FirewallRuleObject::ACTION_BLOCK, + $ipAddress, + $reason ?? 'Blocked by administrator', + $createdBy, + $durationSeconds + ); + $this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason); + + return $rule; + } + + public function allowIp( + FirewallRuleScope $scope, + string $ipAddress, + ?string $reason, + ?string $createdBy + ): FirewallRuleObject { + $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); + $rule = $this->create( + $scope, + FirewallRuleObject::TYPE_IP, + FirewallRuleObject::ACTION_ALLOW, + $ipAddress, + $reason ?? 'Allowed by administrator', + $createdBy + ); + $this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason); + + return $rule; + } + + public function blockIpRange( + FirewallRuleScope $scope, + string $cidr, + ?string $reason, + ?string $createdBy + ): FirewallRuleObject { + return $this->create( + $scope, + FirewallRuleObject::TYPE_IP_RANGE, + FirewallRuleObject::ACTION_BLOCK, + FirewallRuleValidator::cidr($cidr), + $reason ?? 'Range blocked by administrator', + $createdBy + ); + } + + public function blockDevice( + FirewallRuleScope $scope, + string $fingerprint, + ?string $reason, + ?string $createdBy, + ?int $durationSeconds = null + ): FirewallRuleObject { + FirewallRuleValidator::duration($durationSeconds); + $fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint); + $rule = $this->create( + $scope, + FirewallRuleObject::TYPE_DEVICE, + FirewallRuleObject::ACTION_BLOCK, + $fingerprint, + $reason ?? 'Device blocked by administrator', + $createdBy, + $durationSeconds + ); + + $event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]); + $event->setDeviceFingerprint($fingerprint)->setReason($reason)->setTenantId($scope->tenantId); + $this->events->dispatch($event); + + return $rule; + } + + public function disable(FirewallRuleScope $scope, string $ruleId): bool + { + $rule = $this->ownedRule($scope, $ruleId); + if (!$rule) { + return false; + } + + $rule->setEnabled(false); + $this->store->depositRule($rule); + $this->cache->invalidate(); + + return true; + } + + public function remove(FirewallRuleScope $scope, string $ruleId): bool + { + $rule = $this->ownedRule($scope, $ruleId); + if (!$rule) { + return false; + } + + $this->store->destroyRule($rule); + $this->cache->invalidate(); + + return true; + } + + private function create( + FirewallRuleScope $scope, + string $type, + string $action, + string $value, + string $reason, + ?string $createdBy, + ?int $durationSeconds = null + ): FirewallRuleObject { + $rule = (new FirewallRuleObject()) + ->setScope($scope->scope) + ->setTenantId($scope->tenantId) + ->setType($type) + ->setAction($action) + ->setValue($value) + ->setReason($reason) + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + if ($durationSeconds !== null) { + $rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")); + } + + $this->store->depositRule($rule); + $this->cache->invalidate(); + + return $rule; + } + + private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject + { + $rule = $this->store->fetchRule($ruleId); + + 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]); + $event->setIpAddress($ipAddress)->setReason($reason)->setTenantId($scope->tenantId); + $this->events->dispatch($event); + } +} diff --git a/core/lib/Service/FirewallRuleScope.php b/core/lib/Service/FirewallRuleScope.php new file mode 100644 index 0000000..5c990a0 --- /dev/null +++ b/core/lib/Service/FirewallRuleScope.php @@ -0,0 +1,35 @@ +getScope() === $this->scope && $rule->getTenantId() === $this->tenantId; + } +} diff --git a/core/lib/Service/FirewallRuleValidator.php b/core/lib/Service/FirewallRuleValidator.php new file mode 100644 index 0000000..b320229 --- /dev/null +++ b/core/lib/Service/FirewallRuleValidator.php @@ -0,0 +1,63 @@ + $maximumPrefix) { + throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}"); + } + + return $cidr; + } + + public static function deviceFingerprint(string $fingerprint): string + { + $fingerprint = trim($fingerprint); + if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) { + throw new \InvalidArgumentException( + sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH) + ); + } + + return $fingerprint; + } + + public static function duration(?int $durationSeconds): void + { + if ($durationSeconds !== null && $durationSeconds < 1) { + throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.'); + } + } +} diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index f902140..9ab8502 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -32,7 +32,6 @@ class FirewallService private const MAX_AUTH_FAILURES = 1000; private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year - private const MAX_DEVICE_FINGERPRINT_LENGTH = 512; // Configuration keys private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures'; @@ -40,13 +39,12 @@ class FirewallService private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration'; private const CONFIG_ENABLED = 'firewall.enabled'; - /** @var array */ - private array $rulesCache = []; - public function __construct( private readonly FirewallStore $store, private readonly TenantContextInterface $tenantContext, private readonly EventDispatcherInterface $events, + private readonly FirewallRuleManager $rules, + private readonly FirewallRuleCache $ruleCache, ) { } @@ -192,8 +190,8 @@ class FirewallService self::MAX_AUTO_BLOCK_DURATION ); - $this->blockIpForTenant( - $tenantId, + $this->rules->blockIp( + FirewallRuleScope::tenant($tenantId), $ipAddress, sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), null, // System-created @@ -273,297 +271,6 @@ class FirewallService $this->events->dispatch($event); } - // ======================================== - // Rule Management - // ======================================== - - /** - * Block an IP address - */ - public function blockIp( - string $ipAddress, - ?string $reason = null, - ?string $createdBy = null, - ?int $durationSeconds = null - ): FirewallRuleObject { - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); - } - - return $this->blockIpForTenant( - $tenantId, - $ipAddress, - $reason, - $createdBy, - $durationSeconds - ); - } - - private function blockIpForTenant( - string $tenantId, - string $ipAddress, - ?string $reason, - ?string $createdBy, - ?int $durationSeconds - ): FirewallRuleObject { - $ipAddress = $this->validateIpAddress($ipAddress); - $this->validateDuration($durationSeconds); - - // Check if already blocked - $existing = $this->store->findExactIpRule( - $tenantId, - $ipAddress, - FirewallRuleObject::ACTION_BLOCK - ); - - if ($existing) { - return $existing; - } - - $rule = new FirewallRuleObject(); - $rule->setScope(FirewallRuleObject::SCOPE_TENANT) - ->setTenantId($tenantId) - ->setType(FirewallRuleObject::TYPE_IP) - ->setAction(FirewallRuleObject::ACTION_BLOCK) - ->setValue($ipAddress) - ->setReason($reason ?? 'Blocked by administrator') - ->setCreatedBy($createdBy) - ->setCreatedAt(new \DateTimeImmutable()) - ->setEnabled(true); - - if ($durationSeconds !== null) { - $rule->setExpiresAt( - (new \DateTimeImmutable())->modify("+{$durationSeconds} seconds") - ); - } - - $this->store->depositRule($rule); - $this->clearRulesCache(); - - // Publish event - $event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]); - $event->setIpAddress($ipAddress) - ->setReason($reason) - ->setTenantId($tenantId); - $this->events->dispatch($event); - - return $rule; - } - - /** - * Allow an IP address (whitelist) - */ - public function allowIp( - string $ipAddress, - ?string $reason = null, - ?string $createdBy = null - ): FirewallRuleObject { - $ipAddress = $this->validateIpAddress($ipAddress); - - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); - } - - $rule = new FirewallRuleObject(); - $rule->setScope(FirewallRuleObject::SCOPE_TENANT) - ->setTenantId($tenantId) - ->setType(FirewallRuleObject::TYPE_IP) - ->setAction(FirewallRuleObject::ACTION_ALLOW) - ->setValue($ipAddress) - ->setReason($reason ?? 'Allowed by administrator') - ->setCreatedBy($createdBy) - ->setCreatedAt(new \DateTimeImmutable()) - ->setEnabled(true); - - $this->store->depositRule($rule); - $this->clearRulesCache(); - - // Publish event - $event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]); - $event->setIpAddress($ipAddress) - ->setReason($reason) - ->setTenantId($tenantId); - $this->events->dispatch($event); - - return $rule; - } - - /** - * Block an IP range (CIDR notation) - */ - public function blockIpRange( - string $cidr, - ?string $reason = null, - ?string $createdBy = null - ): FirewallRuleObject { - $cidr = $this->validateCidr($cidr); - - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); - } - - $rule = new FirewallRuleObject(); - $rule->setScope(FirewallRuleObject::SCOPE_TENANT) - ->setTenantId($tenantId) - ->setType(FirewallRuleObject::TYPE_IP_RANGE) - ->setAction(FirewallRuleObject::ACTION_BLOCK) - ->setValue($cidr) - ->setReason($reason ?? 'Range blocked by administrator') - ->setCreatedBy($createdBy) - ->setCreatedAt(new \DateTimeImmutable()) - ->setEnabled(true); - - $this->store->depositRule($rule); - $this->clearRulesCache(); - - return $rule; - } - - /** - * Block a device fingerprint - */ - public function blockDevice( - string $fingerprint, - ?string $reason = null, - ?string $createdBy = null, - ?int $durationSeconds = null - ): FirewallRuleObject { - $fingerprint = trim($fingerprint); - if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) { - throw new \InvalidArgumentException( - sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH) - ); - } - $this->validateDuration($durationSeconds); - - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); - } - - $rule = new FirewallRuleObject(); - $rule->setScope(FirewallRuleObject::SCOPE_TENANT) - ->setTenantId($tenantId) - ->setType(FirewallRuleObject::TYPE_DEVICE) - ->setAction(FirewallRuleObject::ACTION_BLOCK) - ->setValue($fingerprint) - ->setReason($reason ?? 'Device blocked by administrator') - ->setCreatedBy($createdBy) - ->setCreatedAt(new \DateTimeImmutable()) - ->setEnabled(true); - - if ($durationSeconds !== null) { - $rule->setExpiresAt( - (new \DateTimeImmutable())->modify("+{$durationSeconds} seconds") - ); - } - - $this->store->depositRule($rule); - $this->clearRulesCache(); - - // Publish event - $event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]); - $event->setDeviceFingerprint($fingerprint) - ->setReason($reason) - ->setTenantId($tenantId); - $this->events->dispatch($event); - - return $rule; - } - - /** - * Remove a rule by ID - */ - public function removeRule(string $ruleId): bool - { - $rule = $this->store->fetchRule($ruleId); - if (!$rule) { - return false; - } - - // Verify tenant ownership - if ($rule->getTenantId() !== $this->tenantContext->identifier()) { - return false; - } - - $this->store->destroyRule($rule); - $this->clearRulesCache(); - - return true; - } - - /** - * Disable a rule (soft delete) - */ - public function disableRule(string $ruleId): bool - { - $rule = $this->store->fetchRule($ruleId); - if (!$rule) { - return false; - } - - // Verify tenant ownership - if ($rule->getTenantId() !== $this->tenantContext->identifier()) { - return false; - } - - $rule->setEnabled(false); - $this->store->depositRule($rule); - $this->clearRulesCache(); - - return true; - } - - /** - * Get all rules for current tenant - */ - public function listRules(bool $activeOnly = true): array - { - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - return []; - } - - return $this->store->listRules($tenantId, $activeOnly); - } - - /** - * Get firewall logs for current tenant - */ - public function getLogs( - ?string $ipAddress = null, - ?string $eventType = null, - ?string $result = null, - int $limit = 100 - ): array { - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - return []; - } - - return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit); - } - - /** - * Get blocked requests count - */ - public function getBlockedCount(?\DateTimeImmutable $since = null): int - { - $tenantId = $this->tenantContext->identifier(); - if (!$tenantId) { - return 0; - } - - return $this->store->countBlockedRequests($tenantId, $since); - } - - // ======================================== - // Helpers - // ======================================== - /** * Check if firewall is enabled for current tenant */ @@ -603,43 +310,6 @@ class FirewallService return $value; } - private function validateIpAddress(string $ipAddress): string - { - $ipAddress = trim($ipAddress); - if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) { - throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}"); - } - - return $ipAddress; - } - - private function validateCidr(string $cidr): string - { - $cidr = trim($cidr); - if (substr_count($cidr, '/') !== 1) { - throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}"); - } - - [$address, $prefix] = explode('/', $cidr, 2); - if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) { - throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}"); - } - - $maximumPrefix = str_contains($address, ':') ? 128 : 32; - if ((int)$prefix > $maximumPrefix) { - throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}"); - } - - return $cidr; - } - - private function validateDuration(?int $durationSeconds): void - { - if ($durationSeconds !== null && $durationSeconds < 1) { - throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.'); - } - } - /** * Get active rules (cached) * @return FirewallRuleObject[] @@ -651,19 +321,7 @@ class FirewallService return []; } - if (!array_key_exists($tenantId, $this->rulesCache)) { - $this->rulesCache[$tenantId] = $this->store->listApplicableRules($tenantId); - } - - return $this->rulesCache[$tenantId]; - } - - /** - * Clear rules cache - */ - private function clearRulesCache(): void - { - $this->rulesCache = []; + return $this->ruleCache->applicable($tenantId); } /** diff --git a/core/lib/Service/SystemFirewallRuleService.php b/core/lib/Service/SystemFirewallRuleService.php new file mode 100644 index 0000000..d63e4dd --- /dev/null +++ b/core/lib/Service/SystemFirewallRuleService.php @@ -0,0 +1,84 @@ +requirePermission(self::PERMISSION_READ); + return $this->rules->list(FirewallRuleScope::system(), $activeOnly); + } + + public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->blockIp( + FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier(), $durationSeconds + ); + } + + public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->allowIp( + FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier() + ); + } + + public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->blockIpRange( + FirewallRuleScope::system(), $cidr, $reason, $this->identity->identifier() + ); + } + + public function blockDevice( + string $fingerprint, + ?string $reason = null, + ?int $durationSeconds = null + ): FirewallRuleObject { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->blockDevice( + FirewallRuleScope::system(), + $fingerprint, + $reason, + $this->identity->identifier(), + $durationSeconds + ); + } + + public function disableRule(string $ruleId): bool + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->disable(FirewallRuleScope::system(), $ruleId); + } + + public function removeRule(string $ruleId): bool + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->remove(FirewallRuleScope::system(), $ruleId); + } + + private function requirePermission(string $permission): void + { + if (!$this->identity->hasPermission($permission)) { + throw new \RuntimeException("Missing required permission: {$permission}"); + } + } +} diff --git a/core/lib/Service/TenantFirewallRuleService.php b/core/lib/Service/TenantFirewallRuleService.php new file mode 100644 index 0000000..c17dc07 --- /dev/null +++ b/core/lib/Service/TenantFirewallRuleService.php @@ -0,0 +1,83 @@ +requirePermission(self::PERMISSION_READ); + return $this->rules->list($this->scope(), $activeOnly); + } + + public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->blockIp( + $this->scope(), $ip, $reason, $this->identity->identifier(), $durationSeconds + ); + } + + public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->allowIp($this->scope(), $ip, $reason, $this->identity->identifier()); + } + + public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->blockIpRange($this->scope(), $cidr, $reason, $this->identity->identifier()); + } + + public function blockDevice( + string $fingerprint, + ?string $reason = null, + ?int $durationSeconds = null + ): FirewallRuleObject { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->blockDevice( + $this->scope(), $fingerprint, $reason, $this->identity->identifier(), $durationSeconds + ); + } + + public function disableRule(string $ruleId): bool + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->disable($this->scope(), $ruleId); + } + + public function removeRule(string $ruleId): bool + { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->remove($this->scope(), $ruleId); + } + + private function scope(): FirewallRuleScope + { + return FirewallRuleScope::tenant($this->tenant->requireIdentifier()); + } + + private function requirePermission(string $permission): void + { + if (!$this->identity->hasPermission($permission)) { + throw new \RuntimeException("Missing required permission: {$permission}"); + } + } +} diff --git a/core/lib/Stores/FirewallStore.php b/core/lib/Stores/FirewallStore.php index 723eaad..2a21de6 100644 --- a/core/lib/Stores/FirewallStore.php +++ b/core/lib/Stores/FirewallStore.php @@ -92,6 +92,26 @@ class FirewallStore return $list; } + public function listSystemRules(bool $activeOnly = true): array + { + $filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null]; + if ($activeOnly) { + $filter['enabled'] = true; + $filter['$or'] = [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]], + ]; + } + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + foreach ($cursor as $entry) { + $list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + } + + return $list; + } + /** * Find rules by IP address */ diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 3f1023f..04010ca 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -4,10 +4,11 @@ declare(strict_types=1); namespace KTXT\Integration\Stores; -use KTXC\Context\TenantContextInterface; use KTXC\Db\DataStore; use KTXC\Models\Firewall\FirewallRuleObject; -use KTXC\Service\FirewallService; +use KTXC\Service\FirewallRuleCache; +use KTXC\Service\FirewallRuleManager; +use KTXC\Service\FirewallRuleScope; use KTXC\Stores\FirewallStore; use KTXF\Event\EventDispatcherInterface; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -93,13 +94,16 @@ class FirewallStoreTest extends TestCase FirewallRuleObject::ACTION_BLOCK )); - $tenantContext = $this->createMock(TenantContextInterface::class); - $tenantContext->method('identifier')->willReturn('tenant-a'); - $tenantContext->method('configuration')->willReturn(null); $events = $this->createMock(EventDispatcherInterface::class); - $service = new FirewallService($this->store, $tenantContext, $events); - - $replacement = $service->blockIp('203.0.113.10', durationSeconds: 300); + $cache = new FirewallRuleCache($this->store); + $manager = new FirewallRuleManager($this->store, $cache, $events); + $replacement = $manager->blockIp( + FirewallRuleScope::tenant('tenant-a'), + '203.0.113.10', + null, + null, + 300 + ); self::assertFalse($replacement->isExpired()); self::assertNotSame($expired->getId(), $replacement->getId()); @@ -146,6 +150,28 @@ class FirewallStoreTest extends TestCase )); } + #[TestDox('System rule listings exclude tenant, disabled, and expired rules')] + public function testSystemListing(): void + { + $this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM)); + $this->store->depositRule( + $this->rule('disabled', FirewallRuleObject::SCOPE_SYSTEM)->setEnabled(false) + ); + $this->store->depositRule( + $this->rule('expired', FirewallRuleObject::SCOPE_SYSTEM) + ->setExpiresAt(new \DateTimeImmutable('-1 minute')) + ); + $this->store->depositRule( + $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') + ); + + $rules = $this->store->listSystemRules(); + + self::assertCount(1, $rules); + self::assertSame('system', $rules[0]->getReason()); + self::assertTrue($rules[0]->isSystemScoped()); + } + private function rule( string $reason, string $scope, diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index fa7ae23..2c95a74 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -7,6 +7,8 @@ namespace KTXT\Unit\Module; use KTXC\Console\Event\EventsDebugCommand; use KTXC\Module\Module; use KTXC\Service\FirewallService; +use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\TenantFirewallRuleService; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistry; use KTXF\Event\SecurityEvent; @@ -43,4 +45,16 @@ final class CoreModuleTest extends TestCase self::assertContains(EventsDebugCommand::class, $module->registerCI()); } + + #[Test] + #[TestDox('Core registers dedicated system firewall permissions')] + public function registersSystemFirewallPermissions(): void + { + $permissions = (new Module(new EventListenerRegistry()))->permissions(); + + self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions); + self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions); + self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions); + self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions); + } } diff --git a/tests/php/Unit/Service/FirewallRuleManagerTest.php b/tests/php/Unit/Service/FirewallRuleManagerTest.php new file mode 100644 index 0000000..870a783 --- /dev/null +++ b/tests/php/Unit/Service/FirewallRuleManagerTest.php @@ -0,0 +1,109 @@ +store = $this->createMock(FirewallStore::class); + $this->events = $this->createMock(EventDispatcherInterface::class); + $this->manager = new FirewallRuleManager( + $this->store, + new FirewallRuleCache($this->store), + $this->events + ); + } + + #[TestDox('The shared manager creates rules with the supplied scope and owner')] + public function testScopeCreation(): void + { + $this->store->method('findExactIpRule')->willReturn(null); + $this->store->expects($this->exactly(2)) + ->method('depositRule') + ->willReturnArgument(0); + + $tenant = $this->manager->blockIp( + FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a' + ); + $system = $this->manager->blockIp( + FirewallRuleScope::system(), '203.0.113.11', null, 'system-admin' + ); + + self::assertSame('tenant-a', $tenant->getTenantId()); + self::assertSame(FirewallRuleObject::SCOPE_TENANT, $tenant->getScope()); + self::assertNull($system->getTenantId()); + self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $system->getScope()); + } + + #[TestDox('Malformed rule values are rejected before persistence')] + public function testValidation(): void + { + $this->store->expects($this->never())->method('depositRule'); + $this->expectException(\InvalidArgumentException::class); + + $this->manager->blockIpRange( + FirewallRuleScope::tenant('tenant-a'), '2001:db8::/129', null, 'admin-a' + ); + } + + #[TestDox('Temporary rules require a positive duration')] + public function testDuration(): void + { + $this->store->expects($this->never())->method('depositRule'); + $this->expectException(\InvalidArgumentException::class); + + $this->manager->blockIp( + FirewallRuleScope::system(), '203.0.113.10', null, 'admin', 0 + ); + } + + #[TestDox('Rule lifecycle operations cannot cross scope ownership')] + public function testOwnership(): void + { + $tenantRule = (new FirewallRuleObject()) + ->setId('tenant-rule') + ->setScope(FirewallRuleObject::SCOPE_TENANT) + ->setTenantId('tenant-a'); + $this->store->method('fetchRule')->willReturn($tenantRule); + $this->store->expects($this->never())->method('destroyRule'); + + self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule')); + self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule')); + } + + #[TestDox('Rule mutations invalidate the shared enforcement cache')] + public function testCacheInvalidation(): void + { + $this->store->expects($this->exactly(2)) + ->method('listApplicableRules') + ->with('tenant-a') + ->willReturnOnConsecutiveCalls([], []); + $cache = new FirewallRuleCache($this->store); + $manager = new FirewallRuleManager($this->store, $cache, $this->events); + $this->store->method('findExactIpRule')->willReturn(null); + $this->store->method('depositRule')->willReturnArgument(0); + + self::assertSame([], $cache->applicable('tenant-a')); + $manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin'); + self::assertSame([], $cache->applicable('tenant-a')); + } +} diff --git a/tests/php/Unit/Service/FirewallRuleServicesTest.php b/tests/php/Unit/Service/FirewallRuleServicesTest.php new file mode 100644 index 0000000..5eca47a --- /dev/null +++ b/tests/php/Unit/Service/FirewallRuleServicesTest.php @@ -0,0 +1,106 @@ +store = $this->createMock(FirewallStore::class); + $this->identity = $this->createMock(IdentityContextInterface::class); + $this->tenant = $this->createMock(TenantContextInterface::class); + $events = $this->createMock(EventDispatcherInterface::class); + $this->manager = new FirewallRuleManager( + $this->store, + new FirewallRuleCache($this->store), + $events + ); + } + + #[TestDox('Tenant and system boundaries require their dedicated permissions')] + public function testPermissions(): void + { + $this->identity->method('hasPermission')->willReturn(false); + $this->store->expects($this->never())->method('depositRule'); + + try { + $this->tenantService()->blockIp('203.0.113.10'); + self::fail('Tenant operation should have been rejected.'); + } catch (\RuntimeException $error) { + self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage()); + } + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE); + $this->systemService()->blockIp('203.0.113.10'); + } + + #[TestDox('Tenant management derives scope from tenant context')] + public function testTenantScope(): void + { + $this->allow(TenantFirewallRuleService::PERMISSION_MANAGE); + $this->tenant->method('requireIdentifier')->willReturn('tenant-a'); + $this->identity->method('identifier')->willReturn('admin-a'); + $this->store->method('findExactIpRule')->willReturn(null); + $this->store->expects($this->once()) + ->method('depositRule') + ->with(self::callback(static fn(FirewallRuleObject $rule): bool => + $rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a' + )) + ->willReturnArgument(0); + + $this->tenantService()->blockIp('203.0.113.10'); + } + + #[TestDox('System management always delegates with system scope')] + public function testSystemScope(): void + { + $this->allow(SystemFirewallRuleService::PERMISSION_MANAGE); + $this->store->method('findExactIpRule')->willReturn(null); + $this->store->expects($this->once()) + ->method('depositRule') + ->with(self::callback(static fn(FirewallRuleObject $rule): bool => + $rule->isSystemScoped() && $rule->getTenantId() === null + )) + ->willReturnArgument(0); + + $this->systemService()->blockIp('203.0.113.10'); + } + + private function allow(string $permission): void + { + $this->identity->method('hasPermission')->with($permission)->willReturn(true); + } + + private function tenantService(): TenantFirewallRuleService + { + return new TenantFirewallRuleService($this->manager, $this->tenant, $this->identity); + } + + private function systemService(): SystemFirewallRuleService + { + return new SystemFirewallRuleService($this->manager, $this->identity); + } +} diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 6910e04..1ea1dde 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -8,6 +8,8 @@ use KTXC\Context\TenantContextInterface; use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Service\FirewallService; +use KTXC\Service\FirewallRuleCache; +use KTXC\Service\FirewallRuleManager; use KTXC\Stores\FirewallStore; use KTXF\Event\EventDispatcherInterface; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -38,7 +40,15 @@ class FirewallServiceTest extends TestCase $this->tenantContext->method('configuration')->willReturnCallback( fn(): ?TenantConfiguration => $this->currentConfiguration ); - $this->service = new FirewallService($this->store, $this->tenantContext, $this->events); + $cache = new FirewallRuleCache($this->store); + $manager = new FirewallRuleManager($this->store, $cache, $this->events); + $this->service = new FirewallService( + $this->store, + $this->tenantContext, + $this->events, + $manager, + $cache + ); } #[TestDox('System blocks cannot be overridden by tenant allows')] @@ -114,85 +124,6 @@ class FirewallServiceTest extends TestCase self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId); } - #[TestDox('New IP blocks are explicitly tenant-scoped')] - public function testIpBlockScope(): void - { - $this->store->method('findExactIpRule')->willReturn(null); - $this->store->expects($this->once()) - ->method('depositRule') - ->with(self::callback(static function (FirewallRuleObject $rule): bool { - return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT - && $rule->getTenantId() === 'tenant-a'; - })) - ->willReturnArgument(0); - - $rule = $this->service->blockIp('203.0.113.10'); - - self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rule->getScope()); - self::assertSame('tenant-a', $rule->getTenantId()); - } - - #[TestDox('Malformed IP addresses are rejected before persistence')] - public function testIpValidation(): void - { - $this->store->expects($this->never())->method('depositRule'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid IP address'); - - $this->service->blockIp('999.2.3.4'); - } - - #[TestDox('Valid IPv6 addresses can be blocked')] - public function testIpv6Validation(): void - { - $this->store->method('findExactIpRule')->willReturn(null); - $this->store->expects($this->once()) - ->method('depositRule') - ->with(self::callback(static fn(FirewallRuleObject $rule): bool => $rule->getValue() === '2001:db8::1')) - ->willReturnArgument(0); - - self::assertSame('2001:db8::1', $this->service->blockIp(' 2001:db8::1 ')->getValue()); - } - - #[TestDox('Malformed CIDR ranges are rejected before persistence')] - public function testCidrValidation(): void - { - $this->store->expects($this->never())->method('depositRule'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid CIDR range'); - - $this->service->blockIpRange('2001:db8::/129'); - } - - #[TestDox('Valid IPv4 and IPv6 CIDR ranges are accepted')] - public function testValidCidrs(): void - { - $this->store->expects($this->exactly(2))->method('depositRule')->willReturnArgument(0); - - self::assertSame('192.0.2.0/24', $this->service->blockIpRange('192.0.2.0/24')->getValue()); - self::assertSame('2001:db8::/32', $this->service->blockIpRange('2001:db8::/32')->getValue()); - } - - #[TestDox('Temporary rules require a positive duration')] - public function testDurationValidation(): void - { - $this->store->expects($this->never())->method('depositRule'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('greater than zero'); - - $this->service->blockIp('203.0.113.10', durationSeconds: 0); - } - - #[TestDox('Device fingerprints must be non-empty and bounded')] - public function testFingerprintValidation(): void - { - $this->store->expects($this->never())->method('depositRule'); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Device fingerprint'); - - $this->service->blockDevice(' '); - } - #[TestDox('Typed tenant firewall settings drive brute-force thresholds')] public function testFirewallConfiguration(): void {