From 12037da367a61a336554025eb66a6daca340b645 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 30 Jul 2026 22:46:27 -0400 Subject: [PATCH] feat(firewall): complete rule-match audit context Signed-off-by: Sebastian Krupinski --- .../lib/Models/Firewall/FirewallLogObject.php | 23 ++++++++ core/lib/Service/FirewallService.php | 6 +- shared/lib/Event/SecurityEvent.php | 2 + .../Integration/Stores/FirewallStoreTest.php | 21 +++++++ .../Models/Firewall/FirewallLogObjectTest.php | 34 +++++++++++ .../php/Unit/Service/FirewallServiceTest.php | 59 +++++++++++++++++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/php/Unit/Models/Firewall/FirewallLogObjectTest.php diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php index 43dbdea..75a4cc1 100644 --- a/core/lib/Models/Firewall/FirewallLogObject.php +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -31,6 +31,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable private ?string $eventType = null; private ?string $result = null; // allowed, blocked private ?string $ruleId = null; // Which rule triggered (if any) + private ?string $ruleScope = null; // tenant or system private ?string $identityId = null; // User ID if authenticated private ?\DateTimeImmutable $timestamp = null; private ?array $metadata = null; // Additional context @@ -74,6 +75,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable if (array_key_exists('ruleId', $data)) { $this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null; } + if (array_key_exists('ruleScope', $data)) { + $this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null; + } if (array_key_exists('identityId', $data)) { $this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null; } @@ -102,6 +106,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable 'eventType' => $this->eventType, 'result' => $this->result, 'ruleId' => $this->ruleId, + 'ruleScope' => $this->ruleScope, 'identityId' => $this->identityId, 'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM), 'metadata' => $this->metadata, @@ -220,6 +225,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable return $this; } + public function getRuleScope(): ?string + { + return $this->ruleScope; + } + + public function setRuleScope(?string $ruleScope): self + { + if ( + $ruleScope !== null + && !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true) + ) { + throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}"); + } + + $this->ruleScope = $ruleScope; + return $this; + } + public function getIdentityId(): ?string { return $this->identityId; diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 7152a3d..6ade478 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -201,7 +201,8 @@ class FirewallService public function logSecurityEvent(SecurityEvent $event): void { $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); - if (!$tenantId) { + $ruleScope = $event->get('ruleScope'); + if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) { return; } @@ -214,6 +215,8 @@ class FirewallService ->setRequestMethod($event->getRequestMethod()) ->setEventType($this->mapEventToLogType($event->getName())) ->setResult($this->mapEventToResult($event->getName())) + ->setRuleId($event->get('ruleId')) + ->setRuleScope($ruleScope) ->setIdentityId($event->getUserId()) ->setTimestamp(new \DateTimeImmutable()) ->setMetadata($event->getData()); @@ -261,6 +264,7 @@ class FirewallService $ipAddress, $deviceFingerprint, $rule->getId(), + $rule->getScope(), $rule->getReason() ); $event->setTenantId($this->tenantContext->identifier()); diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php index 82e1eba..c499b13 100644 --- a/shared/lib/Event/SecurityEvent.php +++ b/shared/lib/Event/SecurityEvent.php @@ -145,10 +145,12 @@ class SecurityEvent extends Event string $ipAddress, ?string $deviceFingerprint = null, ?string $ruleId = null, + ?string $ruleScope = null, ?string $reason = null ): self { $event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [ 'ruleId' => $ruleId, + 'ruleScope' => $ruleScope, 'reason' => $reason, ]); $event->reason = $reason; diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index db53971..59140f0 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -6,6 +6,7 @@ namespace KTXT\Integration\Stores; use KTXC\Db\DataStore; use KTXC\Models\Firewall\FirewallRuleObject; +use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; use KTXC\Service\FirewallRuleScope; @@ -175,6 +176,26 @@ class FirewallStoreTest extends TestCase self::assertTrue($rules[0]->isSystemScoped()); } + #[TestDox('Firewall log persistence retains matched rule context')] + public function testRuleAuditPersistence(): void + { + $log = (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()); + $this->store->createLog($log); + + $logs = $this->store->listLogs('tenant-a'); + + self::assertCount(1, $logs); + self::assertSame('rule-123', $logs[0]->getRuleId()); + self::assertSame(FirewallRuleObject::SCOPE_TENANT, $logs[0]->getRuleScope()); + } + private function rule( string $reason, string $scope, diff --git a/tests/php/Unit/Models/Firewall/FirewallLogObjectTest.php b/tests/php/Unit/Models/Firewall/FirewallLogObjectTest.php new file mode 100644 index 0000000..2f1191f --- /dev/null +++ b/tests/php/Unit/Models/Firewall/FirewallLogObjectTest.php @@ -0,0 +1,34 @@ +setRuleId('rule-123') + ->setRuleScope(FirewallRuleObject::SCOPE_SYSTEM); + + $restored = (new FirewallLogObject())->jsonDeserialize($log->jsonSerialize()); + + self::assertSame('rule-123', $restored->getRuleId()); + self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $restored->getRuleScope()); + } + + #[TestDox('Unknown rule scopes are rejected from firewall logs')] + public function testRuleScopeValidation(): void + { + $this->expectException(\InvalidArgumentException::class); + + (new FirewallLogObject())->setRuleScope('unknown'); + } +} diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index f168312..486229e 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -6,6 +6,7 @@ namespace KTXT\Unit\Service; use KTXC\Context\TenantContextInterface; use KTXC\Models\Firewall\FirewallRuleObject; +use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Service\FirewallService; use KTXC\Service\FirewallRuleCache; @@ -197,6 +198,64 @@ class FirewallServiceTest extends TestCase self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed()); } + #[TestDox('Tenant rule-match logs persist dedicated rule ID and scope fields')] + public function testTenantRuleAuditContext(): void + { + $this->store->expects($this->once()) + ->method('createLog') + ->with(self::callback(static function (FirewallLogObject $log): bool { + return $log->getTenantId() === 'tenant-a' + && $log->getRuleId() === 'tenant-rule' + && $log->getRuleScope() === FirewallRuleObject::SCOPE_TENANT + && $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH; + })) + ->willReturnArgument(0); + $event = \KTXF\Event\SecurityEvent::accessDenied( + '203.0.113.10', + null, + 'tenant-rule', + FirewallRuleObject::SCOPE_TENANT, + 'Tenant block' + ); + $event->setTenantId('tenant-a'); + + $this->service->logSecurityEvent($event); + } + + #[TestDox('System rule matches are logged even when no tenant is resolved')] + public function testSystemRuleAuditContext(): void + { + $this->currentTenant = null; + $this->store->expects($this->once()) + ->method('createLog') + ->with(self::callback(static function (FirewallLogObject $log): bool { + return $log->getTenantId() === null + && $log->getRuleId() === 'system-rule' + && $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM; + })) + ->willReturnArgument(0); + $event = \KTXF\Event\SecurityEvent::accessDenied( + '203.0.113.10', + null, + 'system-rule', + FirewallRuleObject::SCOPE_SYSTEM, + 'System block' + ); + + $this->service->logSecurityEvent($event); + } + + #[TestDox('Tenantless security events without system rule context are ignored')] + public function testTenantlessAuditBoundary(): void + { + $this->currentTenant = null; + $this->store->expects($this->never())->method('createLog'); + + $this->service->logSecurityEvent( + \KTXF\Event\SecurityEvent::authFailure('203.0.113.10') + ); + } + #[TestDox('Typed tenant firewall settings drive brute-force thresholds')] public function testFirewallConfiguration(): void {