From d919b70a2ebc7b899dc6f3bd038fdae035c535ec Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 3 Aug 2026 22:17:13 -0400 Subject: [PATCH] feat(firewall): add scoped log administration reads Signed-off-by: Sebastian Krupinski --- core/lib/Controllers/FirewallController.php | 60 +++++++++ core/lib/Module/Module.php | 12 ++ core/lib/Service/FirewallLogService.php | 124 ++++++++++++++++++ core/lib/Service/SystemFirewallLogService.php | 26 ++++ core/lib/Service/TenantFirewallLogService.php | 29 ++++ core/lib/Stores/FirewallStore.php | 64 +++++++++ .../Integration/Stores/FirewallStoreTest.php | 46 +++++++ .../Controllers/FirewallControllerTest.php | 9 +- tests/php/Unit/Module/CoreModuleTest.php | 4 + .../Unit/Service/FirewallLogServicesTest.php | 95 ++++++++++++++ 10 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 core/lib/Service/FirewallLogService.php create mode 100644 core/lib/Service/SystemFirewallLogService.php create mode 100644 core/lib/Service/TenantFirewallLogService.php create mode 100644 tests/php/Unit/Service/FirewallLogServicesTest.php diff --git a/core/lib/Controllers/FirewallController.php b/core/lib/Controllers/FirewallController.php index 13832c6..54b0bca 100644 --- a/core/lib/Controllers/FirewallController.php +++ b/core/lib/Controllers/FirewallController.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; +use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -15,6 +17,8 @@ final class FirewallController extends ControllerAbstract public function __construct( private readonly TenantFirewallRuleService $tenantRules, private readonly SystemFirewallRuleService $systemRules, + private readonly TenantFirewallLogService $tenantLogs, + private readonly SystemFirewallLogService $systemLogs, ) { } @@ -98,6 +102,62 @@ final class FirewallController extends ControllerAbstract return $this->ruleResponse($this->systemRules->fetchRule($ruleId)); } + #[AuthenticatedRoute( + '/firewall/logs', + name: 'firewall.tenant.logs.list', + permissions: [TenantFirewallLogService::PERMISSION_READ], + )] + public function tenantLogs( + ?string $ipAddress = null, + ?string $eventType = null, + ?string $result = null, + ?string $ruleId = null, + ?string $ruleScope = null, + ?string $from = null, + ?string $to = null, + string $limit = '50', + string $offset = '0' + ): JsonResponse { + return $this->queryResponse( + fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query( + compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'), + $parsedLimit, + $parsedOffset + ), + $limit, + $offset + ); + } + + #[AuthenticatedRoute( + '/firewall/system/logs', + name: 'firewall.system.logs.list', + permissions: [SystemFirewallLogService::PERMISSION_READ], + )] + public function systemLogs( + ?string $tenantId = null, + ?string $ipAddress = null, + ?string $eventType = null, + ?string $result = null, + ?string $ruleId = null, + ?string $ruleScope = null, + ?string $from = null, + ?string $to = null, + string $limit = '50', + string $offset = '0' + ): JsonResponse { + return $this->queryResponse( + fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query( + $tenantId, + compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'), + $parsedLimit, + $parsedOffset + ), + $limit, + $offset + ); + } + private function queryResponse(callable $query, string $limit, string $offset): JsonResponse { try { diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 03a6a57..da44d31 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -5,7 +5,9 @@ namespace KTXC\Module; use KTXC\Console\Firewall\FirewallMaintenanceCommand; use KTXC\Console\Firewall\FirewallSetupCommand; use KTXC\Service\FirewallService; +use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistry; @@ -135,6 +137,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'Create, disable, and remove firewall rules owned by the current tenant', 'group' => 'Firewall Management' ], + TenantFirewallLogService::PERMISSION_READ => [ + 'label' => 'View Tenant Firewall Logs', + 'description' => 'View firewall security and audit logs 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', @@ -145,6 +152,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 'description' => 'Create, disable, and remove firewall rules that apply to every tenant', 'group' => 'System Administration' ], + SystemFirewallLogService::PERMISSION_READ => [ + 'label' => 'View System Firewall Logs', + 'description' => 'View firewall security and audit logs across tenants', + 'group' => 'System Administration' + ], 'system.admin' => [ 'label' => 'System Administrator', 'description' => 'Full system access (superuser)', diff --git a/core/lib/Service/FirewallLogService.php b/core/lib/Service/FirewallLogService.php new file mode 100644 index 0000000..82d9a6c --- /dev/null +++ b/core/lib/Service/FirewallLogService.php @@ -0,0 +1,124 @@ +store->queryTenantLogs( + $tenantId, + $this->validate($filters, $limit, $offset), + $limit, + $offset + ); + } + + public function system(?string $tenantId, array $filters, int $limit, int $offset): array + { + if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) { + throw new \InvalidArgumentException('Invalid tenant filter.'); + } + return $this->store->querySystemLogs( + $tenantId, + $this->validate($filters, $limit, $offset), + $limit, + $offset + ); + } + + private function validate(array $filters, int $limit, int $offset): array + { + if ($limit < 1 || $limit > self::MAX_LIMIT || $offset < 0) { + throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.'); + } + $ipAddress = self::nullableString($filters, 'ipAddress'); + if ($ipAddress !== null && filter_var($ipAddress, FILTER_VALIDATE_IP) === false) { + throw new \InvalidArgumentException('Invalid IP address filter.'); + } + $eventType = self::nullableString($filters, 'eventType'); + if ($eventType !== null && !in_array($eventType, self::EVENT_TYPES, true)) { + throw new \InvalidArgumentException('Invalid firewall event type filter.'); + } + $result = self::nullableString($filters, 'result'); + if ($result !== null && !in_array($result, [ + FirewallLogObject::RESULT_ALLOWED, + FirewallLogObject::RESULT_BLOCKED, + FirewallLogObject::RESULT_RECORDED, + ], true)) { + throw new \InvalidArgumentException('Invalid firewall result filter.'); + } + $ruleScope = self::nullableString($filters, 'ruleScope'); + if ($ruleScope !== null && !in_array($ruleScope, [ + FirewallRuleObject::SCOPE_TENANT, + FirewallRuleObject::SCOPE_SYSTEM, + ], true)) { + throw new \InvalidArgumentException('Invalid rule scope filter.'); + } + $from = self::date($filters, 'from'); + $to = self::date($filters, 'to'); + if ($from !== null && $to !== null && $from > $to) { + throw new \InvalidArgumentException('The from date must not be later than the to date.'); + } + + return [ + 'ipAddress' => $ipAddress, + 'eventType' => $eventType, + 'result' => $result, + 'ruleId' => self::nullableString($filters, 'ruleId'), + 'ruleScope' => $ruleScope, + 'from' => $from, + 'to' => $to, + ]; + } + + private static function nullableString(array $filters, string $key): ?string + { + $value = $filters[$key] ?? null; + if ($value === null) { + return null; + } + if (!is_string($value) || $value === '' || strlen($value) > 255) { + throw new \InvalidArgumentException("Invalid {$key} filter."); + } + return $value; + } + + private static function date(array $filters, string $key): ?\DateTimeImmutable + { + $value = self::nullableString($filters, $key); + if ($value === null) { + return null; + } + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + throw new \InvalidArgumentException("Invalid {$key} date filter."); + } + } +} diff --git a/core/lib/Service/SystemFirewallLogService.php b/core/lib/Service/SystemFirewallLogService.php new file mode 100644 index 0000000..9b9e6d5 --- /dev/null +++ b/core/lib/Service/SystemFirewallLogService.php @@ -0,0 +1,26 @@ +identity->hasPermission(self::PERMISSION_READ)) { + throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ); + } + return $this->logs->system($tenantId, $filters, $limit, $offset); + } +} diff --git a/core/lib/Service/TenantFirewallLogService.php b/core/lib/Service/TenantFirewallLogService.php new file mode 100644 index 0000000..6c6c3ed --- /dev/null +++ b/core/lib/Service/TenantFirewallLogService.php @@ -0,0 +1,29 @@ +identity->hasPermission(self::PERMISSION_READ)) { + throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ); + } + return $this->logs->tenant($this->tenant->requireIdentifier(), $filters, $limit, $offset); + } + +} diff --git a/core/lib/Stores/FirewallStore.php b/core/lib/Stores/FirewallStore.php index 7cfdccf..b577b46 100644 --- a/core/lib/Stores/FirewallStore.php +++ b/core/lib/Stores/FirewallStore.php @@ -66,6 +66,14 @@ class FirewallStore ['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1], ['name' => 'logs_event_type'] ), + $logs->createIndex( + ['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1], + ['name' => 'logs_rule'] + ), + $logs->createIndex( + ['timestamp' => -1], + ['name' => 'logs_global_timeline'] + ), $claims->createIndex( ['expiresAt' => 1], ['name' => 'claims_expiry', 'expireAfterSeconds' => 0] @@ -357,6 +365,62 @@ class FirewallStore // Log Operations // ======================================== + public function queryTenantLogs( + string $tenantId, + array $filters, + int $limit, + int $offset + ): array { + return $this->queryLogs(['tenantId' => $tenantId], $filters, $limit, $offset); + } + + public function querySystemLogs( + ?string $tenantId, + array $filters, + int $limit, + int $offset + ): array { + return $this->queryLogs($tenantId === null ? [] : ['tenantId' => $tenantId], $filters, $limit, $offset); + } + + /** @return array{items: FirewallLogObject[], total: int, limit: int, offset: int} */ + private function queryLogs(array $scopeFilter, array $filters, int $limit, int $offset): array + { + $filter = $scopeFilter; + foreach (['ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope'] as $field) { + if (($filters[$field] ?? null) !== null) { + $filter[$field] = $filters[$field]; + } + } + $timestamp = []; + if (($filters['from'] ?? null) instanceof \DateTimeInterface) { + $timestamp['$gte'] = self::bsonDate($filters['from']); + } + if (($filters['to'] ?? null) instanceof \DateTimeInterface) { + $timestamp['$lte'] = self::bsonDate($filters['to']); + } + if ($timestamp !== []) { + $filter['timestamp'] = $timestamp; + } + + $collection = $this->dataStore->selectCollection(self::LOGS_COLLECTION); + $items = []; + foreach ($collection->find($filter, [ + 'sort' => ['timestamp' => -1, '_id' => -1], + 'limit' => $limit, + 'skip' => $offset, + ]) as $entry) { + $items[] = (new FirewallLogObject())->jsonDeserialize((array)$entry); + } + + return [ + 'items' => $items, + 'total' => $collection->countDocuments($filter), + 'limit' => $limit, + 'offset' => $offset, + ]; + } + /** * Log a firewall event */ diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 057ff15..d653886 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -488,6 +488,52 @@ class FirewallStoreTest extends TestCase self::assertSame('tenant-disabled', $disabled['items'][0]->getReason()); } + #[TestDox('Administrative log queries enforce tenant scope and supported filters')] + public function testAdministrativeLogQuery(): void + { + $matching = (new FirewallLogObject()) + ->setTenantId('tenant-a') + ->setIpAddress('203.0.113.10') + ->setEventType(FirewallLogObject::EVENT_RULE_MATCH) + ->setResult(FirewallLogObject::RESULT_BLOCKED) + ->setRuleId('rule-123') + ->setRuleScope(FirewallRuleObject::SCOPE_TENANT) + ->setTimestamp(new \DateTimeImmutable('-1 hour')); + $otherTenant = (new FirewallLogObject()) + ->setTenantId('tenant-b') + ->setIpAddress('203.0.113.10') + ->setEventType(FirewallLogObject::EVENT_RULE_MATCH) + ->setResult(FirewallLogObject::RESULT_BLOCKED) + ->setRuleId('rule-123') + ->setRuleScope(FirewallRuleObject::SCOPE_TENANT) + ->setTimestamp(new \DateTimeImmutable('-1 hour')); + $tooOld = (new FirewallLogObject()) + ->setTenantId('tenant-a') + ->setEventType(FirewallLogObject::EVENT_RULE_MATCH) + ->setResult(FirewallLogObject::RESULT_BLOCKED) + ->setRuleId('rule-123') + ->setTimestamp(new \DateTimeImmutable('-3 days')); + foreach ([$matching, $otherTenant, $tooOld] as $log) { + $this->store->createLog($log); + } + + $tenant = $this->store->queryTenantLogs('tenant-a', [ + 'ipAddress' => '203.0.113.10', + 'eventType' => FirewallLogObject::EVENT_RULE_MATCH, + 'result' => FirewallLogObject::RESULT_BLOCKED, + 'ruleId' => 'rule-123', + 'ruleScope' => FirewallRuleObject::SCOPE_TENANT, + 'from' => new \DateTimeImmutable('-1 day'), + 'to' => new \DateTimeImmutable(), + ], 50, 0); + $system = $this->store->querySystemLogs(null, [], 2, 0); + + self::assertSame(1, $tenant['total']); + self::assertSame('tenant-a', $tenant['items'][0]->getTenantId()); + self::assertSame(3, $system['total']); + self::assertCount(2, $system['items']); + } + private function rule( string $reason, string $scope, diff --git a/tests/php/Unit/Controllers/FirewallControllerTest.php b/tests/php/Unit/Controllers/FirewallControllerTest.php index c3b2869..b0d11d8 100644 --- a/tests/php/Unit/Controllers/FirewallControllerTest.php +++ b/tests/php/Unit/Controllers/FirewallControllerTest.php @@ -9,7 +9,10 @@ use KTXC\Context\TenantContextInterface; use KTXC\Controllers\FirewallController; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; +use KTXC\Service\FirewallLogService; +use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXC\Stores\FirewallStore; use KTXF\Event\EventDispatcherInterface; @@ -38,7 +41,9 @@ final class FirewallControllerTest extends TestCase ); $this->controller = new FirewallController( new TenantFirewallRuleService($manager, $tenant, $identity), - new SystemFirewallRuleService($manager, $identity) + new SystemFirewallRuleService($manager, $identity), + new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity), + new SystemFirewallLogService(new FirewallLogService($this->store), $identity) ); } @@ -76,6 +81,8 @@ final class FirewallControllerTest extends TestCase 'effectivePolicy' => TenantFirewallRuleService::PERMISSION_READ, 'systemRules' => SystemFirewallRuleService::PERMISSION_READ, 'systemRule' => SystemFirewallRuleService::PERMISSION_READ, + 'tenantLogs' => TenantFirewallLogService::PERMISSION_READ, + 'systemLogs' => SystemFirewallLogService::PERMISSION_READ, ]; foreach ($expected as $method => $permission) { diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index c9444e9..1aebd6f 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -10,6 +10,8 @@ use KTXC\Console\Event\EventsDebugCommand; use KTXC\Module\Module; use KTXC\Service\FirewallService; use KTXC\Service\SystemFirewallRuleService; +use KTXC\Service\SystemFirewallLogService; +use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistry; @@ -74,5 +76,7 @@ final class CoreModuleTest extends TestCase self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions); self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions); self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions); + self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions); + self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions); } } diff --git a/tests/php/Unit/Service/FirewallLogServicesTest.php b/tests/php/Unit/Service/FirewallLogServicesTest.php new file mode 100644 index 0000000..836a459 --- /dev/null +++ b/tests/php/Unit/Service/FirewallLogServicesTest.php @@ -0,0 +1,95 @@ +createMock(FirewallStore::class); + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant-a'); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('hasPermission')->willReturn(true); + $store->expects(self::once()) + ->method('queryTenantLogs') + ->with( + 'tenant-a', + self::callback(static fn(array $filters): bool => + $filters['eventType'] === FirewallLogObject::EVENT_AUTH_FAILURE + && $filters['from'] instanceof \DateTimeImmutable + ), + 25, + 10 + ) + ->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]); + $service = new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity); + + self::assertSame(0, $service->query([ + 'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE, + 'from' => '2026-08-01T00:00:00+00:00', + ], 25, 10)['total']); + } + + #[TestDox('System log reads may filter one tenant without changing ownership')] + public function testSystemQuery(): void + { + $store = $this->createMock(FirewallStore::class); + $identity = $this->createStub(IdentityContextInterface::class); + $identity->method('hasPermission')->willReturn(true); + $store->expects(self::once()) + ->method('querySystemLogs') + ->with('tenant-a', self::isArray(), 50, 0) + ->willReturn(['items' => [], 'total' => 0, 'limit' => 50, 'offset' => 0]); + $service = new SystemFirewallLogService(new FirewallLogService($store), $identity); + + self::assertSame(0, $service->query('tenant-a', [], 50, 0)['total']); + } + + #[TestDox('Log queries reject invalid filters before database access')] + public function testValidation(): void + { + $store = $this->createMock(FirewallStore::class); + $store->expects(self::never())->method('queryTenantLogs'); + $query = new FirewallLogService($store); + $this->expectException(\InvalidArgumentException::class); + + $query->tenant('tenant-a', ['ipAddress' => 'not-an-ip'], 50, 0); + } + + #[TestDox('Log boundaries enforce dedicated read 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('queryTenantLogs'); + $store->expects(self::never())->method('querySystemLogs'); + + try { + (new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity))->query([]); + self::fail('Tenant log read should be rejected.'); + } catch (\RuntimeException $error) { + self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage()); + } + + $this->expectExceptionMessage(SystemFirewallLogService::PERMISSION_READ); + (new SystemFirewallLogService(new FirewallLogService($store), $identity))->query(null, []); + } +}