feat(firewall): add safeguarded rule creation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user