diff --git a/core/lib/Controllers/FirewallController.php b/core/lib/Controllers/FirewallController.php index 50203b9..bc54724 100644 --- a/core/lib/Controllers/FirewallController.php +++ b/core/lib/Controllers/FirewallController.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace KTXC\Controllers; +use KTXC\Http\Request\Request; use KTXC\Http\Response\JsonResponse; +use KTXC\Service\FirewallRuleConflictException; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; use KTXC\Service\SystemFirewallStatusService; @@ -106,6 +108,58 @@ final class FirewallController extends ControllerAbstract return $this->ruleResponse($this->systemRules->fetchRule($ruleId)); } + #[AuthenticatedRoute( + '/firewall/rules', + name: 'firewall.tenant.rules.create', + methods: ['POST'], + permissions: [TenantFirewallRuleService::PERMISSION_MANAGE], + )] + public function createTenantRule( + Request $request, + string $type, + string $action, + string $value, + string $reason, + ?int $durationSeconds = null, + bool $confirmCurrentIp = false + ): JsonResponse { + return $this->mutationResponse(fn() => $this->tenantRules->createRule( + $type, + $action, + $value, + $reason, + $durationSeconds, + $request->getClientIp(), + $confirmCurrentIp + )); + } + + #[AuthenticatedRoute( + '/firewall/system/rules', + name: 'firewall.system.rules.create', + methods: ['POST'], + permissions: [SystemFirewallRuleService::PERMISSION_MANAGE], + )] + public function createSystemRule( + Request $request, + string $type, + string $action, + string $value, + string $reason, + ?int $durationSeconds = null, + bool $confirmCurrentIp = false + ): JsonResponse { + return $this->mutationResponse(fn() => $this->systemRules->createRule( + $type, + $action, + $value, + $reason, + $durationSeconds, + $request->getClientIp(), + $confirmCurrentIp + )); + } + #[AuthenticatedRoute( '/firewall/logs', name: 'firewall.tenant.logs.list', @@ -231,4 +285,21 @@ final class FirewallController extends ControllerAbstract return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST); } } + + private function mutationResponse(callable $mutation): JsonResponse + { + try { + return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED); + } catch (FirewallRuleConflictException $error) { + return new JsonResponse(['error' => [ + 'code' => $error->conflictCode, + 'message' => $error->getMessage(), + ]], JsonResponse::HTTP_CONFLICT); + } catch (\InvalidArgumentException $error) { + return new JsonResponse(['error' => [ + 'code' => 'invalid_firewall_rule', + 'message' => $error->getMessage(), + ]], JsonResponse::HTTP_BAD_REQUEST); + } + } } diff --git a/core/lib/Service/FirewallRuleConflictException.php b/core/lib/Service/FirewallRuleConflictException.php new file mode 100644 index 0000000..354b765 --- /dev/null +++ b/core/lib/Service/FirewallRuleConflictException.php @@ -0,0 +1,15 @@ + 1000) { + throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.'); + } + if ($currentIp !== null) { + $currentIp = FirewallRuleValidator::ipAddress($currentIp); + } + if ( + !$confirmCurrentIp + && $currentIp !== null + && $action === FirewallRuleObject::ACTION_BLOCK + && $this->matchesIp($type, $value, $currentIp) + ) { + throw new FirewallRuleConflictException( + 'current_ip_confirmation_required', + 'This rule would block your current IP address. Explicit confirmation is required.' + ); + } + + return match ([$type, $action]) { + [FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] => + $this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds), + [FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] => + $durationSeconds === null + ? $this->allowIp($scope, $value, $reason, $createdBy) + : throw new \InvalidArgumentException('Temporary allow rules are not supported.'), + [FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] => + $durationSeconds === null + ? $this->blockIpRange($scope, $value, $reason, $createdBy) + : throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'), + [FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] => + $this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds), + default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'), + }; + } + + private function matchesIp(string $type, string $value, string $currentIp): bool + { + if ($type === FirewallRuleObject::TYPE_IP) { + $value = FirewallRuleValidator::ipAddress($value); + return inet_pton($value) === inet_pton($currentIp); + } + if ($type === FirewallRuleObject::TYPE_IP_RANGE) { + return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value)); + } + + return false; + } + public function blockIp( FirewallRuleScope $scope, string $ipAddress, diff --git a/core/lib/Service/SystemFirewallRuleService.php b/core/lib/Service/SystemFirewallRuleService.php index 4f49d3b..12af064 100644 --- a/core/lib/Service/SystemFirewallRuleService.php +++ b/core/lib/Service/SystemFirewallRuleService.php @@ -41,6 +41,29 @@ final class SystemFirewallRuleService return $this->rules->fetch(FirewallRuleScope::system(), $ruleId); } + public function createRule( + string $type, + string $action, + string $value, + string $reason, + ?int $durationSeconds = null, + ?string $currentIp = null, + bool $confirmCurrentIp = false + ): FirewallRuleObject { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->createManualRule( + FirewallRuleScope::system(), + $type, + $action, + $value, + $reason, + $this->identity->identifier(), + $durationSeconds, + $currentIp, + $confirmCurrentIp + ); + } + public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject { $this->requirePermission(self::PERMISSION_MANAGE); diff --git a/core/lib/Service/TenantFirewallRuleService.php b/core/lib/Service/TenantFirewallRuleService.php index fa3d28d..ef9ba45 100644 --- a/core/lib/Service/TenantFirewallRuleService.php +++ b/core/lib/Service/TenantFirewallRuleService.php @@ -43,6 +43,29 @@ final class TenantFirewallRuleService return $this->rules->fetch($this->scope(), $ruleId); } + public function createRule( + string $type, + string $action, + string $value, + string $reason, + ?int $durationSeconds = null, + ?string $currentIp = null, + bool $confirmCurrentIp = false + ): FirewallRuleObject { + $this->requirePermission(self::PERMISSION_MANAGE); + return $this->rules->createManualRule( + $this->scope(), + $type, + $action, + $value, + $reason, + $this->identity->identifier(), + $durationSeconds, + $currentIp, + $confirmCurrentIp + ); + } + public function effectivePolicy(): array { $this->requirePermission(self::PERMISSION_READ); diff --git a/shared/lib/IpUtils.php b/shared/lib/IpUtils.php index df12766..dfd5953 100644 --- a/shared/lib/IpUtils.php +++ b/shared/lib/IpUtils.php @@ -95,6 +95,7 @@ class IpUtils if ($netmask < 0 || $netmask > 32) { return self::setCacheResult($cacheKey, false); } + $netmask = (int)$netmask; } else { $address = $ip; $netmask = 32; @@ -149,6 +150,7 @@ class IpUtils if ($netmask < 1 || $netmask > 128) { return self::setCacheResult($cacheKey, false); } + $netmask = (int)$netmask; } else { if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { return self::setCacheResult($cacheKey, false); diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 9cfe0db..2a103d8 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -488,6 +488,41 @@ class FirewallStoreTest extends TestCase self::assertSame('tenant-disabled', $disabled['items'][0]->getReason()); } + #[TestDox('Administrative creation persists tenant and system ownership')] + public function testAdministrativeRuleCreation(): void + { + $manager = new FirewallRuleManager( + $this->store, + new FirewallRuleCache($this->store), + $this->createStub(EventDispatcherInterface::class) + ); + $tenantRule = $manager->createManualRule( + FirewallRuleScope::tenant('tenant-a'), + FirewallRuleObject::TYPE_DEVICE, + FirewallRuleObject::ACTION_BLOCK, + 'device-123', + 'Compromised device', + 'admin-a', + 3600 + ); + $systemRule = $manager->createManualRule( + FirewallRuleScope::system(), + FirewallRuleObject::TYPE_IP_RANGE, + FirewallRuleObject::ACTION_BLOCK, + '198.51.100.0/24', + 'Malicious network', + 'system-admin', + currentIp: '203.0.113.10' + ); + + $persistedTenant = $this->store->fetchRule($tenantRule->getId()); + $persistedSystem = $this->store->fetchRule($systemRule->getId()); + self::assertSame('tenant-a', $persistedTenant->getTenantId()); + self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $persistedSystem->getScope()); + self::assertNull($persistedSystem->getTenantId()); + self::assertSame('system-admin', $persistedSystem->getCreatedBy()); + } + #[TestDox('Administrative log queries enforce tenant scope and supported filters')] public function testAdministrativeLogQuery(): void { diff --git a/tests/php/Unit/Controllers/FirewallControllerTest.php b/tests/php/Unit/Controllers/FirewallControllerTest.php index b7fbf50..aaadcaf 100644 --- a/tests/php/Unit/Controllers/FirewallControllerTest.php +++ b/tests/php/Unit/Controllers/FirewallControllerTest.php @@ -7,6 +7,7 @@ namespace KTXT\Unit\Controllers; use KTXC\Context\IdentityContextInterface; use KTXC\Context\TenantContextInterface; use KTXC\Controllers\FirewallController; +use KTXC\Http\Request\Request; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; use KTXC\Service\FirewallStatusService; @@ -89,6 +90,39 @@ final class FirewallControllerTest extends TestCase self::assertSame(400, $this->controller->systemMetrics(since: 'not-a-date')->getStatusCode()); } + #[TestDox('Current-IP blocks return a structured confirmation conflict')] + public function testCurrentIpConflict(): void + { + $this->store->expects(self::never())->method('depositRule'); + $response = $this->controller->createTenantRule( + new Request(server: ['REMOTE_ADDR' => '203.0.113.10']), + 'ip', + 'block', + '203.0.113.10', + 'Suspected abuse' + ); + $data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + self::assertSame(409, $response->getStatusCode()); + self::assertSame('current_ip_confirmation_required', $data['error']['code']); + } + + #[TestDox('Invalid manual rules return a stable validation response')] + public function testMutationValidation(): void + { + $response = $this->controller->createSystemRule( + new Request(server: ['REMOTE_ADDR' => '203.0.113.10']), + 'device', + 'allow', + 'device-123', + 'Trusted device' + ); + $data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR); + + self::assertSame(400, $response->getStatusCode()); + self::assertSame('invalid_firewall_rule', $data['error']['code']); + } + #[TestDox('Every rule endpoint declares its scope-specific read permission')] public function testRoutePermissions(): void { @@ -104,6 +138,8 @@ final class FirewallControllerTest extends TestCase 'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ, 'systemMetrics' => SystemFirewallLogService::PERMISSION_READ, 'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, + 'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE, + 'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE, ]; foreach ($expected as $method => $permission) { diff --git a/tests/php/Unit/Service/FirewallRuleManagerTest.php b/tests/php/Unit/Service/FirewallRuleManagerTest.php index 8717780..0aa57f5 100644 --- a/tests/php/Unit/Service/FirewallRuleManagerTest.php +++ b/tests/php/Unit/Service/FirewallRuleManagerTest.php @@ -6,6 +6,7 @@ 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; @@ -77,6 +78,96 @@ class FirewallRuleManagerTest extends TestCase ); } + #[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 { diff --git a/tests/php/Unit/Service/FirewallRuleServicesTest.php b/tests/php/Unit/Service/FirewallRuleServicesTest.php index 2b9bfff..06d5051 100644 --- a/tests/php/Unit/Service/FirewallRuleServicesTest.php +++ b/tests/php/Unit/Service/FirewallRuleServicesTest.php @@ -122,6 +122,23 @@ class FirewallRuleServicesTest extends TestCase $this->systemService()->blockIp('203.0.113.10'); } + #[TestDox('Generic creation remains behind tenant and system management permissions')] + public function testGenericCreationPermissions(): void + { + $this->identity->method('hasPermission')->willReturn(false); + $this->store->expects(self::never())->method('depositRule'); + + try { + $this->tenantService()->createRule('ip', 'block', '203.0.113.10', 'Abuse'); + self::fail('Tenant creation should have been rejected.'); + } catch (\RuntimeException $error) { + self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage()); + } + + $this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE); + $this->systemService()->createRule('ip', 'block', '203.0.113.10', 'Abuse'); + } + private function allow(string $permission): void { $this->identity->method('hasPermission')->with($permission)->willReturn(true);