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
+103
View File
@@ -160,6 +160,84 @@ final class FirewallController extends ControllerAbstract
));
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.update',
methods: ['PATCH'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateTenantRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->tenantRules->disableRule($ruleId, $reason),
'enable' => $this->tenantRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->tenantRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.update',
methods: ['PATCH'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateSystemRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->systemRules->disableRule($ruleId, $reason),
'enable' => $this->systemRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->systemRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.delete',
methods: ['DELETE'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteTenantRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->tenantRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.delete',
methods: ['DELETE'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteSystemRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->systemRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/logs',
name: 'firewall.tenant.logs.list',
@@ -302,4 +380,29 @@ final class FirewallController extends ControllerAbstract
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function lifecycleResponse(callable $mutation): JsonResponse
{
try {
$rule = $mutation();
if ($rule === null) {
return new JsonResponse(['error' => [
'code' => 'firewall_rule_not_found',
'message' => 'Firewall rule not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['rule' => $rule]);
} 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);
}
}
}
@@ -23,6 +23,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
public const EVENT_ACCESS_CHECK = 'access_check';
public const EVENT_RULE_CREATED = 'rule_created';
public const EVENT_RULE_EXTENDED = 'rule_extended';
public const EVENT_RULE_ENABLED = 'rule_enabled';
public const EVENT_RULE_DISABLED = 'rule_disabled';
public const EVENT_RULE_REMOVED = 'rule_removed';
+1
View File
@@ -48,6 +48,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
] as $event) {
+1
View File
@@ -21,6 +21,7 @@ final class FirewallLogService
FirewallLogObject::EVENT_ACCESS_CHECK,
FirewallLogObject::EVENT_RULE_CREATED,
FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallLogObject::EVENT_RULE_ENABLED,
FirewallLogObject::EVENT_RULE_DISABLED,
FirewallLogObject::EVENT_RULE_REMOVED,
];
+133 -15
View File
@@ -261,33 +261,149 @@ final class FirewallRuleManager
return $rule;
}
public function disable(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
public function disableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return false;
return null;
}
if ($rule->isEnabled()) {
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_DISABLED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_DISABLED, $rule, $actorId);
return true;
return $rule;
}
public function remove(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
public function enableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return false;
return null;
}
if (
!$confirmCurrentIp
&& $currentIp !== null
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
) {
throw new FirewallRuleConflictException(
'current_ip_confirmation_required',
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
);
}
if (!$rule->isEnabled()) {
$rule->setEnabled(true);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_ENABLED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
return $rule;
}
public function extendManual(
FirewallRuleScope $scope,
string $ruleId,
int $durationSeconds,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
FirewallRuleValidator::duration($durationSeconds);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$previousExpiry = $rule->getExpiresAt();
if ($previousExpiry === null) {
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
}
$now = new \DateTimeImmutable();
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
->modify("+{$durationSeconds} seconds");
$metadata = $rule->getMetadata() ?? [];
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
$extensions[] = [
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
'origin' => self::ORIGIN_MANUAL,
'actorId' => $actorId,
'reason' => $reason,
];
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_EXTENDED,
$rule,
$actorId,
[
'changeReason' => $reason,
'changeOrigin' => self::ORIGIN_MANUAL,
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
]
);
return $rule;
}
public function removeManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$this->store->destroyRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_REMOVED, $rule, $actorId);
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_REMOVED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
return true;
return $rule;
}
private static function manualReason(string $reason): string
{
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
return $reason;
}
private function create(
@@ -391,7 +507,8 @@ final class FirewallRuleManager
private function publishLifecycleEvent(
string $name,
FirewallRuleObject $rule,
?string $actorId = null
?string $actorId = null,
array $change = []
): void
{
$event = new SecurityEvent($name, [
@@ -404,6 +521,7 @@ final class FirewallRuleManager
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
...$change,
]);
$event->setTenantId($rule->getTenantId())
->setIdentityId($actorId ?? $rule->getCreatedBy());
+2
View File
@@ -269,6 +269,7 @@ class FirewallService
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
@@ -285,6 +286,7 @@ class FirewallService
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
+30 -9
View File
@@ -103,23 +103,44 @@ final class SystemFirewallRuleService
);
}
public function disableRule(string $ruleId): bool
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disable(
FirewallRuleScope::system(),
$ruleId,
$this->identity->identifier()
return $this->rules->disableManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId): bool
{
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->remove(
return $this->rules->enableManual(
FirewallRuleScope::system(),
$ruleId,
$this->identity->identifier()
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->extendManual(
FirewallRuleScope::system(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
+29 -4
View File
@@ -103,16 +103,41 @@ final class TenantFirewallRuleService
);
}
public function disableRule(string $ruleId): bool
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disable($this->scope(), $ruleId, $this->identity->identifier());
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
public function removeRule(string $ruleId): bool
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->enableManual(
$this->scope(),
$ruleId,
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->remove($this->scope(), $ruleId, $this->identity->identifier());
return $this->rules->extendManual(
$this->scope(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
private function scope(): FirewallRuleScope
+1
View File
@@ -28,6 +28,7 @@ class SecurityEvent extends Event
public const DEVICE_BLOCKED = 'security.device.blocked';
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
@@ -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')]