feat(firewall): complete rule-match audit context
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Models\Firewall;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FirewallLogObjectTest extends TestCase
|
||||
{
|
||||
#[TestDox('Rule ID and scope survive firewall log serialization')]
|
||||
public function testRuleContextSerialization(): void
|
||||
{
|
||||
$log = (new FirewallLogObject())
|
||||
->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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user