feat(firewall): add audited rule lifecycle management

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-03 22:35:45 -04:00
parent b06c18d38e
commit d5ca89e160
13 changed files with 457 additions and 43 deletions
@@ -220,6 +220,48 @@ class FirewallStoreTest extends TestCase
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $logs[0]->getMetadata()['origin']);
}
#[TestDox('Manual rule lifecycle changes persist through disable, enable, extension, and deletion')]
public function testManualRuleLifecyclePersistence(): void
{
$manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$this->createStub(EventDispatcherInterface::class)
);
$rule = $manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
'Repeated abuse',
'admin-a',
300
);
$manager->disableManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Investigating', 'admin-a'
);
self::assertFalse($this->store->fetchRule($rule->getId())->isEnabled());
$manager->enableManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Threat confirmed', 'admin-a'
);
self::assertTrue($this->store->fetchRule($rule->getId())->isEnabled());
$previousExpiry = $rule->getExpiresAt();
$manager->extendManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 300, 'Continue monitoring', 'admin-a'
);
$stored = $this->store->fetchRule($rule->getId());
self::assertGreaterThan($previousExpiry, $stored->getExpiresAt());
self::assertSame('Continue monitoring', $stored->getMetadata()['extensions'][0]['reason']);
$manager->removeManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Case closed', 'admin-a'
);
self::assertNull($this->store->fetchRule($rule->getId()));
}
#[TestDox('Event-backed firewall logs are inserted exactly once')]
public function testIdempotentLogPersistence(): void
{
@@ -123,6 +123,44 @@ final class FirewallControllerTest extends TestCase
self::assertSame('invalid_firewall_rule', $data['error']['code']);
}
#[TestDox('Re-enabling a current-IP block requires explicit confirmation')]
public function testEnableSafeguard(): void
{
$rule = (new \KTXC\Models\Firewall\FirewallRuleObject())
->setId('rule-123')
->setScope('tenant')
->setTenantId('tenant-a')
->setType('ip')
->setAction('block')
->setValue('203.0.113.10')
->setEnabled(false);
$this->store->method('fetchRule')->willReturn($rule);
$response = $this->controller->updateTenantRule(
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
'rule-123',
'enable',
'Threat returned'
);
$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('Lifecycle endpoints return stable validation and not-found responses')]
public function testLifecycleResponses(): void
{
$request = new Request(server: ['REMOTE_ADDR' => '203.0.113.10']);
self::assertSame(400, $this->controller->updateSystemRule(
$request, 'rule-123', 'extend', 'More time required'
)->getStatusCode());
self::assertSame(404, $this->controller->deleteTenantRule(
'missing-rule', 'No longer required'
)->getStatusCode());
}
#[TestDox('Every rule endpoint declares its scope-specific read permission')]
public function testRoutePermissions(): void
{
@@ -140,6 +178,10 @@ final class FirewallControllerTest extends TestCase
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'updateSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
'deleteTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'deleteSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
];
foreach ($expected as $method => $permission) {
+2 -1
View File
@@ -34,7 +34,7 @@ final class CoreModuleTest extends TestCase
$module->boot();
$definitions = $registry->definitions();
self::assertCount(10, $definitions);
self::assertCount(11, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame(
FirewallService::class,
@@ -47,6 +47,7 @@ final class CoreModuleTest extends TestCase
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
] as $event) {
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
@@ -178,8 +178,12 @@ class FirewallRuleManagerTest extends TestCase
$this->store->method('fetchRule')->willReturn($tenantRule);
$this->store->expects($this->never())->method('destroyRule');
self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule'));
self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule'));
self::assertNull($this->manager->removeManual(
FirewallRuleScope::system(), 'tenant-rule', 'No longer required', 'operator'
));
self::assertNull($this->manager->removeManual(
FirewallRuleScope::tenant('tenant-b'), 'tenant-rule', 'No longer required', 'operator'
));
}
#[TestDox('Rule queries retain scope, filters, and bounded pagination')]
@@ -286,30 +290,82 @@ class FirewallRuleManagerTest extends TestCase
self::assertNotNull($audit->get('expiresAt'));
}
#[TestDox('Disable and remove audits identify the acting administrator')]
public function testLifecycleActors(): void
#[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')]
public function testManualLifecycle(): void
{
$originalExpiry = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setTenantId(null)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setCreatedBy('creator');
->setEnabled(true)
->setExpiresAt($originalExpiry)
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->method('fetchRule')->willReturn($rule);
$events = [];
$this->events->expects($this->exactly(2))
$this->store->expects(self::exactly(3))->method('depositRule')->willReturnArgument(0);
$this->store->expects(self::once())->method('destroyRule')->with($rule);
$audits = [];
$this->events->expects(self::exactly(4))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[$event->getName()] = $event;
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$audits): void {
$audits[] = $event;
});
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertFalse($this->manager->disableManual(
FirewallRuleScope::system(), 'rule-123', 'Investigation', 'operator'
)->isEnabled());
self::assertTrue($this->manager->enableManual(
FirewallRuleScope::system(), 'rule-123', 'Threat confirmed', 'operator', '203.0.113.10', true
)->isEnabled());
$extended = $this->manager->extendManual(
FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator'
);
self::assertSame($rule, $this->manager->removeManual(
FirewallRuleScope::system(), 'rule-123', 'Case closed', 'operator'
));
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
self::assertCount(1, $extended->getMetadata()['extensions']);
self::assertSame([
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_REMOVED,
], array_map(static fn($event): string => $event->getName(), $audits));
self::assertSame(
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
array_map(static fn($event): string => $event->get('changeReason'), $audits)
);
self::assertSame(['operator'], array_values(array_unique(array_map(
static fn($event): ?string => $event->getIdentityId(),
$audits
))));
}
#[TestDox('Manual lifecycle changes require reasons before reading or writing rules')]
public function testLifecycleReason(): void
{
$this->store->expects(self::never())->method('fetchRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->disableManual(FirewallRuleScope::system(), 'rule-123', ' ', 'operator');
}
#[TestDox('Permanent rules cannot be extended')]
public function testPermanentExtension(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM);
$this->store->method('fetchRule')->willReturn($rule);
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->extendManual(
FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator'
);
}
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]