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