diff --git a/core/lib/Controllers/FirewallController.php b/core/lib/Controllers/FirewallController.php index bc54724..97eee27 100644 --- a/core/lib/Controllers/FirewallController.php +++ b/core/lib/Controllers/FirewallController.php @@ -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); + } + } } diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php index d2c0fd4..b2a3154 100644 --- a/core/lib/Models/Firewall/FirewallLogObject.php +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -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'; diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 2c00877..a9af8cc 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -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) { diff --git a/core/lib/Service/FirewallLogService.php b/core/lib/Service/FirewallLogService.php index 82d9a6c..19addc5 100644 --- a/core/lib/Service/FirewallLogService.php +++ b/core/lib/Service/FirewallLogService.php @@ -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, ]; diff --git a/core/lib/Service/FirewallRuleManager.php b/core/lib/Service/FirewallRuleManager.php index 021aa51..475e371 100644 --- a/core/lib/Service/FirewallRuleManager.php +++ b/core/lib/Service/FirewallRuleManager.php @@ -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()); diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 224cd1f..1415054 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -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, diff --git a/core/lib/Service/SystemFirewallRuleService.php b/core/lib/Service/SystemFirewallRuleService.php index 12af064..4940ead 100644 --- a/core/lib/Service/SystemFirewallRuleService.php +++ b/core/lib/Service/SystemFirewallRuleService.php @@ -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() ); } diff --git a/core/lib/Service/TenantFirewallRuleService.php b/core/lib/Service/TenantFirewallRuleService.php index ef9ba45..76b0596 100644 --- a/core/lib/Service/TenantFirewallRuleService.php +++ b/core/lib/Service/TenantFirewallRuleService.php @@ -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 diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php index 3785748..0215ce0 100644 --- a/shared/lib/Event/SecurityEvent.php +++ b/shared/lib/Event/SecurityEvent.php @@ -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'; diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 2a103d8..cc5e353 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -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 { diff --git a/tests/php/Unit/Controllers/FirewallControllerTest.php b/tests/php/Unit/Controllers/FirewallControllerTest.php index aaadcaf..2429db3 100644 --- a/tests/php/Unit/Controllers/FirewallControllerTest.php +++ b/tests/php/Unit/Controllers/FirewallControllerTest.php @@ -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) { diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 18b80dc..6807e26 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -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); diff --git a/tests/php/Unit/Service/FirewallRuleManagerTest.php b/tests/php/Unit/Service/FirewallRuleManagerTest.php index 0aa57f5..ce832ad 100644 --- a/tests/php/Unit/Service/FirewallRuleManagerTest.php +++ b/tests/php/Unit/Service/FirewallRuleManagerTest.php @@ -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')]