Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a141bbc453 | |||
| a5c10e9b9b | |||
| 7aa8a27b1b | |||
| 5ada4c0c45 | |||
| 12037da367 | |||
| abc5bfcccc | |||
| da81f1ddf1 | |||
| 266b364c93 | |||
| 3b9a5cd5ab | |||
| 63d91ca7fa |
Generated
+8
-8
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d162d02532b7705788ced1ea4c360c05",
|
"content-hash": "68916c1b58d9ce06a59f6c58e4a2d226",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
@@ -1946,16 +1946,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "12.5.33",
|
"version": "12.5.31",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184"
|
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
|
||||||
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
|
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2024,7 +2024,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2032,7 +2032,7 @@
|
|||||||
"type": "other"
|
"type": "other"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-28T13:58:09+00:00"
|
"time": "2026-07-06T14:54:16+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -3057,5 +3057,5 @@
|
|||||||
"ext-iconv": "*"
|
"ext-iconv": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
{
|
{
|
||||||
public const RESULT_ALLOWED = 'allowed';
|
public const RESULT_ALLOWED = 'allowed';
|
||||||
public const RESULT_BLOCKED = 'blocked';
|
public const RESULT_BLOCKED = 'blocked';
|
||||||
|
public const RESULT_RECORDED = 'recorded';
|
||||||
|
|
||||||
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
||||||
public const EVENT_RATE_LIMIT = 'rate_limit';
|
public const EVENT_RATE_LIMIT = 'rate_limit';
|
||||||
@@ -20,8 +21,12 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
public const EVENT_SUSPICIOUS = 'suspicious';
|
public const EVENT_SUSPICIOUS = 'suspicious';
|
||||||
public const EVENT_RULE_MATCH = 'rule_match';
|
public const EVENT_RULE_MATCH = 'rule_match';
|
||||||
public const EVENT_ACCESS_CHECK = 'access_check';
|
public const EVENT_ACCESS_CHECK = 'access_check';
|
||||||
|
public const EVENT_RULE_CREATED = 'rule_created';
|
||||||
|
public const EVENT_RULE_DISABLED = 'rule_disabled';
|
||||||
|
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||||
|
|
||||||
private ?string $id = null;
|
private ?string $id = null;
|
||||||
|
private ?string $eventId = null;
|
||||||
private ?string $tenantId = null;
|
private ?string $tenantId = null;
|
||||||
private ?string $ipAddress = null;
|
private ?string $ipAddress = null;
|
||||||
private ?string $deviceFingerprint = null;
|
private ?string $deviceFingerprint = null;
|
||||||
@@ -31,6 +36,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
private ?string $eventType = null;
|
private ?string $eventType = null;
|
||||||
private ?string $result = null; // allowed, blocked
|
private ?string $result = null; // allowed, blocked
|
||||||
private ?string $ruleId = null; // Which rule triggered (if any)
|
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 ?string $identityId = null; // User ID if authenticated
|
||||||
private ?\DateTimeImmutable $timestamp = null;
|
private ?\DateTimeImmutable $timestamp = null;
|
||||||
private ?array $metadata = null; // Additional context
|
private ?array $metadata = null; // Additional context
|
||||||
@@ -50,6 +56,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
if (array_key_exists('tenantId', $data)) {
|
if (array_key_exists('tenantId', $data)) {
|
||||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
$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)) {
|
if (array_key_exists('ipAddress', $data)) {
|
||||||
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||||
}
|
}
|
||||||
@@ -74,6 +83,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
if (array_key_exists('ruleId', $data)) {
|
if (array_key_exists('ruleId', $data)) {
|
||||||
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
$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)) {
|
if (array_key_exists('identityId', $data)) {
|
||||||
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
||||||
}
|
}
|
||||||
@@ -93,6 +105,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
'eventId' => $this->eventId,
|
||||||
'tenantId' => $this->tenantId,
|
'tenantId' => $this->tenantId,
|
||||||
'ipAddress' => $this->ipAddress,
|
'ipAddress' => $this->ipAddress,
|
||||||
'deviceFingerprint' => $this->deviceFingerprint,
|
'deviceFingerprint' => $this->deviceFingerprint,
|
||||||
@@ -102,6 +115,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
'eventType' => $this->eventType,
|
'eventType' => $this->eventType,
|
||||||
'result' => $this->result,
|
'result' => $this->result,
|
||||||
'ruleId' => $this->ruleId,
|
'ruleId' => $this->ruleId,
|
||||||
|
'ruleScope' => $this->ruleScope,
|
||||||
'identityId' => $this->identityId,
|
'identityId' => $this->identityId,
|
||||||
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
||||||
'metadata' => $this->metadata,
|
'metadata' => $this->metadata,
|
||||||
@@ -121,6 +135,17 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEventId(): ?string
|
||||||
|
{
|
||||||
|
return $this->eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEventId(?string $eventId): self
|
||||||
|
{
|
||||||
|
$this->eventId = $eventId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTenantId(): ?string
|
public function getTenantId(): ?string
|
||||||
{
|
{
|
||||||
return $this->tenantId;
|
return $this->tenantId;
|
||||||
@@ -220,6 +245,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
return $this;
|
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
|
public function getIdentityId(): ?string
|
||||||
{
|
{
|
||||||
return $this->identityId;
|
return $this->identityId;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ use KTXF\Json\JsonDeserializable;
|
|||||||
*/
|
*/
|
||||||
class FirewallRuleObject implements \JsonSerializable, 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 = 'ip';
|
||||||
public const TYPE_IP_RANGE = 'ip_range';
|
public const TYPE_IP_RANGE = 'ip_range';
|
||||||
public const TYPE_DEVICE = 'device';
|
public const TYPE_DEVICE = 'device';
|
||||||
@@ -19,6 +22,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
public const ACTION_BLOCK = 'block';
|
public const ACTION_BLOCK = 'block';
|
||||||
|
|
||||||
private ?string $id = null;
|
private ?string $id = null;
|
||||||
|
private string $scope = self::SCOPE_TENANT;
|
||||||
private ?string $tenantId = null;
|
private ?string $tenantId = null;
|
||||||
private ?string $type = null; // ip, ip_range, device
|
private ?string $type = null; // ip, ip_range, device
|
||||||
private ?string $action = null; // allow, block
|
private ?string $action = null; // allow, block
|
||||||
@@ -42,6 +46,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
$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)) {
|
if (array_key_exists('tenantId', $data)) {
|
||||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||||
}
|
}
|
||||||
@@ -84,6 +92,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
'scope' => $this->scope,
|
||||||
'tenantId' => $this->tenantId,
|
'tenantId' => $this->tenantId,
|
||||||
'type' => $this->type,
|
'type' => $this->type,
|
||||||
'action' => $this->action,
|
'action' => $this->action,
|
||||||
@@ -134,6 +143,42 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
return $this->tenantId;
|
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
|
public function setTenantId(?string $tenantId): self
|
||||||
{
|
{
|
||||||
$this->tenantId = $tenantId;
|
$this->tenantId = $tenantId;
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
{
|
{
|
||||||
protected TenantAuthentication $authentication;
|
protected TenantAuthentication $authentication;
|
||||||
protected TenantSecurity $security;
|
protected TenantSecurity $security;
|
||||||
|
protected TenantFirewall $firewall;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->authentication = new TenantAuthentication();
|
$this->authentication = new TenantAuthentication();
|
||||||
$this->security = new TenantSecurity();
|
$this->security = new TenantSecurity();
|
||||||
|
$this->firewall = new TenantFirewall();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function authentication(): TenantAuthentication {
|
public function authentication(): TenantAuthentication {
|
||||||
@@ -26,4 +28,8 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
return $this->security;
|
return $this->security;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function firewall(): TenantFirewall {
|
||||||
|
return $this->firewall;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace KTXC\Module;
|
namespace KTXC\Module;
|
||||||
|
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
use KTXF\Event\EventListenerRegistry;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
use KTXF\Event\SecurityEvent;
|
use KTXF\Event\SecurityEvent;
|
||||||
@@ -33,10 +35,14 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
);
|
);
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
SecurityEvent::AUTH_FAILURE,
|
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
SecurityEvent::AUTH_SUCCESS,
|
||||||
SecurityEvent::ACCESS_DENIED,
|
SecurityEvent::ACCESS_DENIED,
|
||||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||||
|
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||||
|
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||||
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||||
] as $event) {
|
] as $event) {
|
||||||
$this->events->listen(
|
$this->events->listen(
|
||||||
'core',
|
'core',
|
||||||
@@ -115,7 +121,27 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
'group' => 'Module Management'
|
'group' => 'Module Management'
|
||||||
],
|
],
|
||||||
|
|
||||||
// System Administration
|
// Firewall Management
|
||||||
|
TenantFirewallRuleService::PERMISSION_READ => [
|
||||||
|
'label' => 'View Tenant Firewall Rules',
|
||||||
|
'description' => 'View firewall rules owned by the current tenant',
|
||||||
|
'group' => 'Firewall Management'
|
||||||
|
],
|
||||||
|
TenantFirewallRuleService::PERMISSION_MANAGE => [
|
||||||
|
'label' => 'Manage Tenant Firewall Rules',
|
||||||
|
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
|
||||||
|
'group' => 'Firewall Management'
|
||||||
|
],
|
||||||
|
SystemFirewallRuleService::PERMISSION_READ => [
|
||||||
|
'label' => 'View System Firewall Rules',
|
||||||
|
'description' => 'View firewall rules that apply to every tenant',
|
||||||
|
'group' => 'System Administration'
|
||||||
|
],
|
||||||
|
SystemFirewallRuleService::PERMISSION_MANAGE => [
|
||||||
|
'label' => 'Manage System Firewall Rules',
|
||||||
|
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
|
||||||
|
'group' => 'System Administration'
|
||||||
|
],
|
||||||
'system.admin' => [
|
'system.admin' => [
|
||||||
'label' => 'System Administrator',
|
'label' => 'System Administrator',
|
||||||
'description' => 'Full system access (superuser)',
|
'description' => 'Full system access (superuser)',
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?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
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
||||||
|
FirewallRuleValidator::duration($durationSeconds);
|
||||||
|
|
||||||
|
$existing = $this->store->findExactIpRule(
|
||||||
|
$scope->tenantId,
|
||||||
|
$ipAddress,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
$scope->scope
|
||||||
|
);
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = $this->create(
|
||||||
|
$scope,
|
||||||
|
FirewallRuleObject::TYPE_IP,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK,
|
||||||
|
$ipAddress,
|
||||||
|
$reason ?? 'Blocked by administrator',
|
||||||
|
$createdBy,
|
||||||
|
$durationSeconds,
|
||||||
|
$origin
|
||||||
|
);
|
||||||
|
$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
|
||||||
|
): 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())
|
||||||
|
->setMetadata(['origin' => $origin])
|
||||||
|
->setEnabled(true);
|
||||||
|
|
||||||
|
if ($durationSeconds !== null) {
|
||||||
|
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->store->depositRule($rule);
|
||||||
|
$this->cache->invalidate();
|
||||||
|
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $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),
|
||||||
|
]);
|
||||||
|
$event->setTenantId($rule->getTenantId())
|
||||||
|
->setIdentityId($actorId ?? $rule->getCreatedBy());
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Service;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
|
||||||
|
final class FirewallRuleScope
|
||||||
|
{
|
||||||
|
private function __construct(
|
||||||
|
public readonly string $scope,
|
||||||
|
public readonly ?string $tenantId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenant(string $tenantId): self
|
||||||
|
{
|
||||||
|
if ($tenantId === '') {
|
||||||
|
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function system(): self
|
||||||
|
{
|
||||||
|
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function owns(FirewallRuleObject $rule): bool
|
||||||
|
{
|
||||||
|
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Service;
|
||||||
|
|
||||||
|
final class FirewallRuleValidator
|
||||||
|
{
|
||||||
|
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ipAddress(string $ipAddress): string
|
||||||
|
{
|
||||||
|
$ipAddress = trim($ipAddress);
|
||||||
|
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
|
||||||
|
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cidr(string $cidr): string
|
||||||
|
{
|
||||||
|
$cidr = trim($cidr);
|
||||||
|
if (substr_count($cidr, '/') !== 1) {
|
||||||
|
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[$address, $prefix] = explode('/', $cidr, 2);
|
||||||
|
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
|
||||||
|
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
|
||||||
|
if ((int)$prefix > $maximumPrefix) {
|
||||||
|
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cidr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deviceFingerprint(string $fingerprint): string
|
||||||
|
{
|
||||||
|
$fingerprint = trim($fingerprint);
|
||||||
|
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function duration(?int $durationSeconds): void
|
||||||
|
{
|
||||||
|
if ($durationSeconds !== null && $durationSeconds < 1) {
|
||||||
|
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ class FirewallService
|
|||||||
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
||||||
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
||||||
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
|
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
|
// Configuration keys
|
||||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||||
@@ -36,13 +39,12 @@ class FirewallService
|
|||||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||||
|
|
||||||
/** @var FirewallRuleObject[]|null */
|
|
||||||
private ?array $rulesCache = null;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FirewallStore $store,
|
private readonly FirewallStore $store,
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly TenantContextInterface $tenantContext,
|
||||||
private readonly EventDispatcherInterface $events,
|
private readonly EventDispatcherInterface $events,
|
||||||
|
private readonly FirewallRuleManager $rules,
|
||||||
|
private readonly FirewallRuleCache $ruleCache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,36 +73,33 @@ class FirewallService
|
|||||||
string $ipAddress,
|
string $ipAddress,
|
||||||
?string $deviceFingerprint = null
|
?string $deviceFingerprint = null
|
||||||
): FirewallAnalyzeResult {
|
): FirewallAnalyzeResult {
|
||||||
// Check if firewall is enabled for this tenant
|
|
||||||
if (!$this->isEnabled()) {
|
|
||||||
return new FirewallAnalyzeResult(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenantContext->identifier();
|
||||||
if (!$tenantId) {
|
$ruleGroups = [
|
||||||
return new FirewallAnalyzeResult(true);
|
[$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];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rules = $this->getActiveRules();
|
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
|
||||||
|
|
||||||
// First check for explicit allow rules (whitelist takes precedence)
|
foreach ($ruleGroups as [$rules, $action]) {
|
||||||
foreach ($rules as $rule) {
|
foreach ($rules as $rule) {
|
||||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
|
if ($rule->getAction() !== $action) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === FirewallRuleObject::ACTION_ALLOW) {
|
||||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
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);
|
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
|
||||||
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
||||||
}
|
}
|
||||||
@@ -140,14 +139,22 @@ class FirewallService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$event->setTenantId($tenantId);
|
||||||
|
$log = $this->securityLog($event);
|
||||||
|
if ($log === null || !$this->store->createLogOnce($log)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for brute force
|
// Check for brute force
|
||||||
$windowSeconds = $this->getConfig(
|
$windowSeconds = $this->getBoundedIntegerConfig(
|
||||||
self::CONFIG_FAILURE_WINDOW,
|
self::CONFIG_FAILURE_WINDOW,
|
||||||
self::DEFAULT_AUTH_FAILURE_WINDOW
|
self::DEFAULT_AUTH_FAILURE_WINDOW,
|
||||||
|
self::MAX_AUTH_FAILURE_WINDOW
|
||||||
);
|
);
|
||||||
$maxFailures = $this->getConfig(
|
$maxFailures = $this->getBoundedIntegerConfig(
|
||||||
self::CONFIG_MAX_FAILURES,
|
self::CONFIG_MAX_FAILURES,
|
||||||
self::DEFAULT_MAX_AUTH_FAILURES
|
self::DEFAULT_MAX_AUTH_FAILURES,
|
||||||
|
self::MAX_AUTH_FAILURES
|
||||||
);
|
);
|
||||||
|
|
||||||
$failureCount = $this->store->countRecentFailures(
|
$failureCount = $this->store->countRecentFailures(
|
||||||
@@ -156,11 +163,8 @@ class FirewallService
|
|||||||
$windowSeconds
|
$windowSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
// Include current failure in count
|
|
||||||
$failureCount++;
|
|
||||||
|
|
||||||
if ($failureCount >= $maxFailures) {
|
if ($failureCount >= $maxFailures) {
|
||||||
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
|
$this->handleBruteForce($tenantId, $ipAddress, $failureCount, $windowSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,26 +172,30 @@ class FirewallService
|
|||||||
* Handle detected brute force attack
|
* Handle detected brute force attack
|
||||||
*/
|
*/
|
||||||
private function handleBruteForce(
|
private function handleBruteForce(
|
||||||
|
string $tenantId,
|
||||||
string $ipAddress,
|
string $ipAddress,
|
||||||
int $failureCount,
|
int $failureCount,
|
||||||
int $windowSeconds
|
int $windowSeconds
|
||||||
): void {
|
): void {
|
||||||
// Publish brute force event
|
// Publish brute force event
|
||||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||||
$event->setTenantId($this->tenantContext->identifier());
|
$event->setTenantId($tenantId);
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
// Auto-block the IP
|
// Auto-block the IP
|
||||||
$blockDuration = $this->getConfig(
|
$blockDuration = $this->getBoundedIntegerConfig(
|
||||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||||
self::DEFAULT_AUTO_BLOCK_DURATION
|
self::DEFAULT_AUTO_BLOCK_DURATION,
|
||||||
|
self::MAX_AUTO_BLOCK_DURATION
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->blockIp(
|
$this->rules->blockIp(
|
||||||
|
FirewallRuleScope::tenant($tenantId),
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||||
null, // System-created
|
null, // System-created
|
||||||
$blockDuration
|
$blockDuration,
|
||||||
|
FirewallRuleManager::ORIGIN_AUTOMATIC
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,26 +203,36 @@ class FirewallService
|
|||||||
* Log security event to firewall logs
|
* Log security event to firewall logs
|
||||||
*/
|
*/
|
||||||
public function logSecurityEvent(SecurityEvent $event): void
|
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();
|
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||||
if (!$tenantId) {
|
$ruleScope = $event->get('ruleScope');
|
||||||
return;
|
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$log = new FirewallLogObject();
|
$log = new FirewallLogObject();
|
||||||
$log->setTenantId($tenantId)
|
return $log->setEventId($event->getEventId())
|
||||||
|
->setTenantId($tenantId)
|
||||||
->setIpAddress($event->getIpAddress())
|
->setIpAddress($event->getIpAddress())
|
||||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
->setDeviceFingerprint($event->getDeviceFingerprint())
|
||||||
->setUserAgent($event->getUserAgent())
|
->setUserAgent($event->getUserAgent())
|
||||||
->setRequestPath($event->getRequestPath())
|
->setRequestPath($event->getRequestPath())
|
||||||
->setRequestMethod($event->getRequestMethod())
|
->setRequestMethod($event->getRequestMethod())
|
||||||
->setEventType($this->mapEventToLogType($event->getName()))
|
->setEventType($this->mapEventToLogType($event->getName()))
|
||||||
->setResult($this->mapEventToResult($event->getName()))
|
->setResult($this->mapEventToResult($event))
|
||||||
->setIdentityId($event->getUserId())
|
->setRuleId($event->get('ruleId'))
|
||||||
|
->setRuleScope($ruleScope)
|
||||||
|
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
|
||||||
->setTimestamp(new \DateTimeImmutable())
|
->setTimestamp(new \DateTimeImmutable())
|
||||||
->setMetadata($event->getData());
|
->setMetadata($event->getData());
|
||||||
|
|
||||||
$this->store->createLog($log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +247,9 @@ class FirewallService
|
|||||||
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||||
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||||
|
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -236,11 +257,14 @@ class FirewallService
|
|||||||
/**
|
/**
|
||||||
* Map security event to result
|
* Map security event to result
|
||||||
*/
|
*/
|
||||||
private function mapEventToResult(string $eventName): string
|
private function mapEventToResult(SecurityEvent $event): string
|
||||||
{
|
{
|
||||||
return match ($eventName) {
|
return match ($event->getName()) {
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
SecurityEvent::AUTH_SUCCESS,
|
||||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
|
||||||
default => FirewallLogObject::RESULT_BLOCKED,
|
default => FirewallLogObject::RESULT_BLOCKED,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -257,268 +281,13 @@ class FirewallService
|
|||||||
$ipAddress,
|
$ipAddress,
|
||||||
$deviceFingerprint,
|
$deviceFingerprint,
|
||||||
$rule->getId(),
|
$rule->getId(),
|
||||||
|
$rule->getScope(),
|
||||||
$rule->getReason()
|
$rule->getReason()
|
||||||
);
|
);
|
||||||
$event->setTenantId($this->tenantContext->identifier());
|
$event->setTenantId($this->tenantContext->identifier());
|
||||||
$this->events->dispatch($event);
|
$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
|
* Check if firewall is enabled for current tenant
|
||||||
*/
|
*/
|
||||||
@@ -533,6 +302,9 @@ class FirewallService
|
|||||||
private function getConfig(string $key, mixed $default = null): mixed
|
private function getConfig(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$config = $this->tenantContext->configuration();
|
$config = $this->tenantContext->configuration();
|
||||||
|
if ($config instanceof \JsonSerializable) {
|
||||||
|
$config = $config->jsonSerialize();
|
||||||
|
}
|
||||||
$parts = explode('.', $key);
|
$parts = explode('.', $key);
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
foreach ($parts as $part) {
|
||||||
@@ -545,27 +317,14 @@ class FirewallService
|
|||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
|
||||||
* Get active rules (cached)
|
|
||||||
* @return FirewallRuleObject[]
|
|
||||||
*/
|
|
||||||
private function getActiveRules(): array
|
|
||||||
{
|
{
|
||||||
if ($this->rulesCache === null) {
|
$value = $this->getConfig($key, $default);
|
||||||
$tenantId = $this->tenantContext->identifier();
|
if (!is_int($value) || $value < 1 || $value > $maximum) {
|
||||||
$this->rulesCache = $tenantId
|
return $default;
|
||||||
? $this->store->listRules($tenantId, true)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
return $this->rulesCache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return $value;
|
||||||
* Clear rules cache
|
|
||||||
*/
|
|
||||||
private function clearRulesCache(): void
|
|
||||||
{
|
|
||||||
$this->rulesCache = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Service;
|
||||||
|
|
||||||
|
use KTXC\Context\IdentityContextInterface;
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
|
||||||
|
final class TenantFirewallRuleService
|
||||||
|
{
|
||||||
|
public const PERMISSION_READ = 'firewall.tenant.rules.read';
|
||||||
|
public const PERMISSION_MANAGE = 'firewall.tenant.rules.manage';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly FirewallRuleManager $rules,
|
||||||
|
private readonly TenantContextInterface $tenant,
|
||||||
|
private readonly IdentityContextInterface $identity,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listRules(bool $activeOnly = true): array
|
||||||
|
{
|
||||||
|
$this->requirePermission(self::PERMISSION_READ);
|
||||||
|
return $this->rules->list($this->scope(), $activeOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
||||||
|
{
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->blockIp(
|
||||||
|
$this->scope(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
||||||
|
{
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->allowIp($this->scope(), $ip, $reason, $this->identity->identifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
||||||
|
{
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->blockIpRange($this->scope(), $cidr, $reason, $this->identity->identifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function blockDevice(
|
||||||
|
string $fingerprint,
|
||||||
|
?string $reason = null,
|
||||||
|
?int $durationSeconds = null
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->blockDevice(
|
||||||
|
$this->scope(), $fingerprint, $reason, $this->identity->identifier(), $durationSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disableRule(string $ruleId): bool
|
||||||
|
{
|
||||||
|
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||||
|
return $this->rules->disable($this->scope(), $ruleId, $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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,14 +29,19 @@ class FirewallStore
|
|||||||
*/
|
*/
|
||||||
public function listRules(string $tenantId, bool $activeOnly = true): array
|
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||||
{
|
{
|
||||||
$filter = ['tenantId' => $tenantId];
|
$filter = [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
||||||
|
];
|
||||||
|
|
||||||
if ($activeOnly) {
|
if ($activeOnly) {
|
||||||
$filter['enabled'] = true;
|
$filter['enabled'] = true;
|
||||||
$filter['$or'] = [
|
$filter['$and'] = [[
|
||||||
|
'$or' => [
|
||||||
['expiresAt' => null],
|
['expiresAt' => null],
|
||||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
];
|
],
|
||||||
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||||
@@ -50,6 +55,26 @@ class FirewallStore
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function listSystemRules(bool $activeOnly = true): array
|
||||||
|
{
|
||||||
|
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
|
||||||
|
if ($activeOnly) {
|
||||||
|
$filter['enabled'] = true;
|
||||||
|
$filter['$or'] = [
|
||||||
|
['expiresAt' => null],
|
||||||
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||||
|
$list = [];
|
||||||
|
foreach ($cursor as $entry) {
|
||||||
|
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find rules by IP address
|
* Find rules by IP address
|
||||||
*/
|
*/
|
||||||
@@ -118,15 +143,33 @@ class FirewallStore
|
|||||||
/**
|
/**
|
||||||
* Check if exact IP rule exists
|
* Check if exact IP rule exists
|
||||||
*/
|
*/
|
||||||
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
|
public function findExactIpRule(
|
||||||
|
?string $tenantId,
|
||||||
|
string $ipAddress,
|
||||||
|
string $action,
|
||||||
|
string $scope = FirewallRuleObject::SCOPE_TENANT
|
||||||
|
): ?FirewallRuleObject
|
||||||
{
|
{
|
||||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
$filter = [
|
||||||
'tenantId' => $tenantId,
|
|
||||||
'type' => FirewallRuleObject::TYPE_IP,
|
'type' => FirewallRuleObject::TYPE_IP,
|
||||||
'value' => $ipAddress,
|
'value' => $ipAddress,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
]);
|
'$or' => [
|
||||||
|
['expiresAt' => null],
|
||||||
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
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) {
|
if (!$entry) {
|
||||||
return null;
|
return null;
|
||||||
@@ -139,6 +182,8 @@ class FirewallStore
|
|||||||
*/
|
*/
|
||||||
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||||
{
|
{
|
||||||
|
$rule->assertValidScopeOwnership();
|
||||||
|
|
||||||
if ($rule->getId()) {
|
if ($rule->getId()) {
|
||||||
return $this->updateRule($rule);
|
return $this->updateRule($rule);
|
||||||
} else {
|
} else {
|
||||||
@@ -215,6 +260,30 @@ class FirewallStore
|
|||||||
return $log;
|
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 = $log->jsonSerialize();
|
||||||
|
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
|
* Get logs for a tenant with optional filters
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+24
-24
@@ -642,14 +642,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
|
||||||
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
|
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/devtools-types": "11.4.7",
|
"@intlify/devtools-types": "11.4.8",
|
||||||
"@intlify/message-compiler": "11.4.7",
|
"@intlify/message-compiler": "11.4.8",
|
||||||
"@intlify/shared": "11.4.7"
|
"@intlify/shared": "11.4.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -659,13 +659,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/devtools-types": {
|
"node_modules/@intlify/devtools-types": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
|
||||||
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
|
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.7",
|
"@intlify/core-base": "11.4.8",
|
||||||
"@intlify/shared": "11.4.7"
|
"@intlify/shared": "11.4.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -675,12 +675,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/message-compiler": {
|
"node_modules/@intlify/message-compiler": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
|
||||||
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
|
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/shared": "11.4.7",
|
"@intlify/shared": "11.4.8",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -691,9 +691,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/shared": {
|
"node_modules/@intlify/shared": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
|
||||||
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
|
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -6537,14 +6537,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
|
||||||
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
|
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.7",
|
"@intlify/core-base": "11.4.8",
|
||||||
"@intlify/devtools-types": "11.4.7",
|
"@intlify/devtools-types": "11.4.8",
|
||||||
"@intlify/shared": "11.4.7",
|
"@intlify/shared": "11.4.8",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class Event
|
|||||||
private bool $propagationStopped = false;
|
private bool $propagationStopped = false;
|
||||||
private array $data = [];
|
private array $data = [];
|
||||||
private float $timestamp;
|
private float $timestamp;
|
||||||
|
private string $eventId;
|
||||||
private ?string $tenantId = null;
|
private ?string $tenantId = null;
|
||||||
private ?string $identityId = null;
|
private ?string $identityId = null;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ class Event
|
|||||||
) {
|
) {
|
||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
$this->timestamp = microtime(true);
|
$this->timestamp = microtime(true);
|
||||||
|
$this->eventId = bin2hex(random_bytes(16));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +82,11 @@ class Event
|
|||||||
return $this->timestamp;
|
return $this->timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEventId(): string
|
||||||
|
{
|
||||||
|
return $this->eventId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop event propagation to subsequent listeners
|
* Stop event propagation to subsequent listeners
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class SecurityEvent extends Event
|
|||||||
public const IP_BLOCKED = 'security.ip.blocked';
|
public const IP_BLOCKED = 'security.ip.blocked';
|
||||||
public const IP_ALLOWED = 'security.ip.allowed';
|
public const IP_ALLOWED = 'security.ip.allowed';
|
||||||
public const DEVICE_BLOCKED = 'security.device.blocked';
|
public const DEVICE_BLOCKED = 'security.device.blocked';
|
||||||
|
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
|
||||||
|
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
|
||||||
|
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
||||||
|
|
||||||
private ?string $ipAddress = null;
|
private ?string $ipAddress = null;
|
||||||
private ?string $deviceFingerprint = null;
|
private ?string $deviceFingerprint = null;
|
||||||
@@ -145,10 +148,12 @@ class SecurityEvent extends Event
|
|||||||
string $ipAddress,
|
string $ipAddress,
|
||||||
?string $deviceFingerprint = null,
|
?string $deviceFingerprint = null,
|
||||||
?string $ruleId = null,
|
?string $ruleId = null,
|
||||||
|
?string $ruleScope = null,
|
||||||
?string $reason = null
|
?string $reason = null
|
||||||
): self {
|
): self {
|
||||||
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
|
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
|
||||||
'ruleId' => $ruleId,
|
'ruleId' => $ruleId,
|
||||||
|
'ruleScope' => $ruleScope,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
]);
|
]);
|
||||||
$event->reason = $reason;
|
$event->reason = $reason;
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Integration\Stores;
|
||||||
|
|
||||||
|
use KTXC\Db\DataStore;
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ namespace KTXT\Unit\Module;
|
|||||||
use KTXC\Console\Event\EventsDebugCommand;
|
use KTXC\Console\Event\EventsDebugCommand;
|
||||||
use KTXC\Module\Module;
|
use KTXC\Module\Module;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
use KTXF\Event\EventListenerRegistry;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
use KTXF\Event\SecurityEvent;
|
use KTXF\Event\SecurityEvent;
|
||||||
@@ -26,12 +28,25 @@ final class CoreModuleTest extends TestCase
|
|||||||
$module->boot();
|
$module->boot();
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
|
|
||||||
self::assertCount(5, $definitions);
|
self::assertCount(9, $definitions);
|
||||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
|
$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_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());
|
self::assertFalse($registry->frozen());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,4 +58,16 @@ final class CoreModuleTest extends TestCase
|
|||||||
|
|
||||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Core registers dedicated system firewall permissions')]
|
||||||
|
public function registersSystemFirewallPermissions(): void
|
||||||
|
{
|
||||||
|
$permissions = (new Module(new EventListenerRegistry()))->permissions();
|
||||||
|
|
||||||
|
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
||||||
|
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||||
|
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
|
||||||
|
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Service;
|
||||||
|
|
||||||
|
use KTXC\Context\IdentityContextInterface;
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXC\Service\FirewallRuleCache;
|
||||||
|
use KTXC\Service\FirewallRuleManager;
|
||||||
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
|
use KTXC\Stores\FirewallStore;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
#[AllowMockObjectsWithoutExpectations]
|
||||||
|
class FirewallRuleServicesTest extends TestCase
|
||||||
|
{
|
||||||
|
private FirewallStore&MockObject $store;
|
||||||
|
private IdentityContextInterface&MockObject $identity;
|
||||||
|
private TenantContextInterface&MockObject $tenant;
|
||||||
|
private FirewallRuleManager $manager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->store = $this->createMock(FirewallStore::class);
|
||||||
|
$this->identity = $this->createMock(IdentityContextInterface::class);
|
||||||
|
$this->tenant = $this->createMock(TenantContextInterface::class);
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$this->manager = new FirewallRuleManager(
|
||||||
|
$this->store,
|
||||||
|
new FirewallRuleCache($this->store),
|
||||||
|
$events
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Tenant and system boundaries require their dedicated permissions')]
|
||||||
|
public function testPermissions(): void
|
||||||
|
{
|
||||||
|
$this->identity->method('hasPermission')->willReturn(false);
|
||||||
|
$this->store->expects($this->never())->method('depositRule');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->tenantService()->blockIp('203.0.113.10');
|
||||||
|
self::fail('Tenant operation should have been rejected.');
|
||||||
|
} catch (\RuntimeException $error) {
|
||||||
|
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||||
|
$this->systemService()->blockIp('203.0.113.10');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Tenant management derives scope from tenant context')]
|
||||||
|
public function testTenantScope(): void
|
||||||
|
{
|
||||||
|
$this->allow(TenantFirewallRuleService::PERMISSION_MANAGE);
|
||||||
|
$this->tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||||
|
$this->identity->method('identifier')->willReturn('admin-a');
|
||||||
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('depositRule')
|
||||||
|
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||||
|
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
|
||||||
|
))
|
||||||
|
->willReturnArgument(0);
|
||||||
|
|
||||||
|
$this->tenantService()->blockIp('203.0.113.10');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('System management always delegates with system scope')]
|
||||||
|
public function testSystemScope(): void
|
||||||
|
{
|
||||||
|
$this->allow(SystemFirewallRuleService::PERMISSION_MANAGE);
|
||||||
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('depositRule')
|
||||||
|
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||||
|
$rule->isSystemScoped() && $rule->getTenantId() === null
|
||||||
|
))
|
||||||
|
->willReturnArgument(0);
|
||||||
|
|
||||||
|
$this->systemService()->blockIp('203.0.113.10');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function allow(string $permission): void
|
||||||
|
{
|
||||||
|
$this->identity->method('hasPermission')->with($permission)->willReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantService(): TenantFirewallRuleService
|
||||||
|
{
|
||||||
|
return new TenantFirewallRuleService($this->manager, $this->tenant, $this->identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function systemService(): SystemFirewallRuleService
|
||||||
|
{
|
||||||
|
return new SystemFirewallRuleService($this->manager, $this->identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
<?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('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 {
|
||||||
|
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
||||||
|
&& $rule->getTenantId() === 'tenant-event'
|
||||||
|
&& $rule->getExpiresAt() !== null;
|
||||||
|
}))
|
||||||
|
->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('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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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