feat(firewall): add safeguarded rule creation

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-03 22:27:20 -04:00
parent 6700ff145d
commit b06c18d38e
10 changed files with 374 additions and 0 deletions
@@ -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);
}
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
final class FirewallRuleConflictException extends \RuntimeException
{
public function __construct(
public readonly string $conflictCode,
string $message
) {
parent::__construct($message);
}
}
+61
View File
@@ -8,6 +8,7 @@ use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
final class FirewallRuleManager
{
@@ -84,6 +85,66 @@ final class FirewallRuleManager
];
}
public function createManualRule(
FirewallRuleScope $scope,
string $type,
string $action,
string $value,
string $reason,
?string $createdBy,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 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,
@@ -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);
@@ -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);
+2
View File
@@ -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);
@@ -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
{
@@ -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) {
@@ -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
{
@@ -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);