diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 879a3cc..f902140 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -167,7 +167,7 @@ class FirewallService $failureCount++; if ($failureCount >= $maxFailures) { - $this->handleBruteForce($ipAddress, $failureCount, $windowSeconds); + $this->handleBruteForce($tenantId, $ipAddress, $failureCount, $windowSeconds); } } @@ -175,13 +175,14 @@ class FirewallService * Handle detected brute force attack */ private function handleBruteForce( + string $tenantId, string $ipAddress, int $failureCount, int $windowSeconds ): void { // Publish brute force event $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); - $event->setTenantId($this->tenantContext->identifier()); + $event->setTenantId($tenantId); $this->events->dispatch($event); // Auto-block the IP @@ -191,7 +192,8 @@ class FirewallService self::MAX_AUTO_BLOCK_DURATION ); - $this->blockIp( + $this->blockIpForTenant( + $tenantId, $ipAddress, sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), null, // System-created @@ -284,14 +286,30 @@ class FirewallService ?string $createdBy = null, ?int $durationSeconds = null ): FirewallRuleObject { - $ipAddress = $this->validateIpAddress($ipAddress); - $this->validateDuration($durationSeconds); - $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } + return $this->blockIpForTenant( + $tenantId, + $ipAddress, + $reason, + $createdBy, + $durationSeconds + ); + } + + private function blockIpForTenant( + string $tenantId, + string $ipAddress, + ?string $reason, + ?string $createdBy, + ?int $durationSeconds + ): FirewallRuleObject { + $ipAddress = $this->validateIpAddress($ipAddress); + $this->validateDuration($durationSeconds); + // Check if already blocked $existing = $this->store->findExactIpRule( $tenantId, diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php new file mode 100644 index 0000000..3f1023f --- /dev/null +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -0,0 +1,164 @@ +dataStore = new DataStore($database); + try { + $this->dataStore->getDatabase()->drop(); + $this->databaseAvailable = true; + } catch (\MongoDB\Driver\Exception\Exception $error) { + self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage()); + } + $this->store = new FirewallStore($this->dataStore); + } + + protected function tearDown(): void + { + if ($this->databaseAvailable) { + $this->dataStore->getDatabase()->drop(); + } + } + + #[TestDox('Applicable rules contain system rules and only the requested tenant rules')] + public function testApplicableScopes(): void + { + $tenantA = $this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'); + $tenantB = $this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'); + $system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM); + $expiredSystem = $this->rule('expired-system', FirewallRuleObject::SCOPE_SYSTEM) + ->setExpiresAt(new \DateTimeImmutable('-1 minute')); + $disabledSystem = $this->rule('disabled-system', FirewallRuleObject::SCOPE_SYSTEM) + ->setEnabled(false); + + foreach ([$tenantA, $tenantB, $system, $expiredSystem, $disabledSystem] as $rule) { + $this->store->depositRule($rule); + } + + $rules = $this->store->listApplicableRules('tenant-a'); + $reasons = array_map(static fn(FirewallRuleObject $rule): ?string => $rule->getReason(), $rules); + sort($reasons); + + self::assertSame(['system', 'tenant-a'], $reasons); + } + + #[TestDox('Tenant rule listings cannot expose system or other tenant rules')] + public function testTenantListingIsolation(): void + { + $this->store->depositRule($this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')); + $this->store->depositRule($this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b')); + $this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM)); + + $rules = $this->store->listRules('tenant-a'); + + self::assertCount(1, $rules); + self::assertSame('tenant-a', $rules[0]->getReason()); + self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rules[0]->getScope()); + } + + #[TestDox('Expired exact IP rules do not suppress a replacement block')] + public function testExpiredBlockReplacement(): void + { + $expired = $this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a') + ->setExpiresAt(new \DateTimeImmutable('-1 minute')); + $this->store->depositRule($expired); + + self::assertNull($this->store->findExactIpRule( + 'tenant-a', + '203.0.113.10', + 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); + + self::assertFalse($replacement->isExpired()); + self::assertNotSame($expired->getId(), $replacement->getId()); + self::assertSame( + $replacement->getId(), + $this->store->findExactIpRule( + 'tenant-a', + '203.0.113.10', + FirewallRuleObject::ACTION_BLOCK + )?->getId() + ); + self::assertCount(2, $this->store->listRules('tenant-a', false)); + } + + #[TestDox('Exact IP lookups respect tenant and system scope')] + public function testExactLookupScope(): void + { + $tenant = $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'); + $system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM); + $this->store->depositRule($tenant); + $this->store->depositRule($system); + + self::assertSame( + $tenant->getId(), + $this->store->findExactIpRule( + 'tenant-a', + '203.0.113.10', + FirewallRuleObject::ACTION_BLOCK + )?->getId() + ); + self::assertSame( + $system->getId(), + $this->store->findExactIpRule( + null, + '203.0.113.10', + FirewallRuleObject::ACTION_BLOCK, + FirewallRuleObject::SCOPE_SYSTEM + )?->getId() + ); + self::assertNull($this->store->findExactIpRule( + 'tenant-b', + '203.0.113.10', + FirewallRuleObject::ACTION_BLOCK + )); + } + + private function rule( + string $reason, + string $scope, + ?string $tenantId = null + ): FirewallRuleObject { + return (new FirewallRuleObject()) + ->setScope($scope) + ->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue('203.0.113.10') + ->setReason($reason) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + } +} diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 712707c..6910e04 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -22,7 +22,7 @@ class FirewallServiceTest extends TestCase private TenantContextInterface&MockObject $tenantContext; private EventDispatcherInterface&MockObject $events; private FirewallService $service; - private string $currentTenant; + private ?string $currentTenant; private ?TenantConfiguration $currentConfiguration; protected function setUp(): void @@ -33,7 +33,7 @@ class FirewallServiceTest extends TestCase $this->currentTenant = 'tenant-a'; $this->currentConfiguration = null; $this->tenantContext->method('identifier')->willReturnCallback( - fn(): string => $this->currentTenant + fn(): ?string => $this->currentTenant ); $this->tenantContext->method('configuration')->willReturnCallback( fn(): ?TenantConfiguration => $this->currentConfiguration @@ -238,6 +238,70 @@ class FirewallServiceTest extends TestCase $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,