refactor(security): type firewall rule lifecycle events

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-05 23:51:40 -04:00
parent a5be782c51
commit 2494cef02f
16 changed files with 383 additions and 73 deletions
+10 -5
View File
@@ -15,6 +15,11 @@ use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEvent;
@@ -53,11 +58,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
BruteForceDetectedEvent::class, BruteForceDetectedEvent::class,
RateLimitExceededEvent::class, RateLimitExceededEvent::class,
SuspiciousActivityEvent::class, SuspiciousActivityEvent::class,
SecurityEvent::FIREWALL_RULE_CREATED, FirewallRuleCreatedEvent::class,
SecurityEvent::FIREWALL_RULE_EXTENDED, FirewallRuleExtendedEvent::class,
SecurityEvent::FIREWALL_RULE_ENABLED, FirewallRuleEnabledEvent::class,
SecurityEvent::FIREWALL_RULE_DISABLED, FirewallRuleDisabledEvent::class,
SecurityEvent::FIREWALL_RULE_REMOVED, FirewallRuleRemovedEvent::class,
SecurityEvent::FIREWALL_SETTINGS_UPDATED, SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) { ] as $event) {
$this->events->listen( $this->events->listen(
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
{
final protected function __construct(
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly string $ruleType,
private readonly string $ruleAction,
private readonly string $ruleValue,
private readonly ?string $reason,
private readonly string $origin,
private readonly ?string $expiresAt,
private readonly array $details,
?string $tenantId,
?string $identityId,
) {
if ($ruleId === '') {
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
parent::__construct(
static::class,
[
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'ruleType' => $ruleType,
'ruleAction' => $ruleAction,
'ruleValue' => $ruleValue,
'reason' => $reason,
'origin' => $origin,
'expiresAt' => $expiresAt,
...$details,
],
$tenantId,
$identityId,
);
}
public static function fromRule(
FirewallRuleObject $rule,
?string $actorId = null,
array $change = [],
): static {
$metadata = $rule->getMetadata() ?? [];
$details = [...$metadata, ...$change];
foreach ([
'ruleId',
'ruleScope',
'ruleType',
'ruleAction',
'ruleValue',
'reason',
'origin',
'expiresAt',
] as $reservedKey) {
unset($details[$reservedKey]);
}
return new static(
ruleId: (string) $rule->getId(),
ruleScope: (string) $rule->getScope(),
ruleType: (string) $rule->getType(),
ruleAction: (string) $rule->getAction(),
ruleValue: (string) $rule->getValue(),
reason: $rule->getReason(),
origin: (string) ($metadata['origin'] ?? 'manual'),
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
details: $details,
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getRuleType(): string
{
return $this->ruleType;
}
public function getRuleAction(): string
{
return $this->ruleAction;
}
public function getRuleValue(): string
{
return $this->ruleValue;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getExpiresAt(): ?string
{
return $this->expiresAt;
}
public function getDetails(): array
{
return $this->details;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): int
{
return SecurityEvent::SEVERITY_INFO;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
{
}
@@ -21,11 +21,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface
public const IP_BLOCKED = 'security.ip.blocked'; public const IP_BLOCKED = 'security.ip.blocked';
public const IP_ALLOWED = 'security.ip.allowed'; public const IP_ALLOWED = 'security.ip.allowed';
public const DEVICE_BLOCKED = 'security.device.blocked'; public const DEVICE_BLOCKED = 'security.device.blocked';
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated'; public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
// Severity levels // Severity levels
+19 -25
View File
@@ -5,6 +5,12 @@ declare(strict_types=1);
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface; use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEvent;
@@ -282,7 +288,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule); $this->store->depositRule($rule);
$this->cache->invalidate(); $this->cache->invalidate();
$this->publishLifecycleEvent( $this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_DISABLED, FirewallRuleDisabledEvent::class,
$rule, $rule,
$actorId, $actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL] ['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
@@ -321,7 +327,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule); $this->store->depositRule($rule);
$this->cache->invalidate(); $this->cache->invalidate();
$this->publishLifecycleEvent( $this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_ENABLED, FirewallRuleEnabledEvent::class,
$rule, $rule,
$actorId, $actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL] ['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
@@ -365,7 +371,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule); $this->store->depositRule($rule);
$this->cache->invalidate(); $this->cache->invalidate();
$this->publishLifecycleEvent( $this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_EXTENDED, FirewallRuleExtendedEvent::class,
$rule, $rule,
$actorId, $actorId,
[ [
@@ -392,7 +398,7 @@ final class FirewallRuleManager
$this->store->destroyRule($rule); $this->store->destroyRule($rule);
$this->cache->invalidate(); $this->cache->invalidate();
$this->publishLifecycleEvent( $this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_REMOVED, FirewallRuleRemovedEvent::class,
$rule, $rule,
$actorId, $actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL] ['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
@@ -447,9 +453,10 @@ final class FirewallRuleManager
} }
$rule->setMetadata($metadata); $rule->setMetadata($metadata);
$this->store->depositRule($rule); $rule = $this->store->depositRule($rule)
?? throw new \RuntimeException('Failed to persist firewall rule.');
$this->cache->invalidate(); $this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule); $this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
return $rule; return $rule;
} }
@@ -486,7 +493,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule); $this->store->depositRule($rule);
$this->cache->invalidate(); $this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule); $this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
return $rule; return $rule;
} }
@@ -514,30 +521,17 @@ final class FirewallRuleManager
$this->events->dispatch($event); $this->events->dispatch($event);
} }
/**
* @param class-string<FirewallRuleEvent> $eventClass
*/
private function publishLifecycleEvent( private function publishLifecycleEvent(
string $name, string $eventClass,
FirewallRuleObject $rule, FirewallRuleObject $rule,
?string $actorId = null, ?string $actorId = null,
array $change = [] array $change = []
): void ): void
{ {
$event = new SecurityEvent( $event = $eventClass::fromRule($rule, $actorId, $change);
$name,
[
'ruleId' => $rule->getId(),
'ruleScope' => $rule->getScope(),
'ruleType' => $rule->getType(),
'ruleAction' => $rule->getAction(),
'ruleValue' => $rule->getValue(),
'reason' => $rule->getReason(),
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
...$change,
],
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
$this->events->dispatch($event); $this->events->dispatch($event);
} }
} }
+15 -10
View File
@@ -14,6 +14,11 @@ use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEvent;
@@ -289,11 +294,11 @@ class FirewallService
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT, RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS, SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED, FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED, FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED, FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED, FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED, FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED, SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK, default => FirewallLogObject::EVENT_ACCESS_CHECK,
}; };
@@ -307,11 +312,11 @@ class FirewallService
return match ($event->getName()) { return match ($event->getName()) {
AuthenticationSucceededEvent::class, AuthenticationSucceededEvent::class,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED, SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED, FirewallRuleCreatedEvent::class,
SecurityEvent::FIREWALL_RULE_EXTENDED, FirewallRuleExtendedEvent::class,
SecurityEvent::FIREWALL_RULE_ENABLED, FirewallRuleEnabledEvent::class,
SecurityEvent::FIREWALL_RULE_DISABLED, FirewallRuleDisabledEvent::class,
SecurityEvent::FIREWALL_RULE_REMOVED, FirewallRuleRemovedEvent::class,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED, SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED, default => FirewallLogObject::RESULT_BLOCKED,
}; };
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallRuleEventTest extends TestCase
{
#[Test]
#[TestDox('Firewall rule events capture immutable rule snapshots')]
public function capturesRuleSnapshot(): void
{
$expiresAt = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-a')
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setReason('Repeated abuse')
->setCreatedBy('creator-a')
->setExpiresAt($expiresAt)
->setMetadata(['origin' => 'automatic', 'failureCount' => 5]);
$event = FirewallRuleExtendedEvent::fromRule(
$rule,
'operator-a',
['changeReason' => 'Continue monitoring'],
);
$rule->setReason('Mutated after publication');
self::assertSame(FirewallRuleExtendedEvent::class, $event->getName());
self::assertSame('rule-a', $event->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $event->getRuleAction());
self::assertSame('203.0.113.10', $event->getRuleValue());
self::assertSame('Repeated abuse', $event->getReason());
self::assertSame('automatic', $event->getOrigin());
self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('operator-a', $event->getIdentityId());
self::assertSame(5, $event->get('failureCount'));
self::assertSame('Continue monitoring', $event->get('changeReason'));
self::assertSame(SecurityEvent::SEVERITY_INFO, $event->getSeverity());
}
#[Test]
#[TestDox('Each firewall rule lifecycle operation has a dedicated event key')]
public function exposesDedicatedLifecycleKeys(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-a')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10');
foreach ([
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
] as $eventClass) {
self::assertSame($eventClass, $eventClass::fromRule($rule)->getName());
}
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ final class SecurityEventTest extends TestCase
); );
self::assertSame( self::assertSame(
SecurityEvent::SEVERITY_INFO, SecurityEvent::SEVERITY_INFO,
(new SecurityEvent(SecurityEvent::FIREWALL_RULE_CREATED))->getSeverity(), (new SecurityEvent(SecurityEvent::IP_ALLOWED))->getSeverity(),
); );
} }
+10 -5
View File
@@ -20,6 +20,11 @@ use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEvent;
@@ -53,11 +58,11 @@ final class CoreModuleTest extends TestCase
BruteForceDetectedEvent::class, BruteForceDetectedEvent::class,
RateLimitExceededEvent::class, RateLimitExceededEvent::class,
SuspiciousActivityEvent::class, SuspiciousActivityEvent::class,
SecurityEvent::FIREWALL_RULE_CREATED, FirewallRuleCreatedEvent::class,
SecurityEvent::FIREWALL_RULE_EXTENDED, FirewallRuleExtendedEvent::class,
SecurityEvent::FIREWALL_RULE_DISABLED, FirewallRuleDisabledEvent::class,
SecurityEvent::FIREWALL_RULE_ENABLED, FirewallRuleEnabledEvent::class,
SecurityEvent::FIREWALL_RULE_REMOVED, FirewallRuleRemovedEvent::class,
SecurityEvent::FIREWALL_SETTINGS_UPDATED, SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) { ] as $event) {
$listeners = $registry->listeners($event, DeliveryMode::Deferred); $listeners = $registry->listeners($event, DeliveryMode::Deferred);
@@ -11,7 +11,11 @@ use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallRuleScope; use KTXC\Service\FirewallRuleScope;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface; use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
@@ -41,7 +45,10 @@ class FirewallRuleManagerTest extends TestCase
$this->store->method('findExactIpRule')->willReturn(null); $this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects($this->exactly(2)) $this->store->expects($this->exactly(2))
->method('depositRule') ->method('depositRule')
->willReturnArgument(0); ->willReturnCallback(static function (FirewallRuleObject $rule): FirewallRuleObject {
static $sequence = 0;
return $rule->setId('rule-' . ++$sequence);
});
$tenant = $this->manager->blockIp( $tenant = $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a' FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
@@ -135,7 +142,11 @@ class FirewallRuleManagerTest extends TestCase
public function testConfirmedCurrentIpBlock(): void public function testConfirmedCurrentIpBlock(): void
{ {
$this->store->method('findExactIpRule')->willReturn(null); $this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0); $this->store->expects(self::once())
->method('depositRule')
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('rule-confirmed')
);
$rule = $this->manager->createManualRule( $rule = $this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'), FirewallRuleScope::tenant('tenant-a'),
@@ -249,7 +260,9 @@ class FirewallRuleManagerTest extends TestCase
$cache = new FirewallRuleCache($this->store); $cache = new FirewallRuleCache($this->store);
$manager = new FirewallRuleManager($this->store, $cache, $this->events); $manager = new FirewallRuleManager($this->store, $cache, $this->events);
$this->store->method('findExactIpRule')->willReturn(null); $this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnArgument(0); $this->store->method('depositRule')->willReturnCallback(
static fn(FirewallRuleObject $rule): FirewallRuleObject => $rule->setId('rule-cache')
);
self::assertSame([], $cache->tenant('tenant-a')); self::assertSame([], $cache->tenant('tenant-a'));
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin'); $manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
@@ -280,7 +293,7 @@ class FirewallRuleManagerTest extends TestCase
300 300
); );
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED]; $audit = $events[FirewallRuleCreatedEvent::class];
self::assertSame('rule-123', $audit->get('ruleId')); self::assertSame('rule-123', $audit->get('ruleId'));
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope')); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType')); self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
@@ -329,10 +342,10 @@ class FirewallRuleManagerTest extends TestCase
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt()); self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
self::assertCount(1, $extended->getMetadata()['extensions']); self::assertCount(1, $extended->getMetadata()['extensions']);
self::assertSame([ self::assertSame([
SecurityEvent::FIREWALL_RULE_DISABLED, FirewallRuleDisabledEvent::class,
SecurityEvent::FIREWALL_RULE_ENABLED, FirewallRuleEnabledEvent::class,
SecurityEvent::FIREWALL_RULE_EXTENDED, FirewallRuleExtendedEvent::class,
SecurityEvent::FIREWALL_RULE_REMOVED, FirewallRuleRemovedEvent::class,
], array_map(static fn($event): string => $event->getName(), $audits)); ], array_map(static fn($event): string => $event->getName(), $audits));
self::assertSame( self::assertSame(
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'], ['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
@@ -401,7 +414,7 @@ class FirewallRuleManagerTest extends TestCase
$this->events->expects(self::once()) $this->events->expects(self::once())
->method('dispatch') ->method('dispatch')
->with(self::callback(static fn(\KTXF\Event\Event $event): bool => ->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED $event instanceof FirewallRuleExtendedEvent
&& $event->get('lastFailureCount') === 8 && $event->get('lastFailureCount') === 8
)); ));
@@ -102,7 +102,9 @@ class FirewallRuleServicesTest extends TestCase
->with(self::callback(static fn(FirewallRuleObject $rule): bool => ->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a' $rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
)) ))
->willReturnArgument(0); ->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('tenant-rule')
);
$this->tenantService()->blockIp('203.0.113.10'); $this->tenantService()->blockIp('203.0.113.10');
} }
@@ -117,7 +119,9 @@ class FirewallRuleServicesTest extends TestCase
->with(self::callback(static fn(FirewallRuleObject $rule): bool => ->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
$rule->isSystemScoped() && $rule->getTenantId() === null $rule->isSystemScoped() && $rule->getTenantId() === null
)) ))
->willReturnArgument(0); ->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('system-rule')
);
$this->systemService()->blockIp('203.0.113.10'); $this->systemService()->blockIp('203.0.113.10');
} }
+15 -10
View File
@@ -14,6 +14,8 @@ use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent; use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Service\FirewallService; use KTXC\Service\FirewallService;
@@ -367,14 +369,15 @@ class FirewallServiceTest extends TestCase
&& $log->getIdentityId() === 'operator'; && $log->getIdentityId() === 'operator';
})) }))
->willReturnArgument(0); ->willReturnArgument(0);
$event = new \KTXC\Security\Event\SecurityEvent( $event = FirewallRuleDisabledEvent::fromRule(
\KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_DISABLED, (new FirewallRuleObject())
[ ->setId('rule-123')
'ruleId' => 'rule-123', ->setScope(FirewallRuleObject::SCOPE_SYSTEM)
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM, ->setType(FirewallRuleObject::TYPE_IP)
'origin' => FirewallRuleManager::ORIGIN_MANUAL, ->setAction(FirewallRuleObject::ACTION_BLOCK)
], ->setValue('203.0.113.10')
identityId: 'operator', ->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]),
'operator',
); );
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
@@ -521,7 +524,9 @@ class FirewallServiceTest extends TestCase
&& $metadata['lastFailureCount'] === 5 && $metadata['lastFailureCount'] === 5
&& $metadata['blockDurationSeconds'] === 3600; && $metadata['blockDurationSeconds'] === 3600;
})) }))
->willReturnArgument(0); ->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('automatic-rule')
);
$publishedTenants = []; $publishedTenants = [];
$lifecycleOrigin = null; $lifecycleOrigin = null;
@@ -537,7 +542,7 @@ class FirewallServiceTest extends TestCase
if ($event instanceof BruteForceDetectedEvent) { if ($event instanceof BruteForceDetectedEvent) {
$bruteForceEvent = $event; $bruteForceEvent = $event;
} }
if ($event->getName() === \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_CREATED) { if ($event instanceof FirewallRuleCreatedEvent) {
$lifecycleOrigin = $event->get('origin'); $lifecycleOrigin = $event->get('origin');
} }
}); });