Files
server/core/lib/Service/FirewallRuleManager.php
T
2026-08-03 22:05:00 -04:00

352 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
final class FirewallRuleManager
{
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
public const MAX_QUERY_LIMIT = 100;
public const ORIGIN_MANUAL = 'manual';
public const ORIGIN_AUTOMATIC = 'automatic';
public function __construct(
private readonly FirewallStore $store,
private readonly FirewallRuleCache $cache,
private readonly EventDispatcherInterface $events,
) {
}
public function list(FirewallRuleScope $scope, bool $activeOnly = true): array
{
return $scope->scope === FirewallRuleObject::SCOPE_SYSTEM
? $this->store->listSystemRules($activeOnly)
: $this->store->listRules($scope->tenantId, $activeOnly);
}
public function query(
FirewallRuleScope $scope,
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
if (!in_array($status, self::QUERY_STATUSES, true)) {
throw new \InvalidArgumentException('Invalid rule status filter.');
}
if ($type !== null && !in_array($type, [
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::TYPE_DEVICE,
], true)) {
throw new \InvalidArgumentException('Invalid rule type filter.');
}
if ($action !== null && !in_array($action, [
FirewallRuleObject::ACTION_ALLOW,
FirewallRuleObject::ACTION_BLOCK,
], true)) {
throw new \InvalidArgumentException('Invalid rule action filter.');
}
if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) {
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
}
return $this->store->queryRules(
$scope->scope,
$scope->tenantId,
$status,
$type,
$action,
$limit,
$offset
);
}
public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
return $this->ownedRule($scope, $ruleId);
}
/** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */
public function effectivePolicy(string $tenantId): array
{
return [
'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'],
'system' => $this->store->listSystemRules(),
'tenant' => $this->store->listRules($tenantId),
];
}
public function blockIp(
FirewallRuleScope $scope,
string $ipAddress,
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
FirewallRuleValidator::duration($durationSeconds);
$existing = $this->store->findExactIpRule(
$scope->tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK,
$scope->scope
);
if ($existing) {
if (
$origin === self::ORIGIN_AUTOMATIC
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
&& $durationSeconds !== null
) {
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
}
return $existing;
}
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
$ipAddress,
$reason ?? 'Blocked by administrator',
$createdBy,
$durationSeconds,
$origin,
$metadata
);
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
return $rule;
}
public function allowIp(
FirewallRuleScope $scope,
string $ipAddress,
?string $reason,
?string $createdBy,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_ALLOW,
$ipAddress,
$reason ?? 'Allowed by administrator',
$createdBy,
null,
$origin
);
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
return $rule;
}
public function blockIpRange(
FirewallRuleScope $scope,
string $cidr,
?string $reason,
?string $createdBy,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
return $this->create(
$scope,
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
FirewallRuleValidator::cidr($cidr),
$reason ?? 'Range blocked by administrator',
$createdBy,
null,
$origin
);
}
public function blockDevice(
FirewallRuleScope $scope,
string $fingerprint,
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
FirewallRuleValidator::duration($durationSeconds);
$fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint);
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_BLOCK,
$fingerprint,
$reason ?? 'Device blocked by administrator',
$createdBy,
$durationSeconds,
$origin
);
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)->setReason($reason)->setTenantId($scope->tenantId);
$this->events->dispatch($event);
return $rule;
}
public function disable(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return false;
}
$rule->setEnabled(false);
$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
{
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return false;
}
$this->store->destroyRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_REMOVED, $rule, $actorId);
return true;
}
private function create(
FirewallRuleScope $scope,
string $type,
string $action,
string $value,
string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
}
$rule = (new FirewallRuleObject())
->setScope($scope->scope)
->setTenantId($scope->tenantId)
->setType($type)
->setAction($action)
->setValue($value)
->setReason($reason)
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
}
$metadata = [...$metadata, 'origin' => $origin];
if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) {
$metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM);
$metadata['extensions'] = [];
}
$rule->setMetadata($metadata);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule);
return $rule;
}
private function extendAutomaticBlock(
FirewallRuleObject $rule,
int $durationSeconds,
array $policy
): FirewallRuleObject {
$now = new \DateTimeImmutable();
$previousExpiry = $rule->getExpiresAt();
$newExpiry = $now->modify("+{$durationSeconds} seconds");
if ($previousExpiry !== null && $newExpiry <= $previousExpiry) {
return $rule;
}
$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),
'failureCount' => $policy['lastFailureCount'] ?? null,
];
$rule->setExpiresAt($newExpiry)->setMetadata([
...$metadata,
...$policy,
'origin' => self::ORIGIN_AUTOMATIC,
'originalExpiresAt' => $metadata['originalExpiresAt']
?? $previousExpiry?->format(\DateTimeInterface::ATOM),
'extensions' => $extensions,
'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM),
]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule);
return $rule;
}
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
$rule = $this->store->fetchRule($ruleId);
return $rule && $scope->owns($rule) ? $rule : null;
}
private function publishIpEvent(
string $name,
FirewallRuleScope $scope,
string $ipAddress,
?string $reason
): void {
$event = new SecurityEvent($name, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)->setReason($reason)->setTenantId($scope->tenantId);
$this->events->dispatch($event);
}
private function publishLifecycleEvent(
string $name,
FirewallRuleObject $rule,
?string $actorId = null
): void
{
$event = new SecurityEvent($name, [
'ruleId' => $rule->getId(),
'ruleScope' => $rule->getScope(),
'ruleType' => $rule->getType(),
'ruleAction' => $rule->getAction(),
'ruleValue' => $rule->getValue(),
'reason' => $rule->getReason(),
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
]);
$event->setTenantId($rule->getTenantId())
->setIdentityId($actorId ?? $rule->getCreatedBy());
$this->events->dispatch($event);
}
}