10 Commits

Author SHA1 Message Date
Sebastian a141bbc453 chore(deps): update dependency vue-i18n to v11.4.8
Build Test / build (pull_request) Successful in 20s
JS Unit Tests / test (pull_request) Successful in 16s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m11s
PHP Unit Tests / test (pull_request) Failing after 1m25s
2026-07-31 03:01:55 +00:00
Sebastian a5c10e9b9b fix(firewall): account for authentication failures exactly once
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:01:38 -04:00
Sebastian 7aa8a27b1b feat(firewall): audit rule lifecycle changes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:57:25 -04:00
Sebastian 5ada4c0c45 fix(firewall): register missing security audit events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:50:42 -04:00
Sebastian 12037da367 feat(firewall): complete rule-match audit context
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:46:27 -04:00
Sebastian abc5bfcccc fix(firewall): enforce system rules independently of tenant context
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:41:27 -04:00
Sebastian da81f1ddf1 refactor(firewall): separate enforcement from rule management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:35:24 -04:00
Sebastian 266b364c93 fix(firewall): preserve tenant ownership for automatic blocks
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:11:27 -04:00
Sebastian 3b9a5cd5ab feat(firewall): validate rules and add typed configuration
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 21:54:23 -04:00
Sebastian 63d91ca7fa feat(firewall): add tenant and system rule scopes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 21:38:19 -04:00
23 changed files with 2058 additions and 359 deletions
@@ -13,6 +13,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
public const RESULT_ALLOWED = 'allowed';
public const RESULT_BLOCKED = 'blocked';
public const RESULT_RECORDED = 'recorded';
public const EVENT_AUTH_FAILURE = 'auth_failure';
public const EVENT_RATE_LIMIT = 'rate_limit';
@@ -20,8 +21,12 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
public const EVENT_SUSPICIOUS = 'suspicious';
public const EVENT_RULE_MATCH = 'rule_match';
public const EVENT_ACCESS_CHECK = 'access_check';
public const EVENT_RULE_CREATED = 'rule_created';
public const EVENT_RULE_DISABLED = 'rule_disabled';
public const EVENT_RULE_REMOVED = 'rule_removed';
private ?string $id = null;
private ?string $eventId = null;
private ?string $tenantId = null;
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
@@ -31,6 +36,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
private ?string $eventType = null;
private ?string $result = null; // allowed, blocked
private ?string $ruleId = null; // Which rule triggered (if any)
private ?string $ruleScope = null; // tenant or system
private ?string $identityId = null; // User ID if authenticated
private ?\DateTimeImmutable $timestamp = null;
private ?array $metadata = null; // Additional context
@@ -50,6 +56,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
}
if (array_key_exists('eventId', $data)) {
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
}
if (array_key_exists('ipAddress', $data)) {
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
}
@@ -74,6 +83,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('ruleId', $data)) {
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
}
if (array_key_exists('ruleScope', $data)) {
$this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null;
}
if (array_key_exists('identityId', $data)) {
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
}
@@ -93,6 +105,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'eventId' => $this->eventId,
'tenantId' => $this->tenantId,
'ipAddress' => $this->ipAddress,
'deviceFingerprint' => $this->deviceFingerprint,
@@ -102,6 +115,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
'eventType' => $this->eventType,
'result' => $this->result,
'ruleId' => $this->ruleId,
'ruleScope' => $this->ruleScope,
'identityId' => $this->identityId,
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
'metadata' => $this->metadata,
@@ -121,6 +135,17 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this;
}
public function getEventId(): ?string
{
return $this->eventId;
}
public function setEventId(?string $eventId): self
{
$this->eventId = $eventId;
return $this;
}
public function getTenantId(): ?string
{
return $this->tenantId;
@@ -220,6 +245,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this;
}
public function getRuleScope(): ?string
{
return $this->ruleScope;
}
public function setRuleScope(?string $ruleScope): self
{
if (
$ruleScope !== null
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
}
$this->ruleScope = $ruleScope;
return $this;
}
public function getIdentityId(): ?string
{
return $this->identityId;
@@ -11,6 +11,9 @@ use KTXF\Json\JsonDeserializable;
*/
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
public const SCOPE_TENANT = 'tenant';
public const SCOPE_SYSTEM = 'system';
public const TYPE_IP = 'ip';
public const TYPE_IP_RANGE = 'ip_range';
public const TYPE_DEVICE = 'device';
@@ -19,6 +22,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
public const ACTION_BLOCK = 'block';
private ?string $id = null;
private string $scope = self::SCOPE_TENANT;
private ?string $tenantId = null;
private ?string $type = null; // ip, ip_range, device
private ?string $action = null; // allow, block
@@ -42,6 +46,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
}
if (!array_key_exists('scope', $data)) {
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
}
$this->setScope((string)$data['scope']);
if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
}
@@ -84,6 +92,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'scope' => $this->scope,
'tenantId' => $this->tenantId,
'type' => $this->type,
'action' => $this->action,
@@ -134,6 +143,42 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
return $this->tenantId;
}
public function getScope(): string
{
return $this->scope;
}
public function setScope(string $scope): self
{
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
}
$this->scope = $scope;
return $this;
}
public function isTenantScoped(): bool
{
return $this->scope === self::SCOPE_TENANT;
}
public function isSystemScoped(): bool
{
return $this->scope === self::SCOPE_SYSTEM;
}
public function assertValidScopeOwnership(): void
{
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
}
if ($this->isSystemScoped() && $this->tenantId !== null) {
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
}
}
public function setTenantId(?string $tenantId): self
{
$this->tenantId = $tenantId;
@@ -11,11 +11,13 @@ class TenantConfiguration extends JsonSerializableObject
{
protected TenantAuthentication $authentication;
protected TenantSecurity $security;
protected TenantFirewall $firewall;
public function __construct()
{
$this->authentication = new TenantAuthentication();
$this->security = new TenantSecurity();
$this->firewall = new TenantFirewall();
}
public function authentication(): TenantAuthentication {
@@ -26,4 +28,8 @@ class TenantConfiguration extends JsonSerializableObject
return $this->security;
}
public function firewall(): TenantFirewall {
return $this->firewall;
}
}
+35
View File
@@ -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;
}
}
+28 -2
View File
@@ -3,6 +3,8 @@
namespace KTXC\Module;
use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\TenantFirewallRuleService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
@@ -33,10 +35,14 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
);
foreach ([
SecurityEvent::AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_DENIED,
SecurityEvent::BRUTE_FORCE_DETECTED,
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
] as $event) {
$this->events->listen(
'core',
@@ -115,7 +121,27 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'group' => 'Module Management'
],
// System Administration
// Firewall Management
TenantFirewallRuleService::PERMISSION_READ => [
'label' => 'View Tenant Firewall Rules',
'description' => 'View firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallRuleService::PERMISSION_MANAGE => [
'label' => 'Manage Tenant Firewall Rules',
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
SystemFirewallRuleService::PERMISSION_READ => [
'label' => 'View System Firewall Rules',
'description' => 'View firewall rules that apply to every tenant',
'group' => 'System Administration'
],
SystemFirewallRuleService::PERMISSION_MANAGE => [
'label' => 'Manage System Firewall Rules',
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
'group' => 'System Administration'
],
'system.admin' => [
'label' => 'System Administrator',
'description' => 'Full system access (superuser)',
+39
View File
@@ -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;
}
}
+241
View File
@@ -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);
}
}
+35
View File
@@ -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.');
}
}
}
+83 -324
View File
@@ -29,6 +29,9 @@ class FirewallService
private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
private const MAX_AUTH_FAILURES = 1000;
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
// Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
@@ -36,13 +39,12 @@ class FirewallService
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
private const CONFIG_ENABLED = 'firewall.enabled';
/** @var FirewallRuleObject[]|null */
private ?array $rulesCache = null;
public function __construct(
private readonly FirewallStore $store,
private readonly TenantContextInterface $tenantContext,
private readonly EventDispatcherInterface $events,
private readonly FirewallRuleManager $rules,
private readonly FirewallRuleCache $ruleCache,
) {
}
@@ -71,36 +73,33 @@ class FirewallService
string $ipAddress,
?string $deviceFingerprint = null
): FirewallAnalyzeResult {
// Check if firewall is enabled for this tenant
if (!$this->isEnabled()) {
return new FirewallAnalyzeResult(true);
}
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
$ruleGroups = [
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK],
];
if ($tenantId !== null && $this->isEnabled()) {
$tenantRules = $this->ruleCache->tenant($tenantId);
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
}
$rules = $this->getActiveRules();
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
// First check for explicit allow rules (whitelist takes precedence)
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
}
foreach ($ruleGroups as [$rules, $action]) {
foreach ($rules as $rule) {
if ($rule->getAction() !== $action) {
continue;
}
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
continue;
}
if ($action === FirewallRuleObject::ACTION_ALLOW) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
// Then check for block rules
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
}
@@ -140,14 +139,22 @@ class FirewallService
return;
}
$event->setTenantId($tenantId);
$log = $this->securityLog($event);
if ($log === null || !$this->store->createLogOnce($log)) {
return;
}
// Check for brute force
$windowSeconds = $this->getConfig(
$windowSeconds = $this->getBoundedIntegerConfig(
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::DEFAULT_MAX_AUTH_FAILURES
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
);
$failureCount = $this->store->countRecentFailures(
@@ -156,11 +163,8 @@ class FirewallService
$windowSeconds
);
// Include current failure in count
$failureCount++;
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
*/
private function handleBruteForce(
string $tenantId,
string $ipAddress,
int $failureCount,
int $windowSeconds
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenantContext->identifier());
$event->setTenantId($tenantId);
$this->events->dispatch($event);
// Auto-block the IP
$blockDuration = $this->getConfig(
$blockDuration = $this->getBoundedIntegerConfig(
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,
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
null, // System-created
$blockDuration
$blockDuration,
FirewallRuleManager::ORIGIN_AUTOMATIC
);
}
@@ -195,26 +203,36 @@ class FirewallService
* Log security event to firewall logs
*/
public function logSecurityEvent(SecurityEvent $event): void
{
$log = $this->securityLog($event);
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog(SecurityEvent $event): ?FirewallLogObject
{
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$tenantId) {
return;
$ruleScope = $event->get('ruleScope');
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
return null;
}
$log = new FirewallLogObject();
$log->setTenantId($tenantId)
return $log->setEventId($event->getEventId())
->setTenantId($tenantId)
->setIpAddress($event->getIpAddress())
->setDeviceFingerprint($event->getDeviceFingerprint())
->setUserAgent($event->getUserAgent())
->setRequestPath($event->getRequestPath())
->setRequestMethod($event->getRequestMethod())
->setEventType($this->mapEventToLogType($event->getName()))
->setResult($this->mapEventToResult($event->getName()))
->setIdentityId($event->getUserId())
->setResult($this->mapEventToResult($event))
->setRuleId($event->get('ruleId'))
->setRuleScope($ruleScope)
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
->setTimestamp(new \DateTimeImmutable())
->setMetadata($event->getData());
$this->store->createLog($log);
}
/**
@@ -229,6 +247,9 @@ class FirewallService
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
@@ -236,11 +257,14 @@ class FirewallService
/**
* 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::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
@@ -257,268 +281,13 @@ class FirewallService
$ipAddress,
$deviceFingerprint,
$rule->getId(),
$rule->getScope(),
$rule->getReason()
);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
}
// ========================================
// Rule Management
// ========================================
/**
* Block an IP address
*/
public function blockIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
// Check if already blocked
$existing = $this->store->findExactIpRule(
$tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK
);
if ($existing) {
return $existing;
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($ipAddress)
->setReason($reason ?? 'Blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Allow an IP address (whitelist)
*/
public function allowIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_ALLOW)
->setValue($ipAddress)
->setReason($reason ?? 'Allowed by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Block an IP range (CIDR notation)
*/
public function blockIpRange(
string $cidr,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP_RANGE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($cidr)
->setReason($reason ?? 'Range blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
return $rule;
}
/**
* Block a device fingerprint
*/
public function blockDevice(
string $fingerprint,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_DEVICE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($fingerprint)
->setReason($reason ?? 'Device blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Remove a rule by ID
*/
public function removeRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$this->store->destroyRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Disable a rule (soft delete)
*/
public function disableRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Get all rules for current tenant
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listRules($tenantId, $activeOnly);
}
/**
* Get firewall logs for current tenant
*/
public function getLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
}
/**
* Get blocked requests count
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return 0;
}
return $this->store->countBlockedRequests($tenantId, $since);
}
// ========================================
// Helpers
// ========================================
/**
* Check if firewall is enabled for current tenant
*/
@@ -533,6 +302,9 @@ class FirewallService
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenantContext->configuration();
if ($config instanceof \JsonSerializable) {
$config = $config->jsonSerialize();
}
$parts = explode('.', $key);
foreach ($parts as $part) {
@@ -545,27 +317,14 @@ class FirewallService
return $config;
}
/**
* Get active rules (cached)
* @return FirewallRuleObject[]
*/
private function getActiveRules(): array
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
{
if ($this->rulesCache === null) {
$tenantId = $this->tenantContext->identifier();
$this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
$value = $this->getConfig($key, $default);
if (!is_int($value) || $value < 1 || $value > $maximum) {
return $default;
}
return $this->rulesCache;
}
/**
* Clear rules cache
*/
private function clearRulesCache(): void
{
$this->rulesCache = null;
return $value;
}
/**
@@ -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}");
}
}
}
+76 -7
View File
@@ -29,14 +29,19 @@ class FirewallStore
*/
public function listRules(string $tenantId, bool $activeOnly = true): array
{
$filter = ['tenantId' => $tenantId];
$filter = [
'tenantId' => $tenantId,
'scope' => FirewallRuleObject::SCOPE_TENANT,
];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
$filter['$and'] = [[
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
];
],
]];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
@@ -50,6 +55,26 @@ class FirewallStore
return $list;
}
public function listSystemRules(bool $activeOnly = true): array
{
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
$list = [];
foreach ($cursor as $entry) {
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
return $list;
}
/**
* Find rules by IP address
*/
@@ -118,15 +143,33 @@ class FirewallStore
/**
* 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([
'tenantId' => $tenantId,
$filter = [
'type' => FirewallRuleObject::TYPE_IP,
'value' => $ipAddress,
'action' => $action,
'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) {
return null;
@@ -139,6 +182,8 @@ class FirewallStore
*/
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$rule->assertValidScopeOwnership();
if ($rule->getId()) {
return $this->updateRule($rule);
} else {
@@ -215,6 +260,30 @@ class FirewallStore
return $log;
}
/**
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
*/
public function createLogOnce(FirewallLogObject $log): bool
{
$eventId = $log->getEventId();
if ($eventId === null || $eventId === '') {
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
}
$data = $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
*/
+24 -24
View File
@@ -642,14 +642,14 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.7",
"@intlify/message-compiler": "11.4.7",
"@intlify/shared": "11.4.7"
"@intlify/devtools-types": "11.4.8",
"@intlify/message-compiler": "11.4.8",
"@intlify/shared": "11.4.8"
},
"engines": {
"node": ">= 22"
@@ -659,13 +659,13 @@
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.7",
"@intlify/shared": "11.4.7"
"@intlify/core-base": "11.4.8",
"@intlify/shared": "11.4.8"
},
"engines": {
"node": ">= 22"
@@ -675,12 +675,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.7",
"@intlify/shared": "11.4.8",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -691,9 +691,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
"license": "MIT",
"engines": {
"node": ">= 22"
@@ -6537,14 +6537,14 @@
}
},
"node_modules/vue-i18n": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.7",
"@intlify/devtools-types": "11.4.7",
"@intlify/shared": "11.4.7",
"@intlify/core-base": "11.4.8",
"@intlify/devtools-types": "11.4.8",
"@intlify/shared": "11.4.8",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
+8 -1
View File
@@ -12,6 +12,7 @@ class Event
private bool $propagationStopped = false;
private array $data = [];
private float $timestamp;
private string $eventId;
private ?string $tenantId = null;
private ?string $identityId = null;
@@ -21,6 +22,7 @@ class Event
) {
$this->data = $data;
$this->timestamp = microtime(true);
$this->eventId = bin2hex(random_bytes(16));
}
/**
@@ -80,6 +82,11 @@ class Event
return $this->timestamp;
}
public function getEventId(): string
{
return $this->eventId;
}
/**
* Stop event propagation to subsequent listeners
*/
@@ -143,4 +150,4 @@ class Event
'identityId' => $this->identityId,
];
}
}
}
+5
View File
@@ -26,6 +26,9 @@ class SecurityEvent extends Event
public const IP_BLOCKED = 'security.ip.blocked';
public const IP_ALLOWED = 'security.ip.allowed';
public const DEVICE_BLOCKED = 'security.device.blocked';
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
@@ -145,10 +148,12 @@ class SecurityEvent extends Event
string $ipAddress,
?string $deviceFingerprint = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $reason = null
): self {
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'reason' => $reason,
]);
$event->reason = $reason;
@@ -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();
}
}
+28 -1
View File
@@ -7,6 +7,8 @@ namespace KTXT\Unit\Module;
use KTXC\Console\Event\EventsDebugCommand;
use KTXC\Module\Module;
use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\TenantFirewallRuleService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
@@ -26,12 +28,25 @@ final class CoreModuleTest extends TestCase
$module->boot();
$definitions = $registry->definitions();
self::assertCount(5, $definitions);
self::assertCount(9, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame(
FirewallService::class,
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
);
self::assertSame([], $registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Deferred));
foreach ([
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_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());
}
@@ -43,4 +58,16 @@ final class CoreModuleTest extends TestCase
self::assertContains(EventsDebugCommand::class, $module->registerCI());
}
#[Test]
#[TestDox('Core registers dedicated system firewall permissions')]
public function registersSystemFirewallPermissions(): void
{
$permissions = (new Module(new EventListenerRegistry()))->permissions();
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
}
}
@@ -0,0 +1,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],
]);
}
}