b06c18d38e
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
391 lines
15 KiB
PHP
391 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXT\Unit\Service;
|
|
|
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
use KTXC\Service\FirewallRuleCache;
|
|
use KTXC\Service\FirewallRuleConflictException;
|
|
use KTXC\Service\FirewallRuleManager;
|
|
use KTXC\Service\FirewallRuleScope;
|
|
use KTXC\Stores\FirewallStore;
|
|
use KTXF\Event\EventDispatcherInterface;
|
|
use KTXF\Event\SecurityEvent;
|
|
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
|
use PHPUnit\Framework\Attributes\TestDox;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
#[AllowMockObjectsWithoutExpectations]
|
|
class FirewallRuleManagerTest extends TestCase
|
|
{
|
|
private FirewallStore&MockObject $store;
|
|
private EventDispatcherInterface&MockObject $events;
|
|
private FirewallRuleManager $manager;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->store = $this->createMock(FirewallStore::class);
|
|
$this->events = $this->createMock(EventDispatcherInterface::class);
|
|
$this->manager = new FirewallRuleManager(
|
|
$this->store,
|
|
new FirewallRuleCache($this->store),
|
|
$this->events
|
|
);
|
|
}
|
|
|
|
#[TestDox('The shared manager creates rules with the supplied scope and owner')]
|
|
public function testScopeCreation(): void
|
|
{
|
|
$this->store->method('findExactIpRule')->willReturn(null);
|
|
$this->store->expects($this->exactly(2))
|
|
->method('depositRule')
|
|
->willReturnArgument(0);
|
|
|
|
$tenant = $this->manager->blockIp(
|
|
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
|
|
);
|
|
$system = $this->manager->blockIp(
|
|
FirewallRuleScope::system(), '203.0.113.11', null, 'system-admin'
|
|
);
|
|
|
|
self::assertSame('tenant-a', $tenant->getTenantId());
|
|
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $tenant->getScope());
|
|
self::assertNull($system->getTenantId());
|
|
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $system->getScope());
|
|
}
|
|
|
|
#[TestDox('Malformed rule values are rejected before persistence')]
|
|
public function testValidation(): void
|
|
{
|
|
$this->store->expects($this->never())->method('depositRule');
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
$this->manager->blockIpRange(
|
|
FirewallRuleScope::tenant('tenant-a'), '2001:db8::/129', null, 'admin-a'
|
|
);
|
|
}
|
|
|
|
#[TestDox('Temporary rules require a positive duration')]
|
|
public function testDuration(): void
|
|
{
|
|
$this->store->expects($this->never())->method('depositRule');
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
$this->manager->blockIp(
|
|
FirewallRuleScope::system(), '203.0.113.10', null, 'admin', 0
|
|
);
|
|
}
|
|
|
|
#[TestDox('Manual rule creation requires a reason before persistence')]
|
|
public function testManualReason(): void
|
|
{
|
|
$this->store->expects(self::never())->method('depositRule');
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
$this->manager->createManualRule(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
'203.0.113.10',
|
|
' ',
|
|
'admin-a'
|
|
);
|
|
}
|
|
|
|
#[TestDox('Blocking the current exact IP requires explicit confirmation')]
|
|
public function testCurrentIpSafeguard(): void
|
|
{
|
|
$this->store->expects(self::never())->method('depositRule');
|
|
try {
|
|
$this->manager->createManualRule(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
'2001:db8::1',
|
|
'Confirmed abuse',
|
|
'admin-a',
|
|
currentIp: '2001:0db8:0:0:0:0:0:1'
|
|
);
|
|
self::fail('Current IP block should require confirmation.');
|
|
} catch (FirewallRuleConflictException $error) {
|
|
self::assertSame('current_ip_confirmation_required', $error->conflictCode);
|
|
}
|
|
}
|
|
|
|
#[TestDox('CIDR rules covering the current IP require explicit confirmation')]
|
|
public function testCurrentIpCidrSafeguard(): void
|
|
{
|
|
$this->store->expects(self::never())->method('depositRule');
|
|
$this->expectException(FirewallRuleConflictException::class);
|
|
|
|
$this->manager->createManualRule(
|
|
FirewallRuleScope::system(),
|
|
FirewallRuleObject::TYPE_IP_RANGE,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
'203.0.113.0/24',
|
|
'Network abuse',
|
|
'admin-a',
|
|
currentIp: '203.0.113.10'
|
|
);
|
|
}
|
|
|
|
#[TestDox('Confirmed current-IP blocks retain manual audit context')]
|
|
public function testConfirmedCurrentIpBlock(): void
|
|
{
|
|
$this->store->method('findExactIpRule')->willReturn(null);
|
|
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
|
|
|
|
$rule = $this->manager->createManualRule(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
'203.0.113.10',
|
|
'Emergency lockout',
|
|
'admin-a',
|
|
currentIp: '203.0.113.10',
|
|
confirmCurrentIp: true
|
|
);
|
|
|
|
self::assertSame('Emergency lockout', $rule->getReason());
|
|
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $rule->getMetadata()['origin']);
|
|
}
|
|
|
|
#[TestDox('Manual creation rejects unsupported type and action combinations')]
|
|
public function testUnsupportedManualRule(): void
|
|
{
|
|
$this->store->expects(self::never())->method('depositRule');
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
$this->manager->createManualRule(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
FirewallRuleObject::TYPE_DEVICE,
|
|
FirewallRuleObject::ACTION_ALLOW,
|
|
'device-123',
|
|
'Trusted device',
|
|
'admin-a'
|
|
);
|
|
}
|
|
|
|
#[TestDox('Rule lifecycle operations cannot cross scope ownership')]
|
|
public function testOwnership(): void
|
|
{
|
|
$tenantRule = (new FirewallRuleObject())
|
|
->setId('tenant-rule')
|
|
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
|
->setTenantId('tenant-a');
|
|
$this->store->method('fetchRule')->willReturn($tenantRule);
|
|
$this->store->expects($this->never())->method('destroyRule');
|
|
|
|
self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule'));
|
|
self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule'));
|
|
}
|
|
|
|
#[TestDox('Rule queries retain scope, filters, and bounded pagination')]
|
|
public function testRuleQuery(): void
|
|
{
|
|
$result = ['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 50];
|
|
$this->store->expects(self::once())
|
|
->method('queryRules')
|
|
->with(
|
|
FirewallRuleObject::SCOPE_TENANT,
|
|
'tenant-a',
|
|
'disabled',
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
25,
|
|
50
|
|
)
|
|
->willReturn($result);
|
|
|
|
self::assertSame($result, $this->manager->query(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
'disabled',
|
|
FirewallRuleObject::TYPE_IP,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
25,
|
|
50
|
|
));
|
|
}
|
|
|
|
#[TestDox('Rule queries reject invalid filters and excessive pages')]
|
|
public function testRuleQueryValidation(): void
|
|
{
|
|
$this->store->expects(self::never())->method('queryRules');
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
|
|
$this->manager->query(FirewallRuleScope::system(), 'active', null, null, 101);
|
|
}
|
|
|
|
#[TestDox('Effective policy keeps system and tenant rule sets distinct')]
|
|
public function testEffectivePolicy(): void
|
|
{
|
|
$system = (new FirewallRuleObject())->setScope(FirewallRuleObject::SCOPE_SYSTEM);
|
|
$tenant = (new FirewallRuleObject())
|
|
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
|
->setTenantId('tenant-a');
|
|
$this->store->method('listSystemRules')->willReturn([$system]);
|
|
$this->store->method('listRules')->with('tenant-a')->willReturn([$tenant]);
|
|
|
|
$policy = $this->manager->effectivePolicy('tenant-a');
|
|
|
|
self::assertSame([$system], $policy['system']);
|
|
self::assertSame([$tenant], $policy['tenant']);
|
|
self::assertSame('system_block', $policy['precedence'][0]);
|
|
}
|
|
|
|
#[TestDox('Rule mutations invalidate the shared enforcement cache')]
|
|
public function testCacheInvalidation(): void
|
|
{
|
|
$this->store->expects($this->exactly(2))
|
|
->method('listRules')
|
|
->with('tenant-a')
|
|
->willReturnOnConsecutiveCalls([], []);
|
|
$cache = new FirewallRuleCache($this->store);
|
|
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
|
|
$this->store->method('findExactIpRule')->willReturn(null);
|
|
$this->store->method('depositRule')->willReturnArgument(0);
|
|
|
|
self::assertSame([], $cache->tenant('tenant-a'));
|
|
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
|
|
self::assertSame([], $cache->tenant('tenant-a'));
|
|
}
|
|
|
|
#[TestDox('Rule creation emits complete lifecycle audit context')]
|
|
public function testCreationAudit(): void
|
|
{
|
|
$this->store->method('findExactIpRule')->willReturn(null);
|
|
$this->store->method('depositRule')->willReturnCallback(
|
|
static function (FirewallRuleObject $rule): FirewallRuleObject {
|
|
return $rule->setId('rule-123');
|
|
}
|
|
);
|
|
$events = [];
|
|
$this->events->expects($this->exactly(2))
|
|
->method('dispatch')
|
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
|
$events[$event->getName()] = $event;
|
|
});
|
|
|
|
$this->manager->blockIp(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
'203.0.113.10',
|
|
'Repeated abuse',
|
|
'admin-a',
|
|
300
|
|
);
|
|
|
|
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED];
|
|
self::assertSame('rule-123', $audit->get('ruleId'));
|
|
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
|
|
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
|
|
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
|
|
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
|
|
self::assertSame('admin-a', $audit->getIdentityId());
|
|
self::assertNotNull($audit->get('expiresAt'));
|
|
}
|
|
|
|
#[TestDox('Disable and remove audits identify the acting administrator')]
|
|
public function testLifecycleActors(): void
|
|
{
|
|
$rule = (new FirewallRuleObject())
|
|
->setId('rule-123')
|
|
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
|
->setTenantId(null)
|
|
->setType(FirewallRuleObject::TYPE_IP)
|
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
|
->setValue('203.0.113.10')
|
|
->setCreatedBy('creator');
|
|
$this->store->method('fetchRule')->willReturn($rule);
|
|
$events = [];
|
|
$this->events->expects($this->exactly(2))
|
|
->method('dispatch')
|
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
|
$events[$event->getName()] = $event;
|
|
});
|
|
|
|
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
|
|
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
|
|
|
|
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
|
|
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
|
|
}
|
|
|
|
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
|
|
public function testAutomaticBlockExtension(): void
|
|
{
|
|
$originalExpiry = new \DateTimeImmutable('+5 minutes');
|
|
$rule = (new FirewallRuleObject())
|
|
->setId('rule-123')
|
|
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
|
->setTenantId('tenant-a')
|
|
->setType(FirewallRuleObject::TYPE_IP)
|
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
|
->setValue('203.0.113.10')
|
|
->setExpiresAt($originalExpiry)
|
|
->setMetadata([
|
|
'origin' => FirewallRuleManager::ORIGIN_AUTOMATIC,
|
|
'originalExpiresAt' => $originalExpiry->format(\DateTimeInterface::ATOM),
|
|
'extensions' => [],
|
|
]);
|
|
$this->store->method('findExactIpRule')->willReturn($rule);
|
|
$this->store->expects(self::once())
|
|
->method('depositRule')
|
|
->with(self::callback(static function (FirewallRuleObject $extended) use ($originalExpiry): bool {
|
|
$metadata = $extended->getMetadata();
|
|
return $extended->getExpiresAt() > $originalExpiry
|
|
&& $metadata['failureThreshold'] === 5
|
|
&& $metadata['failureWindowSeconds'] === 300
|
|
&& $metadata['lastFailureCount'] === 8
|
|
&& $metadata['originalExpiresAt'] === $originalExpiry->format(\DateTimeInterface::ATOM)
|
|
&& count($metadata['extensions']) === 1;
|
|
}))
|
|
->willReturnArgument(0);
|
|
$this->events->expects(self::once())
|
|
->method('dispatch')
|
|
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
|
|
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
|
|
&& $event->get('lastFailureCount') === 8
|
|
));
|
|
|
|
$extended = $this->manager->blockIp(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
'203.0.113.10',
|
|
'Continued attack',
|
|
null,
|
|
3600,
|
|
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
|
[
|
|
'failureThreshold' => 5,
|
|
'failureWindowSeconds' => 300,
|
|
'lastFailureCount' => 8,
|
|
'blockDurationSeconds' => 3600,
|
|
]
|
|
);
|
|
|
|
self::assertSame('rule-123', $extended->getId());
|
|
}
|
|
|
|
#[TestDox('Automatic detection never extends a manual block')]
|
|
public function testManualBlockIsNotExtended(): void
|
|
{
|
|
$rule = (new FirewallRuleObject())
|
|
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
|
->setTenantId('tenant-a')
|
|
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
|
|
$this->store->method('findExactIpRule')->willReturn($rule);
|
|
$this->store->expects(self::never())->method('depositRule');
|
|
$this->events->expects(self::never())->method('dispatch');
|
|
|
|
self::assertSame($rule, $this->manager->blockIp(
|
|
FirewallRuleScope::tenant('tenant-a'),
|
|
'203.0.113.10',
|
|
null,
|
|
null,
|
|
3600,
|
|
FirewallRuleManager::ORIGIN_AUTOMATIC
|
|
));
|
|
}
|
|
}
|