feat(firewall): add audited rule lifecycle management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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(
|
#[AuthenticatedRoute(
|
||||||
'/firewall/logs',
|
'/firewall/logs',
|
||||||
name: 'firewall.tenant.logs.list',
|
name: 'firewall.tenant.logs.list',
|
||||||
@@ -302,4 +380,29 @@ final class FirewallController extends ControllerAbstract
|
|||||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
]], 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_ACCESS_CHECK = 'access_check';
|
||||||
public const EVENT_RULE_CREATED = 'rule_created';
|
public const EVENT_RULE_CREATED = 'rule_created';
|
||||||
public const EVENT_RULE_EXTENDED = 'rule_extended';
|
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_DISABLED = 'rule_disabled';
|
||||||
public const EVENT_RULE_REMOVED = 'rule_removed';
|
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_ENABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||||
] as $event) {
|
] as $event) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ final class FirewallLogService
|
|||||||
FirewallLogObject::EVENT_ACCESS_CHECK,
|
FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
FirewallLogObject::EVENT_RULE_CREATED,
|
FirewallLogObject::EVENT_RULE_CREATED,
|
||||||
FirewallLogObject::EVENT_RULE_EXTENDED,
|
FirewallLogObject::EVENT_RULE_EXTENDED,
|
||||||
|
FirewallLogObject::EVENT_RULE_ENABLED,
|
||||||
FirewallLogObject::EVENT_RULE_DISABLED,
|
FirewallLogObject::EVENT_RULE_DISABLED,
|
||||||
FirewallLogObject::EVENT_RULE_REMOVED,
|
FirewallLogObject::EVENT_RULE_REMOVED,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -261,33 +261,149 @@ final class FirewallRuleManager
|
|||||||
return $rule;
|
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);
|
$rule = $this->ownedRule($scope, $ruleId);
|
||||||
if (!$rule) {
|
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);
|
return $rule;
|
||||||
$this->store->depositRule($rule);
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_DISABLED, $rule, $actorId);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
$rule = $this->ownedRule($scope, $ruleId);
|
||||||
if (!$rule) {
|
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->store->destroyRule($rule);
|
||||||
$this->cache->invalidate();
|
$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(
|
private function create(
|
||||||
@@ -391,7 +507,8 @@ final class FirewallRuleManager
|
|||||||
private function publishLifecycleEvent(
|
private function publishLifecycleEvent(
|
||||||
string $name,
|
string $name,
|
||||||
FirewallRuleObject $rule,
|
FirewallRuleObject $rule,
|
||||||
?string $actorId = null
|
?string $actorId = null,
|
||||||
|
array $change = []
|
||||||
): void
|
): void
|
||||||
{
|
{
|
||||||
$event = new SecurityEvent($name, [
|
$event = new SecurityEvent($name, [
|
||||||
@@ -404,6 +521,7 @@ final class FirewallRuleManager
|
|||||||
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
||||||
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
||||||
...($rule->getMetadata() ?? []),
|
...($rule->getMetadata() ?? []),
|
||||||
|
...$change,
|
||||||
]);
|
]);
|
||||||
$event->setTenantId($rule->getTenantId())
|
$event->setTenantId($rule->getTenantId())
|
||||||
->setIdentityId($actorId ?? $rule->getCreatedBy());
|
->setIdentityId($actorId ?? $rule->getCreatedBy());
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ class FirewallService
|
|||||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
|
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_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
@@ -285,6 +286,7 @@ class FirewallService
|
|||||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_ENABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
|
||||||
default => FirewallLogObject::RESULT_BLOCKED,
|
default => FirewallLogObject::RESULT_BLOCKED,
|
||||||
|
|||||||
@@ -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);
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
return $this->rules->disable(
|
return $this->rules->disableManual(
|
||||||
FirewallRuleScope::system(),
|
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
|
||||||
$ruleId,
|
|
||||||
$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);
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
return $this->rules->remove(
|
return $this->rules->enableManual(
|
||||||
FirewallRuleScope::system(),
|
FirewallRuleScope::system(),
|
||||||
$ruleId,
|
$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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
$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);
|
$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
|
private function scope(): FirewallRuleScope
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class SecurityEvent extends Event
|
|||||||
public const DEVICE_BLOCKED = 'security.device.blocked';
|
public const DEVICE_BLOCKED = 'security.device.blocked';
|
||||||
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
|
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
|
||||||
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
|
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_DISABLED = 'security.firewall.rule.disabled';
|
||||||
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
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']);
|
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')]
|
#[TestDox('Event-backed firewall logs are inserted exactly once')]
|
||||||
public function testIdempotentLogPersistence(): void
|
public function testIdempotentLogPersistence(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -123,6 +123,44 @@ final class FirewallControllerTest extends TestCase
|
|||||||
self::assertSame('invalid_firewall_rule', $data['error']['code']);
|
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')]
|
#[TestDox('Every rule endpoint declares its scope-specific read permission')]
|
||||||
public function testRoutePermissions(): void
|
public function testRoutePermissions(): void
|
||||||
{
|
{
|
||||||
@@ -140,6 +178,10 @@ final class FirewallControllerTest extends TestCase
|
|||||||
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
|
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
|
||||||
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
||||||
'createSystemRule' => SystemFirewallRuleService::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) {
|
foreach ($expected as $method => $permission) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final class CoreModuleTest extends TestCase
|
|||||||
$module->boot();
|
$module->boot();
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
|
|
||||||
self::assertCount(10, $definitions);
|
self::assertCount(11, $definitions);
|
||||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
@@ -47,6 +47,7 @@ final class CoreModuleTest extends TestCase
|
|||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_ENABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||||
] as $event) {
|
] as $event) {
|
||||||
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
||||||
|
|||||||
@@ -178,8 +178,12 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
$this->store->method('fetchRule')->willReturn($tenantRule);
|
$this->store->method('fetchRule')->willReturn($tenantRule);
|
||||||
$this->store->expects($this->never())->method('destroyRule');
|
$this->store->expects($this->never())->method('destroyRule');
|
||||||
|
|
||||||
self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule'));
|
self::assertNull($this->manager->removeManual(
|
||||||
self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule'));
|
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')]
|
#[TestDox('Rule queries retain scope, filters, and bounded pagination')]
|
||||||
@@ -286,30 +290,82 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
self::assertNotNull($audit->get('expiresAt'));
|
self::assertNotNull($audit->get('expiresAt'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[TestDox('Disable and remove audits identify the acting administrator')]
|
#[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')]
|
||||||
public function testLifecycleActors(): void
|
public function testManualLifecycle(): void
|
||||||
{
|
{
|
||||||
|
$originalExpiry = new \DateTimeImmutable('+5 minutes');
|
||||||
$rule = (new FirewallRuleObject())
|
$rule = (new FirewallRuleObject())
|
||||||
->setId('rule-123')
|
->setId('rule-123')
|
||||||
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
||||||
->setTenantId(null)
|
|
||||||
->setType(FirewallRuleObject::TYPE_IP)
|
->setType(FirewallRuleObject::TYPE_IP)
|
||||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
->setValue('203.0.113.10')
|
->setValue('203.0.113.10')
|
||||||
->setCreatedBy('creator');
|
->setEnabled(true)
|
||||||
|
->setExpiresAt($originalExpiry)
|
||||||
|
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
|
||||||
$this->store->method('fetchRule')->willReturn($rule);
|
$this->store->method('fetchRule')->willReturn($rule);
|
||||||
$events = [];
|
$this->store->expects(self::exactly(3))->method('depositRule')->willReturnArgument(0);
|
||||||
$this->events->expects($this->exactly(2))
|
$this->store->expects(self::once())->method('destroyRule')->with($rule);
|
||||||
|
$audits = [];
|
||||||
|
$this->events->expects(self::exactly(4))
|
||||||
->method('dispatch')
|
->method('dispatch')
|
||||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$audits): void {
|
||||||
$events[$event->getName()] = $event;
|
$audits[] = $event;
|
||||||
});
|
});
|
||||||
|
|
||||||
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
|
self::assertFalse($this->manager->disableManual(
|
||||||
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
|
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::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
|
||||||
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
|
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')]
|
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
|
||||||
|
|||||||
Reference in New Issue
Block a user