266b364c93
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
321 lines
12 KiB
PHP
321 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXT\Unit\Service;
|
|
|
|
use KTXC\Context\TenantContextInterface;
|
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
use KTXC\Models\Tenant\TenantConfiguration;
|
|
use KTXC\Service\FirewallService;
|
|
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 FirewallServiceTest extends TestCase
|
|
{
|
|
private FirewallStore&MockObject $store;
|
|
private TenantContextInterface&MockObject $tenantContext;
|
|
private EventDispatcherInterface&MockObject $events;
|
|
private FirewallService $service;
|
|
private ?string $currentTenant;
|
|
private ?TenantConfiguration $currentConfiguration;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->store = $this->createMock(FirewallStore::class);
|
|
$this->tenantContext = $this->createMock(TenantContextInterface::class);
|
|
$this->events = $this->createMock(EventDispatcherInterface::class);
|
|
$this->currentTenant = 'tenant-a';
|
|
$this->currentConfiguration = null;
|
|
$this->tenantContext->method('identifier')->willReturnCallback(
|
|
fn(): ?string => $this->currentTenant
|
|
);
|
|
$this->tenantContext->method('configuration')->willReturnCallback(
|
|
fn(): ?TenantConfiguration => $this->currentConfiguration
|
|
);
|
|
$this->service = new FirewallService($this->store, $this->tenantContext, $this->events);
|
|
}
|
|
|
|
#[TestDox('System blocks cannot be overridden by tenant allows')]
|
|
public function testSystemBlockPrecedence(): void
|
|
{
|
|
$systemBlock = $this->rule(
|
|
'system-block',
|
|
FirewallRuleObject::SCOPE_SYSTEM,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
null
|
|
);
|
|
$tenantAllow = $this->rule(
|
|
'tenant-allow',
|
|
FirewallRuleObject::SCOPE_TENANT,
|
|
FirewallRuleObject::ACTION_ALLOW,
|
|
'tenant-a'
|
|
);
|
|
|
|
$this->store->expects($this->once())
|
|
->method('listApplicableRules')
|
|
->with('tenant-a')
|
|
->willReturn([$tenantAllow, $systemBlock]);
|
|
$this->events->expects($this->once())->method('dispatch');
|
|
|
|
$result = $this->service->analyze('203.0.113.10');
|
|
|
|
self::assertTrue($result->isBlocked());
|
|
self::assertSame('system-block', $result->ruleId);
|
|
}
|
|
|
|
#[TestDox('Tenant blocks override system allows')]
|
|
public function testTenantBlockPrecedence(): void
|
|
{
|
|
$systemAllow = $this->rule(
|
|
'system-allow',
|
|
FirewallRuleObject::SCOPE_SYSTEM,
|
|
FirewallRuleObject::ACTION_ALLOW,
|
|
null
|
|
);
|
|
$tenantBlock = $this->rule(
|
|
'tenant-block',
|
|
FirewallRuleObject::SCOPE_TENANT,
|
|
FirewallRuleObject::ACTION_BLOCK,
|
|
'tenant-a'
|
|
);
|
|
|
|
$this->store->method('listApplicableRules')->willReturn([$systemAllow, $tenantBlock]);
|
|
$this->events->expects($this->once())->method('dispatch');
|
|
|
|
$result = $this->service->analyze('203.0.113.10');
|
|
|
|
self::assertTrue($result->isBlocked());
|
|
self::assertSame('tenant-block', $result->ruleId);
|
|
}
|
|
|
|
#[TestDox('Rule caches are isolated by tenant')]
|
|
public function testTenantCacheIsolation(): void
|
|
{
|
|
$this->store->expects($this->exactly(2))
|
|
->method('listApplicableRules')
|
|
->willReturnCallback(static fn(string $tenantId): array => [
|
|
(new FirewallRuleObject())
|
|
->setId($tenantId)
|
|
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
|
->setTenantId($tenantId)
|
|
->setType(FirewallRuleObject::TYPE_IP)
|
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
|
->setValue('203.0.113.10'),
|
|
]);
|
|
|
|
self::assertSame('tenant-a', $this->service->analyze('203.0.113.10')->ruleId);
|
|
$this->currentTenant = 'tenant-b';
|
|
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
|
|
{
|
|
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
|
|
'firewall' => [
|
|
'enabled' => true,
|
|
'maxAuthFailures' => 8,
|
|
'authFailureWindow' => 600,
|
|
'autoBlockDuration' => 7200,
|
|
],
|
|
]);
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 600)
|
|
->willReturn(4);
|
|
$this->events->expects($this->never())->method('dispatch');
|
|
|
|
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
|
$event->setTenantId('tenant-a');
|
|
$this->service->handleAuthFailure($event);
|
|
|
|
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
|
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
|
}
|
|
|
|
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
|
public function testConfigurationBounds(): void
|
|
{
|
|
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
|
|
'firewall' => [
|
|
'maxAuthFailures' => 0,
|
|
'authFailureWindow' => -1,
|
|
'autoBlockDuration' => 0,
|
|
],
|
|
]);
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(0);
|
|
|
|
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
|
$event->setTenantId('tenant-a');
|
|
$this->service->handleAuthFailure($event);
|
|
}
|
|
|
|
#[TestDox('Automatic blocks retain the tenant carried by the authentication event')]
|
|
public function testAutomaticBlockTenant(): void
|
|
{
|
|
$this->currentTenant = 'tenant-context';
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-event', '203.0.113.10', 300)
|
|
->willReturn(4);
|
|
$this->store->expects($this->once())
|
|
->method('findExactIpRule')
|
|
->with(
|
|
'tenant-event',
|
|
'203.0.113.10',
|
|
FirewallRuleObject::ACTION_BLOCK
|
|
)
|
|
->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-event'
|
|
&& $rule->getExpiresAt() !== null;
|
|
}))
|
|
->willReturnArgument(0);
|
|
|
|
$publishedTenants = [];
|
|
$this->events->expects($this->exactly(2))
|
|
->method('dispatch')
|
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$publishedTenants): void {
|
|
$publishedTenants[] = $event->getTenantId();
|
|
});
|
|
|
|
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
|
$event->setTenantId('tenant-event');
|
|
$this->service->handleAuthFailure($event);
|
|
|
|
self::assertSame(['tenant-event', 'tenant-event'], $publishedTenants);
|
|
}
|
|
|
|
#[TestDox('Authentication events without a tenant use the current tenant')]
|
|
public function testAutomaticBlockTenantFallback(): void
|
|
{
|
|
$this->store->expects($this->once())
|
|
->method('countRecentFailures')
|
|
->with('tenant-a', '203.0.113.10', 300)
|
|
->willReturn(0);
|
|
|
|
$this->service->handleAuthFailure(
|
|
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
|
|
);
|
|
}
|
|
|
|
#[TestDox('Authentication failures are ignored when no tenant can be resolved')]
|
|
public function testAutomaticBlockWithoutTenant(): void
|
|
{
|
|
$this->currentTenant = null;
|
|
$this->store->expects($this->never())->method('countRecentFailures');
|
|
$this->store->expects($this->never())->method('depositRule');
|
|
|
|
$this->service->handleAuthFailure(
|
|
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
|
|
);
|
|
}
|
|
|
|
private function rule(
|
|
string $id,
|
|
string $scope,
|
|
string $action,
|
|
?string $tenantId
|
|
): FirewallRuleObject {
|
|
return (new FirewallRuleObject())
|
|
->setId($id)
|
|
->setScope($scope)
|
|
->setTenantId($tenantId)
|
|
->setType(FirewallRuleObject::TYPE_IP)
|
|
->setAction($action)
|
|
->setValue('203.0.113.10')
|
|
->setReason($id);
|
|
}
|
|
}
|