refactor(firewall): separate enforcement from rule management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
@@ -115,7 +117,27 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
'group' => 'Module Management'
|
||||
],
|
||||
|
||||
// System Administration
|
||||
// Firewall Management
|
||||
TenantFirewallRuleService::PERMISSION_READ => [
|
||||
'label' => 'View Tenant Firewall Rules',
|
||||
'description' => 'View firewall rules owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
TenantFirewallRuleService::PERMISSION_MANAGE => [
|
||||
'label' => 'Manage Tenant Firewall Rules',
|
||||
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
SystemFirewallRuleService::PERMISSION_READ => [
|
||||
'label' => 'View System Firewall Rules',
|
||||
'description' => 'View firewall rules that apply to every tenant',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
SystemFirewallRuleService::PERMISSION_MANAGE => [
|
||||
'label' => 'Manage System Firewall Rules',
|
||||
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
'system.admin' => [
|
||||
'label' => 'System Administrator',
|
||||
'description' => 'Full system access (superuser)',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
|
||||
final class FirewallRuleCache
|
||||
{
|
||||
/** @var array<string, FirewallRuleObject[]> */
|
||||
private array $rules = [];
|
||||
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return FirewallRuleObject[] */
|
||||
public function applicable(string $tenantId): array
|
||||
{
|
||||
return $this->rules[$tenantId] ??= $this->store->listApplicableRules($tenantId);
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$this->rules = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?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 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 blockIp(
|
||||
FirewallRuleScope $scope,
|
||||
string $ipAddress,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
||||
FirewallRuleValidator::duration($durationSeconds);
|
||||
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$scope->tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
$scope->scope
|
||||
);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_IP,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
$ipAddress,
|
||||
$reason ?? 'Blocked by administrator',
|
||||
$createdBy,
|
||||
$durationSeconds
|
||||
);
|
||||
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function allowIp(
|
||||
FirewallRuleScope $scope,
|
||||
string $ipAddress,
|
||||
?string $reason,
|
||||
?string $createdBy
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
||||
$rule = $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_IP,
|
||||
FirewallRuleObject::ACTION_ALLOW,
|
||||
$ipAddress,
|
||||
$reason ?? 'Allowed by administrator',
|
||||
$createdBy
|
||||
);
|
||||
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function blockIpRange(
|
||||
FirewallRuleScope $scope,
|
||||
string $cidr,
|
||||
?string $reason,
|
||||
?string $createdBy
|
||||
): FirewallRuleObject {
|
||||
return $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_IP_RANGE,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
FirewallRuleValidator::cidr($cidr),
|
||||
$reason ?? 'Range blocked by administrator',
|
||||
$createdBy
|
||||
);
|
||||
}
|
||||
|
||||
public function blockDevice(
|
||||
FirewallRuleScope $scope,
|
||||
string $fingerprint,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null
|
||||
): 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
|
||||
);
|
||||
|
||||
$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): bool
|
||||
{
|
||||
$rule = $this->ownedRule($scope, $ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule->setEnabled(false);
|
||||
$this->store->depositRule($rule);
|
||||
$this->cache->invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function remove(FirewallRuleScope $scope, string $ruleId): bool
|
||||
{
|
||||
$rule = $this->ownedRule($scope, $ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store->destroyRule($rule);
|
||||
$this->cache->invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function create(
|
||||
FirewallRuleScope $scope,
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$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"));
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->cache->invalidate();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
|
||||
final class FirewallRuleScope
|
||||
{
|
||||
private function __construct(
|
||||
public readonly string $scope,
|
||||
public readonly ?string $tenantId,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function tenant(string $tenantId): self
|
||||
{
|
||||
if ($tenantId === '') {
|
||||
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
|
||||
}
|
||||
|
||||
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
|
||||
}
|
||||
|
||||
public static function system(): self
|
||||
{
|
||||
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
|
||||
}
|
||||
|
||||
public function owns(FirewallRuleObject $rule): bool
|
||||
{
|
||||
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
final class FirewallRuleValidator
|
||||
{
|
||||
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function ipAddress(string $ipAddress): string
|
||||
{
|
||||
$ipAddress = trim($ipAddress);
|
||||
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
|
||||
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
public static function cidr(string $cidr): string
|
||||
{
|
||||
$cidr = trim($cidr);
|
||||
if (substr_count($cidr, '/') !== 1) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
[$address, $prefix] = explode('/', $cidr, 2);
|
||||
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
|
||||
if ((int)$prefix > $maximumPrefix) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
return $cidr;
|
||||
}
|
||||
|
||||
public static function deviceFingerprint(string $fingerprint): string
|
||||
{
|
||||
$fingerprint = trim($fingerprint);
|
||||
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
return $fingerprint;
|
||||
}
|
||||
|
||||
public static function duration(?int $durationSeconds): void
|
||||
{
|
||||
if ($durationSeconds !== null && $durationSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ class FirewallService
|
||||
private const MAX_AUTH_FAILURES = 1000;
|
||||
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
|
||||
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
|
||||
private const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
|
||||
|
||||
// Configuration keys
|
||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||
@@ -40,13 +39,12 @@ class FirewallService
|
||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||
|
||||
/** @var array<string, FirewallRuleObject[]> */
|
||||
private array $rulesCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly FirewallRuleCache $ruleCache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -192,8 +190,8 @@ class FirewallService
|
||||
self::MAX_AUTO_BLOCK_DURATION
|
||||
);
|
||||
|
||||
$this->blockIpForTenant(
|
||||
$tenantId,
|
||||
$this->rules->blockIp(
|
||||
FirewallRuleScope::tenant($tenantId),
|
||||
$ipAddress,
|
||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||
null, // System-created
|
||||
@@ -273,297 +271,6 @@ class FirewallService
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Rule Management
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
*/
|
||||
public function blockIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
return $this->blockIpForTenant(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
$reason,
|
||||
$createdBy,
|
||||
$durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private function blockIpForTenant(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = $this->validateIpAddress($ipAddress);
|
||||
$this->validateDuration($durationSeconds);
|
||||
|
||||
// Check if already blocked
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow an IP address (whitelist)
|
||||
*/
|
||||
public function allowIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = $this->validateIpAddress($ipAddress);
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_ALLOW)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Allowed by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP range (CIDR notation)
|
||||
*/
|
||||
public function blockIpRange(
|
||||
string $cidr,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$cidr = $this->validateCidr($cidr);
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP_RANGE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($cidr)
|
||||
->setReason($reason ?? 'Range blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a device fingerprint
|
||||
*/
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$fingerprint = trim($fingerprint);
|
||||
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
|
||||
);
|
||||
}
|
||||
$this->validateDuration($durationSeconds);
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_DEVICE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($fingerprint)
|
||||
->setReason($reason ?? 'Device blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
|
||||
$event->setDeviceFingerprint($fingerprint)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a rule by ID
|
||||
*/
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store->destroyRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a rule (soft delete)
|
||||
*/
|
||||
public function disableRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule->setEnabled(false);
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules for current tenant
|
||||
*/
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listRules($tenantId, $activeOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get firewall logs for current tenant
|
||||
*/
|
||||
public function getLogs(
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
int $limit = 100
|
||||
): array {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count
|
||||
*/
|
||||
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||
{
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->store->countBlockedRequests($tenantId, $since);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Check if firewall is enabled for current tenant
|
||||
*/
|
||||
@@ -603,43 +310,6 @@ class FirewallService
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function validateIpAddress(string $ipAddress): string
|
||||
{
|
||||
$ipAddress = trim($ipAddress);
|
||||
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
|
||||
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
private function validateCidr(string $cidr): string
|
||||
{
|
||||
$cidr = trim($cidr);
|
||||
if (substr_count($cidr, '/') !== 1) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
[$address, $prefix] = explode('/', $cidr, 2);
|
||||
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
|
||||
if ((int)$prefix > $maximumPrefix) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
return $cidr;
|
||||
}
|
||||
|
||||
private function validateDuration(?int $durationSeconds): void
|
||||
{
|
||||
if ($durationSeconds !== null && $durationSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active rules (cached)
|
||||
* @return FirewallRuleObject[]
|
||||
@@ -651,19 +321,7 @@ class FirewallService
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!array_key_exists($tenantId, $this->rulesCache)) {
|
||||
$this->rulesCache[$tenantId] = $this->store->listApplicableRules($tenantId);
|
||||
}
|
||||
|
||||
return $this->rulesCache[$tenantId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rules cache
|
||||
*/
|
||||
private function clearRulesCache(): void
|
||||
{
|
||||
$this->rulesCache = [];
|
||||
return $this->ruleCache->applicable($tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
|
||||
final class SystemFirewallRuleService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.system.rules.read';
|
||||
public const PERMISSION_MANAGE = 'firewall.system.rules.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->list(FirewallRuleScope::system(), $activeOnly);
|
||||
}
|
||||
|
||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIp(
|
||||
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->allowIp(
|
||||
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIpRange(
|
||||
FirewallRuleScope::system(), $cidr, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockDevice(
|
||||
FirewallRuleScope::system(),
|
||||
$fingerprint,
|
||||
$reason,
|
||||
$this->identity->identifier(),
|
||||
$durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function disableRule(string $ruleId): bool
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->disable(FirewallRuleScope::system(), $ruleId);
|
||||
}
|
||||
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->remove(FirewallRuleScope::system(), $ruleId);
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
|
||||
final class TenantFirewallRuleService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.tenant.rules.read';
|
||||
public const PERMISSION_MANAGE = 'firewall.tenant.rules.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly TenantContextInterface $tenant,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->list($this->scope(), $activeOnly);
|
||||
}
|
||||
|
||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIp(
|
||||
$this->scope(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->allowIp($this->scope(), $ip, $reason, $this->identity->identifier());
|
||||
}
|
||||
|
||||
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIpRange($this->scope(), $cidr, $reason, $this->identity->identifier());
|
||||
}
|
||||
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockDevice(
|
||||
$this->scope(), $fingerprint, $reason, $this->identity->identifier(), $durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function disableRule(string $ruleId): bool
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->disable($this->scope(), $ruleId);
|
||||
}
|
||||
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->remove($this->scope(), $ruleId);
|
||||
}
|
||||
|
||||
private function scope(): FirewallRuleScope
|
||||
{
|
||||
return FirewallRuleScope::tenant($this->tenant->requireIdentifier());
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,26 @@ class FirewallStore
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function listSystemRules(bool $activeOnly = true): array
|
||||
{
|
||||
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
|
||||
if ($activeOnly) {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$or'] = [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
|
||||
];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
$list = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rules by IP address
|
||||
*/
|
||||
|
||||
@@ -4,10 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Integration\Stores;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\FirewallRuleScope;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
@@ -93,13 +94,16 @@ class FirewallStoreTest extends TestCase
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
));
|
||||
|
||||
$tenantContext = $this->createMock(TenantContextInterface::class);
|
||||
$tenantContext->method('identifier')->willReturn('tenant-a');
|
||||
$tenantContext->method('configuration')->willReturn(null);
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$service = new FirewallService($this->store, $tenantContext, $events);
|
||||
|
||||
$replacement = $service->blockIp('203.0.113.10', durationSeconds: 300);
|
||||
$cache = new FirewallRuleCache($this->store);
|
||||
$manager = new FirewallRuleManager($this->store, $cache, $events);
|
||||
$replacement = $manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
null,
|
||||
null,
|
||||
300
|
||||
);
|
||||
|
||||
self::assertFalse($replacement->isExpired());
|
||||
self::assertNotSame($expired->getId(), $replacement->getId());
|
||||
@@ -146,6 +150,28 @@ class FirewallStoreTest extends TestCase
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('System rule listings exclude tenant, disabled, and expired rules')]
|
||||
public function testSystemListing(): void
|
||||
{
|
||||
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
|
||||
$this->store->depositRule(
|
||||
$this->rule('disabled', FirewallRuleObject::SCOPE_SYSTEM)->setEnabled(false)
|
||||
);
|
||||
$this->store->depositRule(
|
||||
$this->rule('expired', FirewallRuleObject::SCOPE_SYSTEM)
|
||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
|
||||
);
|
||||
$this->store->depositRule(
|
||||
$this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
);
|
||||
|
||||
$rules = $this->store->listSystemRules();
|
||||
|
||||
self::assertCount(1, $rules);
|
||||
self::assertSame('system', $rules[0]->getReason());
|
||||
self::assertTrue($rules[0]->isSystemScoped());
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $reason,
|
||||
string $scope,
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace KTXT\Unit\Module;
|
||||
use KTXC\Console\Event\EventsDebugCommand;
|
||||
use KTXC\Module\Module;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
@@ -43,4 +45,16 @@ final class CoreModuleTest extends TestCase
|
||||
|
||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestDox('Core registers dedicated system firewall permissions')]
|
||||
public function registersSystemFirewallPermissions(): void
|
||||
{
|
||||
$permissions = (new Module(new EventListenerRegistry()))->permissions();
|
||||
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\FirewallRuleScope;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
class FirewallRuleManagerTest extends TestCase
|
||||
{
|
||||
private FirewallStore&MockObject $store;
|
||||
private EventDispatcherInterface&MockObject $events;
|
||||
private FirewallRuleManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->store = $this->createMock(FirewallStore::class);
|
||||
$this->events = $this->createMock(EventDispatcherInterface::class);
|
||||
$this->manager = new FirewallRuleManager(
|
||||
$this->store,
|
||||
new FirewallRuleCache($this->store),
|
||||
$this->events
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('The shared manager creates rules with the supplied scope and owner')]
|
||||
public function testScopeCreation(): void
|
||||
{
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->exactly(2))
|
||||
->method('depositRule')
|
||||
->willReturnArgument(0);
|
||||
|
||||
$tenant = $this->manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
|
||||
);
|
||||
$system = $this->manager->blockIp(
|
||||
FirewallRuleScope::system(), '203.0.113.11', null, 'system-admin'
|
||||
);
|
||||
|
||||
self::assertSame('tenant-a', $tenant->getTenantId());
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $tenant->getScope());
|
||||
self::assertNull($system->getTenantId());
|
||||
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $system->getScope());
|
||||
}
|
||||
|
||||
#[TestDox('Malformed rule values are rejected before persistence')]
|
||||
public function testValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->manager->blockIpRange(
|
||||
FirewallRuleScope::tenant('tenant-a'), '2001:db8::/129', null, 'admin-a'
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Temporary rules require a positive duration')]
|
||||
public function testDuration(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->manager->blockIp(
|
||||
FirewallRuleScope::system(), '203.0.113.10', null, 'admin', 0
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Rule lifecycle operations cannot cross scope ownership')]
|
||||
public function testOwnership(): void
|
||||
{
|
||||
$tenantRule = (new FirewallRuleObject())
|
||||
->setId('tenant-rule')
|
||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId('tenant-a');
|
||||
$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'));
|
||||
}
|
||||
|
||||
#[TestDox('Rule mutations invalidate the shared enforcement cache')]
|
||||
public function testCacheInvalidation(): void
|
||||
{
|
||||
$this->store->expects($this->exactly(2))
|
||||
->method('listApplicableRules')
|
||||
->with('tenant-a')
|
||||
->willReturnOnConsecutiveCalls([], []);
|
||||
$cache = new FirewallRuleCache($this->store);
|
||||
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->method('depositRule')->willReturnArgument(0);
|
||||
|
||||
self::assertSame([], $cache->applicable('tenant-a'));
|
||||
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
|
||||
self::assertSame([], $cache->applicable('tenant-a'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
class FirewallRuleServicesTest extends TestCase
|
||||
{
|
||||
private FirewallStore&MockObject $store;
|
||||
private IdentityContextInterface&MockObject $identity;
|
||||
private TenantContextInterface&MockObject $tenant;
|
||||
private FirewallRuleManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->store = $this->createMock(FirewallStore::class);
|
||||
$this->identity = $this->createMock(IdentityContextInterface::class);
|
||||
$this->tenant = $this->createMock(TenantContextInterface::class);
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$this->manager = new FirewallRuleManager(
|
||||
$this->store,
|
||||
new FirewallRuleCache($this->store),
|
||||
$events
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Tenant and system boundaries require their dedicated permissions')]
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$this->identity->method('hasPermission')->willReturn(false);
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
|
||||
try {
|
||||
$this->tenantService()->blockIp('203.0.113.10');
|
||||
self::fail('Tenant operation should have been rejected.');
|
||||
} catch (\RuntimeException $error) {
|
||||
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage());
|
||||
}
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||
$this->systemService()->blockIp('203.0.113.10');
|
||||
}
|
||||
|
||||
#[TestDox('Tenant management derives scope from tenant context')]
|
||||
public function testTenantScope(): void
|
||||
{
|
||||
$this->allow(TenantFirewallRuleService::PERMISSION_MANAGE);
|
||||
$this->tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||
$this->identity->method('identifier')->willReturn('admin-a');
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
|
||||
))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$this->tenantService()->blockIp('203.0.113.10');
|
||||
}
|
||||
|
||||
#[TestDox('System management always delegates with system scope')]
|
||||
public function testSystemScope(): void
|
||||
{
|
||||
$this->allow(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||
$rule->isSystemScoped() && $rule->getTenantId() === null
|
||||
))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$this->systemService()->blockIp('203.0.113.10');
|
||||
}
|
||||
|
||||
private function allow(string $permission): void
|
||||
{
|
||||
$this->identity->method('hasPermission')->with($permission)->willReturn(true);
|
||||
}
|
||||
|
||||
private function tenantService(): TenantFirewallRuleService
|
||||
{
|
||||
return new TenantFirewallRuleService($this->manager, $this->tenant, $this->identity);
|
||||
}
|
||||
|
||||
private function systemService(): SystemFirewallRuleService
|
||||
{
|
||||
return new SystemFirewallRuleService($this->manager, $this->identity);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
@@ -38,7 +40,15 @@ class FirewallServiceTest extends TestCase
|
||||
$this->tenantContext->method('configuration')->willReturnCallback(
|
||||
fn(): ?TenantConfiguration => $this->currentConfiguration
|
||||
);
|
||||
$this->service = new FirewallService($this->store, $this->tenantContext, $this->events);
|
||||
$cache = new FirewallRuleCache($this->store);
|
||||
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
|
||||
$this->service = new FirewallService(
|
||||
$this->store,
|
||||
$this->tenantContext,
|
||||
$this->events,
|
||||
$manager,
|
||||
$cache
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('System blocks cannot be overridden by tenant allows')]
|
||||
@@ -114,85 +124,6 @@ class FirewallServiceTest extends TestCase
|
||||
self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('New IP blocks are explicitly tenant-scoped')]
|
||||
public function testIpBlockScope(): void
|
||||
{
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static function (FirewallRuleObject $rule): bool {
|
||||
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
||||
&& $rule->getTenantId() === 'tenant-a';
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$rule = $this->service->blockIp('203.0.113.10');
|
||||
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rule->getScope());
|
||||
self::assertSame('tenant-a', $rule->getTenantId());
|
||||
}
|
||||
|
||||
#[TestDox('Malformed IP addresses are rejected before persistence')]
|
||||
public function testIpValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid IP address');
|
||||
|
||||
$this->service->blockIp('999.2.3.4');
|
||||
}
|
||||
|
||||
#[TestDox('Valid IPv6 addresses can be blocked')]
|
||||
public function testIpv6Validation(): void
|
||||
{
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool => $rule->getValue() === '2001:db8::1'))
|
||||
->willReturnArgument(0);
|
||||
|
||||
self::assertSame('2001:db8::1', $this->service->blockIp(' 2001:db8::1 ')->getValue());
|
||||
}
|
||||
|
||||
#[TestDox('Malformed CIDR ranges are rejected before persistence')]
|
||||
public function testCidrValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid CIDR range');
|
||||
|
||||
$this->service->blockIpRange('2001:db8::/129');
|
||||
}
|
||||
|
||||
#[TestDox('Valid IPv4 and IPv6 CIDR ranges are accepted')]
|
||||
public function testValidCidrs(): void
|
||||
{
|
||||
$this->store->expects($this->exactly(2))->method('depositRule')->willReturnArgument(0);
|
||||
|
||||
self::assertSame('192.0.2.0/24', $this->service->blockIpRange('192.0.2.0/24')->getValue());
|
||||
self::assertSame('2001:db8::/32', $this->service->blockIpRange('2001:db8::/32')->getValue());
|
||||
}
|
||||
|
||||
#[TestDox('Temporary rules require a positive duration')]
|
||||
public function testDurationValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('greater than zero');
|
||||
|
||||
$this->service->blockIp('203.0.113.10', durationSeconds: 0);
|
||||
}
|
||||
|
||||
#[TestDox('Device fingerprints must be non-empty and bounded')]
|
||||
public function testFingerprintValidation(): void
|
||||
{
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Device fingerprint');
|
||||
|
||||
$this->service->blockDevice(' ');
|
||||
}
|
||||
|
||||
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
|
||||
public function testFirewallConfiguration(): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user