Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36d7402f0d |
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Firewall;
|
||||
|
||||
use KTXC\Service\FirewallService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
|
||||
final class FirewallMaintenanceCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly FirewallService $firewall)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$result = $this->firewall->cleanup();
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Firewall maintenance failed: '.$error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
|
||||
$result['expiredRules'],
|
||||
$result['oldLogs'],
|
||||
$result['expiredBruteForceClaims']
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Firewall;
|
||||
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
|
||||
final class FirewallSetupCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$indexes = $this->store->ensureIndexes();
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Firewall database setup failed: '.$error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,6 @@ class ObjectId
|
||||
*/
|
||||
public static function isValid(string $id): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
|
||||
return MongoObjectId::isValid($id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
public const RESULT_ALLOWED = 'allowed';
|
||||
public const RESULT_BLOCKED = 'blocked';
|
||||
public const RESULT_RECORDED = 'recorded';
|
||||
|
||||
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
||||
public const EVENT_RATE_LIMIT = 'rate_limit';
|
||||
@@ -21,13 +20,8 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
public const EVENT_SUSPICIOUS = 'suspicious';
|
||||
public const EVENT_RULE_MATCH = 'rule_match';
|
||||
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_DISABLED = 'rule_disabled';
|
||||
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||
|
||||
private ?string $id = null;
|
||||
private ?string $eventId = null;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $ipAddress = null;
|
||||
private ?string $deviceFingerprint = null;
|
||||
@@ -37,7 +31,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
private ?string $eventType = null;
|
||||
private ?string $result = null; // allowed, blocked
|
||||
private ?string $ruleId = null; // Which rule triggered (if any)
|
||||
private ?string $ruleScope = null; // tenant or system
|
||||
private ?string $identityId = null; // User ID if authenticated
|
||||
private ?\DateTimeImmutable $timestamp = null;
|
||||
private ?array $metadata = null; // Additional context
|
||||
@@ -57,9 +50,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
if (array_key_exists('eventId', $data)) {
|
||||
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
|
||||
}
|
||||
if (array_key_exists('ipAddress', $data)) {
|
||||
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||
}
|
||||
@@ -84,14 +74,13 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
if (array_key_exists('ruleId', $data)) {
|
||||
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
||||
}
|
||||
if (array_key_exists('ruleScope', $data)) {
|
||||
$this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null;
|
||||
}
|
||||
if (array_key_exists('identityId', $data)) {
|
||||
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
||||
}
|
||||
if (array_key_exists('timestamp', $data)) {
|
||||
$this->timestamp = self::deserializeDate($data['timestamp']);
|
||||
$this->timestamp = $data['timestamp'] !== null
|
||||
? new \DateTimeImmutable($data['timestamp'])
|
||||
: null;
|
||||
}
|
||||
if (array_key_exists('metadata', $data)) {
|
||||
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||
@@ -104,7 +93,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'eventId' => $this->eventId,
|
||||
'tenantId' => $this->tenantId,
|
||||
'ipAddress' => $this->ipAddress,
|
||||
'deviceFingerprint' => $this->deviceFingerprint,
|
||||
@@ -114,31 +102,12 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
'eventType' => $this->eventType,
|
||||
'result' => $this->result,
|
||||
'ruleId' => $this->ruleId,
|
||||
'ruleScope' => $this->ruleScope,
|
||||
'identityId' => $this->identityId,
|
||||
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
return \DateTimeImmutable::createFromMutable($value->toDateTime());
|
||||
}
|
||||
if ($value instanceof \DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return \DateTimeImmutable::createFromInterface($value);
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable((string)$value);
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -152,17 +121,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEventId(): ?string
|
||||
{
|
||||
return $this->eventId;
|
||||
}
|
||||
|
||||
public function setEventId(?string $eventId): self
|
||||
{
|
||||
$this->eventId = $eventId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantId;
|
||||
@@ -262,24 +220,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRuleScope(): ?string
|
||||
{
|
||||
return $this->ruleScope;
|
||||
}
|
||||
|
||||
public function setRuleScope(?string $ruleScope): self
|
||||
{
|
||||
if (
|
||||
$ruleScope !== null
|
||||
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
|
||||
) {
|
||||
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
|
||||
}
|
||||
|
||||
$this->ruleScope = $ruleScope;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIdentityId(): ?string
|
||||
{
|
||||
return $this->identityId;
|
||||
|
||||
@@ -11,9 +11,6 @@ use KTXF\Json\JsonDeserializable;
|
||||
*/
|
||||
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
public const SCOPE_TENANT = 'tenant';
|
||||
public const SCOPE_SYSTEM = 'system';
|
||||
|
||||
public const TYPE_IP = 'ip';
|
||||
public const TYPE_IP_RANGE = 'ip_range';
|
||||
public const TYPE_DEVICE = 'device';
|
||||
@@ -22,7 +19,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
public const ACTION_BLOCK = 'block';
|
||||
|
||||
private ?string $id = null;
|
||||
private string $scope = self::SCOPE_TENANT;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $type = null; // ip, ip_range, device
|
||||
private ?string $action = null; // allow, block
|
||||
@@ -46,10 +42,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
}
|
||||
|
||||
if (!array_key_exists('scope', $data)) {
|
||||
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
|
||||
}
|
||||
$this->setScope((string)$data['scope']);
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
@@ -69,10 +61,14 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
|
||||
}
|
||||
if (array_key_exists('createdAt', $data)) {
|
||||
$this->createdAt = self::deserializeDate($data['createdAt']);
|
||||
$this->createdAt = $data['createdAt'] !== null
|
||||
? new \DateTimeImmutable($data['createdAt'])
|
||||
: null;
|
||||
}
|
||||
if (array_key_exists('expiresAt', $data)) {
|
||||
$this->expiresAt = self::deserializeDate($data['expiresAt']);
|
||||
$this->expiresAt = $data['expiresAt'] !== null
|
||||
? new \DateTimeImmutable($data['expiresAt'])
|
||||
: null;
|
||||
}
|
||||
if (array_key_exists('enabled', $data)) {
|
||||
$this->enabled = (bool)$data['enabled'];
|
||||
@@ -88,7 +84,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'scope' => $this->scope,
|
||||
'tenantId' => $this->tenantId,
|
||||
'type' => $this->type,
|
||||
'action' => $this->action,
|
||||
@@ -102,24 +97,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
];
|
||||
}
|
||||
|
||||
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
return \DateTimeImmutable::createFromMutable($value->toDateTime());
|
||||
}
|
||||
if ($value instanceof \DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return \DateTimeImmutable::createFromInterface($value);
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this rule has expired
|
||||
*/
|
||||
@@ -157,42 +134,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
public function getScope(): string
|
||||
{
|
||||
return $this->scope;
|
||||
}
|
||||
|
||||
public function setScope(string $scope): self
|
||||
{
|
||||
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
|
||||
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
|
||||
}
|
||||
|
||||
$this->scope = $scope;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isTenantScoped(): bool
|
||||
{
|
||||
return $this->scope === self::SCOPE_TENANT;
|
||||
}
|
||||
|
||||
public function isSystemScoped(): bool
|
||||
{
|
||||
return $this->scope === self::SCOPE_SYSTEM;
|
||||
}
|
||||
|
||||
public function assertValidScopeOwnership(): void
|
||||
{
|
||||
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
|
||||
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
|
||||
}
|
||||
|
||||
if ($this->isSystemScoped() && $this->tenantId !== null) {
|
||||
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
|
||||
}
|
||||
}
|
||||
|
||||
public function setTenantId(?string $tenantId): self
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
|
||||
@@ -11,13 +11,11 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
{
|
||||
protected TenantAuthentication $authentication;
|
||||
protected TenantSecurity $security;
|
||||
protected TenantFirewall $firewall;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->authentication = new TenantAuthentication();
|
||||
$this->security = new TenantSecurity();
|
||||
$this->firewall = new TenantFirewall();
|
||||
}
|
||||
|
||||
public function authentication(): TenantAuthentication {
|
||||
@@ -28,8 +26,4 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
return $this->security;
|
||||
}
|
||||
|
||||
public function firewall(): TenantFirewall {
|
||||
return $this->firewall;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class TenantFirewall extends JsonSerializableObject
|
||||
{
|
||||
protected bool $enabled = true;
|
||||
protected int $maxAuthFailures = 5;
|
||||
protected int $authFailureWindow = 300;
|
||||
protected int $autoBlockDuration = 3600;
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function maxAuthFailures(): int
|
||||
{
|
||||
return $this->maxAuthFailures;
|
||||
}
|
||||
|
||||
public function authFailureWindow(): int
|
||||
{
|
||||
return $this->authFailureWindow;
|
||||
}
|
||||
|
||||
public function autoBlockDuration(): int
|
||||
{
|
||||
return $this->autoBlockDuration;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
@@ -37,15 +33,10 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
);
|
||||
|
||||
foreach ([
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_DENIED,
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||
] as $event) {
|
||||
$this->events->listen(
|
||||
'core',
|
||||
@@ -124,27 +115,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
'group' => 'Module Management'
|
||||
],
|
||||
|
||||
// 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 Administration
|
||||
'system.admin' => [
|
||||
'label' => 'System Administrator',
|
||||
'description' => 'Full system access (superuser)',
|
||||
@@ -161,8 +132,6 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
public function registerCI(): array
|
||||
{
|
||||
return [
|
||||
FirewallSetupCommand::class,
|
||||
FirewallMaintenanceCommand::class,
|
||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||
\KTXC\Console\Module\ModuleListCommand::class,
|
||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<?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 $tenantRules = [];
|
||||
|
||||
/** @var FirewallRuleObject[]|null */
|
||||
private ?array $systemRules = null;
|
||||
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return FirewallRuleObject[] */
|
||||
public function tenant(string $tenantId): array
|
||||
{
|
||||
return $this->tenantRules[$tenantId] ??= $this->store->listRules($tenantId);
|
||||
}
|
||||
|
||||
/** @return FirewallRuleObject[] */
|
||||
public function system(): array
|
||||
{
|
||||
return $this->systemRules ??= $this->store->listSystemRules();
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$this->tenantRules = [];
|
||||
$this->systemRules = null;
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
<?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 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 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);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,6 @@ class FirewallService
|
||||
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
||||
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
||||
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
|
||||
private const MAX_AUTH_FAILURES = 1000;
|
||||
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
|
||||
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
|
||||
|
||||
// Configuration keys
|
||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||
@@ -39,12 +36,13 @@ class FirewallService
|
||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||
|
||||
/** @var FirewallRuleObject[]|null */
|
||||
private ?array $rulesCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly FirewallRuleCache $ruleCache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -73,33 +71,36 @@ class FirewallService
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null
|
||||
): FirewallAnalyzeResult {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$ruleGroups = [
|
||||
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK],
|
||||
];
|
||||
|
||||
if ($tenantId !== null && $this->isEnabled()) {
|
||||
$tenantRules = $this->ruleCache->tenant($tenantId);
|
||||
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
|
||||
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
|
||||
// Check if firewall is enabled for this tenant
|
||||
if (!$this->isEnabled()) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
foreach ($ruleGroups as [$rules, $action]) {
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== $action) {
|
||||
continue;
|
||||
}
|
||||
$rules = $this->getActiveRules();
|
||||
|
||||
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($action === FirewallRuleObject::ACTION_ALLOW) {
|
||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||
}
|
||||
// First check for explicit allow rules (whitelist takes precedence)
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for block rules
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
|
||||
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
||||
}
|
||||
@@ -139,22 +140,14 @@ class FirewallService
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setTenantId($tenantId);
|
||||
$log = $this->securityLog($event);
|
||||
if ($log === null || !$this->store->createLogOnce($log)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for brute force
|
||||
$windowSeconds = $this->getBoundedIntegerConfig(
|
||||
$windowSeconds = $this->getConfig(
|
||||
self::CONFIG_FAILURE_WINDOW,
|
||||
self::DEFAULT_AUTH_FAILURE_WINDOW,
|
||||
self::MAX_AUTH_FAILURE_WINDOW
|
||||
self::DEFAULT_AUTH_FAILURE_WINDOW
|
||||
);
|
||||
$maxFailures = $this->getBoundedIntegerConfig(
|
||||
$maxFailures = $this->getConfig(
|
||||
self::CONFIG_MAX_FAILURES,
|
||||
self::DEFAULT_MAX_AUTH_FAILURES,
|
||||
self::MAX_AUTH_FAILURES
|
||||
self::DEFAULT_MAX_AUTH_FAILURES
|
||||
);
|
||||
|
||||
$failureCount = $this->store->countRecentFailures(
|
||||
@@ -163,24 +156,11 @@ class FirewallService
|
||||
$windowSeconds
|
||||
);
|
||||
|
||||
if ($failureCount >= $maxFailures) {
|
||||
$blockDuration = $this->getBoundedIntegerConfig(
|
||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||
self::DEFAULT_AUTO_BLOCK_DURATION,
|
||||
self::MAX_AUTO_BLOCK_DURATION
|
||||
);
|
||||
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
|
||||
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
|
||||
return;
|
||||
}
|
||||
// Include current failure in count
|
||||
$failureCount++;
|
||||
|
||||
$this->handleBruteForce(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
$failureCount,
|
||||
$windowSeconds,
|
||||
$blockDuration
|
||||
);
|
||||
if ($failureCount >= $maxFailures) {
|
||||
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,34 +168,26 @@ class FirewallService
|
||||
* Handle detected brute force attack
|
||||
*/
|
||||
private function handleBruteForce(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
int $failureCount,
|
||||
int $windowSeconds,
|
||||
int $blockDuration
|
||||
int $windowSeconds
|
||||
): void {
|
||||
// Publish brute force event
|
||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||
$event->setTenantId($tenantId);
|
||||
$event->setTenantId($this->tenantContext->identifier());
|
||||
$this->events->dispatch($event);
|
||||
|
||||
$this->rules->blockIp(
|
||||
FirewallRuleScope::tenant($tenantId),
|
||||
// Auto-block the IP
|
||||
$blockDuration = $this->getConfig(
|
||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||
self::DEFAULT_AUTO_BLOCK_DURATION
|
||||
);
|
||||
|
||||
$this->blockIp(
|
||||
$ipAddress,
|
||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||
null, // System-created
|
||||
$blockDuration,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => $this->getBoundedIntegerConfig(
|
||||
self::CONFIG_MAX_FAILURES,
|
||||
self::DEFAULT_MAX_AUTH_FAILURES,
|
||||
self::MAX_AUTH_FAILURES
|
||||
),
|
||||
'failureWindowSeconds' => $windowSeconds,
|
||||
'lastFailureCount' => $failureCount,
|
||||
'blockDurationSeconds' => $blockDuration,
|
||||
]
|
||||
$blockDuration
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,36 +195,26 @@ class FirewallService
|
||||
* Log security event to firewall logs
|
||||
*/
|
||||
public function logSecurityEvent(SecurityEvent $event): void
|
||||
{
|
||||
$log = $this->securityLog($event);
|
||||
if ($log !== null) {
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
}
|
||||
|
||||
private function securityLog(SecurityEvent $event): ?FirewallLogObject
|
||||
{
|
||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||
$ruleScope = $event->get('ruleScope');
|
||||
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
||||
return null;
|
||||
if (!$tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log = new FirewallLogObject();
|
||||
return $log->setEventId($event->getEventId())
|
||||
->setTenantId($tenantId)
|
||||
$log->setTenantId($tenantId)
|
||||
->setIpAddress($event->getIpAddress())
|
||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
||||
->setUserAgent($event->getUserAgent())
|
||||
->setRequestPath($event->getRequestPath())
|
||||
->setRequestMethod($event->getRequestMethod())
|
||||
->setEventType($this->mapEventToLogType($event->getName()))
|
||||
->setResult($this->mapEventToResult($event))
|
||||
->setRuleId($event->get('ruleId'))
|
||||
->setRuleScope($ruleScope)
|
||||
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
|
||||
->setResult($this->mapEventToResult($event->getName()))
|
||||
->setIdentityId($event->getUserId())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->getData());
|
||||
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,10 +229,6 @@ class FirewallService
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
||||
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_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
};
|
||||
}
|
||||
@@ -278,15 +236,11 @@ class FirewallService
|
||||
/**
|
||||
* Map security event to result
|
||||
*/
|
||||
private function mapEventToResult(SecurityEvent $event): string
|
||||
private function mapEventToResult(string $eventName): string
|
||||
{
|
||||
return match ($event->getName()) {
|
||||
return match ($eventName) {
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
|
||||
default => FirewallLogObject::RESULT_BLOCKED,
|
||||
};
|
||||
}
|
||||
@@ -303,13 +257,268 @@ class FirewallService
|
||||
$ipAddress,
|
||||
$deviceFingerprint,
|
||||
$rule->getId(),
|
||||
$rule->getScope(),
|
||||
$rule->getReason()
|
||||
);
|
||||
$event->setTenantId($this->tenantContext->identifier());
|
||||
$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');
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->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 {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->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 {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->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 {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->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
|
||||
*/
|
||||
@@ -324,9 +533,6 @@ class FirewallService
|
||||
private function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$config = $this->tenantContext->configuration();
|
||||
if ($config instanceof \JsonSerializable) {
|
||||
$config = $config->jsonSerialize();
|
||||
}
|
||||
$parts = explode('.', $key);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
@@ -339,14 +545,27 @@ class FirewallService
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
|
||||
/**
|
||||
* Get active rules (cached)
|
||||
* @return FirewallRuleObject[]
|
||||
*/
|
||||
private function getActiveRules(): array
|
||||
{
|
||||
$value = $this->getConfig($key, $default);
|
||||
if (!is_int($value) || $value < 1 || $value > $maximum) {
|
||||
return $default;
|
||||
if ($this->rulesCache === null) {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$this->rulesCache = $tenantId
|
||||
? $this->store->listRules($tenantId, true)
|
||||
: [];
|
||||
}
|
||||
return $this->rulesCache;
|
||||
}
|
||||
|
||||
return $value;
|
||||
/**
|
||||
* Clear rules cache
|
||||
*/
|
||||
private function clearRulesCache(): void
|
||||
{
|
||||
$this->rulesCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,31 +573,13 @@ class FirewallService
|
||||
*/
|
||||
public function cleanup(): array
|
||||
{
|
||||
$startedAt = new \DateTimeImmutable();
|
||||
$expiredRules = $this->store->cleanupExpiredRules();
|
||||
$oldLogs = $this->store->cleanupOldLogs(30);
|
||||
|
||||
try {
|
||||
$result = [
|
||||
'expiredRules' => $this->store->cleanupExpiredRules(),
|
||||
'oldLogs' => $this->store->cleanupOldLogs(30),
|
||||
'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(),
|
||||
];
|
||||
$this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $error) {
|
||||
try {
|
||||
$this->store->recordMaintenanceStatus(
|
||||
$startedAt,
|
||||
new \DateTimeImmutable(),
|
||||
'failed',
|
||||
[],
|
||||
$error->getMessage()
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Preserve the cleanup failure when the status store is also unavailable.
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
return [
|
||||
'expiredRules' => $expiredRules,
|
||||
'oldLogs' => $oldLogs,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?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,
|
||||
$this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->remove(
|
||||
FirewallRuleScope::system(),
|
||||
$ruleId,
|
||||
$this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?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, $this->identity->identifier());
|
||||
}
|
||||
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->remove($this->scope(), $ruleId, $this->identity->identifier());
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\ObjectId;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
|
||||
@@ -17,58 +15,11 @@ class FirewallStore
|
||||
{
|
||||
protected const RULES_COLLECTION = 'firewall_rules';
|
||||
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
||||
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Install the indexes used by firewall enforcement, audit queries, and expiry.
|
||||
*
|
||||
* MongoDB createIndex is idempotent when the name and specification match.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function ensureIndexes(): array
|
||||
{
|
||||
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
|
||||
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
|
||||
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
||||
|
||||
return [
|
||||
$rules->createIndex(
|
||||
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
|
||||
['name' => 'rules_by_scope_tenant_active']
|
||||
),
|
||||
$rules->createIndex(
|
||||
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
|
||||
['name' => 'rules_exact_lookup']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_auth_failures']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_tenant_timeline']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_blocked_counts']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_event_type']
|
||||
),
|
||||
$claims->createIndex(
|
||||
['expiresAt' => 1],
|
||||
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Rule Operations
|
||||
// ========================================
|
||||
@@ -78,19 +29,14 @@ class FirewallStore
|
||||
*/
|
||||
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||
{
|
||||
$filter = [
|
||||
'tenantId' => $tenantId,
|
||||
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
||||
];
|
||||
$filter = ['tenantId' => $tenantId];
|
||||
|
||||
if ($activeOnly) {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$and'] = [[
|
||||
'$or' => [
|
||||
$filter['$or'] = [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
||||
],
|
||||
]];
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
@@ -104,26 +50,6 @@ 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' => self::bsonDate(new \DateTimeImmutable())]],
|
||||
];
|
||||
}
|
||||
|
||||
$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
|
||||
*/
|
||||
@@ -135,7 +61,7 @@ class FirewallStore
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
]
|
||||
];
|
||||
|
||||
@@ -162,7 +88,7 @@ class FirewallStore
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
]
|
||||
];
|
||||
|
||||
@@ -182,7 +108,7 @@ class FirewallStore
|
||||
*/
|
||||
public function fetchRule(string $id): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
@@ -192,33 +118,15 @@ class FirewallStore
|
||||
/**
|
||||
* Check if exact IP rule exists
|
||||
*/
|
||||
public function findExactIpRule(
|
||||
?string $tenantId,
|
||||
string $ipAddress,
|
||||
string $action,
|
||||
string $scope = FirewallRuleObject::SCOPE_TENANT
|
||||
): ?FirewallRuleObject
|
||||
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
|
||||
{
|
||||
$filter = [
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
||||
'tenantId' => $tenantId,
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'value' => $ipAddress,
|
||||
'action' => $action,
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
||||
],
|
||||
];
|
||||
|
||||
if ($scope === FirewallRuleObject::SCOPE_SYSTEM) {
|
||||
$filter['scope'] = FirewallRuleObject::SCOPE_SYSTEM;
|
||||
$filter['tenantId'] = null;
|
||||
} else {
|
||||
$filter['tenantId'] = $tenantId;
|
||||
$filter['scope'] = FirewallRuleObject::SCOPE_TENANT;
|
||||
}
|
||||
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne($filter);
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
@@ -231,8 +139,6 @@ class FirewallStore
|
||||
*/
|
||||
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$rule->assertValidScopeOwnership();
|
||||
|
||||
if ($rule->getId()) {
|
||||
return $this->updateRule($rule);
|
||||
} else {
|
||||
@@ -242,7 +148,7 @@ class FirewallStore
|
||||
|
||||
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$data = self::ruleDocument($rule);
|
||||
$data = $rule->jsonSerialize();
|
||||
unset($data['id']); // Remove id for insert
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
||||
@@ -257,11 +163,11 @@ class FirewallStore
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = self::ruleDocument($rule);
|
||||
$data = $rule->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||
self::ruleIdFilter($id),
|
||||
['_id' => $id],
|
||||
['$set' => $data]
|
||||
);
|
||||
return $rule;
|
||||
@@ -276,7 +182,7 @@ class FirewallStore
|
||||
if (!$id) {
|
||||
return;
|
||||
}
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,10 +191,8 @@ class FirewallStore
|
||||
public function cleanupExpiredRules(): int
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
||||
'expiresAt' => [
|
||||
'$lt' => self::bsonDate(new \DateTimeImmutable()),
|
||||
'$ne' => null,
|
||||
],
|
||||
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
|
||||
'expiresAt' => ['$ne' => null]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
@@ -303,7 +207,7 @@ class FirewallStore
|
||||
*/
|
||||
public function createLog(FirewallLogObject $log): FirewallLogObject
|
||||
{
|
||||
$data = self::logDocument($log);
|
||||
$data = $log->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
||||
@@ -311,30 +215,6 @@ class FirewallStore
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
|
||||
*/
|
||||
public function createLogOnce(FirewallLogObject $log): bool
|
||||
{
|
||||
$eventId = $log->getEventId();
|
||||
if ($eventId === null || $eventId === '') {
|
||||
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
|
||||
}
|
||||
|
||||
$data = self::logDocument($log);
|
||||
unset($data['id']);
|
||||
$data['_id'] = $eventId;
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
|
||||
['_id' => $eventId],
|
||||
['$setOnInsert' => $data],
|
||||
['upsert' => true]
|
||||
);
|
||||
$log->setId($eventId);
|
||||
|
||||
return $result->getUpsertedCount() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a tenant with optional filters
|
||||
*/
|
||||
@@ -390,50 +270,10 @@ class FirewallStore
|
||||
'tenantId' => $tenantId,
|
||||
'ipAddress' => $ipAddress,
|
||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
'timestamp' => ['$gte' => self::bsonDate($since)]
|
||||
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
|
||||
*/
|
||||
public function claimBruteForce(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
int $claimDurationSeconds
|
||||
): bool {
|
||||
if ($claimDurationSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
|
||||
}
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
|
||||
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
||||
|
||||
$collection->deleteOne([
|
||||
'_id' => $claimId,
|
||||
'expiresAt' => ['$lte' => self::bsonDate($now)],
|
||||
]);
|
||||
|
||||
try {
|
||||
$collection->insertOne([
|
||||
'_id' => $claimId,
|
||||
'tenantId' => $tenantId,
|
||||
'ipAddress' => $ipAddress,
|
||||
'createdAt' => self::bsonDate($now),
|
||||
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
|
||||
]);
|
||||
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
|
||||
if ($error->getCode() === 11000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count for dashboard
|
||||
*/
|
||||
@@ -447,7 +287,7 @@ class FirewallStore
|
||||
];
|
||||
|
||||
if ($since !== null) {
|
||||
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
|
||||
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
|
||||
}
|
||||
|
||||
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
||||
@@ -461,79 +301,9 @@ class FirewallStore
|
||||
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
||||
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
|
||||
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
public function cleanupExpiredBruteForceClaims(): int
|
||||
{
|
||||
$result = $this->dataStore
|
||||
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
|
||||
->deleteMany([
|
||||
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
public function recordMaintenanceStatus(
|
||||
\DateTimeImmutable $startedAt,
|
||||
\DateTimeImmutable $completedAt,
|
||||
string $status,
|
||||
array $result,
|
||||
?string $error = null
|
||||
): void {
|
||||
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
|
||||
['_id' => 'cleanup'],
|
||||
['$set' => [
|
||||
'startedAt' => self::bsonDate($startedAt),
|
||||
'completedAt' => self::bsonDate($completedAt),
|
||||
'status' => $status,
|
||||
'result' => $result,
|
||||
'error' => $error,
|
||||
]],
|
||||
['upsert' => true]
|
||||
);
|
||||
}
|
||||
|
||||
public function maintenanceStatus(): ?array
|
||||
{
|
||||
return $this->dataStore
|
||||
->selectCollection(self::MAINTENANCE_COLLECTION)
|
||||
->findOne(['_id' => 'cleanup']);
|
||||
}
|
||||
|
||||
private static function ruleDocument(FirewallRuleObject $rule): array
|
||||
{
|
||||
$data = $rule->jsonSerialize();
|
||||
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
|
||||
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function logDocument(FirewallLogObject $log): array
|
||||
{
|
||||
$data = $log->jsonSerialize();
|
||||
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
|
||||
{
|
||||
return $date === null ? null : self::bsonDate($date);
|
||||
}
|
||||
|
||||
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
|
||||
{
|
||||
return UTCDateTime::fromDateTime($date);
|
||||
}
|
||||
|
||||
private static function ruleIdFilter(string $id): array
|
||||
{
|
||||
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Install in /etc/cron.d/ktrix-firewall after adjusting the user/path if needed.
|
||||
*/15 * * * * www-data cd /var/www/ktrix/main && bin/console firewall:maintenance
|
||||
@@ -0,0 +1,22 @@
|
||||
[program:ktrix-mail-daemon]
|
||||
command=/usr/bin/php /var/www/ktrix/main/bin/console mail:queue:daemon
|
||||
directory=/var/www/ktrix/main
|
||||
user=www-data
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
startretries=3
|
||||
exitcodes=0
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=30
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/www/ktrix/main/var/log/mail-daemon.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stdout_logfile_backups=5
|
||||
environment=PHP_INI_SCAN_DIR="/etc/php/8.2/cli/conf.d"
|
||||
|
||||
; Process name for easier identification
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Ktrix Mail Queue Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/ktrix/main
|
||||
ExecStart=/usr/bin/php bin/console mail:queue:daemon
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/www/ktrix/main/var/log/mail-daemon.log
|
||||
StandardError=append:/var/www/ktrix/main/var/log/mail-daemon.log
|
||||
|
||||
# Process management
|
||||
KillMode=process
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/www/ktrix/main/storage
|
||||
ReadWritePaths=/var/www/ktrix/main/var
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Generated
+94
-97
@@ -39,7 +39,7 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^30.0.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
@@ -53,56 +53,38 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
|
||||
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-calc": "^3.2.1",
|
||||
"@csstools/css-color-parser": "^4.1.9",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
|
||||
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
|
||||
@@ -236,9 +218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -256,9 +238,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -280,9 +262,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -296,8 +278,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -331,9 +313,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
|
||||
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -527,9 +509,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -642,14 +624,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
|
||||
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
|
||||
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/devtools-types": "11.4.8",
|
||||
"@intlify/message-compiler": "11.4.8",
|
||||
"@intlify/shared": "11.4.8"
|
||||
"@intlify/devtools-types": "11.4.7",
|
||||
"@intlify/message-compiler": "11.4.7",
|
||||
"@intlify/shared": "11.4.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -659,13 +641,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/devtools-types": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
|
||||
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
|
||||
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.8",
|
||||
"@intlify/shared": "11.4.8"
|
||||
"@intlify/core-base": "11.4.7",
|
||||
"@intlify/shared": "11.4.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -675,12 +657,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
|
||||
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
|
||||
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.4.8",
|
||||
"@intlify/shared": "11.4.7",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -691,9 +673,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
|
||||
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
|
||||
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -4009,39 +3991,39 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"version": "30.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz",
|
||||
"integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.2.5",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.6",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.7.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
@@ -4049,6 +4031,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
|
||||
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -4425,9 +4422,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -5872,9 +5869,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -5984,13 +5981,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
@@ -6537,14 +6534,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
|
||||
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
|
||||
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.8",
|
||||
"@intlify/devtools-types": "11.4.8",
|
||||
"@intlify/shared": "11.4.8",
|
||||
"@intlify/core-base": "11.4.7",
|
||||
"@intlify/devtools-types": "11.4.7",
|
||||
"@intlify/shared": "11.4.7",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^30.0.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
|
||||
@@ -12,7 +12,6 @@ class Event
|
||||
private bool $propagationStopped = false;
|
||||
private array $data = [];
|
||||
private float $timestamp;
|
||||
private string $eventId;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $identityId = null;
|
||||
|
||||
@@ -22,7 +21,6 @@ class Event
|
||||
) {
|
||||
$this->data = $data;
|
||||
$this->timestamp = microtime(true);
|
||||
$this->eventId = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,11 +80,6 @@ class Event
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function getEventId(): string
|
||||
{
|
||||
return $this->eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop event propagation to subsequent listeners
|
||||
*/
|
||||
@@ -150,4 +143,4 @@ class Event
|
||||
'identityId' => $this->identityId,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,6 @@ class SecurityEvent extends Event
|
||||
public const IP_BLOCKED = 'security.ip.blocked';
|
||||
public const IP_ALLOWED = 'security.ip.allowed';
|
||||
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_DISABLED = 'security.firewall.rule.disabled';
|
||||
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
||||
|
||||
private ?string $ipAddress = null;
|
||||
private ?string $deviceFingerprint = null;
|
||||
@@ -149,12 +145,10 @@ class SecurityEvent extends Event
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null,
|
||||
?string $ruleId = null,
|
||||
?string $ruleScope = null,
|
||||
?string $reason = null
|
||||
): self {
|
||||
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
|
||||
'ruleId' => $ruleId,
|
||||
'ruleScope' => $ruleScope,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$event->reason = $reason;
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Integration\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
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\TestCase;
|
||||
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
class FirewallStoreTest extends TestCase
|
||||
{
|
||||
private DataStore $dataStore;
|
||||
private FirewallStore $store;
|
||||
private bool $databaseAvailable = false;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$system = require dirname(__DIR__, 4).'/config/system.php';
|
||||
$database = $system['database'];
|
||||
$database['database'] = sprintf('ktrix_firewall_test_%d', getmypid());
|
||||
|
||||
$this->dataStore = new DataStore($database);
|
||||
try {
|
||||
$this->dataStore->getDatabase()->drop();
|
||||
$this->databaseAvailable = true;
|
||||
} catch (\MongoDB\Driver\Exception\Exception $error) {
|
||||
self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage());
|
||||
}
|
||||
$this->store = new FirewallStore($this->dataStore);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->databaseAvailable) {
|
||||
$this->dataStore->getDatabase()->drop();
|
||||
}
|
||||
}
|
||||
|
||||
#[TestDox('System and tenant rule sets remain independently scoped')]
|
||||
public function testApplicableScopes(): void
|
||||
{
|
||||
$tenantA = $this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a');
|
||||
$tenantB = $this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b');
|
||||
$system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM);
|
||||
$expiredSystem = $this->rule('expired-system', FirewallRuleObject::SCOPE_SYSTEM)
|
||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'));
|
||||
$disabledSystem = $this->rule('disabled-system', FirewallRuleObject::SCOPE_SYSTEM)
|
||||
->setEnabled(false);
|
||||
|
||||
foreach ([$tenantA, $tenantB, $system, $expiredSystem, $disabledSystem] as $rule) {
|
||||
$this->store->depositRule($rule);
|
||||
}
|
||||
|
||||
$rules = array_merge(
|
||||
$this->store->listSystemRules(),
|
||||
$this->store->listRules('tenant-a')
|
||||
);
|
||||
$reasons = array_map(static fn(FirewallRuleObject $rule): ?string => $rule->getReason(), $rules);
|
||||
sort($reasons);
|
||||
|
||||
self::assertSame(['system', 'tenant-a'], $reasons);
|
||||
}
|
||||
|
||||
#[TestDox('Tenant rule listings cannot expose system or other tenant rules')]
|
||||
public function testTenantListingIsolation(): void
|
||||
{
|
||||
$this->store->depositRule($this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
|
||||
$this->store->depositRule($this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'));
|
||||
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
|
||||
|
||||
$rules = $this->store->listRules('tenant-a');
|
||||
|
||||
self::assertCount(1, $rules);
|
||||
self::assertSame('tenant-a', $rules[0]->getReason());
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rules[0]->getScope());
|
||||
}
|
||||
|
||||
#[TestDox('Expired exact IP rules do not suppress a replacement block')]
|
||||
public function testExpiredBlockReplacement(): void
|
||||
{
|
||||
$expired = $this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'));
|
||||
$this->store->depositRule($expired);
|
||||
|
||||
self::assertNull($this->store->findExactIpRule(
|
||||
'tenant-a',
|
||||
'203.0.113.10',
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
));
|
||||
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$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());
|
||||
self::assertSame(
|
||||
$replacement->getId(),
|
||||
$this->store->findExactIpRule(
|
||||
'tenant-a',
|
||||
'203.0.113.10',
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
)?->getId()
|
||||
);
|
||||
self::assertCount(2, $this->store->listRules('tenant-a', false));
|
||||
}
|
||||
|
||||
#[TestDox('Exact IP lookups respect tenant and system scope')]
|
||||
public function testExactLookupScope(): void
|
||||
{
|
||||
$tenant = $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a');
|
||||
$system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM);
|
||||
$this->store->depositRule($tenant);
|
||||
$this->store->depositRule($system);
|
||||
|
||||
self::assertSame(
|
||||
$tenant->getId(),
|
||||
$this->store->findExactIpRule(
|
||||
'tenant-a',
|
||||
'203.0.113.10',
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
)?->getId()
|
||||
);
|
||||
self::assertSame(
|
||||
$system->getId(),
|
||||
$this->store->findExactIpRule(
|
||||
null,
|
||||
'203.0.113.10',
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
FirewallRuleObject::SCOPE_SYSTEM
|
||||
)?->getId()
|
||||
);
|
||||
self::assertNull($this->store->findExactIpRule(
|
||||
'tenant-b',
|
||||
'203.0.113.10',
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[TestDox('Firewall log persistence retains matched rule context')]
|
||||
public function testRuleAuditPersistence(): void
|
||||
{
|
||||
$log = (new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setIpAddress('203.0.113.10')
|
||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setRuleId('rule-123')
|
||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTimestamp(new \DateTimeImmutable());
|
||||
$this->store->createLog($log);
|
||||
|
||||
$logs = $this->store->listLogs('tenant-a');
|
||||
|
||||
self::assertCount(1, $logs);
|
||||
self::assertSame('rule-123', $logs[0]->getRuleId());
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $logs[0]->getRuleScope());
|
||||
}
|
||||
|
||||
#[TestDox('Rule lifecycle audit persistence retains actor and origin')]
|
||||
public function testLifecycleAuditPersistence(): void
|
||||
{
|
||||
$log = (new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setEventType(FirewallLogObject::EVENT_RULE_CREATED)
|
||||
->setResult(FirewallLogObject::RESULT_RECORDED)
|
||||
->setRuleId('rule-456')
|
||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setIdentityId('admin-a')
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
|
||||
$this->store->createLog($log);
|
||||
|
||||
$logs = $this->store->listLogs('tenant-a');
|
||||
|
||||
self::assertCount(1, $logs);
|
||||
self::assertSame(FirewallLogObject::EVENT_RULE_CREATED, $logs[0]->getEventType());
|
||||
self::assertSame(FirewallLogObject::RESULT_RECORDED, $logs[0]->getResult());
|
||||
self::assertSame('admin-a', $logs[0]->getIdentityId());
|
||||
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $logs[0]->getMetadata()['origin']);
|
||||
}
|
||||
|
||||
#[TestDox('Event-backed firewall logs are inserted exactly once')]
|
||||
public function testIdempotentLogPersistence(): void
|
||||
{
|
||||
$log = (new FirewallLogObject())
|
||||
->setEventId('event-123')
|
||||
->setTenantId('tenant-a')
|
||||
->setIpAddress('203.0.113.10')
|
||||
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setTimestamp(new \DateTimeImmutable());
|
||||
|
||||
self::assertTrue($this->store->createLogOnce($log));
|
||||
self::assertFalse($this->store->createLogOnce($log));
|
||||
|
||||
$logs = $this->store->listLogs('tenant-a');
|
||||
self::assertCount(1, $logs);
|
||||
self::assertSame('event-123', $logs[0]->getEventId());
|
||||
}
|
||||
|
||||
#[TestDox('Only one worker can claim a tenant and IP brute-force response')]
|
||||
public function testBruteForceClaim(): void
|
||||
{
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
self::assertFalse($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-b', '203.0.113.10', 3600));
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.11', 3600));
|
||||
}
|
||||
|
||||
#[TestDox('Expired brute-force claims can be acquired again and cleaned up')]
|
||||
public function testExpiredBruteForceClaim(): void
|
||||
{
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
|
||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
||||
['_id' => $claimId],
|
||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
||||
);
|
||||
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
||||
['_id' => $claimId],
|
||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
||||
);
|
||||
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
|
||||
}
|
||||
|
||||
#[TestDox('Firewall dates use BSON date storage and indexes are installed idempotently')]
|
||||
public function testBsonDatesAndIndexes(): void
|
||||
{
|
||||
$rule = $this->rule('bson-date', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('+1 hour'));
|
||||
$this->store->depositRule($rule);
|
||||
|
||||
$log = (new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
|
||||
->setResult(FirewallLogObject::RESULT_ALLOWED)
|
||||
->setTimestamp(new \DateTimeImmutable());
|
||||
$this->store->createLog($log);
|
||||
|
||||
$storedRule = $this->dataStore->selectCollection('firewall_rules')
|
||||
->getMongoCollection()
|
||||
->findOne(['_id' => new \MongoDB\BSON\ObjectId($rule->getId())]);
|
||||
$storedLog = $this->dataStore->selectCollection('firewall_logs')
|
||||
->getMongoCollection()
|
||||
->findOne(['_id' => new \MongoDB\BSON\ObjectId($log->getId())]);
|
||||
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['createdAt']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['expiresAt']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedLog['timestamp']);
|
||||
|
||||
$expected = $this->store->ensureIndexes();
|
||||
self::assertSame($expected, $this->store->ensureIndexes());
|
||||
|
||||
$claimIndex = null;
|
||||
foreach ($this->dataStore->selectCollection('firewall_brute_force_claims')->getMongoCollection()->listIndexes() as $index) {
|
||||
if ($index->getName() === 'claims_expiry') {
|
||||
$claimIndex = $index;
|
||||
}
|
||||
}
|
||||
self::assertNotNull($claimIndex);
|
||||
self::assertSame(0, $claimIndex['expireAfterSeconds']);
|
||||
}
|
||||
|
||||
#[TestDox('Automatic block extensions persist policy and expiration history')]
|
||||
public function testAutomaticBlockExtensionPersistence(): void
|
||||
{
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$manager = new FirewallRuleManager($this->store, new FirewallRuleCache($this->store), $events);
|
||||
$scope = FirewallRuleScope::tenant('tenant-a');
|
||||
$rule = $manager->blockIp(
|
||||
$scope,
|
||||
'203.0.113.10',
|
||||
'Initial attack',
|
||||
null,
|
||||
60,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => 5,
|
||||
'failureWindowSeconds' => 300,
|
||||
'lastFailureCount' => 5,
|
||||
'blockDurationSeconds' => 60,
|
||||
]
|
||||
);
|
||||
$originalExpiry = $rule->getExpiresAt();
|
||||
|
||||
$extended = $manager->blockIp(
|
||||
$scope,
|
||||
'203.0.113.10',
|
||||
'Continued attack',
|
||||
null,
|
||||
3600,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => 5,
|
||||
'failureWindowSeconds' => 300,
|
||||
'lastFailureCount' => 9,
|
||||
'blockDurationSeconds' => 3600,
|
||||
]
|
||||
);
|
||||
$persisted = $this->store->fetchRule($rule->getId());
|
||||
|
||||
self::assertSame($rule->getId(), $extended->getId());
|
||||
self::assertNotNull($persisted);
|
||||
self::assertGreaterThan($originalExpiry, $persisted->getExpiresAt());
|
||||
self::assertSame(9, $persisted->getMetadata()['lastFailureCount']);
|
||||
self::assertCount(1, $persisted->getMetadata()['extensions']);
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup removes only expired rules, old logs, and expired claims')]
|
||||
public function testCleanupBoundaries(): void
|
||||
{
|
||||
$this->store->depositRule(
|
||||
$this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
|
||||
);
|
||||
$this->store->depositRule(
|
||||
$this->rule('active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('+1 hour'))
|
||||
);
|
||||
foreach (['-31 days', '-29 days'] as $age) {
|
||||
$this->store->createLog(
|
||||
(new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
|
||||
->setResult(FirewallLogObject::RESULT_ALLOWED)
|
||||
->setTimestamp(new \DateTimeImmutable($age))
|
||||
);
|
||||
}
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
|
||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
||||
['_id' => $claimId],
|
||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
||||
);
|
||||
|
||||
self::assertSame(1, $this->store->cleanupExpiredRules());
|
||||
self::assertSame(1, $this->store->cleanupOldLogs());
|
||||
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
|
||||
self::assertCount(1, $this->store->listRules('tenant-a'));
|
||||
self::assertCount(1, $this->store->listLogs('tenant-a'));
|
||||
}
|
||||
|
||||
#[TestDox('Maintenance outcomes are persisted with BSON timestamps')]
|
||||
public function testMaintenanceStatusPersistence(): void
|
||||
{
|
||||
$this->store->recordMaintenanceStatus(
|
||||
new \DateTimeImmutable('-1 second'),
|
||||
new \DateTimeImmutable(),
|
||||
'success',
|
||||
['expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4]
|
||||
);
|
||||
|
||||
$status = $this->store->maintenanceStatus();
|
||||
$raw = $this->dataStore->selectCollection('firewall_maintenance')
|
||||
->getMongoCollection()
|
||||
->findOne(['_id' => 'cleanup']);
|
||||
|
||||
self::assertSame('success', $status['status']);
|
||||
self::assertSame(3, $status['result']['oldLogs']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['startedAt']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['completedAt']);
|
||||
}
|
||||
|
||||
#[TestDox('Firewall query shapes select their intended indexes')]
|
||||
public function testIndexedQueryShapes(): void
|
||||
{
|
||||
$this->store->ensureIndexes();
|
||||
$this->store->depositRule($this->rule('indexed', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
|
||||
$this->store->createLog(
|
||||
(new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setIpAddress('203.0.113.10')
|
||||
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
);
|
||||
|
||||
$database = $this->dataStore->getDatabase()->getMongoDatabase();
|
||||
$now = new \MongoDB\BSON\UTCDateTime();
|
||||
$rulePlan = $database->command([
|
||||
'explain' => [
|
||||
'find' => 'firewall_rules',
|
||||
'filter' => [
|
||||
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
||||
'tenantId' => 'tenant-a',
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'value' => '203.0.113.10',
|
||||
'action' => FirewallRuleObject::ACTION_BLOCK,
|
||||
'enabled' => true,
|
||||
'$or' => [['expiresAt' => null], ['expiresAt' => ['$gt' => $now]]],
|
||||
],
|
||||
],
|
||||
'verbosity' => 'queryPlanner',
|
||||
])->toArray()[0];
|
||||
$failurePlan = $database->command([
|
||||
'explain' => [
|
||||
'find' => 'firewall_logs',
|
||||
'filter' => [
|
||||
'tenantId' => 'tenant-a',
|
||||
'ipAddress' => '203.0.113.10',
|
||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
'timestamp' => ['$gte' => new \MongoDB\BSON\UTCDateTime(0)],
|
||||
],
|
||||
],
|
||||
'verbosity' => 'queryPlanner',
|
||||
])->toArray()[0];
|
||||
|
||||
self::assertContains('rules_exact_lookup', self::indexNames($rulePlan));
|
||||
self::assertContains('logs_auth_failures', self::indexNames($failurePlan));
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $reason,
|
||||
string $scope,
|
||||
?string $tenantId = null
|
||||
): FirewallRuleObject {
|
||||
return (new FirewallRuleObject())
|
||||
->setScope($scope)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue('203.0.113.10')
|
||||
->setReason($reason)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
}
|
||||
|
||||
private static function indexNames(mixed $value): array
|
||||
{
|
||||
if (is_object($value)) {
|
||||
$value = (array)$value;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = isset($value['indexName']) ? [(string)$value['indexName']] : [];
|
||||
foreach ($value as $child) {
|
||||
$names = [...$names, ...self::indexNames($child)];
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Console\Firewall;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class FirewallCommandsTest extends TestCase
|
||||
{
|
||||
#[TestDox('Setup verifies every firewall database index')]
|
||||
public function testSetup(): void
|
||||
{
|
||||
$store = $this->createMock(FirewallStore::class);
|
||||
$store->expects(self::once())->method('ensureIndexes')->willReturn(array_fill(0, 7, 'index'));
|
||||
$tester = new CommandTester(new FirewallSetupCommand($store));
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->execute([]));
|
||||
self::assertStringContainsString('7 indexes verified', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[TestDox('Maintenance reports cleanup counts for schedulers')]
|
||||
public function testMaintenance(): void
|
||||
{
|
||||
$firewall = $this->createMock(FirewallService::class);
|
||||
$firewall->expects(self::once())->method('cleanup')->willReturn([
|
||||
'expiredRules' => 2,
|
||||
'oldLogs' => 3,
|
||||
'expiredBruteForceClaims' => 4,
|
||||
]);
|
||||
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->execute([]));
|
||||
self::assertStringContainsString('2 expired rules, 3 old logs', $tester->getDisplay());
|
||||
self::assertStringContainsString('4 expired', $tester->getDisplay());
|
||||
self::assertStringContainsString('claims removed', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[TestDox('Maintenance returns failure to its scheduler')]
|
||||
public function testMaintenanceFailure(): void
|
||||
{
|
||||
$firewall = $this->createStub(FirewallService::class);
|
||||
$firewall->method('cleanup')->willThrowException(new \RuntimeException('database unavailable'));
|
||||
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
|
||||
|
||||
self::assertSame(Command::FAILURE, $tester->execute([]));
|
||||
self::assertStringContainsString('database unavailable', $tester->getDisplay());
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Models\Firewall;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FirewallLogObjectTest extends TestCase
|
||||
{
|
||||
#[TestDox('Rule ID and scope survive firewall log serialization')]
|
||||
public function testRuleContextSerialization(): void
|
||||
{
|
||||
$log = (new FirewallLogObject())
|
||||
->setEventId('event-123')
|
||||
->setRuleId('rule-123')
|
||||
->setRuleScope(FirewallRuleObject::SCOPE_SYSTEM);
|
||||
|
||||
$restored = (new FirewallLogObject())->jsonDeserialize($log->jsonSerialize());
|
||||
|
||||
self::assertSame('rule-123', $restored->getRuleId());
|
||||
self::assertSame('event-123', $restored->getEventId());
|
||||
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $restored->getRuleScope());
|
||||
}
|
||||
|
||||
#[TestDox('Unknown rule scopes are rejected from firewall logs')]
|
||||
public function testRuleScopeValidation(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
(new FirewallLogObject())->setRuleScope('unknown');
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Models\Firewall;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FirewallRuleObjectTest extends TestCase
|
||||
{
|
||||
#[TestDox('Persisted rules without an explicit scope are rejected')]
|
||||
public function testMissingScope(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('explicit scope');
|
||||
|
||||
(new FirewallRuleObject())->jsonDeserialize([
|
||||
'tenantId' => 'tenant-a',
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'action' => FirewallRuleObject::ACTION_BLOCK,
|
||||
'value' => '203.0.113.10',
|
||||
]);
|
||||
}
|
||||
|
||||
#[TestDox('Unknown rule scopes are rejected')]
|
||||
public function testUnknownScope(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
(new FirewallRuleObject())->setScope('unknown');
|
||||
}
|
||||
|
||||
#[TestDox('Tenant rules require a tenant ID')]
|
||||
public function testTenantOwnership(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('require a tenant ID');
|
||||
|
||||
(new FirewallRuleObject())->assertValidScopeOwnership();
|
||||
}
|
||||
|
||||
#[TestDox('System rules cannot have a tenant ID')]
|
||||
public function testSystemOwnership(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('cannot have a tenant ID');
|
||||
|
||||
(new FirewallRuleObject())
|
||||
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
||||
->setTenantId('tenant-a')
|
||||
->assertValidScopeOwnership();
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Module;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
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;
|
||||
@@ -30,26 +26,12 @@ final class CoreModuleTest extends TestCase
|
||||
$module->boot();
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
self::assertCount(10, $definitions);
|
||||
self::assertCount(5, $definitions);
|
||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||
self::assertSame(
|
||||
FirewallService::class,
|
||||
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
|
||||
);
|
||||
self::assertSame([], $registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Deferred));
|
||||
foreach ([
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||
] as $event) {
|
||||
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
||||
self::assertCount(1, $listeners);
|
||||
self::assertSame(FirewallService::class, $listeners[0]->service);
|
||||
self::assertSame('logSecurityEvent', $listeners[0]->method);
|
||||
}
|
||||
self::assertFalse($registry->frozen());
|
||||
}
|
||||
|
||||
@@ -60,19 +42,5 @@ final class CoreModuleTest extends TestCase
|
||||
$module = new Module(new EventListenerRegistry());
|
||||
|
||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
|
||||
self::assertContains(FirewallMaintenanceCommand::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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
<?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 KTXF\Event\SecurityEvent;
|
||||
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('listRules')
|
||||
->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->tenant('tenant-a'));
|
||||
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
|
||||
self::assertSame([], $cache->tenant('tenant-a'));
|
||||
}
|
||||
|
||||
#[TestDox('Rule creation emits complete lifecycle audit context')]
|
||||
public function testCreationAudit(): void
|
||||
{
|
||||
$this->store->method('findExactIpRule')->willReturn(null);
|
||||
$this->store->method('depositRule')->willReturnCallback(
|
||||
static function (FirewallRuleObject $rule): FirewallRuleObject {
|
||||
return $rule->setId('rule-123');
|
||||
}
|
||||
);
|
||||
$events = [];
|
||||
$this->events->expects($this->exactly(2))
|
||||
->method('dispatch')
|
||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
||||
$events[$event->getName()] = $event;
|
||||
});
|
||||
|
||||
$this->manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
'Repeated abuse',
|
||||
'admin-a',
|
||||
300
|
||||
);
|
||||
|
||||
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED];
|
||||
self::assertSame('rule-123', $audit->get('ruleId'));
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
|
||||
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
|
||||
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
|
||||
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
|
||||
self::assertSame('admin-a', $audit->getIdentityId());
|
||||
self::assertNotNull($audit->get('expiresAt'));
|
||||
}
|
||||
|
||||
#[TestDox('Disable and remove audits identify the acting administrator')]
|
||||
public function testLifecycleActors(): void
|
||||
{
|
||||
$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');
|
||||
$this->store->method('fetchRule')->willReturn($rule);
|
||||
$events = [];
|
||||
$this->events->expects($this->exactly(2))
|
||||
->method('dispatch')
|
||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
||||
$events[$event->getName()] = $event;
|
||||
});
|
||||
|
||||
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
|
||||
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
|
||||
|
||||
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
|
||||
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
|
||||
}
|
||||
|
||||
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
|
||||
public function testAutomaticBlockExtension(): void
|
||||
{
|
||||
$originalExpiry = new \DateTimeImmutable('+5 minutes');
|
||||
$rule = (new FirewallRuleObject())
|
||||
->setId('rule-123')
|
||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId('tenant-a')
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue('203.0.113.10')
|
||||
->setExpiresAt($originalExpiry)
|
||||
->setMetadata([
|
||||
'origin' => FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
'originalExpiresAt' => $originalExpiry->format(\DateTimeInterface::ATOM),
|
||||
'extensions' => [],
|
||||
]);
|
||||
$this->store->method('findExactIpRule')->willReturn($rule);
|
||||
$this->store->expects(self::once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static function (FirewallRuleObject $extended) use ($originalExpiry): bool {
|
||||
$metadata = $extended->getMetadata();
|
||||
return $extended->getExpiresAt() > $originalExpiry
|
||||
&& $metadata['failureThreshold'] === 5
|
||||
&& $metadata['failureWindowSeconds'] === 300
|
||||
&& $metadata['lastFailureCount'] === 8
|
||||
&& $metadata['originalExpiresAt'] === $originalExpiry->format(\DateTimeInterface::ATOM)
|
||||
&& count($metadata['extensions']) === 1;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$this->events->expects(self::once())
|
||||
->method('dispatch')
|
||||
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
|
||||
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
|
||||
&& $event->get('lastFailureCount') === 8
|
||||
));
|
||||
|
||||
$extended = $this->manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
'Continued attack',
|
||||
null,
|
||||
3600,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => 5,
|
||||
'failureWindowSeconds' => 300,
|
||||
'lastFailureCount' => 8,
|
||||
'blockDurationSeconds' => 3600,
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame('rule-123', $extended->getId());
|
||||
}
|
||||
|
||||
#[TestDox('Automatic detection never extends a manual block')]
|
||||
public function testManualBlockIsNotExtended(): void
|
||||
{
|
||||
$rule = (new FirewallRuleObject())
|
||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId('tenant-a')
|
||||
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
|
||||
$this->store->method('findExactIpRule')->willReturn($rule);
|
||||
$this->store->expects(self::never())->method('depositRule');
|
||||
$this->events->expects(self::never())->method('dispatch');
|
||||
|
||||
self::assertSame($rule, $this->manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
null,
|
||||
null,
|
||||
3600,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Service;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
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;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
class FirewallServiceTest extends TestCase
|
||||
{
|
||||
private FirewallStore&MockObject $store;
|
||||
private TenantContextInterface&MockObject $tenantContext;
|
||||
private EventDispatcherInterface&MockObject $events;
|
||||
private FirewallService $service;
|
||||
private ?string $currentTenant;
|
||||
private ?TenantConfiguration $currentConfiguration;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->store = $this->createMock(FirewallStore::class);
|
||||
$this->tenantContext = $this->createMock(TenantContextInterface::class);
|
||||
$this->events = $this->createMock(EventDispatcherInterface::class);
|
||||
$this->currentTenant = 'tenant-a';
|
||||
$this->currentConfiguration = null;
|
||||
$this->tenantContext->method('identifier')->willReturnCallback(
|
||||
fn(): ?string => $this->currentTenant
|
||||
);
|
||||
$this->tenantContext->method('configuration')->willReturnCallback(
|
||||
fn(): ?TenantConfiguration => $this->currentConfiguration
|
||||
);
|
||||
$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')]
|
||||
public function testSystemBlockPrecedence(): void
|
||||
{
|
||||
$systemBlock = $this->rule(
|
||||
'system-block',
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
null
|
||||
);
|
||||
$tenantAllow = $this->rule(
|
||||
'tenant-allow',
|
||||
FirewallRuleObject::SCOPE_TENANT,
|
||||
FirewallRuleObject::ACTION_ALLOW,
|
||||
'tenant-a'
|
||||
);
|
||||
|
||||
$this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]);
|
||||
$this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]);
|
||||
$this->events->expects($this->once())->method('dispatch');
|
||||
|
||||
$result = $this->service->analyze('203.0.113.10');
|
||||
|
||||
self::assertTrue($result->isBlocked());
|
||||
self::assertSame('system-block', $result->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('Tenant blocks override system allows')]
|
||||
public function testTenantBlockPrecedence(): void
|
||||
{
|
||||
$systemAllow = $this->rule(
|
||||
'system-allow',
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
FirewallRuleObject::ACTION_ALLOW,
|
||||
null
|
||||
);
|
||||
$tenantBlock = $this->rule(
|
||||
'tenant-block',
|
||||
FirewallRuleObject::SCOPE_TENANT,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
'tenant-a'
|
||||
);
|
||||
|
||||
$this->store->method('listSystemRules')->willReturn([$systemAllow]);
|
||||
$this->store->method('listRules')->with('tenant-a')->willReturn([$tenantBlock]);
|
||||
$this->events->expects($this->once())->method('dispatch');
|
||||
|
||||
$result = $this->service->analyze('203.0.113.10');
|
||||
|
||||
self::assertTrue($result->isBlocked());
|
||||
self::assertSame('tenant-block', $result->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('Rule caches are isolated by tenant')]
|
||||
public function testTenantCacheIsolation(): void
|
||||
{
|
||||
$this->store->expects($this->once())->method('listSystemRules')->willReturn([]);
|
||||
$this->store->expects($this->exactly(2))
|
||||
->method('listRules')
|
||||
->willReturnCallback(static fn(string $tenantId): array => [
|
||||
(new FirewallRuleObject())
|
||||
->setId($tenantId)
|
||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue('203.0.113.10'),
|
||||
]);
|
||||
|
||||
self::assertSame('tenant-a', $this->service->analyze('203.0.113.10')->ruleId);
|
||||
$this->currentTenant = 'tenant-b';
|
||||
self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('System blocks apply when no tenant is resolved')]
|
||||
public function testSystemBlockWithoutTenant(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->method('listSystemRules')->willReturn([
|
||||
$this->rule(
|
||||
'system-block',
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
null
|
||||
),
|
||||
]);
|
||||
$this->store->expects($this->never())->method('listRules');
|
||||
|
||||
self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('System allows can match when no tenant is resolved')]
|
||||
public function testSystemAllowWithoutTenant(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->method('listSystemRules')->willReturn([
|
||||
$this->rule(
|
||||
'system-allow',
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
FirewallRuleObject::ACTION_ALLOW,
|
||||
null
|
||||
),
|
||||
]);
|
||||
|
||||
$result = $this->service->analyze('203.0.113.10');
|
||||
|
||||
self::assertTrue($result->isAllowed());
|
||||
self::assertSame('system-allow', $result->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('System blocks remain active when the tenant firewall is disabled')]
|
||||
public function testSystemBlockWithDisabledTenant(): void
|
||||
{
|
||||
$this->disableTenantFirewall();
|
||||
$this->store->method('listSystemRules')->willReturn([
|
||||
$this->rule(
|
||||
'system-block',
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
null
|
||||
),
|
||||
]);
|
||||
$this->store->expects($this->never())->method('listRules');
|
||||
|
||||
self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId);
|
||||
}
|
||||
|
||||
#[TestDox('Tenant rules are ignored when the tenant firewall is disabled')]
|
||||
public function testTenantRulesDisabled(): void
|
||||
{
|
||||
$this->disableTenantFirewall();
|
||||
$this->store->method('listSystemRules')->willReturn([]);
|
||||
$this->store->expects($this->never())->method('listRules');
|
||||
|
||||
self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed());
|
||||
}
|
||||
|
||||
#[TestDox('Requests without a tenant and without a system match are allowed')]
|
||||
public function testNoTenantDefault(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->method('listSystemRules')->willReturn([]);
|
||||
$this->store->expects($this->never())->method('listRules');
|
||||
|
||||
self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed());
|
||||
}
|
||||
|
||||
#[TestDox('Tenant rule-match logs persist dedicated rule ID and scope fields')]
|
||||
public function testTenantRuleAuditContext(): void
|
||||
{
|
||||
$this->store->expects($this->once())
|
||||
->method('createLog')
|
||||
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||
return $log->getTenantId() === 'tenant-a'
|
||||
&& $log->getRuleId() === 'tenant-rule'
|
||||
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_TENANT
|
||||
&& $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$event = \KTXF\Event\SecurityEvent::accessDenied(
|
||||
'203.0.113.10',
|
||||
null,
|
||||
'tenant-rule',
|
||||
FirewallRuleObject::SCOPE_TENANT,
|
||||
'Tenant block'
|
||||
);
|
||||
$event->setTenantId('tenant-a');
|
||||
|
||||
$this->service->logSecurityEvent($event);
|
||||
}
|
||||
|
||||
#[TestDox('System rule matches are logged even when no tenant is resolved')]
|
||||
public function testSystemRuleAuditContext(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->expects($this->once())
|
||||
->method('createLog')
|
||||
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||
return $log->getTenantId() === null
|
||||
&& $log->getRuleId() === 'system-rule'
|
||||
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$event = \KTXF\Event\SecurityEvent::accessDenied(
|
||||
'203.0.113.10',
|
||||
null,
|
||||
'system-rule',
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
'System block'
|
||||
);
|
||||
|
||||
$this->service->logSecurityEvent($event);
|
||||
}
|
||||
|
||||
#[TestDox('Tenantless security events without system rule context are ignored')]
|
||||
public function testTenantlessAuditBoundary(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->expects($this->never())->method('createLog');
|
||||
|
||||
$this->service->logSecurityEvent(
|
||||
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Rate-limit events retain their request and threshold audit data')]
|
||||
public function testRateLimitAudit(): void
|
||||
{
|
||||
$this->store->expects($this->once())
|
||||
->method('createLog')
|
||||
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||
$metadata = $log->getMetadata();
|
||||
return $log->getEventType() === FirewallLogObject::EVENT_RATE_LIMIT
|
||||
&& $log->getResult() === FirewallLogObject::RESULT_BLOCKED
|
||||
&& $log->getIpAddress() === '203.0.113.10'
|
||||
&& $log->getRequestPath() === '/login'
|
||||
&& $metadata['requestCount'] === 101
|
||||
&& $metadata['windowSeconds'] === 60;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$event = \KTXF\Event\SecurityEvent::rateLimitExceeded(
|
||||
'203.0.113.10',
|
||||
101,
|
||||
60,
|
||||
'/login'
|
||||
);
|
||||
$event->setTenantId('tenant-a');
|
||||
|
||||
$this->service->logSecurityEvent($event);
|
||||
}
|
||||
|
||||
#[TestDox('Suspicious-activity events retain request and detection metadata')]
|
||||
public function testSuspiciousActivityAudit(): void
|
||||
{
|
||||
$this->store->expects($this->once())
|
||||
->method('createLog')
|
||||
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||
return $log->getEventType() === FirewallLogObject::EVENT_SUSPICIOUS
|
||||
&& $log->getResult() === FirewallLogObject::RESULT_BLOCKED
|
||||
&& $log->getIpAddress() === '203.0.113.20'
|
||||
&& $log->getRequestPath() === '/admin'
|
||||
&& $log->getRequestMethod() === 'POST'
|
||||
&& $log->getMetadata()['detector'] === 'payload-signature';
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$event = \KTXF\Event\SecurityEvent::create(
|
||||
\KTXF\Event\SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||
'203.0.113.20',
|
||||
null,
|
||||
['detector' => 'payload-signature']
|
||||
);
|
||||
$event->setTenantId('tenant-a')
|
||||
->setRequestPath('/admin')
|
||||
->setRequestMethod('POST');
|
||||
|
||||
$this->service->logSecurityEvent($event);
|
||||
}
|
||||
|
||||
#[TestDox('Rule lifecycle events map to recorded audit entries')]
|
||||
public function testRuleLifecycleAudit(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->expects($this->once())
|
||||
->method('createLog')
|
||||
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||
return $log->getEventType() === FirewallLogObject::EVENT_RULE_DISABLED
|
||||
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
|
||||
&& $log->getRuleId() === 'rule-123'
|
||||
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
|
||||
&& $log->getIdentityId() === 'operator';
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$event = new \KTXF\Event\SecurityEvent(
|
||||
\KTXF\Event\SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
[
|
||||
'ruleId' => 'rule-123',
|
||||
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
|
||||
'origin' => FirewallRuleManager::ORIGIN_MANUAL,
|
||||
]
|
||||
);
|
||||
$event->setIdentityId('operator');
|
||||
|
||||
$this->service->logSecurityEvent($event);
|
||||
}
|
||||
|
||||
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
|
||||
public function testFirewallConfiguration(): void
|
||||
{
|
||||
$this->store->method('createLogOnce')->willReturn(true);
|
||||
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'firewall' => [
|
||||
'enabled' => true,
|
||||
'maxAuthFailures' => 8,
|
||||
'authFailureWindow' => 600,
|
||||
'autoBlockDuration' => 7200,
|
||||
],
|
||||
]);
|
||||
$this->store->expects($this->once())
|
||||
->method('countRecentFailures')
|
||||
->with('tenant-a', '203.0.113.10', 600)
|
||||
->willReturn(4);
|
||||
$this->events->expects($this->never())->method('dispatch');
|
||||
|
||||
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
||||
$event->setTenantId('tenant-a');
|
||||
$this->service->handleAuthFailure($event);
|
||||
|
||||
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
||||
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
||||
}
|
||||
|
||||
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
||||
public function testConfigurationBounds(): void
|
||||
{
|
||||
$this->store->method('createLogOnce')->willReturn(true);
|
||||
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'firewall' => [
|
||||
'maxAuthFailures' => 0,
|
||||
'authFailureWindow' => -1,
|
||||
'autoBlockDuration' => 0,
|
||||
],
|
||||
]);
|
||||
$this->store->expects($this->once())
|
||||
->method('countRecentFailures')
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(0);
|
||||
|
||||
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
||||
$event->setTenantId('tenant-a');
|
||||
$this->service->handleAuthFailure($event);
|
||||
}
|
||||
|
||||
#[TestDox('Automatic blocks retain the tenant carried by the authentication event')]
|
||||
public function testAutomaticBlockTenant(): void
|
||||
{
|
||||
$this->store->method('createLogOnce')->willReturn(true);
|
||||
$this->currentTenant = 'tenant-context';
|
||||
$this->store->expects($this->once())
|
||||
->method('countRecentFailures')
|
||||
->with('tenant-event', '203.0.113.10', 300)
|
||||
->willReturn(5);
|
||||
$this->store->expects($this->once())
|
||||
->method('claimBruteForce')
|
||||
->with('tenant-event', '203.0.113.10', 300)
|
||||
->willReturn(true);
|
||||
$this->store->expects($this->once())
|
||||
->method('findExactIpRule')
|
||||
->with(
|
||||
'tenant-event',
|
||||
'203.0.113.10',
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
)
|
||||
->willReturn(null);
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static function (FirewallRuleObject $rule): bool {
|
||||
$metadata = $rule->getMetadata();
|
||||
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
||||
&& $rule->getTenantId() === 'tenant-event'
|
||||
&& $rule->getExpiresAt() !== null
|
||||
&& $metadata['failureThreshold'] === 5
|
||||
&& $metadata['failureWindowSeconds'] === 300
|
||||
&& $metadata['lastFailureCount'] === 5
|
||||
&& $metadata['blockDurationSeconds'] === 3600;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
|
||||
$publishedTenants = [];
|
||||
$lifecycleOrigin = null;
|
||||
$this->events->expects($this->exactly(3))
|
||||
->method('dispatch')
|
||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
|
||||
&$publishedTenants,
|
||||
&$lifecycleOrigin
|
||||
): void {
|
||||
$publishedTenants[] = $event->getTenantId();
|
||||
if ($event->getName() === \KTXF\Event\SecurityEvent::FIREWALL_RULE_CREATED) {
|
||||
$lifecycleOrigin = $event->get('origin');
|
||||
}
|
||||
});
|
||||
|
||||
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
||||
$event->setTenantId('tenant-event');
|
||||
$this->service->handleAuthFailure($event);
|
||||
|
||||
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
|
||||
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
|
||||
}
|
||||
|
||||
#[TestDox('Workers that lose the brute-force claim do not block or publish detection events')]
|
||||
public function testAutomaticBlockClaimLoss(): void
|
||||
{
|
||||
$this->store->method('createLogOnce')->willReturn(true);
|
||||
$this->store->expects($this->once())
|
||||
->method('countRecentFailures')
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(5);
|
||||
$this->store->expects($this->once())
|
||||
->method('claimBruteForce')
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(false);
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->events->expects($this->never())->method('dispatch');
|
||||
|
||||
$this->service->handleAuthFailure(
|
||||
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Authentication events without a tenant use the current tenant')]
|
||||
public function testAutomaticBlockTenantFallback(): void
|
||||
{
|
||||
$this->store->method('createLogOnce')->willReturn(true);
|
||||
$this->store->expects($this->once())
|
||||
->method('countRecentFailures')
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(0);
|
||||
|
||||
$this->service->handleAuthFailure(
|
||||
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Authentication failures are ignored when no tenant can be resolved')]
|
||||
public function testAutomaticBlockWithoutTenant(): void
|
||||
{
|
||||
$this->currentTenant = null;
|
||||
$this->store->expects($this->never())->method('countRecentFailures');
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
|
||||
$this->service->handleAuthFailure(
|
||||
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('Repeated delivery of one authentication event is counted once')]
|
||||
public function testAuthenticationFailureIdempotency(): void
|
||||
{
|
||||
$this->store->expects($this->exactly(2))
|
||||
->method('createLogOnce')
|
||||
->willReturnOnConsecutiveCalls(true, false);
|
||||
$this->store->expects($this->once())
|
||||
->method('countRecentFailures')
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(1);
|
||||
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
|
||||
$eventId = $event->getEventId();
|
||||
|
||||
$this->service->handleAuthFailure($event);
|
||||
$this->service->handleAuthFailure($event);
|
||||
|
||||
self::assertSame($eventId, $event->getEventId());
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup records successful maintenance counts')]
|
||||
public function testCleanupStatus(): void
|
||||
{
|
||||
$this->store->method('cleanupExpiredRules')->willReturn(2);
|
||||
$this->store->method('cleanupOldLogs')->with(30)->willReturn(3);
|
||||
$this->store->method('cleanupExpiredBruteForceClaims')->willReturn(4);
|
||||
$this->store->expects(self::once())
|
||||
->method('recordMaintenanceStatus')
|
||||
->with(
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
'success',
|
||||
[
|
||||
'expiredRules' => 2,
|
||||
'oldLogs' => 3,
|
||||
'expiredBruteForceClaims' => 4,
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'expiredRules' => 2,
|
||||
'oldLogs' => 3,
|
||||
'expiredBruteForceClaims' => 4,
|
||||
], $this->service->cleanup());
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup failures are recorded and rethrown')]
|
||||
public function testCleanupFailureStatus(): void
|
||||
{
|
||||
$this->store->method('cleanupExpiredRules')->willThrowException(new \RuntimeException('cleanup failed'));
|
||||
$this->store->expects(self::once())
|
||||
->method('recordMaintenanceStatus')
|
||||
->with(
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
'failed',
|
||||
[],
|
||||
'cleanup failed'
|
||||
);
|
||||
$this->expectExceptionMessage('cleanup failed');
|
||||
|
||||
$this->service->cleanup();
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $id,
|
||||
string $scope,
|
||||
string $action,
|
||||
?string $tenantId
|
||||
): FirewallRuleObject {
|
||||
return (new FirewallRuleObject())
|
||||
->setId($id)
|
||||
->setScope($scope)
|
||||
->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction($action)
|
||||
->setValue('203.0.113.10')
|
||||
->setReason($id);
|
||||
}
|
||||
|
||||
private function disableTenantFirewall(): void
|
||||
{
|
||||
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'firewall' => ['enabled' => false],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user