refactor(firewall): separate enforcement from rule management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Integration\Stores;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\FirewallRuleScope;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
@@ -93,13 +94,16 @@ class FirewallStoreTest extends TestCase
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
));
|
||||
|
||||
$tenantContext = $this->createMock(TenantContextInterface::class);
|
||||
$tenantContext->method('identifier')->willReturn('tenant-a');
|
||||
$tenantContext->method('configuration')->willReturn(null);
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$service = new FirewallService($this->store, $tenantContext, $events);
|
||||
|
||||
$replacement = $service->blockIp('203.0.113.10', durationSeconds: 300);
|
||||
$cache = new FirewallRuleCache($this->store);
|
||||
$manager = new FirewallRuleManager($this->store, $cache, $events);
|
||||
$replacement = $manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
null,
|
||||
null,
|
||||
300
|
||||
);
|
||||
|
||||
self::assertFalse($replacement->isExpired());
|
||||
self::assertNotSame($expired->getId(), $replacement->getId());
|
||||
@@ -146,6 +150,28 @@ class FirewallStoreTest extends TestCase
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('System rule listings exclude tenant, disabled, and expired rules')]
|
||||
public function testSystemListing(): void
|
||||
{
|
||||
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
|
||||
$this->store->depositRule(
|
||||
$this->rule('disabled', FirewallRuleObject::SCOPE_SYSTEM)->setEnabled(false)
|
||||
);
|
||||
$this->store->depositRule(
|
||||
$this->rule('expired', FirewallRuleObject::SCOPE_SYSTEM)
|
||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
|
||||
);
|
||||
$this->store->depositRule(
|
||||
$this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
);
|
||||
|
||||
$rules = $this->store->listSystemRules();
|
||||
|
||||
self::assertCount(1, $rules);
|
||||
self::assertSame('system', $rules[0]->getReason());
|
||||
self::assertTrue($rules[0]->isSystemScoped());
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $reason,
|
||||
string $scope,
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace KTXT\Unit\Module;
|
||||
use KTXC\Console\Event\EventsDebugCommand;
|
||||
use KTXC\Module\Module;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
@@ -43,4 +45,16 @@ final class CoreModuleTest extends TestCase
|
||||
|
||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestDox('Core registers dedicated system firewall permissions')]
|
||||
public function registersSystemFirewallPermissions(): void
|
||||
{
|
||||
$permissions = (new Module(new EventListenerRegistry()))->permissions();
|
||||
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\FirewallRuleScope;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
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('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 mutations invalidate the shared enforcement cache')]
|
||||
public function testCacheInvalidation(): void
|
||||
{
|
||||
$this->store->expects($this->exactly(2))
|
||||
->method('listApplicableRules')
|
||||
->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->applicable('tenant-a'));
|
||||
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
|
||||
self::assertSame([], $cache->applicable('tenant-a'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
class FirewallRuleServicesTest extends TestCase
|
||||
{
|
||||
private FirewallStore&MockObject $store;
|
||||
private IdentityContextInterface&MockObject $identity;
|
||||
private TenantContextInterface&MockObject $tenant;
|
||||
private FirewallRuleManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->store = $this->createMock(FirewallStore::class);
|
||||
$this->identity = $this->createMock(IdentityContextInterface::class);
|
||||
$this->tenant = $this->createMock(TenantContextInterface::class);
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$this->manager = new FirewallRuleManager(
|
||||
$this->store,
|
||||
new FirewallRuleCache($this->store),
|
||||
$events
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Tenant and system boundaries require their dedicated permissions')]
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$this->identity->method('hasPermission')->willReturn(false);
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
|
||||
try {
|
||||
$this->tenantService()->blockIp('203.0.113.10');
|
||||
self::fail('Tenant operation should have been rejected.');
|
||||
} catch (\RuntimeException $error) {
|
||||
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage());
|
||||
}
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||
$this->systemService()->blockIp('203.0.113.10');
|
||||
}
|
||||
|
||||
#[TestDox('Tenant management derives scope from tenant context')]
|
||||
public function testTenantScope(): void
|
||||
{
|
||||
$this->allow(TenantFirewallRuleService::PERMISSION_MANAGE);
|
||||
$this->tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||
$this->identity->method('identifier')->willReturn('admin-a');
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
|
||||
))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$this->tenantService()->blockIp('203.0.113.10');
|
||||
}
|
||||
|
||||
#[TestDox('System management always delegates with system scope')]
|
||||
public function testSystemScope(): void
|
||||
{
|
||||
$this->allow(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||
$rule->isSystemScoped() && $rule->getTenantId() === null
|
||||
))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$this->systemService()->blockIp('203.0.113.10');
|
||||
}
|
||||
|
||||
private function allow(string $permission): void
|
||||
{
|
||||
$this->identity->method('hasPermission')->with($permission)->willReturn(true);
|
||||
}
|
||||
|
||||
private function tenantService(): TenantFirewallRuleService
|
||||
{
|
||||
return new TenantFirewallRuleService($this->manager, $this->tenant, $this->identity);
|
||||
}
|
||||
|
||||
private function systemService(): SystemFirewallRuleService
|
||||
{
|
||||
return new SystemFirewallRuleService($this->manager, $this->identity);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
@@ -38,7 +40,15 @@ class FirewallServiceTest extends TestCase
|
||||
$this->tenantContext->method('configuration')->willReturnCallback(
|
||||
fn(): ?TenantConfiguration => $this->currentConfiguration
|
||||
);
|
||||
$this->service = new FirewallService($this->store, $this->tenantContext, $this->events);
|
||||
$cache = new FirewallRuleCache($this->store);
|
||||
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
|
||||
$this->service = new FirewallService(
|
||||
$this->store,
|
||||
$this->tenantContext,
|
||||
$this->events,
|
||||
$manager,
|
||||
$cache
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('System blocks cannot be overridden by tenant allows')]
|
||||
@@ -114,85 +124,6 @@ class FirewallServiceTest extends TestCase
|
||||
self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('New IP blocks are explicitly tenant-scoped')]
|
||||
public function testIpBlockScope(): void
|
||||
{
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static function (FirewallRuleObject $rule): bool {
|
||||
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
||||
&& $rule->getTenantId() === 'tenant-a';
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$rule = $this->service->blockIp('203.0.113.10');
|
||||
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rule->getScope());
|
||||
self::assertSame('tenant-a', $rule->getTenantId());
|
||||
}
|
||||
|
||||
#[TestDox('Malformed IP addresses are rejected before persistence')]
|
||||
public function testIpValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid IP address');
|
||||
|
||||
$this->service->blockIp('999.2.3.4');
|
||||
}
|
||||
|
||||
#[TestDox('Valid IPv6 addresses can be blocked')]
|
||||
public function testIpv6Validation(): void
|
||||
{
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool => $rule->getValue() === '2001:db8::1'))
|
||||
->willReturnArgument(0);
|
||||
|
||||
self::assertSame('2001:db8::1', $this->service->blockIp(' 2001:db8::1 ')->getValue());
|
||||
}
|
||||
|
||||
#[TestDox('Malformed CIDR ranges are rejected before persistence')]
|
||||
public function testCidrValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid CIDR range');
|
||||
|
||||
$this->service->blockIpRange('2001:db8::/129');
|
||||
}
|
||||
|
||||
#[TestDox('Valid IPv4 and IPv6 CIDR ranges are accepted')]
|
||||
public function testValidCidrs(): void
|
||||
{
|
||||
$this->store->expects($this->exactly(2))->method('depositRule')->willReturnArgument(0);
|
||||
|
||||
self::assertSame('192.0.2.0/24', $this->service->blockIpRange('192.0.2.0/24')->getValue());
|
||||
self::assertSame('2001:db8::/32', $this->service->blockIpRange('2001:db8::/32')->getValue());
|
||||
}
|
||||
|
||||
#[TestDox('Temporary rules require a positive duration')]
|
||||
public function testDurationValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('greater than zero');
|
||||
|
||||
$this->service->blockIp('203.0.113.10', durationSeconds: 0);
|
||||
}
|
||||
|
||||
#[TestDox('Device fingerprints must be non-empty and bounded')]
|
||||
public function testFingerprintValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Device fingerprint');
|
||||
|
||||
$this->service->blockDevice(' ');
|
||||
}
|
||||
|
||||
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
|
||||
public function testFirewallConfiguration(): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user