1 Commits

Author SHA1 Message Date
Sebastian a54fe7b239 chore(deps): update dependency phpunit/phpunit to v12.5.33
Build Test / build (pull_request) Successful in 32s
JS Unit Tests / test (pull_request) Successful in 31s
PHP Unit Tests / test (pull_request) Failing after 1m2s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m18s
2026-07-29 03:01:58 +00:00
44 changed files with 381 additions and 5388 deletions
Generated
+8 -8
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "68916c1b58d9ce06a59f6c58e4a2d226",
"content-hash": "d162d02532b7705788ced1ea4c360c05",
"packages": [
{
"name": "laravel/serializable-closure",
@@ -1946,16 +1946,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.31",
"version": "12.5.33",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"shasum": ""
},
"require": {
@@ -2024,7 +2024,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33"
},
"funding": [
{
@@ -2032,7 +2032,7 @@
"type": "other"
}
],
"time": "2026-07-06T14:54:16+00:00"
"time": "2026-07-28T13:58:09+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -3057,5 +3057,5 @@
"ext-iconv": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Service\FirewallService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
final class FirewallMaintenanceCommand extends Command
{
public function __construct(private readonly FirewallService $firewall)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->firewall->cleanup();
} catch (\Throwable $error) {
$io->error('Firewall maintenance failed: '.$error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf(
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
$result['expiredRules'],
$result['oldLogs'],
$result['expiredBruteForceClaims']
));
return Command::SUCCESS;
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Stores\FirewallStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
final class FirewallSetupCommand extends Command
{
public function __construct(private readonly FirewallStore $store)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$indexes = $this->store->ensureIndexes();
} catch (\Throwable $error) {
$io->error('Firewall database setup failed: '.$error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
return Command::SUCCESS;
}
}
-465
View File
@@ -1,465 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\FirewallRuleConflictException;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
final class FirewallController extends ControllerAbstract
{
public function __construct(
private readonly TenantFirewallRuleService $tenantRules,
private readonly SystemFirewallRuleService $systemRules,
private readonly TenantFirewallLogService $tenantLogs,
private readonly SystemFirewallLogService $systemLogs,
private readonly TenantFirewallStatusService $tenantStatus,
private readonly SystemFirewallStatusService $systemStatus,
) {
}
#[AuthenticatedRoute(
'/firewall/rules',
name: 'firewall.tenant.rules.list',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function tenantRules(
string $status = 'active',
?string $type = null,
?string $action = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantRules->queryRules(
$status,
$type,
$action,
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.fetch',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function tenantRule(string $ruleId): JsonResponse
{
return $this->ruleResponse($this->tenantRules->fetchRule($ruleId));
}
#[AuthenticatedRoute(
'/firewall/effective-policy',
name: 'firewall.tenant.policy.effective',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function effectivePolicy(): JsonResponse
{
return new JsonResponse($this->tenantRules->effectivePolicy());
}
#[AuthenticatedRoute(
'/firewall/system/rules',
name: 'firewall.system.rules.list',
permissions: [SystemFirewallRuleService::PERMISSION_READ],
)]
public function systemRules(
string $status = 'active',
?string $type = null,
?string $action = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->systemRules->queryRules(
$status,
$type,
$action,
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.fetch',
permissions: [SystemFirewallRuleService::PERMISSION_READ],
)]
public function systemRule(string $ruleId): JsonResponse
{
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
}
#[AuthenticatedRoute(
'/firewall/rules',
name: 'firewall.tenant.rules.create',
methods: ['POST'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function createTenantRule(
Request $request,
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->mutationResponse(fn() => $this->tenantRules->createRule(
$type,
$action,
$value,
$reason,
$durationSeconds,
$request->getClientIp(),
$confirmCurrentIp
));
}
#[AuthenticatedRoute(
'/firewall/system/rules',
name: 'firewall.system.rules.create',
methods: ['POST'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function createSystemRule(
Request $request,
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->mutationResponse(fn() => $this->systemRules->createRule(
$type,
$action,
$value,
$reason,
$durationSeconds,
$request->getClientIp(),
$confirmCurrentIp
));
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.update',
methods: ['PATCH'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateTenantRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->tenantRules->disableRule($ruleId, $reason),
'enable' => $this->tenantRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->tenantRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.update',
methods: ['PATCH'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateSystemRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->systemRules->disableRule($ruleId, $reason),
'enable' => $this->systemRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->systemRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.delete',
methods: ['DELETE'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteTenantRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->tenantRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.delete',
methods: ['DELETE'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteSystemRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->systemRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/logs',
name: 'firewall.tenant.logs.list',
permissions: [TenantFirewallLogService::PERMISSION_READ],
)]
public function tenantLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $from = null,
?string $to = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query(
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/system/logs',
name: 'firewall.system.logs.list',
permissions: [SystemFirewallLogService::PERMISSION_READ],
)]
public function systemLogs(
?string $tenantId = null,
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $from = null,
?string $to = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query(
$tenantId,
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/metrics',
name: 'firewall.tenant.metrics.read',
permissions: [TenantFirewallLogService::PERMISSION_READ],
)]
public function tenantMetrics(?string $since = null): JsonResponse
{
return $this->readResponse(fn(): array => $this->tenantStatus->metrics($since));
}
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.read',
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_READ],
)]
public function tenantConfiguration(): JsonResponse
{
return new JsonResponse($this->tenantStatus->configuration());
}
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.update',
methods: ['PUT'],
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateTenantConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration(
$enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/tenants/{tenantId}/configuration',
name: 'firewall.system.tenant.configuration.update',
methods: ['PUT'],
permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateSystemTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration(
$tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/metrics',
name: 'firewall.system.metrics.read',
permissions: [SystemFirewallLogService::PERMISSION_READ],
)]
public function systemMetrics(?string $tenantId = null, ?string $since = null): JsonResponse
{
return $this->readResponse(fn(): array => $this->systemStatus->metrics($tenantId, $since));
}
#[AuthenticatedRoute(
'/firewall/system/maintenance',
name: 'firewall.system.maintenance.read',
permissions: [SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ],
)]
public function maintenanceStatus(): JsonResponse
{
return new JsonResponse($this->systemStatus->maintenanceStatus());
}
private function queryResponse(callable $query, string $limit, string $offset): JsonResponse
{
try {
if (!ctype_digit($limit) || !ctype_digit($offset)) {
throw new \InvalidArgumentException('Pagination values must be non-negative integers.');
}
return new JsonResponse($query((int)$limit, (int)$offset));
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function ruleResponse(?\JsonSerializable $rule): JsonResponse
{
if ($rule === null) {
return new JsonResponse(['error' => 'Firewall rule not found.'], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse($rule);
}
private function readResponse(callable $read): JsonResponse
{
try {
return new JsonResponse($read());
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function mutationResponse(callable $mutation): JsonResponse
{
try {
return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED);
} catch (FirewallRuleConflictException $error) {
return new JsonResponse(['error' => [
'code' => $error->conflictCode,
'message' => $error->getMessage(),
]], JsonResponse::HTTP_CONFLICT);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_rule',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function lifecycleResponse(callable $mutation): JsonResponse
{
try {
$rule = $mutation();
if ($rule === null) {
return new JsonResponse(['error' => [
'code' => 'firewall_rule_not_found',
'message' => 'Firewall rule not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['rule' => $rule]);
} catch (FirewallRuleConflictException $error) {
return new JsonResponse(['error' => [
'code' => $error->conflictCode,
'message' => $error->getMessage(),
]], JsonResponse::HTTP_CONFLICT);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_rule',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function settingsResponse(callable $mutation): JsonResponse
{
try {
$configuration = $mutation();
if ($configuration === null) {
return new JsonResponse(['error' => [
'code' => 'tenant_not_found',
'message' => 'Tenant not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['configuration' => $configuration]);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_configuration',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
}
+1 -1
View File
@@ -66,6 +66,6 @@ class ObjectId
*/
public static function isValid(string $id): bool
{
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
return MongoObjectId::isValid($id);
}
}
+3 -65
View File
@@ -13,7 +13,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
public const RESULT_ALLOWED = 'allowed';
public const RESULT_BLOCKED = 'blocked';
public const RESULT_RECORDED = 'recorded';
public const EVENT_AUTH_FAILURE = 'auth_failure';
public const EVENT_RATE_LIMIT = 'rate_limit';
@@ -21,15 +20,8 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
public const EVENT_SUSPICIOUS = 'suspicious';
public const EVENT_RULE_MATCH = 'rule_match';
public const EVENT_ACCESS_CHECK = 'access_check';
public const EVENT_RULE_CREATED = 'rule_created';
public const EVENT_RULE_EXTENDED = 'rule_extended';
public const EVENT_RULE_ENABLED = 'rule_enabled';
public const EVENT_RULE_DISABLED = 'rule_disabled';
public const EVENT_RULE_REMOVED = 'rule_removed';
public const EVENT_SETTINGS_UPDATED = 'settings_updated';
private ?string $id = null;
private ?string $eventId = null;
private ?string $tenantId = null;
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
@@ -39,7 +31,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
private ?string $eventType = null;
private ?string $result = null; // allowed, blocked
private ?string $ruleId = null; // Which rule triggered (if any)
private ?string $ruleScope = null; // tenant or system
private ?string $identityId = null; // User ID if authenticated
private ?\DateTimeImmutable $timestamp = null;
private ?array $metadata = null; // Additional context
@@ -59,9 +50,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
}
if (array_key_exists('eventId', $data)) {
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
}
if (array_key_exists('ipAddress', $data)) {
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
}
@@ -86,14 +74,13 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('ruleId', $data)) {
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
}
if (array_key_exists('ruleScope', $data)) {
$this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null;
}
if (array_key_exists('identityId', $data)) {
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
}
if (array_key_exists('timestamp', $data)) {
$this->timestamp = self::deserializeDate($data['timestamp']);
$this->timestamp = $data['timestamp'] !== null
? new \DateTimeImmutable($data['timestamp'])
: null;
}
if (array_key_exists('metadata', $data)) {
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
@@ -106,7 +93,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'eventId' => $this->eventId,
'tenantId' => $this->tenantId,
'ipAddress' => $this->ipAddress,
'deviceFingerprint' => $this->deviceFingerprint,
@@ -116,31 +102,12 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
'eventType' => $this->eventType,
'result' => $this->result,
'ruleId' => $this->ruleId,
'ruleScope' => $this->ruleScope,
'identityId' => $this->identityId,
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
'metadata' => $this->metadata,
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
// Getters and setters
public function getId(): ?string
@@ -154,17 +121,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this;
}
public function getEventId(): ?string
{
return $this->eventId;
}
public function setEventId(?string $eventId): self
{
$this->eventId = $eventId;
return $this;
}
public function getTenantId(): ?string
{
return $this->tenantId;
@@ -264,24 +220,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this;
}
public function getRuleScope(): ?string
{
return $this->ruleScope;
}
public function setRuleScope(?string $ruleScope): self
{
if (
$ruleScope !== null
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
}
$this->ruleScope = $ruleScope;
return $this;
}
public function getIdentityId(): ?string
{
return $this->identityId;
@@ -11,9 +11,6 @@ use KTXF\Json\JsonDeserializable;
*/
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
public const SCOPE_TENANT = 'tenant';
public const SCOPE_SYSTEM = 'system';
public const TYPE_IP = 'ip';
public const TYPE_IP_RANGE = 'ip_range';
public const TYPE_DEVICE = 'device';
@@ -22,7 +19,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
public const ACTION_BLOCK = 'block';
private ?string $id = null;
private string $scope = self::SCOPE_TENANT;
private ?string $tenantId = null;
private ?string $type = null; // ip, ip_range, device
private ?string $action = null; // allow, block
@@ -46,10 +42,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
}
if (!array_key_exists('scope', $data)) {
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
}
$this->setScope((string)$data['scope']);
if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
}
@@ -69,10 +61,14 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
}
if (array_key_exists('createdAt', $data)) {
$this->createdAt = self::deserializeDate($data['createdAt']);
$this->createdAt = $data['createdAt'] !== null
? new \DateTimeImmutable($data['createdAt'])
: null;
}
if (array_key_exists('expiresAt', $data)) {
$this->expiresAt = self::deserializeDate($data['expiresAt']);
$this->expiresAt = $data['expiresAt'] !== null
? new \DateTimeImmutable($data['expiresAt'])
: null;
}
if (array_key_exists('enabled', $data)) {
$this->enabled = (bool)$data['enabled'];
@@ -88,7 +84,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'scope' => $this->scope,
'tenantId' => $this->tenantId,
'type' => $this->type,
'action' => $this->action,
@@ -102,24 +97,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
/**
* Check if this rule has expired
*/
@@ -157,42 +134,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
return $this->tenantId;
}
public function getScope(): string
{
return $this->scope;
}
public function setScope(string $scope): self
{
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
}
$this->scope = $scope;
return $this;
}
public function isTenantScoped(): bool
{
return $this->scope === self::SCOPE_TENANT;
}
public function isSystemScoped(): bool
{
return $this->scope === self::SCOPE_SYSTEM;
}
public function assertValidScopeOwnership(): void
{
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
}
if ($this->isSystemScoped() && $this->tenantId !== null) {
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
}
}
public function setTenantId(?string $tenantId): self
{
$this->tenantId = $tenantId;
@@ -11,13 +11,11 @@ class TenantConfiguration extends JsonSerializableObject
{
protected TenantAuthentication $authentication;
protected TenantSecurity $security;
protected TenantFirewall $firewall;
public function __construct()
{
$this->authentication = new TenantAuthentication();
$this->security = new TenantSecurity();
$this->firewall = new TenantFirewall();
}
public function authentication(): TenantAuthentication {
@@ -28,8 +26,4 @@ class TenantConfiguration extends JsonSerializableObject
return $this->security;
}
public function firewall(): TenantFirewall {
return $this->firewall;
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use KTXF\Json\JsonSerializableObject;
class TenantFirewall extends JsonSerializableObject
{
protected bool $enabled = true;
protected int $maxAuthFailures = 5;
protected int $authFailureWindow = 300;
protected int $autoBlockDuration = 3600;
public function enabled(): bool
{
return $this->enabled;
}
public function maxAuthFailures(): int
{
return $this->maxAuthFailures;
}
public function authFailureWindow(): int
{
return $this->authFailureWindow;
}
public function autoBlockDuration(): int
{
return $this->autoBlockDuration;
}
}
+2 -69
View File
@@ -2,15 +2,7 @@
namespace KTXC\Module;
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
use KTXC\Console\Firewall\FirewallSetupCommand;
use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
@@ -41,17 +33,10 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
);
foreach ([
SecurityEvent::AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_DENIED,
SecurityEvent::BRUTE_FORCE_DETECTED,
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) {
$this->events->listen(
'core',
@@ -130,57 +115,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'group' => 'Module Management'
],
// Firewall Management
TenantFirewallRuleService::PERMISSION_READ => [
'label' => 'View Tenant Firewall Rules',
'description' => 'View firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallRuleService::PERMISSION_MANAGE => [
'label' => 'Manage Tenant Firewall Rules',
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallLogService::PERMISSION_READ => [
'label' => 'View Tenant Firewall Logs',
'description' => 'View firewall security and audit logs owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallStatusService::PERMISSION_SETTINGS_READ => [
'label' => 'View Tenant Firewall Settings',
'description' => 'View effective firewall settings for the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
'label' => 'Manage Tenant Firewall Settings',
'description' => 'Update firewall settings for 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'
],
SystemFirewallLogService::PERMISSION_READ => [
'label' => 'View System Firewall Logs',
'description' => 'View firewall security and audit logs across tenants',
'group' => 'System Administration'
],
SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ => [
'label' => 'View Firewall Maintenance Status',
'description' => 'View the last firewall cleanup result and operational status',
'group' => 'System Administration'
],
SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
'label' => 'Manage Tenant Firewall Settings System-Wide',
'description' => 'Update firewall settings for any tenant',
'group' => 'System Administration'
],
// System Administration
'system.admin' => [
'label' => 'System Administrator',
'description' => 'Full system access (superuser)',
@@ -197,8 +132,6 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function registerCI(): array
{
return [
FirewallSetupCommand::class,
FirewallMaintenanceCommand::class,
\KTXC\Console\Event\EventsDebugCommand::class,
\KTXC\Console\Module\ModuleListCommand::class,
\KTXC\Console\Module\ModuleEnableCommand::class,
-126
View File
@@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
final class FirewallLogService
{
public const MAX_LIMIT = 100;
private const EVENT_TYPES = [
FirewallLogObject::EVENT_AUTH_FAILURE,
FirewallLogObject::EVENT_RATE_LIMIT,
FirewallLogObject::EVENT_BRUTE_FORCE,
FirewallLogObject::EVENT_SUSPICIOUS,
FirewallLogObject::EVENT_RULE_MATCH,
FirewallLogObject::EVENT_ACCESS_CHECK,
FirewallLogObject::EVENT_RULE_CREATED,
FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallLogObject::EVENT_RULE_ENABLED,
FirewallLogObject::EVENT_RULE_DISABLED,
FirewallLogObject::EVENT_RULE_REMOVED,
FirewallLogObject::EVENT_SETTINGS_UPDATED,
];
public function __construct(private readonly FirewallStore $store)
{
}
public function tenant(string $tenantId, array $filters, int $limit, int $offset): array
{
return $this->store->queryTenantLogs(
$tenantId,
$this->validate($filters, $limit, $offset),
$limit,
$offset
);
}
public function system(?string $tenantId, array $filters, int $limit, int $offset): array
{
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
throw new \InvalidArgumentException('Invalid tenant filter.');
}
return $this->store->querySystemLogs(
$tenantId,
$this->validate($filters, $limit, $offset),
$limit,
$offset
);
}
private function validate(array $filters, int $limit, int $offset): array
{
if ($limit < 1 || $limit > self::MAX_LIMIT || $offset < 0) {
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
}
$ipAddress = self::nullableString($filters, 'ipAddress');
if ($ipAddress !== null && filter_var($ipAddress, FILTER_VALIDATE_IP) === false) {
throw new \InvalidArgumentException('Invalid IP address filter.');
}
$eventType = self::nullableString($filters, 'eventType');
if ($eventType !== null && !in_array($eventType, self::EVENT_TYPES, true)) {
throw new \InvalidArgumentException('Invalid firewall event type filter.');
}
$result = self::nullableString($filters, 'result');
if ($result !== null && !in_array($result, [
FirewallLogObject::RESULT_ALLOWED,
FirewallLogObject::RESULT_BLOCKED,
FirewallLogObject::RESULT_RECORDED,
], true)) {
throw new \InvalidArgumentException('Invalid firewall result filter.');
}
$ruleScope = self::nullableString($filters, 'ruleScope');
if ($ruleScope !== null && !in_array($ruleScope, [
FirewallRuleObject::SCOPE_TENANT,
FirewallRuleObject::SCOPE_SYSTEM,
], true)) {
throw new \InvalidArgumentException('Invalid rule scope filter.');
}
$from = self::date($filters, 'from');
$to = self::date($filters, 'to');
if ($from !== null && $to !== null && $from > $to) {
throw new \InvalidArgumentException('The from date must not be later than the to date.');
}
return [
'ipAddress' => $ipAddress,
'eventType' => $eventType,
'result' => $result,
'ruleId' => self::nullableString($filters, 'ruleId'),
'ruleScope' => $ruleScope,
'from' => $from,
'to' => $to,
];
}
private static function nullableString(array $filters, string $key): ?string
{
$value = $filters[$key] ?? null;
if ($value === null) {
return null;
}
if (!is_string($value) || $value === '' || strlen($value) > 255) {
throw new \InvalidArgumentException("Invalid {$key} filter.");
}
return $value;
}
private static function date(array $filters, string $key): ?\DateTimeImmutable
{
$value = self::nullableString($filters, $key);
if ($value === null) {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception) {
throw new \InvalidArgumentException("Invalid {$key} date filter.");
}
}
}
-39
View File
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
final class FirewallRuleCache
{
/** @var array<string, FirewallRuleObject[]> */
private array $tenantRules = [];
/** @var FirewallRuleObject[]|null */
private ?array $systemRules = null;
public function __construct(private readonly FirewallStore $store)
{
}
/** @return FirewallRuleObject[] */
public function tenant(string $tenantId): array
{
return $this->tenantRules[$tenantId] ??= $this->store->listRules($tenantId);
}
/** @return FirewallRuleObject[] */
public function system(): array
{
return $this->systemRules ??= $this->store->listSystemRules();
}
public function invalidate(): void
{
$this->tenantRules = [];
$this->systemRules = null;
}
}
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
final class FirewallRuleConflictException extends \RuntimeException
{
public function __construct(
public readonly string $conflictCode,
string $message
) {
parent::__construct($message);
}
}
-530
View File
@@ -1,530 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
final class FirewallRuleManager
{
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
public const MAX_QUERY_LIMIT = 100;
public const ORIGIN_MANUAL = 'manual';
public const ORIGIN_AUTOMATIC = 'automatic';
public function __construct(
private readonly FirewallStore $store,
private readonly FirewallRuleCache $cache,
private readonly EventDispatcherInterface $events,
) {
}
public function list(FirewallRuleScope $scope, bool $activeOnly = true): array
{
return $scope->scope === FirewallRuleObject::SCOPE_SYSTEM
? $this->store->listSystemRules($activeOnly)
: $this->store->listRules($scope->tenantId, $activeOnly);
}
public function query(
FirewallRuleScope $scope,
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
if (!in_array($status, self::QUERY_STATUSES, true)) {
throw new \InvalidArgumentException('Invalid rule status filter.');
}
if ($type !== null && !in_array($type, [
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::TYPE_DEVICE,
], true)) {
throw new \InvalidArgumentException('Invalid rule type filter.');
}
if ($action !== null && !in_array($action, [
FirewallRuleObject::ACTION_ALLOW,
FirewallRuleObject::ACTION_BLOCK,
], true)) {
throw new \InvalidArgumentException('Invalid rule action filter.');
}
if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) {
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
}
return $this->store->queryRules(
$scope->scope,
$scope->tenantId,
$status,
$type,
$action,
$limit,
$offset
);
}
public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
return $this->ownedRule($scope, $ruleId);
}
/** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */
public function effectivePolicy(string $tenantId): array
{
return [
'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'],
'system' => $this->store->listSystemRules(),
'tenant' => $this->store->listRules($tenantId),
];
}
public function createManualRule(
FirewallRuleScope $scope,
string $type,
string $action,
string $value,
string $reason,
?string $createdBy,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.');
}
if ($currentIp !== null) {
$currentIp = FirewallRuleValidator::ipAddress($currentIp);
}
if (
!$confirmCurrentIp
&& $currentIp !== null
&& $action === FirewallRuleObject::ACTION_BLOCK
&& $this->matchesIp($type, $value, $currentIp)
) {
throw new FirewallRuleConflictException(
'current_ip_confirmation_required',
'This rule would block your current IP address. Explicit confirmation is required.'
);
}
return match ([$type, $action]) {
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] =>
$this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds),
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] =>
$durationSeconds === null
? $this->allowIp($scope, $value, $reason, $createdBy)
: throw new \InvalidArgumentException('Temporary allow rules are not supported.'),
[FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] =>
$durationSeconds === null
? $this->blockIpRange($scope, $value, $reason, $createdBy)
: throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'),
[FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] =>
$this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds),
default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'),
};
}
private function matchesIp(string $type, string $value, string $currentIp): bool
{
if ($type === FirewallRuleObject::TYPE_IP) {
$value = FirewallRuleValidator::ipAddress($value);
return inet_pton($value) === inet_pton($currentIp);
}
if ($type === FirewallRuleObject::TYPE_IP_RANGE) {
return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value));
}
return false;
}
public function blockIp(
FirewallRuleScope $scope,
string $ipAddress,
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
FirewallRuleValidator::duration($durationSeconds);
$existing = $this->store->findExactIpRule(
$scope->tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK,
$scope->scope
);
if ($existing) {
if (
$origin === self::ORIGIN_AUTOMATIC
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
&& $durationSeconds !== null
) {
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
}
return $existing;
}
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
$ipAddress,
$reason ?? 'Blocked by administrator',
$createdBy,
$durationSeconds,
$origin,
$metadata
);
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
return $rule;
}
public function allowIp(
FirewallRuleScope $scope,
string $ipAddress,
?string $reason,
?string $createdBy,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_ALLOW,
$ipAddress,
$reason ?? 'Allowed by administrator',
$createdBy,
null,
$origin
);
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
return $rule;
}
public function blockIpRange(
FirewallRuleScope $scope,
string $cidr,
?string $reason,
?string $createdBy,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
return $this->create(
$scope,
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
FirewallRuleValidator::cidr($cidr),
$reason ?? 'Range blocked by administrator',
$createdBy,
null,
$origin
);
}
public function blockDevice(
FirewallRuleScope $scope,
string $fingerprint,
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
FirewallRuleValidator::duration($durationSeconds);
$fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint);
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_BLOCK,
$fingerprint,
$reason ?? 'Device blocked by administrator',
$createdBy,
$durationSeconds,
$origin
);
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)->setReason($reason)->setTenantId($scope->tenantId);
$this->events->dispatch($event);
return $rule;
}
public function disableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
if ($rule->isEnabled()) {
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_DISABLED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
return $rule;
}
public function enableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
if (
!$confirmCurrentIp
&& $currentIp !== null
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
) {
throw new FirewallRuleConflictException(
'current_ip_confirmation_required',
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
);
}
if (!$rule->isEnabled()) {
$rule->setEnabled(true);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_ENABLED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
return $rule;
}
public function extendManual(
FirewallRuleScope $scope,
string $ruleId,
int $durationSeconds,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
FirewallRuleValidator::duration($durationSeconds);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$previousExpiry = $rule->getExpiresAt();
if ($previousExpiry === null) {
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
}
$now = new \DateTimeImmutable();
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
->modify("+{$durationSeconds} seconds");
$metadata = $rule->getMetadata() ?? [];
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
$extensions[] = [
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
'origin' => self::ORIGIN_MANUAL,
'actorId' => $actorId,
'reason' => $reason,
];
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_EXTENDED,
$rule,
$actorId,
[
'changeReason' => $reason,
'changeOrigin' => self::ORIGIN_MANUAL,
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
]
);
return $rule;
}
public function removeManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$this->store->destroyRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_REMOVED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
return $rule;
}
private static function manualReason(string $reason): string
{
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
return $reason;
}
private function create(
FirewallRuleScope $scope,
string $type,
string $action,
string $value,
string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
}
$rule = (new FirewallRuleObject())
->setScope($scope->scope)
->setTenantId($scope->tenantId)
->setType($type)
->setAction($action)
->setValue($value)
->setReason($reason)
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
}
$metadata = [...$metadata, 'origin' => $origin];
if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) {
$metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM);
$metadata['extensions'] = [];
}
$rule->setMetadata($metadata);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule);
return $rule;
}
private function extendAutomaticBlock(
FirewallRuleObject $rule,
int $durationSeconds,
array $policy
): FirewallRuleObject {
$now = new \DateTimeImmutable();
$previousExpiry = $rule->getExpiresAt();
$newExpiry = $now->modify("+{$durationSeconds} seconds");
if ($previousExpiry !== null && $newExpiry <= $previousExpiry) {
return $rule;
}
$metadata = $rule->getMetadata() ?? [];
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
$extensions[] = [
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
'previousExpiresAt' => $previousExpiry?->format(\DateTimeInterface::ATOM),
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
'failureCount' => $policy['lastFailureCount'] ?? null,
];
$rule->setExpiresAt($newExpiry)->setMetadata([
...$metadata,
...$policy,
'origin' => self::ORIGIN_AUTOMATIC,
'originalExpiresAt' => $metadata['originalExpiresAt']
?? $previousExpiry?->format(\DateTimeInterface::ATOM),
'extensions' => $extensions,
'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM),
]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule);
return $rule;
}
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
$rule = $this->store->fetchRule($ruleId);
return $rule && $scope->owns($rule) ? $rule : null;
}
private function publishIpEvent(
string $name,
FirewallRuleScope $scope,
string $ipAddress,
?string $reason
): void {
$event = new SecurityEvent($name, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)->setReason($reason)->setTenantId($scope->tenantId);
$this->events->dispatch($event);
}
private function publishLifecycleEvent(
string $name,
FirewallRuleObject $rule,
?string $actorId = null,
array $change = []
): void
{
$event = new SecurityEvent($name, [
'ruleId' => $rule->getId(),
'ruleScope' => $rule->getScope(),
'ruleType' => $rule->getType(),
'ruleAction' => $rule->getAction(),
'ruleValue' => $rule->getValue(),
'reason' => $rule->getReason(),
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
...$change,
]);
$event->setTenantId($rule->getTenantId())
->setIdentityId($actorId ?? $rule->getCreatedBy());
$this->events->dispatch($event);
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
final class FirewallRuleScope
{
private function __construct(
public readonly string $scope,
public readonly ?string $tenantId,
) {
}
public static function tenant(string $tenantId): self
{
if ($tenantId === '') {
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
}
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
}
public static function system(): self
{
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
}
public function owns(FirewallRuleObject $rule): bool
{
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
}
}
@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
final class FirewallRuleValidator
{
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
private function __construct()
{
}
public static function ipAddress(string $ipAddress): string
{
$ipAddress = trim($ipAddress);
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
}
return $ipAddress;
}
public static function cidr(string $cidr): string
{
$cidr = trim($cidr);
if (substr_count($cidr, '/') !== 1) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
[$address, $prefix] = explode('/', $cidr, 2);
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
if ((int)$prefix > $maximumPrefix) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
return $cidr;
}
public static function deviceFingerprint(string $fingerprint): string
{
$fingerprint = trim($fingerprint);
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
throw new \InvalidArgumentException(
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
);
}
return $fingerprint;
}
public static function duration(?int $durationSeconds): void
{
if ($durationSeconds !== null && $durationSeconds < 1) {
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
}
}
}
+335 -138
View File
@@ -29,9 +29,6 @@ class FirewallService
private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
private const MAX_AUTH_FAILURES = 1000;
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
// Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
@@ -39,12 +36,13 @@ class FirewallService
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
private const CONFIG_ENABLED = 'firewall.enabled';
/** @var FirewallRuleObject[]|null */
private ?array $rulesCache = null;
public function __construct(
private readonly FirewallStore $store,
private readonly TenantContextInterface $tenantContext,
private readonly EventDispatcherInterface $events,
private readonly FirewallRuleManager $rules,
private readonly FirewallRuleCache $ruleCache,
) {
}
@@ -73,33 +71,36 @@ class FirewallService
string $ipAddress,
?string $deviceFingerprint = null
): FirewallAnalyzeResult {
$tenantId = $this->tenantContext->identifier();
$ruleGroups = [
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK],
];
if ($tenantId !== null && $this->isEnabled()) {
$tenantRules = $this->ruleCache->tenant($tenantId);
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
// Check if firewall is enabled for this tenant
if (!$this->isEnabled()) {
return new FirewallAnalyzeResult(true);
}
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
}
foreach ($ruleGroups as [$rules, $action]) {
foreach ($rules as $rule) {
if ($rule->getAction() !== $action) {
continue;
}
$rules = $this->getActiveRules();
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
continue;
}
if ($action === FirewallRuleObject::ACTION_ALLOW) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
// First check for explicit allow rules (whitelist takes precedence)
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
}
// Then check for block rules
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
}
@@ -139,22 +140,14 @@ class FirewallService
return;
}
$event->setTenantId($tenantId);
$log = $this->securityLog($event);
if ($log === null || !$this->store->createLogOnce($log)) {
return;
}
// Check for brute force
$windowSeconds = $this->getBoundedIntegerConfig(
$windowSeconds = $this->getConfig(
self::CONFIG_FAILURE_WINDOW,
self::DEFAULT_AUTH_FAILURE_WINDOW,
self::MAX_AUTH_FAILURE_WINDOW
self::DEFAULT_AUTH_FAILURE_WINDOW
);
$maxFailures = $this->getBoundedIntegerConfig(
$maxFailures = $this->getConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
self::DEFAULT_MAX_AUTH_FAILURES
);
$failureCount = $this->store->countRecentFailures(
@@ -163,24 +156,11 @@ class FirewallService
$windowSeconds
);
if ($failureCount >= $maxFailures) {
$blockDuration = $this->getBoundedIntegerConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
return;
}
// Include current failure in count
$failureCount++;
$this->handleBruteForce(
$tenantId,
$ipAddress,
$failureCount,
$windowSeconds,
$blockDuration
);
if ($failureCount >= $maxFailures) {
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
}
}
@@ -188,34 +168,26 @@ class FirewallService
* Handle detected brute force attack
*/
private function handleBruteForce(
string $tenantId,
string $ipAddress,
int $failureCount,
int $windowSeconds,
int $blockDuration
int $windowSeconds
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($tenantId);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
$this->rules->blockIp(
FirewallRuleScope::tenant($tenantId),
// Auto-block the IP
$blockDuration = $this->getConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION
);
$this->blockIp(
$ipAddress,
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
null, // System-created
$blockDuration,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => $this->getBoundedIntegerConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
),
'failureWindowSeconds' => $windowSeconds,
'lastFailureCount' => $failureCount,
'blockDurationSeconds' => $blockDuration,
]
$blockDuration
);
}
@@ -223,36 +195,26 @@ class FirewallService
* Log security event to firewall logs
*/
public function logSecurityEvent(SecurityEvent $event): void
{
$log = $this->securityLog($event);
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog(SecurityEvent $event): ?FirewallLogObject
{
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
$ruleScope = $event->get('ruleScope');
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
return null;
if (!$tenantId) {
return;
}
$log = new FirewallLogObject();
return $log->setEventId($event->getEventId())
->setTenantId($tenantId)
$log->setTenantId($tenantId)
->setIpAddress($event->getIpAddress())
->setDeviceFingerprint($event->getDeviceFingerprint())
->setUserAgent($event->getUserAgent())
->setRequestPath($event->getRequestPath())
->setRequestMethod($event->getRequestMethod())
->setEventType($this->mapEventToLogType($event->getName()))
->setResult($this->mapEventToResult($event))
->setRuleId($event->get('ruleId'))
->setRuleScope($ruleScope)
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
->setResult($this->mapEventToResult($event->getName()))
->setIdentityId($event->getUserId())
->setTimestamp(new \DateTimeImmutable())
->setMetadata($event->getData());
$this->store->createLog($log);
}
/**
@@ -267,12 +229,6 @@ class FirewallService
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
@@ -280,17 +236,11 @@ class FirewallService
/**
* Map security event to result
*/
private function mapEventToResult(SecurityEvent $event): string
private function mapEventToResult(string $eventName): string
{
return match ($event->getName()) {
return match ($eventName) {
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
@@ -307,13 +257,268 @@ class FirewallService
$ipAddress,
$deviceFingerprint,
$rule->getId(),
$rule->getScope(),
$rule->getReason()
);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
}
// ========================================
// Rule Management
// ========================================
/**
* Block an IP address
*/
public function blockIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
// Check if already blocked
$existing = $this->store->findExactIpRule(
$tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK
);
if ($existing) {
return $existing;
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($ipAddress)
->setReason($reason ?? 'Blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Allow an IP address (whitelist)
*/
public function allowIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_ALLOW)
->setValue($ipAddress)
->setReason($reason ?? 'Allowed by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Block an IP range (CIDR notation)
*/
public function blockIpRange(
string $cidr,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP_RANGE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($cidr)
->setReason($reason ?? 'Range blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
return $rule;
}
/**
* Block a device fingerprint
*/
public function blockDevice(
string $fingerprint,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_DEVICE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($fingerprint)
->setReason($reason ?? 'Device blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Remove a rule by ID
*/
public function removeRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$this->store->destroyRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Disable a rule (soft delete)
*/
public function disableRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Get all rules for current tenant
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listRules($tenantId, $activeOnly);
}
/**
* Get firewall logs for current tenant
*/
public function getLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
}
/**
* Get blocked requests count
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return 0;
}
return $this->store->countBlockedRequests($tenantId, $since);
}
// ========================================
// Helpers
// ========================================
/**
* Check if firewall is enabled for current tenant
*/
@@ -328,9 +533,6 @@ class FirewallService
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenantContext->configuration();
if ($config instanceof \JsonSerializable) {
$config = $config->jsonSerialize();
}
$parts = explode('.', $key);
foreach ($parts as $part) {
@@ -343,14 +545,27 @@ class FirewallService
return $config;
}
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
/**
* Get active rules (cached)
* @return FirewallRuleObject[]
*/
private function getActiveRules(): array
{
$value = $this->getConfig($key, $default);
if (!is_int($value) || $value < 1 || $value > $maximum) {
return $default;
if ($this->rulesCache === null) {
$tenantId = $this->tenantContext->identifier();
$this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
}
return $this->rulesCache;
}
return $value;
/**
* Clear rules cache
*/
private function clearRulesCache(): void
{
$this->rulesCache = null;
}
/**
@@ -358,31 +573,13 @@ class FirewallService
*/
public function cleanup(): array
{
$startedAt = new \DateTimeImmutable();
$expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
try {
$result = [
'expiredRules' => $this->store->cleanupExpiredRules(),
'oldLogs' => $this->store->cleanupOldLogs(30),
'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(),
];
$this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result);
return $result;
} catch (\Throwable $error) {
try {
$this->store->recordMaintenanceStatus(
$startedAt,
new \DateTimeImmutable(),
'failed',
[],
$error->getMessage()
);
} catch (\Throwable) {
// Preserve the cleanup failure when the status store is also unavailable.
}
throw $error;
}
return [
'expiredRules' => $expiredRules,
'oldLogs' => $oldLogs,
];
}
}
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
final class FirewallSettingsService
{
public function __construct(
private readonly TenantService $tenants,
private readonly EventDispatcherInterface $events,
) {
}
public function update(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason,
?string $actorId
): ?array {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures');
self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window');
self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration');
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
return null;
}
$previous = $tenant->getConfiguration()->firewall()->jsonSerialize();
$current = [
'enabled' => $enabled,
'maxAuthFailures' => $maxAuthFailures,
'authFailureWindow' => $authFailureWindow,
'autoBlockDuration' => $autoBlockDuration,
];
$configuration = (new TenantConfiguration())->jsonDeserialize([
...$tenant->getConfiguration()->jsonSerialize(),
'firewall' => $current,
]);
$tenant->setConfiguration($configuration);
$this->tenants->deposit($tenant);
$event = new SecurityEvent(SecurityEvent::FIREWALL_SETTINGS_UPDATED, [
'changeReason' => $reason,
'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL,
'previous' => $previous,
'current' => $current,
]);
$event->setTenantId($tenantId)->setIdentityId($actorId);
$this->events->dispatch($event);
return $current;
}
private static function bounded(int $value, int $minimum, int $maximum, string $label): void
{
if ($value < $minimum || $value > $maximum) {
throw new \InvalidArgumentException(
"{$label} must be between {$minimum} and {$maximum}."
);
}
}
}
@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Stores\FirewallStore;
final class FirewallStatusService
{
public function __construct(private readonly FirewallStore $store)
{
}
public function tenantMetrics(string $tenantId, ?string $since = null): array
{
$sinceDate = self::date($since);
return [
'tenantId' => $tenantId,
'blockedRequests' => $this->store->countBlockedRequests($tenantId, $sinceDate),
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
];
}
public function systemMetrics(?string $tenantId = null, ?string $since = null): array
{
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
throw new \InvalidArgumentException('Invalid tenant filter.');
}
$sinceDate = self::date($since);
return [
'tenantId' => $tenantId,
'blockedRequests' => $this->store->countSystemBlockedRequests($tenantId, $sinceDate),
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
];
}
public function maintenanceStatus(): array
{
return $this->store->maintenanceStatus() ?? [
'status' => 'never_run',
'startedAt' => null,
'completedAt' => null,
'result' => null,
'error' => null,
];
}
private static function date(?string $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value === '' || strlen($value) > 255) {
throw new \InvalidArgumentException('Invalid since date filter.');
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception) {
throw new \InvalidArgumentException('Invalid since date filter.');
}
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
final class SystemFirewallLogService
{
public const PERMISSION_READ = 'firewall.system.logs.read';
public function __construct(
private readonly FirewallLogService $logs,
private readonly IdentityContextInterface $identity,
) {
}
public function query(?string $tenantId, array $filters, int $limit = 50, int $offset = 0): array
{
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
}
return $this->logs->system($tenantId, $filters, $limit, $offset);
}
}
@@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
final class SystemFirewallRuleService
{
public const PERMISSION_READ = 'firewall.system.rules.read';
public const PERMISSION_MANAGE = 'firewall.system.rules.manage';
public function __construct(
private readonly FirewallRuleManager $rules,
private readonly IdentityContextInterface $identity,
) {
}
public function listRules(bool $activeOnly = true): array
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->list(FirewallRuleScope::system(), $activeOnly);
}
public function queryRules(
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->query(FirewallRuleScope::system(), $status, $type, $action, $limit, $offset);
}
public function fetchRule(string $ruleId): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->fetch(FirewallRuleScope::system(), $ruleId);
}
public function createRule(
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->createManualRule(
FirewallRuleScope::system(),
$type,
$action,
$value,
$reason,
$this->identity->identifier(),
$durationSeconds,
$currentIp,
$confirmCurrentIp
);
}
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, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disableManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->enableManual(
FirewallRuleScope::system(),
$ruleId,
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->extendManual(
FirewallRuleScope::system(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
final class SystemFirewallStatusService
{
public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read';
public const PERMISSION_SETTINGS_MANAGE = 'firewall.system.settings.manage';
public function __construct(
private readonly FirewallStatusService $status,
private readonly IdentityContextInterface $identity,
private readonly FirewallSettingsService $settings,
) {
}
public function metrics(?string $tenantId = null, ?string $since = null): array
{
$this->requirePermission(SystemFirewallLogService::PERMISSION_READ);
return $this->status->systemMetrics($tenantId, $since);
}
public function maintenanceStatus(): array
{
$this->requirePermission(self::PERMISSION_MAINTENANCE_READ);
return $this->status->maintenanceStatus();
}
public function updateTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): ?array {
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
return $this->settings->update(
$tenantId,
$enabled,
$maxAuthFailures,
$authFailureWindow,
$autoBlockDuration,
$reason,
$this->identity->identifier()
);
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
final class TenantFirewallLogService
{
public const PERMISSION_READ = 'firewall.tenant.logs.read';
public function __construct(
private readonly FirewallLogService $logs,
private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity,
) {
}
public function query(array $filters, int $limit = 50, int $offset = 0): array
{
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
}
return $this->logs->tenant($this->tenant->requireIdentifier(), $filters, $limit, $offset);
}
}
@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
final class TenantFirewallRuleService
{
public const PERMISSION_READ = 'firewall.tenant.rules.read';
public const PERMISSION_MANAGE = 'firewall.tenant.rules.manage';
public function __construct(
private readonly FirewallRuleManager $rules,
private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity,
) {
}
public function listRules(bool $activeOnly = true): array
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->list($this->scope(), $activeOnly);
}
public function queryRules(
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->query($this->scope(), $status, $type, $action, $limit, $offset);
}
public function fetchRule(string $ruleId): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->fetch($this->scope(), $ruleId);
}
public function createRule(
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->createManualRule(
$this->scope(),
$type,
$action,
$value,
$reason,
$this->identity->identifier(),
$durationSeconds,
$currentIp,
$confirmCurrentIp
);
}
public function effectivePolicy(): array
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->effectivePolicy($this->tenant->requireIdentifier());
}
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, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->enableManual(
$this->scope(),
$ruleId,
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->extendManual(
$this->scope(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual($this->scope(), $ruleId, $reason, $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}");
}
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
final class TenantFirewallStatusService
{
public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read';
public const PERMISSION_SETTINGS_MANAGE = 'firewall.tenant.settings.manage';
public function __construct(
private readonly FirewallStatusService $status,
private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity,
private readonly FirewallSettingsService $settings,
) {
}
public function metrics(?string $since = null): array
{
$this->requirePermission(TenantFirewallLogService::PERMISSION_READ);
return $this->status->tenantMetrics($this->tenant->requireIdentifier(), $since);
}
public function configuration(): array
{
$this->requirePermission(self::PERMISSION_SETTINGS_READ);
return $this->tenant->configuration()?->firewall()->jsonSerialize()
?? [
'enabled' => true,
'maxAuthFailures' => 5,
'authFailureWindow' => 300,
'autoBlockDuration' => 3600,
];
}
public function updateConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): ?array {
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
return $this->settings->update(
$this->tenant->requireIdentifier(),
$enabled,
$maxAuthFailures,
$authFailureWindow,
$autoBlockDuration,
$reason,
$this->identity->identifier()
);
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
+21 -389
View File
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
use KTXC\Db\UTCDateTime;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
@@ -17,147 +15,28 @@ class FirewallStore
{
protected const RULES_COLLECTION = 'firewall_rules';
protected const LOGS_COLLECTION = 'firewall_logs';
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
public function __construct(
protected readonly DataStore $dataStore
) {}
/**
* Install the indexes used by firewall enforcement, audit queries, and expiry.
*
* MongoDB createIndex is idempotent when the name and specification match.
*
* @return string[]
*/
public function ensureIndexes(): array
{
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
return [
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_by_scope_tenant_active']
),
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_exact_lookup']
),
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'createdAt' => -1],
['name' => 'rules_browse']
),
$logs->createIndex(
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_auth_failures']
),
$logs->createIndex(
['tenantId' => 1, 'timestamp' => -1],
['name' => 'logs_tenant_timeline']
),
$logs->createIndex(
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
['name' => 'logs_blocked_counts']
),
$logs->createIndex(
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_event_type']
),
$logs->createIndex(
['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1],
['name' => 'logs_rule']
),
$logs->createIndex(
['timestamp' => -1],
['name' => 'logs_global_timeline']
),
$claims->createIndex(
['expiresAt' => 1],
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
),
];
}
// ========================================
// Rule Operations
// ========================================
/**
* Query rules within one ownership scope.
*
* @return array{items: FirewallRuleObject[], total: int, limit: int, offset: int}
*/
public function queryRules(
string $scope,
?string $tenantId,
string $status,
?string $type,
?string $action,
int $limit,
int $offset
): array {
$filter = [
'scope' => $scope,
'tenantId' => $scope === FirewallRuleObject::SCOPE_SYSTEM ? null : $tenantId,
];
$now = self::bsonDate(new \DateTimeImmutable());
if ($status === 'active') {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => $now]],
];
} elseif ($status === 'disabled') {
$filter['enabled'] = false;
} elseif ($status === 'expired') {
$filter['expiresAt'] = ['$ne' => null, '$lte' => $now];
}
if ($type !== null) {
$filter['type'] = $type;
}
if ($action !== null) {
$filter['action'] = $action;
}
$collection = $this->dataStore->selectCollection(self::RULES_COLLECTION);
$items = [];
foreach ($collection->find($filter, [
'sort' => ['createdAt' => -1, '_id' => -1],
'limit' => $limit,
'skip' => $offset,
]) as $entry) {
$items[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
return [
'items' => $items,
'total' => $collection->countDocuments($filter),
'limit' => $limit,
'offset' => $offset,
];
}
/**
* List all rules for a tenant
*/
public function listRules(string $tenantId, bool $activeOnly = true): array
{
$filter = [
'tenantId' => $tenantId,
'scope' => FirewallRuleObject::SCOPE_TENANT,
];
$filter = ['tenantId' => $tenantId];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$and'] = [[
'$or' => [
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
],
]];
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
@@ -171,26 +50,6 @@ class FirewallStore
return $list;
}
public function listSystemRules(bool $activeOnly = true): array
{
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
$list = [];
foreach ($cursor as $entry) {
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
return $list;
}
/**
* Find rules by IP address
*/
@@ -202,7 +61,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
]
];
@@ -229,7 +88,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
]
];
@@ -249,7 +108,7 @@ class FirewallStore
*/
public function fetchRule(string $id): ?FirewallRuleObject
{
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
if (!$entry) {
return null;
}
@@ -259,33 +118,15 @@ class FirewallStore
/**
* Check if exact IP rule exists
*/
public function findExactIpRule(
?string $tenantId,
string $ipAddress,
string $action,
string $scope = FirewallRuleObject::SCOPE_TENANT
): ?FirewallRuleObject
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
{
$filter = [
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
'tenantId' => $tenantId,
'type' => FirewallRuleObject::TYPE_IP,
'value' => $ipAddress,
'action' => $action,
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
],
];
if ($scope === FirewallRuleObject::SCOPE_SYSTEM) {
$filter['scope'] = FirewallRuleObject::SCOPE_SYSTEM;
$filter['tenantId'] = null;
} else {
$filter['tenantId'] = $tenantId;
$filter['scope'] = FirewallRuleObject::SCOPE_TENANT;
}
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne($filter);
]);
if (!$entry) {
return null;
@@ -298,8 +139,6 @@ class FirewallStore
*/
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$rule->assertValidScopeOwnership();
if ($rule->getId()) {
return $this->updateRule($rule);
} else {
@@ -309,7 +148,7 @@ class FirewallStore
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$data = self::ruleDocument($rule);
$data = $rule->jsonSerialize();
unset($data['id']); // Remove id for insert
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
@@ -324,11 +163,11 @@ class FirewallStore
return null;
}
$data = self::ruleDocument($rule);
$data = $rule->jsonSerialize();
unset($data['id']);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
self::ruleIdFilter($id),
['_id' => $id],
['$set' => $data]
);
return $rule;
@@ -343,7 +182,7 @@ class FirewallStore
if (!$id) {
return;
}
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
}
/**
@@ -352,10 +191,8 @@ class FirewallStore
public function cleanupExpiredRules(): int
{
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
'expiresAt' => [
'$lt' => self::bsonDate(new \DateTimeImmutable()),
'$ne' => null,
],
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$ne' => null]
]);
return $result->getDeletedCount();
@@ -365,68 +202,12 @@ class FirewallStore
// Log Operations
// ========================================
public function queryTenantLogs(
string $tenantId,
array $filters,
int $limit,
int $offset
): array {
return $this->queryLogs(['tenantId' => $tenantId], $filters, $limit, $offset);
}
public function querySystemLogs(
?string $tenantId,
array $filters,
int $limit,
int $offset
): array {
return $this->queryLogs($tenantId === null ? [] : ['tenantId' => $tenantId], $filters, $limit, $offset);
}
/** @return array{items: FirewallLogObject[], total: int, limit: int, offset: int} */
private function queryLogs(array $scopeFilter, array $filters, int $limit, int $offset): array
{
$filter = $scopeFilter;
foreach (['ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope'] as $field) {
if (($filters[$field] ?? null) !== null) {
$filter[$field] = $filters[$field];
}
}
$timestamp = [];
if (($filters['from'] ?? null) instanceof \DateTimeInterface) {
$timestamp['$gte'] = self::bsonDate($filters['from']);
}
if (($filters['to'] ?? null) instanceof \DateTimeInterface) {
$timestamp['$lte'] = self::bsonDate($filters['to']);
}
if ($timestamp !== []) {
$filter['timestamp'] = $timestamp;
}
$collection = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
$items = [];
foreach ($collection->find($filter, [
'sort' => ['timestamp' => -1, '_id' => -1],
'limit' => $limit,
'skip' => $offset,
]) as $entry) {
$items[] = (new FirewallLogObject())->jsonDeserialize((array)$entry);
}
return [
'items' => $items,
'total' => $collection->countDocuments($filter),
'limit' => $limit,
'offset' => $offset,
];
}
/**
* Log a firewall event
*/
public function createLog(FirewallLogObject $log): FirewallLogObject
{
$data = self::logDocument($log);
$data = $log->jsonSerialize();
unset($data['id']);
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
@@ -434,30 +215,6 @@ class FirewallStore
return $log;
}
/**
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
*/
public function createLogOnce(FirewallLogObject $log): bool
{
$eventId = $log->getEventId();
if ($eventId === null || $eventId === '') {
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
}
$data = self::logDocument($log);
unset($data['id']);
$data['_id'] = $eventId;
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
['_id' => $eventId],
['$setOnInsert' => $data],
['upsert' => true]
);
$log->setId($eventId);
return $result->getUpsertedCount() === 1;
}
/**
* Get logs for a tenant with optional filters
*/
@@ -513,50 +270,10 @@ class FirewallStore
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => self::bsonDate($since)]
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
]);
}
/**
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
*/
public function claimBruteForce(
string $tenantId,
string $ipAddress,
int $claimDurationSeconds
): bool {
if ($claimDurationSeconds < 1) {
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
}
$now = new \DateTimeImmutable();
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
$collection->deleteOne([
'_id' => $claimId,
'expiresAt' => ['$lte' => self::bsonDate($now)],
]);
try {
$collection->insertOne([
'_id' => $claimId,
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'createdAt' => self::bsonDate($now),
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
]);
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
if ($error->getCode() === 11000) {
return false;
}
throw $error;
}
return true;
}
/**
* Get blocked requests count for dashboard
*/
@@ -570,27 +287,12 @@ class FirewallStore
];
if ($since !== null) {
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
}
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
}
public function countSystemBlockedRequests(
?string $tenantId = null,
?\DateTimeImmutable $since = null
): int {
$filter = ['result' => FirewallLogObject::RESULT_BLOCKED];
if ($tenantId !== null) {
$filter['tenantId'] = $tenantId;
}
if ($since !== null) {
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
}
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
}
/**
* Clean up old logs
*/
@@ -599,79 +301,9 @@ class FirewallStore
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
]);
return $result->getDeletedCount();
}
public function cleanupExpiredBruteForceClaims(): int
{
$result = $this->dataStore
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
->deleteMany([
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
]);
return $result->getDeletedCount();
}
public function recordMaintenanceStatus(
\DateTimeImmutable $startedAt,
\DateTimeImmutable $completedAt,
string $status,
array $result,
?string $error = null
): void {
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
['_id' => 'cleanup'],
['$set' => [
'startedAt' => self::bsonDate($startedAt),
'completedAt' => self::bsonDate($completedAt),
'status' => $status,
'result' => $result,
'error' => $error,
]],
['upsert' => true]
);
}
public function maintenanceStatus(): ?array
{
return $this->dataStore
->selectCollection(self::MAINTENANCE_COLLECTION)
->findOne(['_id' => 'cleanup']);
}
private static function ruleDocument(FirewallRuleObject $rule): array
{
$data = $rule->jsonSerialize();
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
return $data;
}
private static function logDocument(FirewallLogObject $log): array
{
$data = $log->jsonSerialize();
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
return $data;
}
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
{
return $date === null ? null : self::bsonDate($date);
}
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
{
return UTCDateTime::fromDateTime($date);
}
private static function ruleIdFilter(string $id): array
{
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
}
}
-2
View File
@@ -1,2 +0,0 @@
# Install in /etc/cron.d/ktrix-firewall after adjusting the user/path if needed.
*/15 * * * * www-data cd /var/www/ktrix/main && bin/console firewall:maintenance
+3 -3
View File
@@ -6642,9 +6642,9 @@
}
},
"node_modules/vuetify": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.7.tgz",
"integrity": "sha512-07qxu0S1oxPjmknP/Yn5276PtIhlCvHnpcp7HuOKEHni89/6BGiM2aVdKlEGeyM5RfytDkiv0SNieabGT2D8qQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
"license": "MIT",
"funding": {
"type": "github",
+1 -8
View File
@@ -12,7 +12,6 @@ class Event
private bool $propagationStopped = false;
private array $data = [];
private float $timestamp;
private string $eventId;
private ?string $tenantId = null;
private ?string $identityId = null;
@@ -22,7 +21,6 @@ class Event
) {
$this->data = $data;
$this->timestamp = microtime(true);
$this->eventId = bin2hex(random_bytes(16));
}
/**
@@ -82,11 +80,6 @@ class Event
return $this->timestamp;
}
public function getEventId(): string
{
return $this->eventId;
}
/**
* Stop event propagation to subsequent listeners
*/
@@ -150,4 +143,4 @@ class Event
'identityId' => $this->identityId,
];
}
}
}
-8
View File
@@ -26,12 +26,6 @@ class SecurityEvent extends Event
public const IP_BLOCKED = 'security.ip.blocked';
public const IP_ALLOWED = 'security.ip.allowed';
public const DEVICE_BLOCKED = 'security.device.blocked';
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
@@ -151,12 +145,10 @@ class SecurityEvent extends Event
string $ipAddress,
?string $deviceFingerprint = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $reason = null
): self {
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'reason' => $reason,
]);
$event->reason = $reason;
-2
View File
@@ -95,7 +95,6 @@ class IpUtils
if ($netmask < 0 || $netmask > 32) {
return self::setCacheResult($cacheKey, false);
}
$netmask = (int)$netmask;
} else {
$address = $ip;
$netmask = 32;
@@ -150,7 +149,6 @@ class IpUtils
if ($netmask < 1 || $netmask > 128) {
return self::setCacheResult($cacheKey, false);
}
$netmask = (int)$netmask;
} else {
if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return self::setCacheResult($cacheKey, false);
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Integration\Service;
use KTXC\Db\DataStore;
use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\TenantService;
use KTXC\Stores\TenantStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallSettingsServiceTest extends TestCase
{
private DataStore $dataStore;
private bool $databaseAvailable = false;
protected function setUp(): void
{
$system = require dirname(__DIR__, 4).'/config/system.php';
$database = $system['database'];
$database['database'] = sprintf('ktrix_firewall_settings_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());
}
}
protected function tearDown(): void
{
if ($this->databaseAvailable) {
$this->dataStore->getDatabase()->drop();
}
}
#[TestDox('Firewall settings updates persist without replacing unrelated tenant configuration')]
public function testPersistence(): void
{
$tenants = new TenantService(new TenantStore($this->dataStore));
$tenants->deposit((new TenantObject())
->setIdentifier('tenant-a')
->setEnabled(true)
->setLabel('Tenant A')
->setDomains(new DomainCollection(['tenant-a.example.test']))
->setConfiguration((new TenantConfiguration())->jsonDeserialize([
'security' => ['code' => 'keep-me'],
])));
$settings = new FirewallSettingsService(
$tenants,
$this->createStub(EventDispatcherInterface::class)
);
$settings->update(
'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a'
);
$stored = $tenants->fetchById('tenant-a')->getConfiguration();
self::assertFalse($stored->firewall()->enabled());
self::assertSame(8, $stored->firewall()->maxAuthFailures());
self::assertSame(600, $stored->firewall()->authFailureWindow());
self::assertSame(7200, $stored->firewall()->autoBlockDuration());
self::assertSame('keep-me', $stored->security()->code());
}
}
@@ -1,649 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Integration\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\UTCDateTime;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallRuleScope;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
class FirewallStoreTest extends TestCase
{
private DataStore $dataStore;
private FirewallStore $store;
private bool $databaseAvailable = false;
protected function setUp(): void
{
$system = require dirname(__DIR__, 4).'/config/system.php';
$database = $system['database'];
$database['database'] = sprintf('ktrix_firewall_test_%d', getmypid());
$this->dataStore = new DataStore($database);
try {
$this->dataStore->getDatabase()->drop();
$this->databaseAvailable = true;
} catch (\MongoDB\Driver\Exception\Exception $error) {
self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage());
}
$this->store = new FirewallStore($this->dataStore);
}
protected function tearDown(): void
{
if ($this->databaseAvailable) {
$this->dataStore->getDatabase()->drop();
}
}
#[TestDox('System and tenant rule sets remain independently scoped')]
public function testApplicableScopes(): void
{
$tenantA = $this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a');
$tenantB = $this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b');
$system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM);
$expiredSystem = $this->rule('expired-system', FirewallRuleObject::SCOPE_SYSTEM)
->setExpiresAt(new \DateTimeImmutable('-1 minute'));
$disabledSystem = $this->rule('disabled-system', FirewallRuleObject::SCOPE_SYSTEM)
->setEnabled(false);
foreach ([$tenantA, $tenantB, $system, $expiredSystem, $disabledSystem] as $rule) {
$this->store->depositRule($rule);
}
$rules = array_merge(
$this->store->listSystemRules(),
$this->store->listRules('tenant-a')
);
$reasons = array_map(static fn(FirewallRuleObject $rule): ?string => $rule->getReason(), $rules);
sort($reasons);
self::assertSame(['system', 'tenant-a'], $reasons);
}
#[TestDox('Tenant rule listings cannot expose system or other tenant rules')]
public function testTenantListingIsolation(): void
{
$this->store->depositRule($this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
$this->store->depositRule($this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'));
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
$rules = $this->store->listRules('tenant-a');
self::assertCount(1, $rules);
self::assertSame('tenant-a', $rules[0]->getReason());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rules[0]->getScope());
}
#[TestDox('Expired exact IP rules do not suppress a replacement block')]
public function testExpiredBlockReplacement(): void
{
$expired = $this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('-1 minute'));
$this->store->depositRule($expired);
self::assertNull($this->store->findExactIpRule(
'tenant-a',
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK
));
$events = $this->createMock(EventDispatcherInterface::class);
$cache = new FirewallRuleCache($this->store);
$manager = new FirewallRuleManager($this->store, $cache, $events);
$replacement = $manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
null,
null,
300
);
self::assertFalse($replacement->isExpired());
self::assertNotSame($expired->getId(), $replacement->getId());
self::assertSame(
$replacement->getId(),
$this->store->findExactIpRule(
'tenant-a',
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK
)?->getId()
);
self::assertCount(2, $this->store->listRules('tenant-a', false));
}
#[TestDox('Exact IP lookups respect tenant and system scope')]
public function testExactLookupScope(): void
{
$tenant = $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a');
$system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM);
$this->store->depositRule($tenant);
$this->store->depositRule($system);
self::assertSame(
$tenant->getId(),
$this->store->findExactIpRule(
'tenant-a',
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK
)?->getId()
);
self::assertSame(
$system->getId(),
$this->store->findExactIpRule(
null,
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK,
FirewallRuleObject::SCOPE_SYSTEM
)?->getId()
);
self::assertNull($this->store->findExactIpRule(
'tenant-b',
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK
));
}
#[TestDox('System rule listings exclude tenant, disabled, and expired rules')]
public function testSystemListing(): void
{
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
$this->store->depositRule(
$this->rule('disabled', FirewallRuleObject::SCOPE_SYSTEM)->setEnabled(false)
);
$this->store->depositRule(
$this->rule('expired', FirewallRuleObject::SCOPE_SYSTEM)
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
);
$this->store->depositRule(
$this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
);
$rules = $this->store->listSystemRules();
self::assertCount(1, $rules);
self::assertSame('system', $rules[0]->getReason());
self::assertTrue($rules[0]->isSystemScoped());
}
#[TestDox('Firewall log persistence retains matched rule context')]
public function testRuleAuditPersistence(): void
{
$log = (new FirewallLogObject())
->setTenantId('tenant-a')
->setIpAddress('203.0.113.10')
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
->setResult(FirewallLogObject::RESULT_BLOCKED)
->setRuleId('rule-123')
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
->setTimestamp(new \DateTimeImmutable());
$this->store->createLog($log);
$logs = $this->store->listLogs('tenant-a');
self::assertCount(1, $logs);
self::assertSame('rule-123', $logs[0]->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $logs[0]->getRuleScope());
}
#[TestDox('Rule lifecycle audit persistence retains actor and origin')]
public function testLifecycleAuditPersistence(): void
{
$log = (new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_RULE_CREATED)
->setResult(FirewallLogObject::RESULT_RECORDED)
->setRuleId('rule-456')
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
->setIdentityId('admin-a')
->setTimestamp(new \DateTimeImmutable())
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->createLog($log);
$logs = $this->store->listLogs('tenant-a');
self::assertCount(1, $logs);
self::assertSame(FirewallLogObject::EVENT_RULE_CREATED, $logs[0]->getEventType());
self::assertSame(FirewallLogObject::RESULT_RECORDED, $logs[0]->getResult());
self::assertSame('admin-a', $logs[0]->getIdentityId());
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $logs[0]->getMetadata()['origin']);
}
#[TestDox('Manual rule lifecycle changes persist through disable, enable, extension, and deletion')]
public function testManualRuleLifecyclePersistence(): void
{
$manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$this->createStub(EventDispatcherInterface::class)
);
$rule = $manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
'Repeated abuse',
'admin-a',
300
);
$manager->disableManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Investigating', 'admin-a'
);
self::assertFalse($this->store->fetchRule($rule->getId())->isEnabled());
$manager->enableManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Threat confirmed', 'admin-a'
);
self::assertTrue($this->store->fetchRule($rule->getId())->isEnabled());
$previousExpiry = $rule->getExpiresAt();
$manager->extendManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 300, 'Continue monitoring', 'admin-a'
);
$stored = $this->store->fetchRule($rule->getId());
self::assertGreaterThan($previousExpiry, $stored->getExpiresAt());
self::assertSame('Continue monitoring', $stored->getMetadata()['extensions'][0]['reason']);
$manager->removeManual(
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Case closed', 'admin-a'
);
self::assertNull($this->store->fetchRule($rule->getId()));
}
#[TestDox('Event-backed firewall logs are inserted exactly once')]
public function testIdempotentLogPersistence(): void
{
$log = (new FirewallLogObject())
->setEventId('event-123')
->setTenantId('tenant-a')
->setIpAddress('203.0.113.10')
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
->setResult(FirewallLogObject::RESULT_BLOCKED)
->setTimestamp(new \DateTimeImmutable());
self::assertTrue($this->store->createLogOnce($log));
self::assertFalse($this->store->createLogOnce($log));
$logs = $this->store->listLogs('tenant-a');
self::assertCount(1, $logs);
self::assertSame('event-123', $logs[0]->getEventId());
}
#[TestDox('Only one worker can claim a tenant and IP brute-force response')]
public function testBruteForceClaim(): void
{
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
self::assertFalse($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
self::assertTrue($this->store->claimBruteForce('tenant-b', '203.0.113.10', 3600));
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.11', 3600));
}
#[TestDox('Expired brute-force claims can be acquired again and cleaned up')]
public function testExpiredBruteForceClaim(): void
{
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
['_id' => $claimId],
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
);
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
['_id' => $claimId],
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
);
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
}
#[TestDox('Firewall dates use BSON date storage and indexes are installed idempotently')]
public function testBsonDatesAndIndexes(): void
{
$rule = $this->rule('bson-date', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('+1 hour'));
$this->store->depositRule($rule);
$log = (new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
->setResult(FirewallLogObject::RESULT_ALLOWED)
->setTimestamp(new \DateTimeImmutable());
$this->store->createLog($log);
$storedRule = $this->dataStore->selectCollection('firewall_rules')
->getMongoCollection()
->findOne(['_id' => new \MongoDB\BSON\ObjectId($rule->getId())]);
$storedLog = $this->dataStore->selectCollection('firewall_logs')
->getMongoCollection()
->findOne(['_id' => new \MongoDB\BSON\ObjectId($log->getId())]);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['createdAt']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['expiresAt']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedLog['timestamp']);
$expected = $this->store->ensureIndexes();
self::assertSame($expected, $this->store->ensureIndexes());
$claimIndex = null;
foreach ($this->dataStore->selectCollection('firewall_brute_force_claims')->getMongoCollection()->listIndexes() as $index) {
if ($index->getName() === 'claims_expiry') {
$claimIndex = $index;
}
}
self::assertNotNull($claimIndex);
self::assertSame(0, $claimIndex['expireAfterSeconds']);
}
#[TestDox('Automatic block extensions persist policy and expiration history')]
public function testAutomaticBlockExtensionPersistence(): void
{
$events = $this->createMock(EventDispatcherInterface::class);
$manager = new FirewallRuleManager($this->store, new FirewallRuleCache($this->store), $events);
$scope = FirewallRuleScope::tenant('tenant-a');
$rule = $manager->blockIp(
$scope,
'203.0.113.10',
'Initial attack',
null,
60,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => 5,
'failureWindowSeconds' => 300,
'lastFailureCount' => 5,
'blockDurationSeconds' => 60,
]
);
$originalExpiry = $rule->getExpiresAt();
$extended = $manager->blockIp(
$scope,
'203.0.113.10',
'Continued attack',
null,
3600,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => 5,
'failureWindowSeconds' => 300,
'lastFailureCount' => 9,
'blockDurationSeconds' => 3600,
]
);
$persisted = $this->store->fetchRule($rule->getId());
self::assertSame($rule->getId(), $extended->getId());
self::assertNotNull($persisted);
self::assertGreaterThan($originalExpiry, $persisted->getExpiresAt());
self::assertSame(9, $persisted->getMetadata()['lastFailureCount']);
self::assertCount(1, $persisted->getMetadata()['extensions']);
}
#[TestDox('Cleanup removes only expired rules, old logs, and expired claims')]
public function testCleanupBoundaries(): void
{
$this->store->depositRule(
$this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
);
$this->store->depositRule(
$this->rule('active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('+1 hour'))
);
foreach (['-31 days', '-29 days'] as $age) {
$this->store->createLog(
(new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
->setResult(FirewallLogObject::RESULT_ALLOWED)
->setTimestamp(new \DateTimeImmutable($age))
);
}
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
['_id' => $claimId],
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
);
self::assertSame(1, $this->store->cleanupExpiredRules());
self::assertSame(1, $this->store->cleanupOldLogs());
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
self::assertCount(1, $this->store->listRules('tenant-a'));
self::assertCount(1, $this->store->listLogs('tenant-a'));
}
#[TestDox('Maintenance outcomes are persisted with BSON timestamps')]
public function testMaintenanceStatusPersistence(): void
{
$this->store->recordMaintenanceStatus(
new \DateTimeImmutable('-1 second'),
new \DateTimeImmutable(),
'success',
['expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4]
);
$status = $this->store->maintenanceStatus();
$raw = $this->dataStore->selectCollection('firewall_maintenance')
->getMongoCollection()
->findOne(['_id' => 'cleanup']);
self::assertSame('success', $status['status']);
self::assertSame(3, $status['result']['oldLogs']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['startedAt']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['completedAt']);
}
#[TestDox('Firewall query shapes select their intended indexes')]
public function testIndexedQueryShapes(): void
{
$this->store->ensureIndexes();
$this->store->depositRule($this->rule('indexed', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
$this->store->createLog(
(new FirewallLogObject())
->setTenantId('tenant-a')
->setIpAddress('203.0.113.10')
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
->setResult(FirewallLogObject::RESULT_BLOCKED)
->setTimestamp(new \DateTimeImmutable())
);
$database = $this->dataStore->getDatabase()->getMongoDatabase();
$now = new \MongoDB\BSON\UTCDateTime();
$rulePlan = $database->command([
'explain' => [
'find' => 'firewall_rules',
'filter' => [
'scope' => FirewallRuleObject::SCOPE_TENANT,
'tenantId' => 'tenant-a',
'type' => FirewallRuleObject::TYPE_IP,
'value' => '203.0.113.10',
'action' => FirewallRuleObject::ACTION_BLOCK,
'enabled' => true,
'$or' => [['expiresAt' => null], ['expiresAt' => ['$gt' => $now]]],
],
],
'verbosity' => 'queryPlanner',
])->toArray()[0];
$failurePlan = $database->command([
'explain' => [
'find' => 'firewall_logs',
'filter' => [
'tenantId' => 'tenant-a',
'ipAddress' => '203.0.113.10',
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => new \MongoDB\BSON\UTCDateTime(0)],
],
],
'verbosity' => 'queryPlanner',
])->toArray()[0];
self::assertContains('rules_exact_lookup', self::indexNames($rulePlan));
self::assertContains('logs_auth_failures', self::indexNames($failurePlan));
}
#[TestDox('Administrative rule queries filter, paginate, and preserve scope')]
public function testAdministrativeRuleQuery(): void
{
$this->store->depositRule($this->rule('tenant-active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
$this->store->depositRule(
$this->rule('tenant-disabled', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')->setEnabled(false)
);
$this->store->depositRule($this->rule('other-tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'));
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
$active = $this->store->queryRules(
FirewallRuleObject::SCOPE_TENANT,
'tenant-a',
'active',
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
1,
0
);
$disabled = $this->store->queryRules(
FirewallRuleObject::SCOPE_TENANT,
'tenant-a',
'disabled',
null,
null,
50,
0
);
self::assertSame(1, $active['total']);
self::assertCount(1, $active['items']);
self::assertSame('tenant-active', $active['items'][0]->getReason());
self::assertSame(1, $disabled['total']);
self::assertSame('tenant-disabled', $disabled['items'][0]->getReason());
}
#[TestDox('Administrative creation persists tenant and system ownership')]
public function testAdministrativeRuleCreation(): void
{
$manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$this->createStub(EventDispatcherInterface::class)
);
$tenantRule = $manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_BLOCK,
'device-123',
'Compromised device',
'admin-a',
3600
);
$systemRule = $manager->createManualRule(
FirewallRuleScope::system(),
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
'198.51.100.0/24',
'Malicious network',
'system-admin',
currentIp: '203.0.113.10'
);
$persistedTenant = $this->store->fetchRule($tenantRule->getId());
$persistedSystem = $this->store->fetchRule($systemRule->getId());
self::assertSame('tenant-a', $persistedTenant->getTenantId());
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $persistedSystem->getScope());
self::assertNull($persistedSystem->getTenantId());
self::assertSame('system-admin', $persistedSystem->getCreatedBy());
}
#[TestDox('Administrative log queries enforce tenant scope and supported filters')]
public function testAdministrativeLogQuery(): void
{
$matching = (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('-1 hour'));
$otherTenant = (new FirewallLogObject())
->setTenantId('tenant-b')
->setIpAddress('203.0.113.10')
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
->setResult(FirewallLogObject::RESULT_BLOCKED)
->setRuleId('rule-123')
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
->setTimestamp(new \DateTimeImmutable('-1 hour'));
$tooOld = (new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
->setResult(FirewallLogObject::RESULT_BLOCKED)
->setRuleId('rule-123')
->setTimestamp(new \DateTimeImmutable('-3 days'));
foreach ([$matching, $otherTenant, $tooOld] as $log) {
$this->store->createLog($log);
}
$tenant = $this->store->queryTenantLogs('tenant-a', [
'ipAddress' => '203.0.113.10',
'eventType' => FirewallLogObject::EVENT_RULE_MATCH,
'result' => FirewallLogObject::RESULT_BLOCKED,
'ruleId' => 'rule-123',
'ruleScope' => FirewallRuleObject::SCOPE_TENANT,
'from' => new \DateTimeImmutable('-1 day'),
'to' => new \DateTimeImmutable(),
], 50, 0);
$system = $this->store->querySystemLogs(null, [], 2, 0);
self::assertSame(1, $tenant['total']);
self::assertSame('tenant-a', $tenant['items'][0]->getTenantId());
self::assertSame(3, $system['total']);
self::assertCount(2, $system['items']);
self::assertSame(2, $this->store->countSystemBlockedRequests('tenant-a'));
self::assertSame(3, $this->store->countSystemBlockedRequests());
self::assertSame(2, $this->store->countSystemBlockedRequests(null, new \DateTimeImmutable('-1 day')));
}
private function rule(
string $reason,
string $scope,
?string $tenantId = null
): FirewallRuleObject {
return (new FirewallRuleObject())
->setScope($scope)
->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setReason($reason)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
}
private static function indexNames(mixed $value): array
{
if (is_object($value)) {
$value = (array)$value;
}
if (!is_array($value)) {
return [];
}
$names = isset($value['indexName']) ? [(string)$value['indexName']] : [];
foreach ($value as $child) {
$names = [...$names, ...self::indexNames($child)];
}
return array_values(array_unique($names));
}
}
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Console\Firewall;
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
use KTXC\Console\Firewall\FirewallSetupCommand;
use KTXC\Service\FirewallService;
use KTXC\Stores\FirewallStore;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
final class FirewallCommandsTest extends TestCase
{
#[TestDox('Setup verifies every firewall database index')]
public function testSetup(): void
{
$store = $this->createMock(FirewallStore::class);
$store->expects(self::once())->method('ensureIndexes')->willReturn(array_fill(0, 7, 'index'));
$tester = new CommandTester(new FirewallSetupCommand($store));
self::assertSame(Command::SUCCESS, $tester->execute([]));
self::assertStringContainsString('7 indexes verified', $tester->getDisplay());
}
#[TestDox('Maintenance reports cleanup counts for schedulers')]
public function testMaintenance(): void
{
$firewall = $this->createMock(FirewallService::class);
$firewall->expects(self::once())->method('cleanup')->willReturn([
'expiredRules' => 2,
'oldLogs' => 3,
'expiredBruteForceClaims' => 4,
]);
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
self::assertSame(Command::SUCCESS, $tester->execute([]));
self::assertStringContainsString('2 expired rules, 3 old logs', $tester->getDisplay());
self::assertStringContainsString('4 expired', $tester->getDisplay());
self::assertStringContainsString('claims removed', $tester->getDisplay());
}
#[TestDox('Maintenance returns failure to its scheduler')]
public function testMaintenanceFailure(): void
{
$firewall = $this->createStub(FirewallService::class);
$firewall->method('cleanup')->willThrowException(new \RuntimeException('database unavailable'));
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
self::assertSame(Command::FAILURE, $tester->execute([]));
self::assertStringContainsString('database unavailable', $tester->getDisplay());
}
}
@@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Controllers;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Controllers\FirewallController;
use KTXC\Http\Request\Request;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\FirewallStatusService;
use KTXC\Service\FirewallLogService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\TenantService;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
final class FirewallControllerTest extends TestCase
{
private FirewallStore $store;
private FirewallController $controller;
protected function setUp(): void
{
$this->store = $this->createMock(FirewallStore::class);
$tenant = $this->createMock(TenantContextInterface::class);
$tenant->method('requireIdentifier')->willReturn('tenant-a');
$identity = $this->createMock(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$this->createStub(EventDispatcherInterface::class)
);
$settings = new FirewallSettingsService(
$this->createStub(TenantService::class),
$this->createStub(EventDispatcherInterface::class)
);
$this->controller = new FirewallController(
new TenantFirewallRuleService($manager, $tenant, $identity),
new SystemFirewallRuleService($manager, $identity),
new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity),
new SystemFirewallLogService(new FirewallLogService($this->store), $identity),
new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity, $settings),
new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity, $settings)
);
}
#[TestDox('Tenant rule endpoint returns bounded paginated results')]
public function testTenantRules(): void
{
$this->store->expects(self::once())
->method('queryRules')
->with('tenant', 'tenant-a', 'active', null, null, 25, 10)
->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]);
$response = $this->controller->tenantRules(limit: '25', offset: '10');
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(200, $response->getStatusCode());
self::assertSame(25, $data['limit']);
self::assertSame(10, $data['offset']);
}
#[TestDox('Rule endpoints reject malformed and excessive pagination')]
public function testPaginationValidation(): void
{
$this->store->expects(self::never())->method('queryRules');
self::assertSame(400, $this->controller->tenantRules(limit: 'invalid')->getStatusCode());
self::assertSame(400, $this->controller->systemRules(limit: '101')->getStatusCode());
}
#[TestDox('Metric endpoints return scoped counts and stable validation errors')]
public function testMetrics(): void
{
$this->store->method('countBlockedRequests')->willReturn(4);
$response = $this->controller->tenantMetrics();
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(4, $data['blockedRequests']);
self::assertSame(400, $this->controller->systemMetrics(since: 'not-a-date')->getStatusCode());
}
#[TestDox('Current-IP blocks return a structured confirmation conflict')]
public function testCurrentIpConflict(): void
{
$this->store->expects(self::never())->method('depositRule');
$response = $this->controller->createTenantRule(
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
'ip',
'block',
'203.0.113.10',
'Suspected abuse'
);
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(409, $response->getStatusCode());
self::assertSame('current_ip_confirmation_required', $data['error']['code']);
}
#[TestDox('Invalid manual rules return a stable validation response')]
public function testMutationValidation(): void
{
$response = $this->controller->createSystemRule(
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
'device',
'allow',
'device-123',
'Trusted device'
);
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(400, $response->getStatusCode());
self::assertSame('invalid_firewall_rule', $data['error']['code']);
}
#[TestDox('Re-enabling a current-IP block requires explicit confirmation')]
public function testEnableSafeguard(): void
{
$rule = (new \KTXC\Models\Firewall\FirewallRuleObject())
->setId('rule-123')
->setScope('tenant')
->setTenantId('tenant-a')
->setType('ip')
->setAction('block')
->setValue('203.0.113.10')
->setEnabled(false);
$this->store->method('fetchRule')->willReturn($rule);
$response = $this->controller->updateTenantRule(
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
'rule-123',
'enable',
'Threat returned'
);
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame(409, $response->getStatusCode());
self::assertSame('current_ip_confirmation_required', $data['error']['code']);
}
#[TestDox('Lifecycle endpoints return stable validation and not-found responses')]
public function testLifecycleResponses(): void
{
$request = new Request(server: ['REMOTE_ADDR' => '203.0.113.10']);
self::assertSame(400, $this->controller->updateSystemRule(
$request, 'rule-123', 'extend', 'More time required'
)->getStatusCode());
self::assertSame(404, $this->controller->deleteTenantRule(
'missing-rule', 'No longer required'
)->getStatusCode());
}
#[TestDox('Every rule endpoint declares its scope-specific read permission')]
public function testRoutePermissions(): void
{
$expected = [
'tenantRules' => TenantFirewallRuleService::PERMISSION_READ,
'tenantRule' => TenantFirewallRuleService::PERMISSION_READ,
'effectivePolicy' => TenantFirewallRuleService::PERMISSION_READ,
'systemRules' => SystemFirewallRuleService::PERMISSION_READ,
'systemRule' => SystemFirewallRuleService::PERMISSION_READ,
'tenantLogs' => TenantFirewallLogService::PERMISSION_READ,
'systemLogs' => SystemFirewallLogService::PERMISSION_READ,
'tenantMetrics' => TenantFirewallLogService::PERMISSION_READ,
'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ,
'systemMetrics' => SystemFirewallLogService::PERMISSION_READ,
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
'updateTenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
'updateSystemTenantConfiguration' => SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'updateSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
'deleteTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'deleteSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
];
foreach ($expected as $method => $permission) {
$attributes = (new \ReflectionMethod(FirewallController::class, $method))
->getAttributes(AuthenticatedRoute::class);
self::assertCount(1, $attributes);
self::assertSame([$permission], $attributes[0]->newInstance()->permissions);
}
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Models\Firewall;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Firewall\FirewallRuleObject;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class FirewallLogObjectTest extends TestCase
{
#[TestDox('Rule ID and scope survive firewall log serialization')]
public function testRuleContextSerialization(): void
{
$log = (new FirewallLogObject())
->setEventId('event-123')
->setRuleId('rule-123')
->setRuleScope(FirewallRuleObject::SCOPE_SYSTEM);
$restored = (new FirewallLogObject())->jsonDeserialize($log->jsonSerialize());
self::assertSame('rule-123', $restored->getRuleId());
self::assertSame('event-123', $restored->getEventId());
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $restored->getRuleScope());
}
#[TestDox('Unknown rule scopes are rejected from firewall logs')]
public function testRuleScopeValidation(): void
{
$this->expectException(\InvalidArgumentException::class);
(new FirewallLogObject())->setRuleScope('unknown');
}
}
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Models\Firewall;
use KTXC\Models\Firewall\FirewallRuleObject;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class FirewallRuleObjectTest extends TestCase
{
#[TestDox('Persisted rules without an explicit scope are rejected')]
public function testMissingScope(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('explicit scope');
(new FirewallRuleObject())->jsonDeserialize([
'tenantId' => 'tenant-a',
'type' => FirewallRuleObject::TYPE_IP,
'action' => FirewallRuleObject::ACTION_BLOCK,
'value' => '203.0.113.10',
]);
}
#[TestDox('Unknown rule scopes are rejected')]
public function testUnknownScope(): void
{
$this->expectException(\InvalidArgumentException::class);
(new FirewallRuleObject())->setScope('unknown');
}
#[TestDox('Tenant rules require a tenant ID')]
public function testTenantOwnership(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('require a tenant ID');
(new FirewallRuleObject())->assertValidScopeOwnership();
}
#[TestDox('System rules cannot have a tenant ID')]
public function testSystemOwnership(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('cannot have a tenant ID');
(new FirewallRuleObject())
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setTenantId('tenant-a')
->assertValidScopeOwnership();
}
}
+1 -45
View File
@@ -4,17 +4,9 @@ declare(strict_types=1);
namespace KTXT\Unit\Module;
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
use KTXC\Console\Firewall\FirewallSetupCommand;
use KTXC\Console\Event\EventsDebugCommand;
use KTXC\Module\Module;
use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallRuleService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
@@ -34,28 +26,12 @@ final class CoreModuleTest extends TestCase
$module->boot();
$definitions = $registry->definitions();
self::assertCount(12, $definitions);
self::assertCount(5, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame(
FirewallService::class,
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
);
self::assertSame([], $registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Deferred));
foreach ([
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] 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());
}
@@ -66,25 +42,5 @@ final class CoreModuleTest extends TestCase
$module = new Module(new EventListenerRegistry());
self::assertContains(EventsDebugCommand::class, $module->registerCI());
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI());
}
#[Test]
#[TestDox('Core registers dedicated system firewall permissions')]
public function registersSystemFirewallPermissions(): void
{
$permissions = (new Module(new EventListenerRegistry()))->permissions();
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_READ, $permissions);
self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions);
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
}
}
@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Service\FirewallLogService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Stores\FirewallStore;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
final class FirewallLogServicesTest extends TestCase
{
#[TestDox('Tenant log reads derive tenant ownership and validated filters')]
public function testTenantQuery(): void
{
$store = $this->createMock(FirewallStore::class);
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('requireIdentifier')->willReturn('tenant-a');
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$store->expects(self::once())
->method('queryTenantLogs')
->with(
'tenant-a',
self::callback(static fn(array $filters): bool =>
$filters['eventType'] === FirewallLogObject::EVENT_AUTH_FAILURE
&& $filters['from'] instanceof \DateTimeImmutable
),
25,
10
)
->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]);
$service = new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity);
self::assertSame(0, $service->query([
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'from' => '2026-08-01T00:00:00+00:00',
], 25, 10)['total']);
}
#[TestDox('System log reads may filter one tenant without changing ownership')]
public function testSystemQuery(): void
{
$store = $this->createMock(FirewallStore::class);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$store->expects(self::once())
->method('querySystemLogs')
->with('tenant-a', self::isArray(), 50, 0)
->willReturn(['items' => [], 'total' => 0, 'limit' => 50, 'offset' => 0]);
$service = new SystemFirewallLogService(new FirewallLogService($store), $identity);
self::assertSame(0, $service->query('tenant-a', [], 50, 0)['total']);
}
#[TestDox('Log queries reject invalid filters before database access')]
public function testValidation(): void
{
$store = $this->createMock(FirewallStore::class);
$store->expects(self::never())->method('queryTenantLogs');
$query = new FirewallLogService($store);
$this->expectException(\InvalidArgumentException::class);
$query->tenant('tenant-a', ['ipAddress' => 'not-an-ip'], 50, 0);
}
#[TestDox('Log boundaries enforce dedicated read permissions')]
public function testPermissions(): void
{
$store = $this->createMock(FirewallStore::class);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(false);
$tenant = $this->createStub(TenantContextInterface::class);
$store->expects(self::never())->method('queryTenantLogs');
$store->expects(self::never())->method('querySystemLogs');
try {
(new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity))->query([]);
self::fail('Tenant log read should be rejected.');
} catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
}
$this->expectExceptionMessage(SystemFirewallLogService::PERMISSION_READ);
(new SystemFirewallLogService(new FirewallLogService($store), $identity))->query(null, []);
}
}
@@ -1,446 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleConflictException;
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('Manual rule creation requires a reason before persistence')]
public function testManualReason(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
' ',
'admin-a'
);
}
#[TestDox('Blocking the current exact IP requires explicit confirmation')]
public function testCurrentIpSafeguard(): void
{
$this->store->expects(self::never())->method('depositRule');
try {
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'2001:db8::1',
'Confirmed abuse',
'admin-a',
currentIp: '2001:0db8:0:0:0:0:0:1'
);
self::fail('Current IP block should require confirmation.');
} catch (FirewallRuleConflictException $error) {
self::assertSame('current_ip_confirmation_required', $error->conflictCode);
}
}
#[TestDox('CIDR rules covering the current IP require explicit confirmation')]
public function testCurrentIpCidrSafeguard(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(FirewallRuleConflictException::class);
$this->manager->createManualRule(
FirewallRuleScope::system(),
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.0/24',
'Network abuse',
'admin-a',
currentIp: '203.0.113.10'
);
}
#[TestDox('Confirmed current-IP blocks retain manual audit context')]
public function testConfirmedCurrentIpBlock(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
$rule = $this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
'Emergency lockout',
'admin-a',
currentIp: '203.0.113.10',
confirmCurrentIp: true
);
self::assertSame('Emergency lockout', $rule->getReason());
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $rule->getMetadata()['origin']);
}
#[TestDox('Manual creation rejects unsupported type and action combinations')]
public function testUnsupportedManualRule(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_ALLOW,
'device-123',
'Trusted device',
'admin-a'
);
}
#[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::assertNull($this->manager->removeManual(
FirewallRuleScope::system(), 'tenant-rule', 'No longer required', 'operator'
));
self::assertNull($this->manager->removeManual(
FirewallRuleScope::tenant('tenant-b'), 'tenant-rule', 'No longer required', 'operator'
));
}
#[TestDox('Rule queries retain scope, filters, and bounded pagination')]
public function testRuleQuery(): void
{
$result = ['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 50];
$this->store->expects(self::once())
->method('queryRules')
->with(
FirewallRuleObject::SCOPE_TENANT,
'tenant-a',
'disabled',
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
25,
50
)
->willReturn($result);
self::assertSame($result, $this->manager->query(
FirewallRuleScope::tenant('tenant-a'),
'disabled',
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
25,
50
));
}
#[TestDox('Rule queries reject invalid filters and excessive pages')]
public function testRuleQueryValidation(): void
{
$this->store->expects(self::never())->method('queryRules');
$this->expectException(\InvalidArgumentException::class);
$this->manager->query(FirewallRuleScope::system(), 'active', null, null, 101);
}
#[TestDox('Effective policy keeps system and tenant rule sets distinct')]
public function testEffectivePolicy(): void
{
$system = (new FirewallRuleObject())->setScope(FirewallRuleObject::SCOPE_SYSTEM);
$tenant = (new FirewallRuleObject())
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a');
$this->store->method('listSystemRules')->willReturn([$system]);
$this->store->method('listRules')->with('tenant-a')->willReturn([$tenant]);
$policy = $this->manager->effectivePolicy('tenant-a');
self::assertSame([$system], $policy['system']);
self::assertSame([$tenant], $policy['tenant']);
self::assertSame('system_block', $policy['precedence'][0]);
}
#[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('Manual lifecycle changes persist state, reasons, actors, and extension history')]
public function testManualLifecycle(): void
{
$originalExpiry = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setEnabled(true)
->setExpiresAt($originalExpiry)
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->method('fetchRule')->willReturn($rule);
$this->store->expects(self::exactly(3))->method('depositRule')->willReturnArgument(0);
$this->store->expects(self::once())->method('destroyRule')->with($rule);
$audits = [];
$this->events->expects(self::exactly(4))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$audits): void {
$audits[] = $event;
});
self::assertFalse($this->manager->disableManual(
FirewallRuleScope::system(), 'rule-123', 'Investigation', 'operator'
)->isEnabled());
self::assertTrue($this->manager->enableManual(
FirewallRuleScope::system(), 'rule-123', 'Threat confirmed', 'operator', '203.0.113.10', true
)->isEnabled());
$extended = $this->manager->extendManual(
FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator'
);
self::assertSame($rule, $this->manager->removeManual(
FirewallRuleScope::system(), 'rule-123', 'Case closed', 'operator'
));
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
self::assertCount(1, $extended->getMetadata()['extensions']);
self::assertSame([
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_REMOVED,
], array_map(static fn($event): string => $event->getName(), $audits));
self::assertSame(
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
array_map(static fn($event): string => $event->get('changeReason'), $audits)
);
self::assertSame(['operator'], array_values(array_unique(array_map(
static fn($event): ?string => $event->getIdentityId(),
$audits
))));
}
#[TestDox('Manual lifecycle changes require reasons before reading or writing rules')]
public function testLifecycleReason(): void
{
$this->store->expects(self::never())->method('fetchRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->disableManual(FirewallRuleScope::system(), 'rule-123', ' ', 'operator');
}
#[TestDox('Permanent rules cannot be extended')]
public function testPermanentExtension(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM);
$this->store->method('fetchRule')->willReturn($rule);
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->extendManual(
FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator'
);
}
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
public function testAutomaticBlockExtension(): void
{
$originalExpiry = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setExpiresAt($originalExpiry)
->setMetadata([
'origin' => FirewallRuleManager::ORIGIN_AUTOMATIC,
'originalExpiresAt' => $originalExpiry->format(\DateTimeInterface::ATOM),
'extensions' => [],
]);
$this->store->method('findExactIpRule')->willReturn($rule);
$this->store->expects(self::once())
->method('depositRule')
->with(self::callback(static function (FirewallRuleObject $extended) use ($originalExpiry): bool {
$metadata = $extended->getMetadata();
return $extended->getExpiresAt() > $originalExpiry
&& $metadata['failureThreshold'] === 5
&& $metadata['failureWindowSeconds'] === 300
&& $metadata['lastFailureCount'] === 8
&& $metadata['originalExpiresAt'] === $originalExpiry->format(\DateTimeInterface::ATOM)
&& count($metadata['extensions']) === 1;
}))
->willReturnArgument(0);
$this->events->expects(self::once())
->method('dispatch')
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
&& $event->get('lastFailureCount') === 8
));
$extended = $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
'Continued attack',
null,
3600,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => 5,
'failureWindowSeconds' => 300,
'lastFailureCount' => 8,
'blockDurationSeconds' => 3600,
]
);
self::assertSame('rule-123', $extended->getId());
}
#[TestDox('Automatic detection never extends a manual block')]
public function testManualBlockIsNotExtended(): void
{
$rule = (new FirewallRuleObject())
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->method('findExactIpRule')->willReturn($rule);
$this->store->expects(self::never())->method('depositRule');
$this->events->expects(self::never())->method('dispatch');
self::assertSame($rule, $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.10',
null,
null,
3600,
FirewallRuleManager::ORIGIN_AUTOMATIC
));
}
}
@@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
class FirewallRuleServicesTest extends TestCase
{
private FirewallStore&MockObject $store;
private IdentityContextInterface&MockObject $identity;
private TenantContextInterface&MockObject $tenant;
private FirewallRuleManager $manager;
protected function setUp(): void
{
$this->store = $this->createMock(FirewallStore::class);
$this->identity = $this->createMock(IdentityContextInterface::class);
$this->tenant = $this->createMock(TenantContextInterface::class);
$events = $this->createMock(EventDispatcherInterface::class);
$this->manager = new FirewallRuleManager(
$this->store,
new FirewallRuleCache($this->store),
$events
);
}
#[TestDox('Tenant and system boundaries require their dedicated permissions')]
public function testPermissions(): void
{
$this->identity->method('hasPermission')->willReturn(false);
$this->store->expects($this->never())->method('depositRule');
try {
$this->tenantService()->blockIp('203.0.113.10');
self::fail('Tenant operation should have been rejected.');
} catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage());
}
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE);
$this->systemService()->blockIp('203.0.113.10');
}
#[TestDox('Read operations require their scope-specific permission')]
public function testReadPermissions(): void
{
$this->identity->method('hasPermission')->willReturn(false);
$this->store->expects(self::never())->method('queryRules');
try {
$this->tenantService()->queryRules();
self::fail('Tenant read should have been rejected.');
} catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_READ, $error->getMessage());
}
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_READ);
$this->systemService()->queryRules();
}
#[TestDox('Tenant reads derive ownership and effective policy from context')]
public function testTenantReads(): void
{
$this->allow(TenantFirewallRuleService::PERMISSION_READ);
$this->tenant->method('requireIdentifier')->willReturn('tenant-a');
$this->store->expects(self::once())
->method('queryRules')
->with(FirewallRuleObject::SCOPE_TENANT, 'tenant-a', 'active', null, null, 50, 0)
->willReturn(['items' => [], 'total' => 0, 'limit' => 50, 'offset' => 0]);
$this->store->method('listSystemRules')->willReturn([]);
$this->store->method('listRules')->with('tenant-a')->willReturn([]);
self::assertSame(0, $this->tenantService()->queryRules()['total']);
self::assertSame([], $this->tenantService()->effectivePolicy()['system']);
}
#[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');
}
#[TestDox('Generic creation remains behind tenant and system management permissions')]
public function testGenericCreationPermissions(): void
{
$this->identity->method('hasPermission')->willReturn(false);
$this->store->expects(self::never())->method('depositRule');
try {
$this->tenantService()->createRule('ip', 'block', '203.0.113.10', 'Abuse');
self::fail('Tenant creation should have been rejected.');
} catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallRuleService::PERMISSION_MANAGE, $error->getMessage());
}
$this->expectExceptionMessage(SystemFirewallRuleService::PERMISSION_MANAGE);
$this->systemService()->createRule('ip', 'block', '203.0.113.10', 'Abuse');
}
private function allow(string $permission): void
{
$this->identity->method('hasPermission')->with($permission)->willReturn(true);
}
private function tenantService(): TenantFirewallRuleService
{
return new TenantFirewallRuleService($this->manager, $this->tenant, $this->identity);
}
private function systemService(): SystemFirewallRuleService
{
return new SystemFirewallRuleService($this->manager, $this->identity);
}
}
@@ -1,596 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Service\FirewallService;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
class FirewallServiceTest extends TestCase
{
private FirewallStore&MockObject $store;
private TenantContextInterface&MockObject $tenantContext;
private EventDispatcherInterface&MockObject $events;
private FirewallService $service;
private ?string $currentTenant;
private ?TenantConfiguration $currentConfiguration;
protected function setUp(): void
{
$this->store = $this->createMock(FirewallStore::class);
$this->tenantContext = $this->createMock(TenantContextInterface::class);
$this->events = $this->createMock(EventDispatcherInterface::class);
$this->currentTenant = 'tenant-a';
$this->currentConfiguration = null;
$this->tenantContext->method('identifier')->willReturnCallback(
fn(): ?string => $this->currentTenant
);
$this->tenantContext->method('configuration')->willReturnCallback(
fn(): ?TenantConfiguration => $this->currentConfiguration
);
$cache = new FirewallRuleCache($this->store);
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
$this->service = new FirewallService(
$this->store,
$this->tenantContext,
$this->events,
$manager,
$cache
);
}
#[TestDox('System blocks cannot be overridden by tenant allows')]
public function testSystemBlockPrecedence(): void
{
$systemBlock = $this->rule(
'system-block',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_BLOCK,
null
);
$tenantAllow = $this->rule(
'tenant-allow',
FirewallRuleObject::SCOPE_TENANT,
FirewallRuleObject::ACTION_ALLOW,
'tenant-a'
);
$this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]);
$this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]);
$this->events->expects($this->once())->method('dispatch');
$result = $this->service->analyze('203.0.113.10');
self::assertTrue($result->isBlocked());
self::assertSame('system-block', $result->ruleId);
}
#[TestDox('Tenant blocks override system allows')]
public function testTenantBlockPrecedence(): void
{
$systemAllow = $this->rule(
'system-allow',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_ALLOW,
null
);
$tenantBlock = $this->rule(
'tenant-block',
FirewallRuleObject::SCOPE_TENANT,
FirewallRuleObject::ACTION_BLOCK,
'tenant-a'
);
$this->store->method('listSystemRules')->willReturn([$systemAllow]);
$this->store->method('listRules')->with('tenant-a')->willReturn([$tenantBlock]);
$this->events->expects($this->once())->method('dispatch');
$result = $this->service->analyze('203.0.113.10');
self::assertTrue($result->isBlocked());
self::assertSame('tenant-block', $result->ruleId);
}
#[TestDox('Rule caches are isolated by tenant')]
public function testTenantCacheIsolation(): void
{
$this->store->expects($this->once())->method('listSystemRules')->willReturn([]);
$this->store->expects($this->exactly(2))
->method('listRules')
->willReturnCallback(static fn(string $tenantId): array => [
(new FirewallRuleObject())
->setId($tenantId)
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10'),
]);
self::assertSame('tenant-a', $this->service->analyze('203.0.113.10')->ruleId);
$this->currentTenant = 'tenant-b';
self::assertSame('tenant-b', $this->service->analyze('203.0.113.10')->ruleId);
}
#[TestDox('System blocks apply when no tenant is resolved')]
public function testSystemBlockWithoutTenant(): void
{
$this->currentTenant = null;
$this->store->method('listSystemRules')->willReturn([
$this->rule(
'system-block',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_BLOCK,
null
),
]);
$this->store->expects($this->never())->method('listRules');
self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId);
}
#[TestDox('System allows can match when no tenant is resolved')]
public function testSystemAllowWithoutTenant(): void
{
$this->currentTenant = null;
$this->store->method('listSystemRules')->willReturn([
$this->rule(
'system-allow',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_ALLOW,
null
),
]);
$result = $this->service->analyze('203.0.113.10');
self::assertTrue($result->isAllowed());
self::assertSame('system-allow', $result->ruleId);
}
#[TestDox('System blocks remain active when the tenant firewall is disabled')]
public function testSystemBlockWithDisabledTenant(): void
{
$this->disableTenantFirewall();
$this->store->method('listSystemRules')->willReturn([
$this->rule(
'system-block',
FirewallRuleObject::SCOPE_SYSTEM,
FirewallRuleObject::ACTION_BLOCK,
null
),
]);
$this->store->expects($this->never())->method('listRules');
self::assertSame('system-block', $this->service->analyze('203.0.113.10')->ruleId);
}
#[TestDox('Tenant rules are ignored when the tenant firewall is disabled')]
public function testTenantRulesDisabled(): void
{
$this->disableTenantFirewall();
$this->store->method('listSystemRules')->willReturn([]);
$this->store->expects($this->never())->method('listRules');
self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed());
}
#[TestDox('Requests without a tenant and without a system match are allowed')]
public function testNoTenantDefault(): void
{
$this->currentTenant = null;
$this->store->method('listSystemRules')->willReturn([]);
$this->store->expects($this->never())->method('listRules');
self::assertTrue($this->service->analyze('203.0.113.10')->isAllowed());
}
#[TestDox('Tenant rule-match logs persist dedicated rule ID and scope fields')]
public function testTenantRuleAuditContext(): void
{
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getTenantId() === 'tenant-a'
&& $log->getRuleId() === 'tenant-rule'
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_TENANT
&& $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH;
}))
->willReturnArgument(0);
$event = \KTXF\Event\SecurityEvent::accessDenied(
'203.0.113.10',
null,
'tenant-rule',
FirewallRuleObject::SCOPE_TENANT,
'Tenant block'
);
$event->setTenantId('tenant-a');
$this->service->logSecurityEvent($event);
}
#[TestDox('System rule matches are logged even when no tenant is resolved')]
public function testSystemRuleAuditContext(): void
{
$this->currentTenant = null;
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getTenantId() === null
&& $log->getRuleId() === 'system-rule'
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM;
}))
->willReturnArgument(0);
$event = \KTXF\Event\SecurityEvent::accessDenied(
'203.0.113.10',
null,
'system-rule',
FirewallRuleObject::SCOPE_SYSTEM,
'System block'
);
$this->service->logSecurityEvent($event);
}
#[TestDox('Tenantless security events without system rule context are ignored')]
public function testTenantlessAuditBoundary(): void
{
$this->currentTenant = null;
$this->store->expects($this->never())->method('createLog');
$this->service->logSecurityEvent(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
#[TestDox('Rate-limit events retain their request and threshold audit data')]
public function testRateLimitAudit(): void
{
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
$metadata = $log->getMetadata();
return $log->getEventType() === FirewallLogObject::EVENT_RATE_LIMIT
&& $log->getResult() === FirewallLogObject::RESULT_BLOCKED
&& $log->getIpAddress() === '203.0.113.10'
&& $log->getRequestPath() === '/login'
&& $metadata['requestCount'] === 101
&& $metadata['windowSeconds'] === 60;
}))
->willReturnArgument(0);
$event = \KTXF\Event\SecurityEvent::rateLimitExceeded(
'203.0.113.10',
101,
60,
'/login'
);
$event->setTenantId('tenant-a');
$this->service->logSecurityEvent($event);
}
#[TestDox('Suspicious-activity events retain request and detection metadata')]
public function testSuspiciousActivityAudit(): void
{
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getEventType() === FirewallLogObject::EVENT_SUSPICIOUS
&& $log->getResult() === FirewallLogObject::RESULT_BLOCKED
&& $log->getIpAddress() === '203.0.113.20'
&& $log->getRequestPath() === '/admin'
&& $log->getRequestMethod() === 'POST'
&& $log->getMetadata()['detector'] === 'payload-signature';
}))
->willReturnArgument(0);
$event = \KTXF\Event\SecurityEvent::create(
\KTXF\Event\SecurityEvent::SUSPICIOUS_ACTIVITY,
'203.0.113.20',
null,
['detector' => 'payload-signature']
);
$event->setTenantId('tenant-a')
->setRequestPath('/admin')
->setRequestMethod('POST');
$this->service->logSecurityEvent($event);
}
#[TestDox('Rule lifecycle events map to recorded audit entries')]
public function testRuleLifecycleAudit(): void
{
$this->currentTenant = null;
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getEventType() === FirewallLogObject::EVENT_RULE_DISABLED
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
&& $log->getRuleId() === 'rule-123'
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
&& $log->getIdentityId() === 'operator';
}))
->willReturnArgument(0);
$event = new \KTXF\Event\SecurityEvent(
\KTXF\Event\SecurityEvent::FIREWALL_RULE_DISABLED,
[
'ruleId' => 'rule-123',
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
'origin' => FirewallRuleManager::ORIGIN_MANUAL,
]
);
$event->setIdentityId('operator');
$this->service->logSecurityEvent($event);
}
#[TestDox('Settings changes map to recorded tenant audit entries')]
public function testSettingsAudit(): void
{
$this->store->expects(self::once())
->method('createLog')
->with(self::callback(static fn(FirewallLogObject $log): bool =>
$log->getEventType() === FirewallLogObject::EVENT_SETTINGS_UPDATED
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
&& $log->getTenantId() === 'tenant-a'
&& $log->getIdentityId() === 'operator'
&& $log->getMetadata()['changeReason'] === 'Tighten controls'
))
->willReturnArgument(0);
$event = new \KTXF\Event\SecurityEvent(
\KTXF\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
['changeReason' => 'Tighten controls']
);
$event->setTenantId('tenant-a')->setIdentityId('operator');
$this->service->logSecurityEvent($event);
}
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
public function testFirewallConfiguration(): void
{
$this->store->method('createLogOnce')->willReturn(true);
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
'firewall' => [
'enabled' => true,
'maxAuthFailures' => 8,
'authFailureWindow' => 600,
'autoBlockDuration' => 7200,
],
]);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 600)
->willReturn(4);
$this->events->expects($this->never())->method('dispatch');
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-a');
$this->service->handleAuthFailure($event);
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
}
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
public function testConfigurationBounds(): void
{
$this->store->method('createLogOnce')->willReturn(true);
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
'firewall' => [
'maxAuthFailures' => 0,
'authFailureWindow' => -1,
'autoBlockDuration' => 0,
],
]);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-a');
$this->service->handleAuthFailure($event);
}
#[TestDox('Automatic blocks retain the tenant carried by the authentication event')]
public function testAutomaticBlockTenant(): void
{
$this->store->method('createLogOnce')->willReturn(true);
$this->currentTenant = 'tenant-context';
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-event', '203.0.113.10', 300)
->willReturn(5);
$this->store->expects($this->once())
->method('claimBruteForce')
->with('tenant-event', '203.0.113.10', 300)
->willReturn(true);
$this->store->expects($this->once())
->method('findExactIpRule')
->with(
'tenant-event',
'203.0.113.10',
FirewallRuleObject::ACTION_BLOCK
)
->willReturn(null);
$this->store->expects($this->once())
->method('depositRule')
->with(self::callback(static function (FirewallRuleObject $rule): bool {
$metadata = $rule->getMetadata();
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
&& $rule->getTenantId() === 'tenant-event'
&& $rule->getExpiresAt() !== null
&& $metadata['failureThreshold'] === 5
&& $metadata['failureWindowSeconds'] === 300
&& $metadata['lastFailureCount'] === 5
&& $metadata['blockDurationSeconds'] === 3600;
}))
->willReturnArgument(0);
$publishedTenants = [];
$lifecycleOrigin = null;
$this->events->expects($this->exactly(3))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
&$publishedTenants,
&$lifecycleOrigin
): void {
$publishedTenants[] = $event->getTenantId();
if ($event->getName() === \KTXF\Event\SecurityEvent::FIREWALL_RULE_CREATED) {
$lifecycleOrigin = $event->get('origin');
}
});
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$event->setTenantId('tenant-event');
$this->service->handleAuthFailure($event);
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
}
#[TestDox('Workers that lose the brute-force claim do not block or publish detection events')]
public function testAutomaticBlockClaimLoss(): void
{
$this->store->method('createLogOnce')->willReturn(true);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(5);
$this->store->expects($this->once())
->method('claimBruteForce')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(false);
$this->store->expects($this->never())->method('depositRule');
$this->events->expects($this->never())->method('dispatch');
$this->service->handleAuthFailure(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
#[TestDox('Authentication events without a tenant use the current tenant')]
public function testAutomaticBlockTenantFallback(): void
{
$this->store->method('createLogOnce')->willReturn(true);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$this->service->handleAuthFailure(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
#[TestDox('Authentication failures are ignored when no tenant can be resolved')]
public function testAutomaticBlockWithoutTenant(): void
{
$this->currentTenant = null;
$this->store->expects($this->never())->method('countRecentFailures');
$this->store->expects($this->never())->method('depositRule');
$this->service->handleAuthFailure(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
#[TestDox('Repeated delivery of one authentication event is counted once')]
public function testAuthenticationFailureIdempotency(): void
{
$this->store->expects($this->exactly(2))
->method('createLogOnce')
->willReturnOnConsecutiveCalls(true, false);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(1);
$event = \KTXF\Event\SecurityEvent::authFailure('203.0.113.10');
$eventId = $event->getEventId();
$this->service->handleAuthFailure($event);
$this->service->handleAuthFailure($event);
self::assertSame($eventId, $event->getEventId());
}
#[TestDox('Cleanup records successful maintenance counts')]
public function testCleanupStatus(): void
{
$this->store->method('cleanupExpiredRules')->willReturn(2);
$this->store->method('cleanupOldLogs')->with(30)->willReturn(3);
$this->store->method('cleanupExpiredBruteForceClaims')->willReturn(4);
$this->store->expects(self::once())
->method('recordMaintenanceStatus')
->with(
self::isInstanceOf(\DateTimeImmutable::class),
self::isInstanceOf(\DateTimeImmutable::class),
'success',
[
'expiredRules' => 2,
'oldLogs' => 3,
'expiredBruteForceClaims' => 4,
]
);
self::assertSame([
'expiredRules' => 2,
'oldLogs' => 3,
'expiredBruteForceClaims' => 4,
], $this->service->cleanup());
}
#[TestDox('Cleanup failures are recorded and rethrown')]
public function testCleanupFailureStatus(): void
{
$this->store->method('cleanupExpiredRules')->willThrowException(new \RuntimeException('cleanup failed'));
$this->store->expects(self::once())
->method('recordMaintenanceStatus')
->with(
self::isInstanceOf(\DateTimeImmutable::class),
self::isInstanceOf(\DateTimeImmutable::class),
'failed',
[],
'cleanup failed'
);
$this->expectExceptionMessage('cleanup failed');
$this->service->cleanup();
}
private function rule(
string $id,
string $scope,
string $action,
?string $tenantId
): FirewallRuleObject {
return (new FirewallRuleObject())
->setId($id)
->setScope($scope)
->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction($action)
->setValue('203.0.113.10')
->setReason($id);
}
private function disableTenantFirewall(): void
{
$this->currentConfiguration = (new TenantConfiguration())->jsonDeserialize([
'firewall' => ['enabled' => false],
]);
}
}
@@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\TenantService;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallSettingsServiceTest extends TestCase
{
#[TestDox('Settings updates preserve other tenant configuration and emit complete audit context')]
public function testUpdate(): void
{
$tenant = (new TenantObject())
->setId('507f1f77bcf86cd799439011')
->setIdentifier('tenant-a')
->setConfiguration((new TenantConfiguration())->jsonDeserialize([
'firewall' => ['enabled' => true, 'maxAuthFailures' => 5],
'security' => ['code' => 'preserved'],
]));
$tenants = $this->createMock(TenantService::class);
$tenants->method('fetchById')->with('tenant-a')->willReturn($tenant);
$tenants->expects(self::once())
->method('deposit')
->with(self::callback(static fn(TenantObject $updated): bool =>
$updated->getConfiguration()->firewall()->maxAuthFailures() === 8
&& $updated->getConfiguration()->security()->jsonSerialize()['code'] === 'preserved'
))
->willReturnArgument(0);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::callback(static fn(SecurityEvent $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_SETTINGS_UPDATED
&& $event->getTenantId() === 'tenant-a'
&& $event->getIdentityId() === 'admin-a'
&& $event->get('changeReason') === 'Tighten authentication controls'
&& $event->get('previous')['maxAuthFailures'] === 5
&& $event->get('current')['maxAuthFailures'] === 8
));
$result = (new FirewallSettingsService($tenants, $events))->update(
'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a'
);
self::assertSame([
'enabled' => false,
'maxAuthFailures' => 8,
'authFailureWindow' => 600,
'autoBlockDuration' => 7200,
], $result);
}
#[TestDox('Invalid settings are rejected before tenant reads or persistence')]
public function testValidation(): void
{
$tenants = $this->createMock(TenantService::class);
$tenants->expects(self::never())->method('fetchById');
$tenants->expects(self::never())->method('deposit');
$this->expectException(\InvalidArgumentException::class);
(new FirewallSettingsService(
$tenants,
$this->createStub(EventDispatcherInterface::class)
))->update('tenant-a', true, 0, 300, 3600, 'Invalid threshold', 'admin-a');
}
#[TestDox('Unknown tenants return no configuration without emitting audit events')]
public function testMissingTenant(): void
{
$tenants = $this->createStub(TenantService::class);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
self::assertNull((new FirewallSettingsService($tenants, $events))->update(
'missing', true, 5, 300, 3600, 'Apply defaults', 'admin-a'
));
}
}
@@ -1,121 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Service\FirewallStatusService;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\TenantService;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
#[AllowMockObjectsWithoutExpectations]
final class FirewallStatusServicesTest extends TestCase
{
#[TestDox('Tenant metrics and configuration derive their tenant context')]
public function testTenantStatus(): void
{
$store = $this->createMock(FirewallStore::class);
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('requireIdentifier')->willReturn('tenant-a');
$tenant->method('configuration')->willReturn(
(new TenantConfiguration())->jsonDeserialize([
'firewall' => ['enabled' => false, 'maxAuthFailures' => 8],
])
);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$store->expects(self::once())
->method('countBlockedRequests')
->with('tenant-a', self::isInstanceOf(\DateTimeImmutable::class))
->willReturn(7);
$service = new TenantFirewallStatusService(
new FirewallStatusService($store), $tenant, $identity, $this->settings()
);
self::assertSame(7, $service->metrics('2026-08-01T00:00:00+00:00')['blockedRequests']);
self::assertFalse($service->configuration()['enabled']);
self::assertSame(8, $service->configuration()['maxAuthFailures']);
}
#[TestDox('System metrics and maintenance status expose operational state')]
public function testSystemStatus(): void
{
$store = $this->createMock(FirewallStore::class);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(true);
$store->expects(self::once())
->method('countSystemBlockedRequests')
->with('tenant-a', null)
->willReturn(12);
$store->method('maintenanceStatus')->willReturn(['status' => 'success']);
$service = new SystemFirewallStatusService(
new FirewallStatusService($store), $identity, $this->settings()
);
self::assertSame(12, $service->metrics('tenant-a')['blockedRequests']);
self::assertSame('success', $service->maintenanceStatus()['status']);
}
#[TestDox('Maintenance reports an explicit state before its first run')]
public function testNeverRunStatus(): void
{
$store = $this->createStub(FirewallStore::class);
self::assertSame('never_run', (new FirewallStatusService($store))->maintenanceStatus()['status']);
}
#[TestDox('Status reads require their dedicated permissions')]
public function testPermissions(): void
{
$store = $this->createMock(FirewallStore::class);
$identity = $this->createStub(IdentityContextInterface::class);
$identity->method('hasPermission')->willReturn(false);
$tenant = $this->createStub(TenantContextInterface::class);
$store->expects(self::never())->method('countBlockedRequests');
$store->expects(self::never())->method('maintenanceStatus');
try {
(new TenantFirewallStatusService(
new FirewallStatusService($store), $tenant, $identity, $this->settings()
))->metrics();
self::fail('Tenant metrics should be rejected.');
} catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
}
$this->expectExceptionMessage(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ);
(new SystemFirewallStatusService(
new FirewallStatusService($store), $identity, $this->settings()
))->maintenanceStatus();
}
#[TestDox('Metrics reject invalid dates before querying storage')]
public function testMetricValidation(): void
{
$store = $this->createMock(FirewallStore::class);
$store->expects(self::never())->method('countSystemBlockedRequests');
$this->expectException(\InvalidArgumentException::class);
(new FirewallStatusService($store))->systemMetrics(null, 'not-a-date');
}
private function settings(): FirewallSettingsService
{
return new FirewallSettingsService(
$this->createStub(TenantService::class),
$this->createStub(EventDispatcherInterface::class)
);
}
}