From 6700ff145d66fd5dd36e89a524cdd7e6bb0b2eb0 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 3 Aug 2026 22:22:07 -0400 Subject: [PATCH] feat(firewall): add operational status reads Signed-off-by: Sebastian Krupinski --- core/lib/Controllers/FirewallController.php | 53 +++++++++ core/lib/Module/Module.php | 12 +++ core/lib/Service/FirewallStatusService.php | 63 +++++++++++ .../Service/SystemFirewallStatusService.php | 37 +++++++ .../Service/TenantFirewallStatusService.php | 45 ++++++++ core/lib/Stores/FirewallStore.php | 15 +++ .../Integration/Stores/FirewallStoreTest.php | 3 + .../Controllers/FirewallControllerTest.php | 23 +++- tests/php/Unit/Module/CoreModuleTest.php | 4 + .../Service/FirewallStatusServicesTest.php | 102 ++++++++++++++++++ 10 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 core/lib/Service/FirewallStatusService.php create mode 100644 core/lib/Service/SystemFirewallStatusService.php create mode 100644 core/lib/Service/TenantFirewallStatusService.php create mode 100644 tests/php/Unit/Service/FirewallStatusServicesTest.php diff --git a/core/lib/Controllers/FirewallController.php b/core/lib/Controllers/FirewallController.php index 54b0bca..50203b9 100644 --- a/core/lib/Controllers/FirewallController.php +++ b/core/lib/Controllers/FirewallController.php @@ -7,8 +7,10 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; +use KTXC\Service\TenantFirewallStatusService; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -19,6 +21,8 @@ final class FirewallController extends ControllerAbstract private readonly SystemFirewallRuleService $systemRules, private readonly TenantFirewallLogService $tenantLogs, private readonly SystemFirewallLogService $systemLogs, + private readonly TenantFirewallStatusService $tenantStatus, + private readonly SystemFirewallStatusService $systemStatus, ) { } @@ -158,6 +162,46 @@ final class FirewallController extends ControllerAbstract ); } + #[AuthenticatedRoute( + '/firewall/metrics', + name: 'firewall.tenant.metrics.read', + permissions: [TenantFirewallLogService::PERMISSION_READ], + )] + public function tenantMetrics(?string $since = null): JsonResponse + { + return $this->readResponse(fn(): array => $this->tenantStatus->metrics($since)); + } + + #[AuthenticatedRoute( + '/firewall/configuration', + name: 'firewall.tenant.configuration.read', + permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_READ], + )] + public function tenantConfiguration(): JsonResponse + { + return new JsonResponse($this->tenantStatus->configuration()); + } + + #[AuthenticatedRoute( + '/firewall/system/metrics', + name: 'firewall.system.metrics.read', + permissions: [SystemFirewallLogService::PERMISSION_READ], + )] + public function systemMetrics(?string $tenantId = null, ?string $since = null): JsonResponse + { + return $this->readResponse(fn(): array => $this->systemStatus->metrics($tenantId, $since)); + } + + #[AuthenticatedRoute( + '/firewall/system/maintenance', + name: 'firewall.system.maintenance.read', + permissions: [SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ], + )] + public function maintenanceStatus(): JsonResponse + { + return new JsonResponse($this->systemStatus->maintenanceStatus()); + } + private function queryResponse(callable $query, string $limit, string $offset): JsonResponse { try { @@ -178,4 +222,13 @@ final class FirewallController extends ControllerAbstract return new JsonResponse($rule); } + + private function readResponse(callable $read): JsonResponse + { + try { + return new JsonResponse($read()); + } catch (\InvalidArgumentException $error) { + return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST); + } + } } diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index da44d31..2c00877 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -7,8 +7,10 @@ use KTXC\Console\Firewall\FirewallSetupCommand; use KTXC\Service\FirewallService; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; +use KTXC\Service\TenantFirewallStatusService; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistry; use KTXF\Event\SecurityEvent; @@ -142,6 +144,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'View firewall security and audit logs owned by the current tenant', 'group' => 'Firewall Management' ], + TenantFirewallStatusService::PERMISSION_SETTINGS_READ => [ + 'label' => 'View Tenant Firewall Settings', + 'description' => 'View effective firewall settings for the current tenant', + 'group' => 'Firewall Management' + ], SystemFirewallRuleService::PERMISSION_READ => [ 'label' => 'View System Firewall Rules', 'description' => 'View firewall rules that apply to every tenant', @@ -157,6 +164,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'View firewall security and audit logs across tenants', 'group' => 'System Administration' ], + SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ => [ + 'label' => 'View Firewall Maintenance Status', + 'description' => 'View the last firewall cleanup result and operational status', + 'group' => 'System Administration' + ], 'system.admin' => [ 'label' => 'System Administrator', 'description' => 'Full system access (superuser)', diff --git a/core/lib/Service/FirewallStatusService.php b/core/lib/Service/FirewallStatusService.php new file mode 100644 index 0000000..6b44167 --- /dev/null +++ b/core/lib/Service/FirewallStatusService.php @@ -0,0 +1,63 @@ + $tenantId, + 'blockedRequests' => $this->store->countBlockedRequests($tenantId, $sinceDate), + 'since' => $sinceDate?->format(\DateTimeInterface::ATOM), + ]; + } + + public function systemMetrics(?string $tenantId = null, ?string $since = null): array + { + if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) { + throw new \InvalidArgumentException('Invalid tenant filter.'); + } + $sinceDate = self::date($since); + return [ + 'tenantId' => $tenantId, + 'blockedRequests' => $this->store->countSystemBlockedRequests($tenantId, $sinceDate), + 'since' => $sinceDate?->format(\DateTimeInterface::ATOM), + ]; + } + + public function maintenanceStatus(): array + { + return $this->store->maintenanceStatus() ?? [ + 'status' => 'never_run', + 'startedAt' => null, + 'completedAt' => null, + 'result' => null, + 'error' => null, + ]; + } + + private static function date(?string $value): ?\DateTimeImmutable + { + if ($value === null) { + return null; + } + if ($value === '' || strlen($value) > 255) { + throw new \InvalidArgumentException('Invalid since date filter.'); + } + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + throw new \InvalidArgumentException('Invalid since date filter.'); + } + } +} diff --git a/core/lib/Service/SystemFirewallStatusService.php b/core/lib/Service/SystemFirewallStatusService.php new file mode 100644 index 0000000..0eb101d --- /dev/null +++ b/core/lib/Service/SystemFirewallStatusService.php @@ -0,0 +1,37 @@ +requirePermission(SystemFirewallLogService::PERMISSION_READ); + return $this->status->systemMetrics($tenantId, $since); + } + + public function maintenanceStatus(): array + { + $this->requirePermission(self::PERMISSION_MAINTENANCE_READ); + return $this->status->maintenanceStatus(); + } + + private function requirePermission(string $permission): void + { + if (!$this->identity->hasPermission($permission)) { + throw new \RuntimeException("Missing required permission: {$permission}"); + } + } +} diff --git a/core/lib/Service/TenantFirewallStatusService.php b/core/lib/Service/TenantFirewallStatusService.php new file mode 100644 index 0000000..bb80993 --- /dev/null +++ b/core/lib/Service/TenantFirewallStatusService.php @@ -0,0 +1,45 @@ +requirePermission(TenantFirewallLogService::PERMISSION_READ); + return $this->status->tenantMetrics($this->tenant->requireIdentifier(), $since); + } + + public function configuration(): array + { + $this->requirePermission(self::PERMISSION_SETTINGS_READ); + return $this->tenant->configuration()?->firewall()->jsonSerialize() + ?? [ + 'enabled' => true, + 'maxAuthFailures' => 5, + 'authFailureWindow' => 300, + 'autoBlockDuration' => 3600, + ]; + } + + 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 b577b46..17bb99b 100644 --- a/core/lib/Stores/FirewallStore.php +++ b/core/lib/Stores/FirewallStore.php @@ -576,6 +576,21 @@ class FirewallStore return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter); } + public function countSystemBlockedRequests( + ?string $tenantId = null, + ?\DateTimeImmutable $since = null + ): int { + $filter = ['result' => FirewallLogObject::RESULT_BLOCKED]; + if ($tenantId !== null) { + $filter['tenantId'] = $tenantId; + } + if ($since !== null) { + $filter['timestamp'] = ['$gte' => self::bsonDate($since)]; + } + + return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter); + } + /** * Clean up old logs */ diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index d653886..9cfe0db 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -532,6 +532,9 @@ class FirewallStoreTest extends TestCase self::assertSame('tenant-a', $tenant['items'][0]->getTenantId()); self::assertSame(3, $system['total']); self::assertCount(2, $system['items']); + self::assertSame(2, $this->store->countSystemBlockedRequests('tenant-a')); + self::assertSame(3, $this->store->countSystemBlockedRequests()); + self::assertSame(2, $this->store->countSystemBlockedRequests(null, new \DateTimeImmutable('-1 day'))); } private function rule( diff --git a/tests/php/Unit/Controllers/FirewallControllerTest.php b/tests/php/Unit/Controllers/FirewallControllerTest.php index b0d11d8..b7fbf50 100644 --- a/tests/php/Unit/Controllers/FirewallControllerTest.php +++ b/tests/php/Unit/Controllers/FirewallControllerTest.php @@ -9,11 +9,14 @@ use KTXC\Context\TenantContextInterface; use KTXC\Controllers\FirewallController; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; +use KTXC\Service\FirewallStatusService; use KTXC\Service\FirewallLogService; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; +use KTXC\Service\TenantFirewallStatusService; use KTXC\Stores\FirewallStore; use KTXF\Event\EventDispatcherInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -43,7 +46,9 @@ final class FirewallControllerTest extends TestCase new TenantFirewallRuleService($manager, $tenant, $identity), new SystemFirewallRuleService($manager, $identity), new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity), - new SystemFirewallLogService(new FirewallLogService($this->store), $identity) + new SystemFirewallLogService(new FirewallLogService($this->store), $identity), + new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity), + new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity) ); } @@ -72,6 +77,18 @@ final class FirewallControllerTest extends TestCase self::assertSame(400, $this->controller->systemRules(limit: '101')->getStatusCode()); } + #[TestDox('Metric endpoints return scoped counts and stable validation errors')] + public function testMetrics(): void + { + $this->store->method('countBlockedRequests')->willReturn(4); + + $response = $this->controller->tenantMetrics(); + $data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + self::assertSame(4, $data['blockedRequests']); + self::assertSame(400, $this->controller->systemMetrics(since: 'not-a-date')->getStatusCode()); + } + #[TestDox('Every rule endpoint declares its scope-specific read permission')] public function testRoutePermissions(): void { @@ -83,6 +100,10 @@ final class FirewallControllerTest extends TestCase 'systemRule' => SystemFirewallRuleService::PERMISSION_READ, 'tenantLogs' => TenantFirewallLogService::PERMISSION_READ, 'systemLogs' => SystemFirewallLogService::PERMISSION_READ, + 'tenantMetrics' => TenantFirewallLogService::PERMISSION_READ, + 'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ, + 'systemMetrics' => SystemFirewallLogService::PERMISSION_READ, + 'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, ]; foreach ($expected as $method => $permission) { diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 1aebd6f..18b80dc 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -12,6 +12,8 @@ use KTXC\Service\FirewallService; use KTXC\Service\SystemFirewallRuleService; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\TenantFirewallLogService; +use KTXC\Service\TenantFirewallStatusService; +use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallRuleService; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistry; @@ -78,5 +80,7 @@ final class CoreModuleTest extends TestCase self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions); self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions); self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions); + self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_READ, $permissions); + self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions); } } diff --git a/tests/php/Unit/Service/FirewallStatusServicesTest.php b/tests/php/Unit/Service/FirewallStatusServicesTest.php new file mode 100644 index 0000000..62b6832 --- /dev/null +++ b/tests/php/Unit/Service/FirewallStatusServicesTest.php @@ -0,0 +1,102 @@ +createMock(FirewallStore::class); + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $tenant->method('configuration')->willReturn( + (new TenantConfiguration())->jsonDeserialize([ + 'firewall' => ['enabled' => false, 'maxAuthFailures' => 8], + ]) + ); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('hasPermission')->willReturn(true); + $store->expects(self::once()) + ->method('countBlockedRequests') + ->with('tenant-a', self::isInstanceOf(\DateTimeImmutable::class)) + ->willReturn(7); + $service = new TenantFirewallStatusService(new FirewallStatusService($store), $tenant, $identity); + + self::assertSame(7, $service->metrics('2026-08-01T00:00:00+00:00')['blockedRequests']); + self::assertFalse($service->configuration()['enabled']); + self::assertSame(8, $service->configuration()['maxAuthFailures']); + } + + #[TestDox('System metrics and maintenance status expose operational state')] + public function testSystemStatus(): void + { + $store = $this->createMock(FirewallStore::class); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('hasPermission')->willReturn(true); + $store->expects(self::once()) + ->method('countSystemBlockedRequests') + ->with('tenant-a', null) + ->willReturn(12); + $store->method('maintenanceStatus')->willReturn(['status' => 'success']); + $service = new SystemFirewallStatusService(new FirewallStatusService($store), $identity); + + self::assertSame(12, $service->metrics('tenant-a')['blockedRequests']); + self::assertSame('success', $service->maintenanceStatus()['status']); + } + + #[TestDox('Maintenance reports an explicit state before its first run')] + public function testNeverRunStatus(): void + { + $store = $this->createStub(FirewallStore::class); + + self::assertSame('never_run', (new FirewallStatusService($store))->maintenanceStatus()['status']); + } + + #[TestDox('Status reads require their dedicated permissions')] + public function testPermissions(): void + { + $store = $this->createMock(FirewallStore::class); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('hasPermission')->willReturn(false); + $tenant = $this->createStub(TenantContextInterface::class); + $store->expects(self::never())->method('countBlockedRequests'); + $store->expects(self::never())->method('maintenanceStatus'); + + try { + (new TenantFirewallStatusService(new FirewallStatusService($store), $tenant, $identity))->metrics(); + self::fail('Tenant metrics should be rejected.'); + } catch (\RuntimeException $error) { + self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage()); + } + + $this->expectExceptionMessage(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ); + (new SystemFirewallStatusService(new FirewallStatusService($store), $identity))->maintenanceStatus(); + } + + #[TestDox('Metrics reject invalid dates before querying storage')] + public function testMetricValidation(): void + { + $store = $this->createMock(FirewallStore::class); + $store->expects(self::never())->method('countSystemBlockedRequests'); + $this->expectException(\InvalidArgumentException::class); + + (new FirewallStatusService($store))->systemMetrics(null, 'not-a-date'); + } +}