38 Commits

Author SHA1 Message Date
Sebastian b8d301ee21 chore(deps): update dependency vuetify to v4.1.7
JS Unit Tests / test (pull_request) Successful in 24s
Build Test / build (pull_request) Successful in 24s
PHP Unit Tests / test (pull_request) Successful in 1m3s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m8s
2026-08-07 03:03:20 +00:00
Sebastian b14bd302a3 fix(security): emit authentication success events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-06 00:08:58 -04:00
Sebastian 5b3e8f6588 fix(events): close deferred scope after listener failure
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-06 00:02:15 -04:00
Sebastian 84eb0e2c21 refactor(security): replace generic event with severity enum
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:59:49 -04:00
Sebastian 3f9c2500d9 refactor(security): type firewall policy events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:56:28 -04:00
Sebastian 2494cef02f refactor(security): type firewall rule lifecycle events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:51:40 -04:00
Sebastian a5be782c51 refactor(security): add typed authentication-success event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:42:24 -04:00
Sebastian f4df769b3c refactor(security): add typed suspicious-activity event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:38:59 -04:00
Sebastian 688afbe5a1 refactor(security): add typed rate-limit event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:36:44 -04:00
Sebastian 7c2a8dfbd3 refactor(security): add typed access-denied event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:34:05 -04:00
Sebastian e223ae7543 refactor(security): add typed brute-force detection event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:31:13 -04:00
Sebastian a8e29d0305 feat(security): emit authentication failures for firewall handling
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:26:01 -04:00
Sebastian a363a1a4bc refactor: use consistant naming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:17:04 -04:00
Sebastian 5c65c8592c feat: Introduce typed authentication failure event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:50:38 -04:00
Sebastian 52afd35d6f feat: make event state constructor-only and immutable
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:46:33 -04:00
Sebastian ba4deccea9 feat: Remove unused event serialization
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:21:04 -04:00
Sebastian 2012d67be7 fix: Enforce security event severity defaults
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:17:04 -04:00
Sebastian 649fa47c68 feat: Move security events into core
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:14:22 -04:00
Sebastian a73ca3abd6 feat: Move event runtime implementation into core
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:09:53 -04:00
Sebastian 01ed0f3080 feat: Introduce event listener registration contract
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 22:04:26 -04:00
Sebastian fb39aa57fd feat(firewall): add audited configuration management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-03 22:44:34 -04:00
Sebastian d5ca89e160 feat(firewall): add audited rule lifecycle management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-03 22:35:45 -04:00
Sebastian b06c18d38e feat(firewall): add safeguarded rule creation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-03 22:27:20 -04:00
Sebastian 6700ff145d feat(firewall): add operational status reads
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-03 22:22:07 -04:00
Sebastian d919b70a2e feat(firewall): add scoped log administration reads
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-03 22:17:13 -04:00
Sebastian 74696bbeb3 feat(firewall): add scoped rule administration reads
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-03 22:05:00 -04:00
Sebastian 4ee91a7918 feat(firewall): complete operational reliability phase
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:39:37 -04:00
Sebastian 90d847ffae perf(firewall): use BSON dates and add query indexes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:13:37 -04:00
Sebastian ad6443bff3 fix(firewall): serialize automatic brute-force blocking
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:05:32 -04:00
Sebastian a5c10e9b9b fix(firewall): account for authentication failures exactly once
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:01:38 -04:00
Sebastian 7aa8a27b1b feat(firewall): audit rule lifecycle changes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:57:25 -04:00
Sebastian 5ada4c0c45 fix(firewall): register missing security audit events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:50:42 -04:00
Sebastian 12037da367 feat(firewall): complete rule-match audit context
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:46:27 -04:00
Sebastian abc5bfcccc fix(firewall): enforce system rules independently of tenant context
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:41:27 -04:00
Sebastian da81f1ddf1 refactor(firewall): separate enforcement from rule management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:35:24 -04:00
Sebastian 266b364c93 fix(firewall): preserve tenant ownership for automatic blocks
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 22:11:27 -04:00
Sebastian 3b9a5cd5ab feat(firewall): validate rules and add typed configuration
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 21:54:23 -04:00
Sebastian 63d91ca7fa feat(firewall): add tenant and system rule scopes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 21:38:19 -04:00
90 changed files with 7687 additions and 837 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": "d162d02532b7705788ced1ea4c360c05",
"content-hash": "68916c1b58d9ce06a59f6c58e4a2d226",
"packages": [
{
"name": "laravel/serializable-closure",
@@ -1946,16 +1946,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.33",
"version": "12.5.31",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184"
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
"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.33"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
},
"funding": [
{
@@ -2032,7 +2032,7 @@
"type": "other"
}
],
"time": "2026-07-28T13:58:09+00:00"
"time": "2026-07-06T14:54:16+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -3057,5 +3057,5 @@
"ext-iconv": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace KTXC\Console\Event;
use KTXF\Event\EventListenerRegistry;
use KTXC\Event\EventListenerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -0,0 +1,40 @@
<?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;
}
}
@@ -0,0 +1,35 @@
<?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;
}
}
@@ -92,9 +92,9 @@ class AuthenticationController extends ControllerAbstract
}
$request = AuthenticationRequest::verify($session, $method, $response);
$authResponse = $this->authManager->handle($request);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($authResponse);
return $this->buildJsonResponse($response);
}
/**
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
$host = $request->getHost();
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
return $this->redirectWithError('Missing state parameter');
}
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($request);
if ($response->isSuccess()) {
$returnUrl = $response->returnUrl ?? '/';
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
);
}
$authRequest = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
{
$sessionId = $request->query->get('session', '');
$authRequest = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($authRequest);
$request = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($request);
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
}
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
);
}
$authRequest = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($request);
if ($response->isFailed()) {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
{
$token = $request->cookies->get('accessToken');
$authRequest = AuthenticationRequest::logout($token, false);
$this->authManager->handle($authRequest);
$request = AuthenticationRequest::logout($token, false);
$this->authManager->handle($request);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
return $this->clearTokenCookies($response);
@@ -274,8 +274,8 @@ class AuthenticationController extends ControllerAbstract
{
$token = $request->cookies->get('accessToken');
$authRequest = AuthenticationRequest::logout($token, true);
$this->authManager->handle($authRequest);
$request = AuthenticationRequest::logout($token, true);
$this->authManager->handle($request);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
return $this->clearTokenCookies($response);
+465
View File
@@ -0,0 +1,465 @@
<?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 MongoObjectId::isValid($id);
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXF\Event;
namespace KTXC\Event;
interface DeferredEventProcessorInterface
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXF\Event;
namespace KTXC\Event;
final readonly class DeferredProcessingResult
{
@@ -2,8 +2,12 @@
declare(strict_types=1);
namespace KTXF\Event;
namespace KTXC\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\FailurePolicy;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@@ -56,34 +60,34 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
throw new \LogicException('Cannot process deferred events for an inactive execution.');
}
$processed = 0;
$deadline = microtime(true) + 1.0;
$deadlineExceeded = false;
$limitExceeded = false;
while (($event = array_shift($this->deferred[$executionId])) !== null) {
if ($processed >= 1000) {
$limitExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
try {
$processed = 0;
$deadline = microtime(true) + 1.0;
$deadlineExceeded = false;
$limitExceeded = false;
while (($event = array_shift($this->deferred[$executionId])) !== null) {
if ($processed >= 1000) {
$limitExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
if (microtime(true) >= $deadline) {
$deadlineExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
$processed += $this->invoke($event, DeliveryMode::Deferred);
}
if (microtime(true) >= $deadline) {
$deadlineExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
$processed += $this->invoke($event, DeliveryMode::Deferred);
return new DeferredProcessingResult(
$processed,
count($this->deferred[$executionId]),
$deadlineExceeded,
$limitExceeded,
);
} finally {
$this->discardDeferred($executionId);
}
$remaining = count($this->deferred[$executionId]);
unset($this->deferred[$executionId]);
$this->activeExecution = null;
return new DeferredProcessingResult(
$processed,
$remaining,
$deadlineExceeded,
$limitExceeded,
);
}
public function discardDeferred(string $executionId): void
@@ -2,7 +2,10 @@
declare(strict_types=1);
namespace KTXF\Event;
namespace KTXC\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\FailurePolicy;
final readonly class EventListenerDefinition
{
@@ -2,11 +2,14 @@
declare(strict_types=1);
namespace KTXF\Event;
namespace KTXC\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Event\FailurePolicy;
use Psr\Container\ContainerInterface;
final class EventListenerRegistry
final class EventListenerRegistry implements EventListenerRegistrarInterface
{
/** @var array<string, list<EventListenerDefinition>> */
private array $listeners = [];
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Request;
/**
* Holds the HTTP request for the duration of the current runtime execution.
*/
final class RequestContext
{
private ?Request $request = null;
public function initialize(Request $request): void
{
if ($this->request !== null) {
throw new \LogicException('The request context has already been initialized.');
}
$this->request = $request;
}
public function current(): ?Request
{
return $this->request;
}
public function clear(): void
{
$this->request = null;
}
}
+5 -3
View File
@@ -27,10 +27,11 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\EventDispatcher;
use KTXC\Event\DeferredEventProcessorInterface;
use KTXC\Event\EventDispatcher;
use KTXC\Event\EventListenerRegistry;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface;
@@ -410,6 +411,7 @@ class Kernel implements KernelInterface
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
EventListenerRegistrarInterface::class => \DI\get(EventListenerRegistry::class),
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
+65 -3
View File
@@ -13,6 +13,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
public const RESULT_ALLOWED = 'allowed';
public const RESULT_BLOCKED = 'blocked';
public const RESULT_RECORDED = 'recorded';
public const EVENT_AUTH_FAILURE = 'auth_failure';
public const EVENT_RATE_LIMIT = 'rate_limit';
@@ -20,8 +21,15 @@ 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;
@@ -31,6 +39,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
private ?string $eventType = null;
private ?string $result = null; // allowed, blocked
private ?string $ruleId = null; // Which rule triggered (if any)
private ?string $ruleScope = null; // tenant or system
private ?string $identityId = null; // User ID if authenticated
private ?\DateTimeImmutable $timestamp = null;
private ?array $metadata = null; // Additional context
@@ -50,6 +59,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
}
if (array_key_exists('eventId', $data)) {
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
}
if (array_key_exists('ipAddress', $data)) {
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
}
@@ -74,13 +86,14 @@ 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 = $data['timestamp'] !== null
? new \DateTimeImmutable($data['timestamp'])
: null;
$this->timestamp = self::deserializeDate($data['timestamp']);
}
if (array_key_exists('metadata', $data)) {
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
@@ -93,6 +106,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'eventId' => $this->eventId,
'tenantId' => $this->tenantId,
'ipAddress' => $this->ipAddress,
'deviceFingerprint' => $this->deviceFingerprint,
@@ -102,12 +116,31 @@ 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
@@ -121,6 +154,17 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this;
}
public function getEventId(): ?string
{
return $this->eventId;
}
public function setEventId(?string $eventId): self
{
$this->eventId = $eventId;
return $this;
}
public function getTenantId(): ?string
{
return $this->tenantId;
@@ -220,6 +264,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this;
}
public function getRuleScope(): ?string
{
return $this->ruleScope;
}
public function setRuleScope(?string $ruleScope): self
{
if (
$ruleScope !== null
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
}
$this->ruleScope = $ruleScope;
return $this;
}
public function getIdentityId(): ?string
{
return $this->identityId;
@@ -11,6 +11,9 @@ use KTXF\Json\JsonDeserializable;
*/
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
public const SCOPE_TENANT = 'tenant';
public const SCOPE_SYSTEM = 'system';
public const TYPE_IP = 'ip';
public const TYPE_IP_RANGE = 'ip_range';
public const TYPE_DEVICE = 'device';
@@ -19,6 +22,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
public const ACTION_BLOCK = 'block';
private ?string $id = null;
private string $scope = self::SCOPE_TENANT;
private ?string $tenantId = null;
private ?string $type = null; // ip, ip_range, device
private ?string $action = null; // allow, block
@@ -42,6 +46,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
}
if (!array_key_exists('scope', $data)) {
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
}
$this->setScope((string)$data['scope']);
if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
}
@@ -61,14 +69,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
}
if (array_key_exists('createdAt', $data)) {
$this->createdAt = $data['createdAt'] !== null
? new \DateTimeImmutable($data['createdAt'])
: null;
$this->createdAt = self::deserializeDate($data['createdAt']);
}
if (array_key_exists('expiresAt', $data)) {
$this->expiresAt = $data['expiresAt'] !== null
? new \DateTimeImmutable($data['expiresAt'])
: null;
$this->expiresAt = self::deserializeDate($data['expiresAt']);
}
if (array_key_exists('enabled', $data)) {
$this->enabled = (bool)$data['enabled'];
@@ -84,6 +88,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'scope' => $this->scope,
'tenantId' => $this->tenantId,
'type' => $this->type,
'action' => $this->action,
@@ -97,6 +102,24 @@ 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
*/
@@ -134,6 +157,42 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
return $this->tenantId;
}
public function getScope(): string
{
return $this->scope;
}
public function setScope(string $scope): self
{
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
}
$this->scope = $scope;
return $this;
}
public function isTenantScoped(): bool
{
return $this->scope === self::SCOPE_TENANT;
}
public function isSystemScoped(): bool
{
return $this->scope === self::SCOPE_SYSTEM;
}
public function assertValidScopeOwnership(): void
{
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
}
if ($this->isSystemScoped() && $this->tenantId !== null) {
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
}
}
public function setTenantId(?string $tenantId): self
{
$this->tenantId = $tenantId;
@@ -11,11 +11,13 @@ class TenantConfiguration extends JsonSerializableObject
{
protected TenantAuthentication $authentication;
protected TenantSecurity $security;
protected TenantFirewall $firewall;
public function __construct()
{
$this->authentication = new TenantAuthentication();
$this->security = new TenantSecurity();
$this->firewall = new TenantFirewall();
}
public function authentication(): TenantAuthentication {
@@ -26,4 +28,8 @@ class TenantConfiguration extends JsonSerializableObject
return $this->security;
}
public function firewall(): TenantFirewall {
return $this->firewall;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use KTXF\Json\JsonSerializableObject;
class TenantFirewall extends JsonSerializableObject
{
protected bool $enabled = true;
protected int $maxAuthFailures = 5;
protected int $authFailureWindow = 300;
protected int $autoBlockDuration = 3600;
public function enabled(): bool
{
return $this->enabled;
}
public function maxAuthFailures(): int
{
return $this->maxAuthFailures;
}
public function authFailureWindow(): int
{
return $this->authFailureWindow;
}
public function autoBlockDuration(): int
{
return $this->autoBlockDuration;
}
}
+95 -9
View File
@@ -2,10 +2,29 @@
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 KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract;
@@ -18,7 +37,7 @@ use KTXF\Module\ModuleInstanceAbstract;
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{
public function __construct(
private readonly EventListenerRegistry $events,
private readonly EventListenerRegistrarInterface $events,
) {
}
@@ -26,17 +45,32 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
{
$this->events->listen(
'core',
SecurityEvent::AUTH_FAILURE,
AuthenticationFailedEvent::class,
FirewallService::class,
'handleAuthFailure',
DeliveryMode::Immediate,
priority: 100,
);
$this->events->listen(
'core',
AuthenticationSucceededEvent::class,
FirewallService::class,
'logAuthenticationSuccess',
DeliveryMode::Deferred,
);
foreach ([
SecurityEvent::AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_DENIED,
SecurityEvent::BRUTE_FORCE_DETECTED,
AccessDeniedEvent::class,
BruteForceDetectedEvent::class,
RateLimitExceededEvent::class,
SuspiciousActivityEvent::class,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class,
] as $event) {
$this->events->listen(
'core',
@@ -115,7 +149,57 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'group' => 'Module Management'
],
// System Administration
// Firewall Management
TenantFirewallRuleService::PERMISSION_READ => [
'label' => 'View Tenant Firewall Rules',
'description' => 'View firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallRuleService::PERMISSION_MANAGE => [
'label' => 'Manage Tenant Firewall Rules',
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
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.admin' => [
'label' => 'System Administrator',
'description' => 'Full system access (superuser)',
@@ -132,6 +216,8 @@ 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,
+26 -17
View File
@@ -11,6 +11,7 @@ use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Http\Response\Response;
use KTXC\KernelInterface;
@@ -25,26 +26,34 @@ final class HttpRuntime
public function run(?Request $request = null, bool $send = true): Response
{
$request ??= Request::createFromGlobals();
$requestContext = null;
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::http(),
function () use ($request, $send): Response {
$response = $this->pipeline()->handle($request);
if ($send) {
$response->send();
}
try {
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::http(),
function () use ($request, $send, &$requestContext): Response {
$requestContext = $this->kernel->container()->get(RequestContext::class);
$requestContext->initialize($request);
return $response;
},
function (\Throwable $error) use ($send): Response {
$response = $this->errorResponse($error);
if ($send) {
$response->send();
}
$response = $this->pipeline()->handle($request);
if ($send) {
$response->send();
}
return $response;
},
);
return $response;
},
function (\Throwable $error) use ($send): Response {
$response = $this->errorResponse($error);
if ($send) {
$response->send();
}
return $response;
},
);
} finally {
$requestContext?->clear();
}
}
private function pipeline(): MiddlewarePipeline
+38 -1
View File
@@ -8,11 +8,14 @@ use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
use KTXF\Security\Authentication\AuthenticationSession;
use KTXF\Security\Authentication\ProviderContext;
@@ -31,6 +34,7 @@ class AuthenticationManager
private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
private readonly EventDispatcherInterface $events,
) {
$this->securityCode = $this->tenantContext->configuration()->security()->code();
}
@@ -187,6 +191,10 @@ class AuthenticationManager
if (!$result->isSuccess()) {
$this->saveSession($session);
$this->publishAuthenticationFailure(
$session,
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Authentication failed. If you haven\'t set up this method, try another option.',
@@ -389,6 +397,10 @@ class AuthenticationManager
$result = $provider->completeRedirect($context, $request->params);
if ($result->isFailed()) {
$this->publishAuthenticationFailure(
$session,
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
);
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
@@ -566,6 +578,17 @@ class AuthenticationManager
// Helper Methods
// =========================================================================
private function publishAuthenticationFailure(
AuthenticationSession $session,
string $reason,
): void {
$this->events->dispatch(new AuthenticationFailedEvent(
userId: $session->userIdentifier,
reason: $reason,
tenantId: $session->tenantIdentifier,
));
}
/**
* Build provider context from session
*/
@@ -594,7 +617,16 @@ class AuthenticationManager
*/
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
{
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
$userId = $session->userIdentifier;
if ($userId === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Authenticated user is missing',
401,
);
}
$userData = $this->userService->fetchByIdentifier($userId);
if ($userData === null) {
return AuthenticationResponse::failed(
@@ -611,6 +643,11 @@ class AuthenticationManager
$this->deleteSession($session->id);
$this->events->dispatch(new AuthenticationSucceededEvent(
$userId,
$session->tenantIdentifier,
));
return AuthenticationResponse::success(
$this->buildUserData($user),
$tokens
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Access denial requires an IP address.');
}
if ($ruleId === '') {
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
}
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
}
parent::__construct(
self::class,
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly ?string $userId = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
parent::__construct(
self::class,
['userId' => $userId, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $userId,
?string $tenantId = null,
) {
if ($userId === '') {
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
}
parent::__construct(
self::class,
['userId' => $userId],
$tenantId,
);
}
public function getUserId(): string
{
return $this->userId;
}
public function getReason(): ?string
{
return null;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $failureCount,
private readonly int $windowSeconds,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
}
if ($failureCount < 1) {
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
}
$this->reason = sprintf(
'%d failed attempts in %d seconds',
$failureCount,
$windowSeconds,
);
parent::__construct(
self::class,
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getFailureCount(): int
{
return $this->failureCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $deviceFingerprint,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($deviceFingerprint === '') {
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
}
parent::__construct(
self::class,
['device' => $deviceFingerprint, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): ?string
{
return null;
}
public function getDeviceFingerprint(): string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
final public function __construct(
private readonly string $ipAddress,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
}
parent::__construct(
static::class,
['ip' => $ipAddress, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return static::SEVERITY;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
{
final protected function __construct(
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly string $ruleType,
private readonly string $ruleAction,
private readonly string $ruleValue,
private readonly ?string $reason,
private readonly string $origin,
private readonly ?string $expiresAt,
private readonly array $details,
?string $tenantId,
?string $identityId,
) {
if ($ruleId === '') {
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
parent::__construct(
static::class,
[
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'ruleType' => $ruleType,
'ruleAction' => $ruleAction,
'ruleValue' => $ruleValue,
'reason' => $reason,
'origin' => $origin,
'expiresAt' => $expiresAt,
...$details,
],
$tenantId,
$identityId,
);
}
public static function fromRule(
FirewallRuleObject $rule,
?string $actorId = null,
array $change = [],
): static {
$metadata = $rule->getMetadata() ?? [];
$details = [...$metadata, ...$change];
foreach ([
'ruleId',
'ruleScope',
'ruleType',
'ruleAction',
'ruleValue',
'reason',
'origin',
'expiresAt',
] as $reservedKey) {
unset($details[$reservedKey]);
}
return new static(
ruleId: (string) $rule->getId(),
ruleScope: (string) $rule->getScope(),
ruleType: (string) $rule->getType(),
ruleAction: (string) $rule->getAction(),
ruleValue: (string) $rule->getValue(),
reason: $rule->getReason(),
origin: (string) ($metadata['origin'] ?? 'manual'),
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
details: $details,
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getRuleType(): string
{
return $this->ruleType;
}
public function getRuleAction(): string
{
return $this->ruleAction;
}
public function getRuleValue(): string
{
return $this->ruleValue;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getExpiresAt(): ?string
{
return $this->expiresAt;
}
public function getDetails(): array
{
return $this->details;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $changeReason,
private readonly array $previous,
private readonly array $current,
string $tenantId,
?string $actorId = null,
private readonly string $changeOrigin = 'manual',
) {
if ($changeReason === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
}
if ($tenantId === '') {
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
}
if ($changeOrigin === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
}
parent::__construct(
self::class,
[
'changeReason' => $changeReason,
'changeOrigin' => $changeOrigin,
'previous' => $previous,
'current' => $current,
],
$tenantId,
$actorId,
);
}
public function getChangeReason(): string
{
return $this->changeReason;
}
public function getPrevious(): array
{
return $this->previous;
}
public function getCurrent(): array
{
return $this->current;
}
public function getChangeOrigin(): string
{
return $this->changeOrigin;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->changeReason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpAllowedEvent extends FirewallIpEvent
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpBlockedEvent extends FirewallIpEvent
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $requestCount,
private readonly int $windowSeconds,
private readonly ?string $endpoint = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
}
if ($requestCount < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
}
if ($endpoint === '') {
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
}
$this->reason = sprintf(
'%d requests in %d seconds',
$requestCount,
$windowSeconds,
);
parent::__construct(
self::class,
[
'requestCount' => $requestCount,
'windowSeconds' => $windowSeconds,
'endpoint' => $endpoint,
],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRequestCount(): int
{
return $this->requestCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getEndpoint(): ?string
{
return $this->endpoint;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return $this->endpoint;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
interface SecurityEventInterface
{
public function getName(): string;
public function get(string $key, mixed $default = null): mixed;
public function getData(): array;
public function getEventId(): string;
public function getTenantId(): ?string;
public function getIdentityId(): ?string;
public function getUserId(): ?string;
public function getReason(): ?string;
public function getSeverity(): SecurityEventSeverity;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
enum SecurityEventSeverity: int
{
case DEBUG = 0;
case INFO = 1;
case WARNING = 2;
case ERROR = 3;
case CRITICAL = 4;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
interface SecurityRequestEventInterface extends SecurityEventInterface
{
public function getIpAddress(): ?string;
public function getDeviceFingerprint(): ?string;
public function getUserAgent(): ?string;
public function getRequestPath(): ?string;
public function getRequestMethod(): ?string;
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $detector,
private readonly array $detectionData = [],
?string $tenantId = null,
?string $identityId = null,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $userAgent = null,
private readonly ?string $requestPath = null,
private readonly ?string $requestMethod = null,
private readonly ?string $userId = null,
private readonly ?string $reason = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
}
if ($detector === '') {
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
}
if (array_key_exists('detector', $detectionData)) {
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
}
if (array_key_exists('detector', $detectionData)) {
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
}
if ($requestPath === '') {
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
}
if ($requestMethod === '') {
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
}
parent::__construct(
self::class,
['detector' => $detector] + $detectionData,
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDetector(): string
{
return $this->detector;
}
public function getDetectionData(): array
{
return $this->detectionData;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function getRequestPath(): ?string
{
return $this->requestPath;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
final class FirewallRuleCache
{
/** @var array<string, FirewallRuleObject[]> */
private array $tenantRules = [];
/** @var FirewallRuleObject[]|null */
private ?array $systemRules = null;
public function __construct(private readonly FirewallStore $store)
{
}
/** @return FirewallRuleObject[] */
public function tenant(string $tenantId): array
{
return $this->tenantRules[$tenantId] ??= $this->store->listRules($tenantId);
}
/** @return FirewallRuleObject[] */
public function system(): array
{
return $this->systemRules ??= $this->store->listSystemRules();
}
public function invalidate(): void
{
$this->tenantRules = [];
$this->systemRules = null;
}
}
@@ -0,0 +1,15 @@
<?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);
}
}
+517
View File
@@ -0,0 +1,517 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
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->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
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->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
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 DeviceBlockedEvent($fingerprint, $reason, $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(
FirewallRuleDisabledEvent::class,
$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(
FirewallRuleEnabledEvent::class,
$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(
FirewallRuleExtendedEvent::class,
$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(
FirewallRuleRemovedEvent::class,
$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);
$rule = $this->store->depositRule($rule)
?? throw new \RuntimeException('Failed to persist firewall rule.');
$this->cache->invalidate();
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $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(FirewallRuleExtendedEvent::class, $rule);
return $rule;
}
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
$rule = $this->store->fetchRule($ruleId);
return $rule && $scope->owns($rule) ? $rule : null;
}
/**
* @param class-string<FirewallRuleEvent> $eventClass
*/
private function publishLifecycleEvent(
string $eventClass,
FirewallRuleObject $rule,
?string $actorId = null,
array $change = []
): void
{
$event = $eventClass::fromRule($rule, $actorId, $change);
$this->events->dispatch($event);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
final class FirewallRuleScope
{
private function __construct(
public readonly string $scope,
public readonly ?string $tenantId,
) {
}
public static function tenant(string $tenantId): self
{
if ($tenantId === '') {
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
}
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
}
public static function system(): self
{
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
}
public function owns(FirewallRuleObject $rule): bool
{
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
final class FirewallRuleValidator
{
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
private function __construct()
{
}
public static function ipAddress(string $ipAddress): string
{
$ipAddress = trim($ipAddress);
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
}
return $ipAddress;
}
public static function cidr(string $cidr): string
{
$cidr = trim($cidr);
if (substr_count($cidr, '/') !== 1) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
[$address, $prefix] = explode('/', $cidr, 2);
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
if ((int)$prefix > $maximumPrefix) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
return $cidr;
}
public static function deviceFingerprint(string $fingerprint): string
{
$fingerprint = trim($fingerprint);
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
throw new \InvalidArgumentException(
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
);
}
return $fingerprint;
}
public static function duration(?int $durationSeconds): void
{
if ($durationSeconds !== null && $durationSeconds < 1) {
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
}
}
}
+196 -359
View File
@@ -5,12 +5,26 @@ declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore;
use KTXC\Context\TenantContextInterface;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Security\Event\SecurityEventInterface;
use KTXC\Security\Event\SecurityRequestEventInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
/**
@@ -29,6 +43,9 @@ class FirewallService
private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
private const MAX_AUTH_FAILURES = 1000;
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
// Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
@@ -36,13 +53,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,
private readonly RequestContext $requestContext,
) {
}
@@ -71,36 +88,33 @@ class FirewallService
string $ipAddress,
?string $deviceFingerprint = null
): FirewallAnalyzeResult {
// Check if firewall is enabled for this tenant
if (!$this->isEnabled()) {
return new FirewallAnalyzeResult(true);
}
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
$ruleGroups = [
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK],
];
if ($tenantId !== null && $this->isEnabled()) {
$tenantRules = $this->ruleCache->tenant($tenantId);
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
}
$rules = $this->getActiveRules();
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
// First check for explicit allow rules (whitelist takes precedence)
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
}
foreach ($ruleGroups as [$rules, $action]) {
foreach ($rules as $rule) {
if ($rule->getAction() !== $action) {
continue;
}
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
continue;
}
if ($action === FirewallRuleObject::ACTION_ALLOW) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
// Then check for block rules
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
}
@@ -131,23 +145,31 @@ class FirewallService
/**
* Handle authentication failure event
*/
public function handleAuthFailure(SecurityEvent $event): void
public function handleAuthFailure(AuthenticationFailedEvent $event): void
{
$ipAddress = $event->getIpAddress();
$request = $this->requestContext->current();
$ipAddress = $request?->getClientIp();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) {
return;
}
$log = $this->securityLog($event, $request);
if ($log === null || !$this->store->createLogOnce($log)) {
return;
}
// Check for brute force
$windowSeconds = $this->getConfig(
$windowSeconds = $this->getBoundedIntegerConfig(
self::CONFIG_FAILURE_WINDOW,
self::DEFAULT_AUTH_FAILURE_WINDOW
self::DEFAULT_AUTH_FAILURE_WINDOW,
self::MAX_AUTH_FAILURE_WINDOW
);
$maxFailures = $this->getConfig(
$maxFailures = $this->getBoundedIntegerConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
);
$failureCount = $this->store->countRecentFailures(
@@ -156,11 +178,24 @@ class FirewallService
$windowSeconds
);
// Include current failure in count
$failureCount++;
if ($failureCount >= $maxFailures) {
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
$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;
}
$this->handleBruteForce(
$tenantId,
$ipAddress,
$failureCount,
$windowSeconds,
$blockDuration
);
}
}
@@ -168,53 +203,91 @@ class FirewallService
* Handle detected brute force attack
*/
private function handleBruteForce(
string $tenantId,
string $ipAddress,
int $failureCount,
int $windowSeconds
int $windowSeconds,
int $blockDuration
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenantContext->identifier());
$event = new BruteForceDetectedEvent(
$ipAddress,
$failureCount,
$windowSeconds,
$tenantId,
);
$this->events->dispatch($event);
// Auto-block the IP
$blockDuration = $this->getConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION
);
$this->blockIp(
$this->rules->blockIp(
FirewallRuleScope::tenant($tenantId),
$ipAddress,
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
null, // System-created
$blockDuration
$blockDuration,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => $this->getBoundedIntegerConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
),
'failureWindowSeconds' => $windowSeconds,
'lastFailureCount' => $failureCount,
'blockDurationSeconds' => $blockDuration,
]
);
}
/**
* Log security event to firewall logs
*/
public function logSecurityEvent(SecurityEvent $event): void
public function logSecurityEvent(SecurityEventInterface $event): void
{
$log = $this->securityLog($event);
if ($log !== null) {
$this->store->createLog($log);
}
}
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
{
$log = $this->securityLog($event, $this->requestContext->current());
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog(
SecurityEventInterface $event,
?Request $request = null,
): ?FirewallLogObject
{
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$tenantId) {
return;
$ruleScope = $event->get('ruleScope');
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
return null;
}
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
$log = new FirewallLogObject();
$log->setTenantId($tenantId)
->setIpAddress($event->getIpAddress())
->setDeviceFingerprint($event->getDeviceFingerprint())
->setUserAgent($event->getUserAgent())
->setRequestPath($event->getRequestPath())
->setRequestMethod($event->getRequestMethod())
return $log->setEventId($event->getEventId())
->setTenantId($tenantId)
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
->setDeviceFingerprint(
$request?->headers->get('X-Device-Fingerprint')
?? $requestEvent?->getDeviceFingerprint()
)
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
->setEventType($this->mapEventToLogType($event->getName()))
->setResult($this->mapEventToResult($event->getName()))
->setIdentityId($event->getUserId())
->setResult($this->mapEventToResult($event))
->setRuleId($event->get('ruleId'))
->setRuleScope($ruleScope)
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
->setTimestamp(new \DateTimeImmutable())
->setMetadata($event->getData());
$this->store->createLog($log);
}
/**
@@ -223,12 +296,18 @@ class FirewallService
private function mapEventToLogType(string $eventName): string
{
return match ($eventName) {
SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK,
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE,
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
@@ -236,11 +315,16 @@ class FirewallService
/**
* Map security event to result
*/
private function mapEventToResult(string $eventName): string
private function mapEventToResult(SecurityEventInterface $event): string
{
return match ($eventName) {
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
return match ($event->getName()) {
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
@@ -253,272 +337,17 @@ class FirewallService
?string $deviceFingerprint,
FirewallRuleObject $rule
): void {
$event = SecurityEvent::accessDenied(
$ipAddress,
$deviceFingerprint,
$rule->getId(),
$rule->getReason()
$event = new AccessDeniedEvent(
ipAddress: $ipAddress,
ruleId: $rule->getId(),
ruleScope: $rule->getScope(),
deviceFingerprint: $deviceFingerprint,
reason: $rule->getReason(),
tenantId: $this->tenantContext->identifier(),
);
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event);
}
// ========================================
// Rule Management
// ========================================
/**
* Block an IP address
*/
public function blockIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
// Check if already blocked
$existing = $this->store->findExactIpRule(
$tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK
);
if ($existing) {
return $existing;
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($ipAddress)
->setReason($reason ?? 'Blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Allow an IP address (whitelist)
*/
public function allowIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_ALLOW)
->setValue($ipAddress)
->setReason($reason ?? 'Allowed by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Block an IP range (CIDR notation)
*/
public function blockIpRange(
string $cidr,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP_RANGE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($cidr)
->setReason($reason ?? 'Range blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
return $rule;
}
/**
* Block a device fingerprint
*/
public function blockDevice(
string $fingerprint,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_DEVICE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($fingerprint)
->setReason($reason ?? 'Device blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Remove a rule by ID
*/
public function removeRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$this->store->destroyRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Disable a rule (soft delete)
*/
public function disableRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Get all rules for current tenant
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listRules($tenantId, $activeOnly);
}
/**
* Get firewall logs for current tenant
*/
public function getLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
}
/**
* Get blocked requests count
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return 0;
}
return $this->store->countBlockedRequests($tenantId, $since);
}
// ========================================
// Helpers
// ========================================
/**
* Check if firewall is enabled for current tenant
*/
@@ -533,6 +362,9 @@ class FirewallService
private function getConfig(string $key, mixed $default = null): mixed
{
$config = $this->tenantContext->configuration();
if ($config instanceof \JsonSerializable) {
$config = $config->jsonSerialize();
}
$parts = explode('.', $key);
foreach ($parts as $part) {
@@ -545,27 +377,14 @@ class FirewallService
return $config;
}
/**
* Get active rules (cached)
* @return FirewallRuleObject[]
*/
private function getActiveRules(): array
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
{
if ($this->rulesCache === null) {
$tenantId = $this->tenantContext->identifier();
$this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
$value = $this->getConfig($key, $default);
if (!is_int($value) || $value < 1 || $value > $maximum) {
return $default;
}
return $this->rulesCache;
}
/**
* Clear rules cache
*/
private function clearRulesCache(): void
{
$this->rulesCache = null;
return $value;
}
/**
@@ -573,13 +392,31 @@ class FirewallService
*/
public function cleanup(): array
{
$expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
$startedAt = new \DateTimeImmutable();
return [
'expiredRules' => $expiredRules,
'oldLogs' => $oldLogs,
];
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;
}
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
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 FirewallSettingsUpdatedEvent(
changeReason: $reason,
previous: $previous,
current: $current,
tenantId: $tenantId,
actorId: $actorId,
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
);
$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}."
);
}
}
}
@@ -0,0 +1,63 @@
<?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.');
}
}
}
@@ -0,0 +1,26 @@
<?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);
}
}
@@ -0,0 +1,153 @@
<?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}");
}
}
}
@@ -0,0 +1,59 @@
<?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}");
}
}
}
@@ -0,0 +1,29 @@
<?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);
}
}
@@ -0,0 +1,154 @@
<?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}");
}
}
}
@@ -0,0 +1,66 @@
<?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}");
}
}
}
+389 -21
View File
@@ -5,6 +5,8 @@ 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;
@@ -15,28 +17,147 @@ 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];
$filter = [
'tenantId' => $tenantId,
'scope' => FirewallRuleObject::SCOPE_TENANT,
];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
$filter['$and'] = [[
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
];
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
],
]];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
@@ -50,6 +171,26 @@ class FirewallStore
return $list;
}
public function listSystemRules(bool $activeOnly = true): array
{
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => 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
*/
@@ -61,7 +202,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
]
];
@@ -88,7 +229,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
]
];
@@ -108,7 +249,7 @@ class FirewallStore
*/
public function fetchRule(string $id): ?FirewallRuleObject
{
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
if (!$entry) {
return null;
}
@@ -118,15 +259,33 @@ class FirewallStore
/**
* Check if exact IP rule exists
*/
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
public function findExactIpRule(
?string $tenantId,
string $ipAddress,
string $action,
string $scope = FirewallRuleObject::SCOPE_TENANT
): ?FirewallRuleObject
{
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
'tenantId' => $tenantId,
$filter = [
'type' => FirewallRuleObject::TYPE_IP,
'value' => $ipAddress,
'action' => $action,
'enabled' => true,
]);
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => 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;
@@ -139,6 +298,8 @@ class FirewallStore
*/
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$rule->assertValidScopeOwnership();
if ($rule->getId()) {
return $this->updateRule($rule);
} else {
@@ -148,7 +309,7 @@ class FirewallStore
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$data = $rule->jsonSerialize();
$data = self::ruleDocument($rule);
unset($data['id']); // Remove id for insert
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
@@ -163,11 +324,11 @@ class FirewallStore
return null;
}
$data = $rule->jsonSerialize();
$data = self::ruleDocument($rule);
unset($data['id']);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
['_id' => $id],
self::ruleIdFilter($id),
['$set' => $data]
);
return $rule;
@@ -182,7 +343,7 @@ class FirewallStore
if (!$id) {
return;
}
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
}
/**
@@ -191,8 +352,10 @@ class FirewallStore
public function cleanupExpiredRules(): int
{
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$ne' => null]
'expiresAt' => [
'$lt' => self::bsonDate(new \DateTimeImmutable()),
'$ne' => null,
],
]);
return $result->getDeletedCount();
@@ -202,12 +365,68 @@ 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 = $log->jsonSerialize();
$data = self::logDocument($log);
unset($data['id']);
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
@@ -215,6 +434,30 @@ class FirewallStore
return $log;
}
/**
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
*/
public function createLogOnce(FirewallLogObject $log): bool
{
$eventId = $log->getEventId();
if ($eventId === null || $eventId === '') {
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
}
$data = 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
*/
@@ -270,10 +513,50 @@ class FirewallStore
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
'timestamp' => ['$gte' => self::bsonDate($since)]
]);
}
/**
* 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
*/
@@ -287,12 +570,27 @@ class FirewallStore
];
if ($since !== null) {
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
}
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
*/
@@ -301,9 +599,79 @@ class FirewallStore
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
]);
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
@@ -0,0 +1,2 @@
# 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.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.7.tgz",
"integrity": "sha512-07qxu0S1oxPjmknP/Yn5276PtIhlCvHnpcp7HuOKEHni89/6BGiM2aVdKlEGeyM5RfytDkiv0SNieabGT2D8qQ==",
"license": "MIT",
"funding": {
"type": "github",
+27 -44
View File
@@ -10,17 +10,21 @@ namespace KTXF\Event;
class Event
{
private bool $propagationStopped = false;
private array $data = [];
private float $timestamp;
private ?string $tenantId = null;
private ?string $identityId = null;
private readonly array $data;
private readonly float $timestamp;
private readonly string $eventId;
public function __construct(
private readonly string $name,
array $data = []
array $data = [],
private readonly ?string $tenantId = null,
private readonly ?string $identityId = null,
) {
self::validateData($data);
$this->data = $data;
$this->timestamp = microtime(true);
$this->eventId = bin2hex(random_bytes(16));
}
/**
@@ -39,15 +43,6 @@ class Event
return $this->data[$key] ?? $default;
}
/**
* Set a data value
*/
public function set(string $key, mixed $value): self
{
$this->data[$key] = $value;
return $this;
}
/**
* Check if a data key exists
*/
@@ -80,6 +75,11 @@ class Event
return $this->timestamp;
}
public function getEventId(): string
{
return $this->eventId;
}
/**
* Stop event propagation to subsequent listeners
*/
@@ -104,15 +104,6 @@ class Event
return $this->tenantId;
}
/**
* Set tenant ID for multi-tenant context
*/
public function setTenantId(?string $tenantId): self
{
$this->tenantId = $tenantId;
return $this;
}
/**
* Get identity ID (user who triggered the event)
*/
@@ -121,26 +112,18 @@ class Event
return $this->identityId;
}
/**
* Set identity ID
*/
public function setIdentityId(?string $identityId): self
private static function validateData(array $data): void
{
$this->identityId = $identityId;
return $this;
foreach ($data as $value) {
if (is_array($value)) {
self::validateData($value);
continue;
}
if ($value !== null && !is_scalar($value)) {
throw new \InvalidArgumentException(
'Event data must contain only scalar, null, or array values.',
);
}
}
}
/**
* Convert event to array for serialization/logging
*/
public function toArray(): array
{
return [
'name' => $this->name,
'data' => $this->data,
'timestamp' => $this->timestamp,
'tenantId' => $this->tenantId,
'identityId' => $this->identityId,
];
}
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
interface EventListenerRegistrarInterface
{
/**
* @param class-string $service
*/
public function listen(
string $module,
string $event,
string $service,
string $method,
DeliveryMode $delivery = DeliveryMode::Immediate,
int $priority = 0,
FailurePolicy $failurePolicy = FailurePolicy::Continue,
): void;
}
-303
View File
@@ -1,303 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
/**
* Security-specific event for authentication and access control events
*/
class SecurityEvent extends Event
{
// Event names
public const AUTH_SUCCESS = 'security.auth.success';
public const AUTH_FAILURE = 'security.auth.failure';
public const AUTH_LOGOUT = 'security.auth.logout';
public const TOKEN_REFRESH = 'security.token.refresh';
public const TOKEN_REVOKED = 'security.token.revoked';
public const ACCESS_DENIED = 'security.access.denied';
public const ACCESS_GRANTED = 'security.access.granted';
public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected';
public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded';
public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity';
public const IP_BLOCKED = 'security.ip.blocked';
public const IP_ALLOWED = 'security.ip.allowed';
public const DEVICE_BLOCKED = 'security.device.blocked';
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
private ?string $userAgent = null;
private ?string $requestPath = null;
private ?string $requestMethod = null;
private ?string $userId = null;
private ?string $reason = null;
private int $severity = self::SEVERITY_INFO;
// Severity levels
public const SEVERITY_DEBUG = 0;
public const SEVERITY_INFO = 1;
public const SEVERITY_WARNING = 2;
public const SEVERITY_ERROR = 3;
public const SEVERITY_CRITICAL = 4;
/**
* Create a security event with common parameters
*/
public static function create(
string $name,
?string $ipAddress = null,
?string $deviceFingerprint = null,
array $data = []
): self {
$event = new self($name, $data);
$event->ipAddress = $ipAddress;
$event->deviceFingerprint = $deviceFingerprint;
// Set default severity based on event type
$event->severity = self::getSeverityForEvent($name);
return $event;
}
/**
* Create an authentication failure event
*/
public static function authFailure(
string $ipAddress,
?string $deviceFingerprint = null,
?string $userId = null,
?string $reason = null
): self {
$event = self::create(self::AUTH_FAILURE, $ipAddress, $deviceFingerprint, [
'userId' => $userId,
'reason' => $reason,
]);
$event->userId = $userId;
$event->reason = $reason;
return $event;
}
/**
* Create an authentication success event
*/
public static function authSuccess(
string $ipAddress,
?string $deviceFingerprint = null,
string $userId = null
): self {
$event = self::create(self::AUTH_SUCCESS, $ipAddress, $deviceFingerprint, [
'userId' => $userId,
]);
$event->userId = $userId;
return $event;
}
/**
* Create a brute force detection event
*/
public static function bruteForceDetected(
string $ipAddress,
int $failureCount,
int $windowSeconds
): self {
$event = self::create(self::BRUTE_FORCE_DETECTED, $ipAddress, null, [
'failureCount' => $failureCount,
'windowSeconds' => $windowSeconds,
]);
$event->reason = sprintf(
'%d failed attempts in %d seconds',
$failureCount,
$windowSeconds
);
return $event;
}
/**
* Create a rate limit exceeded event
*/
public static function rateLimitExceeded(
string $ipAddress,
int $requestCount,
int $windowSeconds,
?string $endpoint = null
): self {
$event = self::create(self::RATE_LIMIT_EXCEEDED, $ipAddress, null, [
'requestCount' => $requestCount,
'windowSeconds' => $windowSeconds,
'endpoint' => $endpoint,
]);
$event->requestPath = $endpoint;
$event->reason = sprintf(
'%d requests in %d seconds',
$requestCount,
$windowSeconds
);
return $event;
}
/**
* Create an access denied event
*/
public static function accessDenied(
string $ipAddress,
?string $deviceFingerprint = null,
?string $ruleId = null,
?string $reason = null
): self {
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
'ruleId' => $ruleId,
'reason' => $reason,
]);
$event->reason = $reason;
return $event;
}
/**
* Get default severity for event types
*/
private static function getSeverityForEvent(string $eventName): int
{
return match ($eventName) {
self::AUTH_SUCCESS,
self::ACCESS_GRANTED,
self::TOKEN_REFRESH => self::SEVERITY_INFO,
self::AUTH_FAILURE,
self::ACCESS_DENIED,
self::AUTH_LOGOUT,
self::TOKEN_REVOKED => self::SEVERITY_WARNING,
self::RATE_LIMIT_EXCEEDED,
self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR,
self::BRUTE_FORCE_DETECTED,
self::IP_BLOCKED,
self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL,
default => self::SEVERITY_INFO,
};
}
// Getters and setters
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function setDeviceFingerprint(?string $deviceFingerprint): self
{
$this->deviceFingerprint = $deviceFingerprint;
return $this;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function setUserAgent(?string $userAgent): self
{
$this->userAgent = $userAgent;
return $this;
}
public function getRequestPath(): ?string
{
return $this->requestPath;
}
public function setRequestPath(?string $requestPath): self
{
$this->requestPath = $requestPath;
return $this;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function setRequestMethod(?string $requestMethod): self
{
$this->requestMethod = $requestMethod;
return $this;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function setUserId(?string $userId): self
{
$this->userId = $userId;
return $this;
}
public function getReason(): ?string
{
return $this->reason;
}
public function setReason(?string $reason): self
{
$this->reason = $reason;
return $this;
}
public function getSeverity(): int
{
return $this->severity;
}
public function setSeverity(int $severity): self
{
$this->severity = $severity;
return $this;
}
public function getSeverityLabel(): string
{
return match ($this->severity) {
self::SEVERITY_DEBUG => 'DEBUG',
self::SEVERITY_INFO => 'INFO',
self::SEVERITY_WARNING => 'WARNING',
self::SEVERITY_ERROR => 'ERROR',
self::SEVERITY_CRITICAL => 'CRITICAL',
default => 'UNKNOWN',
};
}
/**
* Override toArray to include security-specific fields
*/
public function toArray(): array
{
return array_merge(parent::toArray(), [
'ipAddress' => $this->ipAddress,
'deviceFingerprint' => $this->deviceFingerprint,
'userAgent' => $this->userAgent,
'requestPath' => $this->requestPath,
'requestMethod' => $this->requestMethod,
'userId' => $this->userId,
'reason' => $this->reason,
'severity' => $this->severity,
'severityLabel' => $this->getSeverityLabel(),
]);
}
}
+2
View File
@@ -95,6 +95,7 @@ class IpUtils
if ($netmask < 0 || $netmask > 32) {
return self::setCacheResult($cacheKey, false);
}
$netmask = (int)$netmask;
} else {
$address = $ip;
$netmask = 32;
@@ -149,6 +150,7 @@ 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);
@@ -0,0 +1,73 @@
<?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());
}
}
@@ -0,0 +1,649 @@
<?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));
}
}
+3 -3
View File
@@ -17,9 +17,9 @@ use KTXC\Service\TenantService;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\DeferredProcessingResult;
use KTXF\Event\EventListenerRegistry;
use KTXC\Event\DeferredEventProcessorInterface;
use KTXC\Event\DeferredProcessingResult;
use KTXC\Event\EventListenerRegistry;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
@@ -0,0 +1,56 @@
<?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());
}
}
@@ -0,0 +1,202 @@
<?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);
}
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class AccessDeniedEventTest extends TestCase
{
#[Test]
#[TestDox('Access-denial state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new AccessDeniedEvent(
'203.0.113.10',
'rule-a',
FirewallRuleObject::SCOPE_TENANT,
'device-a',
'Blocked by policy',
'tenant-a',
'identity-a',
);
self::assertSame(AccessDeniedEvent::class, $event->getName());
self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame('device-a', $event->getDeviceFingerprint());
self::assertSame('rule-a', $event->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
self::assertSame('Blocked by policy', $event->getReason());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
}
#[Test]
#[TestDox('Access denial rejects incomplete rule context')]
public function rejectsIncompleteRuleContext(): void
{
foreach ([
['', 'rule-a', FirewallRuleObject::SCOPE_TENANT],
['203.0.113.10', '', FirewallRuleObject::SCOPE_TENANT],
['203.0.113.10', 'rule-a', 'unknown'],
] as $arguments) {
try {
new AccessDeniedEvent(...$arguments);
self::fail('Incomplete access-denial context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SecurityRequestEventInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class AuthenticationFailedEventTest extends TestCase
{
#[Test]
#[TestDox('Authentication failure state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new AuthenticationFailedEvent(
'user-a',
'Invalid credentials',
'tenant-a',
'identity-a',
);
self::assertSame(AuthenticationFailedEvent::class, $event->getName());
self::assertSame('user-a', $event->getUserId());
self::assertSame('Invalid credentials', $event->getReason());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SecurityRequestEventInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class AuthenticationSucceededEventTest extends TestCase
{
#[Test]
#[TestDox('Successful-authentication state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new AuthenticationSucceededEvent(
'user-a',
'tenant-a',
);
self::assertSame(AuthenticationSucceededEvent::class, $event->getName());
self::assertSame('user-a', $event->getUserId());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame(['userId' => 'user-a'], $event->getData());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
}
#[Test]
#[TestDox('Successful authentication requires a user ID')]
public function rejectsIncompleteAuthenticationContext(): void
{
$this->expectException(\InvalidArgumentException::class);
new AuthenticationSucceededEvent('');
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class BruteForceDetectedEventTest extends TestCase
{
#[Test]
#[TestDox('Brute-force detection state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new BruteForceDetectedEvent(
'203.0.113.10',
5,
300,
'tenant-a',
);
self::assertSame(BruteForceDetectedEvent::class, $event->getName());
self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame(5, $event->getFailureCount());
self::assertSame(300, $event->getWindowSeconds());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('5 failed attempts in 300 seconds', $event->getReason());
self::assertSame(
['failureCount' => 5, 'windowSeconds' => 300],
$event->getData(),
);
self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity());
}
#[Test]
#[TestDox('Brute-force detection rejects invalid measurements')]
public function rejectsInvalidMeasurements(): void
{
foreach ([
['', 5, 300],
['203.0.113.10', 0, 300],
['203.0.113.10', 5, 0],
] as $arguments) {
try {
new BruteForceDetectedEvent(...$arguments);
self::fail('Invalid brute-force measurements were accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
+39 -2
View File
@@ -6,8 +6,8 @@ namespace KTXT\Unit\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventListenerRegistry;
use KTXC\Event\EventDispatcher;
use KTXC\Event\EventListenerRegistry;
use KTXF\Event\FailurePolicy;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
@@ -167,6 +167,43 @@ final class EventDispatcherTest extends TestCase
$dispatcher->dispatch(new Event('test.event'));
}
#[Test]
#[TestDox('Propagated deferred failures close their execution scope')]
public function recoversFromDeferredFailure(): void
{
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
FailingListener::class,
'fail',
DeliveryMode::Deferred,
failurePolicy: FailurePolicy::Propagate,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([FailingListener::class => new FailingListener()]),
new NullLogger(),
);
$dispatcher->beginExecution('failed');
$dispatcher->dispatch(new Event('test.event'));
try {
$dispatcher->processDeferred('failed');
self::fail('Expected deferred listener failure to propagate.');
} catch (\RuntimeException $error) {
self::assertSame('Listener failed.', $error->getMessage());
}
$dispatcher->beginExecution('next');
$result = $dispatcher->processDeferred('next');
self::assertSame(0, $result->processed);
self::assertSame(0, $result->remaining);
}
#[Test]
#[TestDox('Deferred processing stops at its configured count limit')]
public function boundsDeferredWork(): void
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXF\Event\Event;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class EventTest extends TestCase
{
#[Test]
#[TestDox('Event data and context are supplied during construction')]
public function constructsCompleteEventState(): void
{
$event = new Event(
'test.event',
['nested' => ['value' => 'original']],
'tenant-a',
'identity-a',
);
$copy = $event->getData();
$copy['nested']['value'] = 'changed';
self::assertSame('original', $event->get('nested')['value']);
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
}
#[Test]
#[TestDox('Event data rejects mutable object references')]
public function rejectsMutablePayloadValues(): void
{
$this->expectException(\InvalidArgumentException::class);
new Event('test.event', ['mutable' => new \stdClass()]);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallPolicyEventTest extends TestCase
{
#[Test]
#[TestDox('IP and device policy events expose typed immutable state')]
public function constructsSubjectPolicyEvents(): void
{
$blocked = new IpBlockedEvent('203.0.113.10', 'Repeated abuse', 'tenant-a');
$allowed = new IpAllowedEvent('203.0.113.11', 'Trusted service', 'tenant-a');
$device = new DeviceBlockedEvent('device-a', 'Compromised device', 'tenant-a');
self::assertSame(IpBlockedEvent::class, $blocked->getName());
self::assertSame('203.0.113.10', $blocked->getIpAddress());
self::assertSame('Repeated abuse', $blocked->getReason());
self::assertSame(SecurityEventSeverity::CRITICAL, $blocked->getSeverity());
self::assertSame(IpAllowedEvent::class, $allowed->getName());
self::assertSame('203.0.113.11', $allowed->getIpAddress());
self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity());
self::assertSame(DeviceBlockedEvent::class, $device->getName());
self::assertSame('device-a', $device->getDeviceFingerprint());
self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity());
}
#[Test]
#[TestDox('Firewall settings events capture the complete configuration transition')]
public function constructsSettingsEvent(): void
{
$event = new FirewallSettingsUpdatedEvent(
'Tighten controls',
['maxAuthFailures' => 5],
['maxAuthFailures' => 3],
'tenant-a',
'operator-a',
);
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->getName());
self::assertSame('Tighten controls', $event->getChangeReason());
self::assertSame(['maxAuthFailures' => 5], $event->getPrevious());
self::assertSame(['maxAuthFailures' => 3], $event->getCurrent());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('operator-a', $event->getIdentityId());
self::assertSame('manual', $event->getChangeOrigin());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
}
#[Test]
#[TestDox('Firewall policy events reject missing subject and ownership context')]
public function rejectsIncompletePolicyContext(): void
{
foreach ([
static fn() => new IpBlockedEvent(''),
static fn() => new IpAllowedEvent(''),
static fn() => new DeviceBlockedEvent(''),
static fn() => new FirewallSettingsUpdatedEvent('', [], [], 'tenant-a'),
static fn() => new FirewallSettingsUpdatedEvent('Reason', [], [], ''),
] as $construction) {
try {
$construction();
self::fail('Incomplete firewall policy context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallRuleEventTest extends TestCase
{
#[Test]
#[TestDox('Firewall rule events capture immutable rule snapshots')]
public function capturesRuleSnapshot(): void
{
$expiresAt = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-a')
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setReason('Repeated abuse')
->setCreatedBy('creator-a')
->setExpiresAt($expiresAt)
->setMetadata(['origin' => 'automatic', 'failureCount' => 5]);
$event = FirewallRuleExtendedEvent::fromRule(
$rule,
'operator-a',
['changeReason' => 'Continue monitoring'],
);
$rule->setReason('Mutated after publication');
self::assertSame(FirewallRuleExtendedEvent::class, $event->getName());
self::assertSame('rule-a', $event->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $event->getRuleAction());
self::assertSame('203.0.113.10', $event->getRuleValue());
self::assertSame('Repeated abuse', $event->getReason());
self::assertSame('automatic', $event->getOrigin());
self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('operator-a', $event->getIdentityId());
self::assertSame(5, $event->get('failureCount'));
self::assertSame('Continue monitoring', $event->get('changeReason'));
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
}
#[Test]
#[TestDox('Each firewall rule lifecycle operation has a dedicated event key')]
public function exposesDedicatedLifecycleKeys(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-a')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10');
foreach ([
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
] as $eventClass) {
self::assertSame($eventClass, $eventClass::fromRule($rule)->getName());
}
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class RateLimitExceededEventTest extends TestCase
{
#[Test]
#[TestDox('Rate-limit state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new RateLimitExceededEvent(
'203.0.113.10',
101,
60,
'/login',
'tenant-a',
);
self::assertSame(RateLimitExceededEvent::class, $event->getName());
self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame(101, $event->getRequestCount());
self::assertSame(60, $event->getWindowSeconds());
self::assertSame('/login', $event->getEndpoint());
self::assertSame('/login', $event->getRequestPath());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('101 requests in 60 seconds', $event->getReason());
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
}
#[Test]
#[TestDox('Rate-limit detection rejects invalid measurements')]
public function rejectsInvalidMeasurements(): void
{
foreach ([
['', 101, 60, '/login'],
['203.0.113.10', 0, 60, '/login'],
['203.0.113.10', 101, 0, '/login'],
['203.0.113.10', 101, 60, ''],
] as $arguments) {
try {
new RateLimitExceededEvent(...$arguments);
self::fail('Invalid rate-limit measurements were accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class SecurityEventSeverityTest extends TestCase
{
#[Test]
#[TestDox('Security severity levels expose stable numeric values')]
public function exposesStableNumericValues(): void
{
self::assertSame(0, SecurityEventSeverity::DEBUG->value);
self::assertSame(1, SecurityEventSeverity::INFO->value);
self::assertSame(2, SecurityEventSeverity::WARNING->value);
self::assertSame(3, SecurityEventSeverity::ERROR->value);
self::assertSame(4, SecurityEventSeverity::CRITICAL->value);
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SuspiciousActivityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class SuspiciousActivityEventTest extends TestCase
{
#[Test]
#[TestDox('Suspicious-activity state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new SuspiciousActivityEvent(
ipAddress: '203.0.113.20',
detector: 'payload-signature',
detectionData: ['score' => 98],
tenantId: 'tenant-a',
identityId: 'identity-a',
deviceFingerprint: 'device-a',
userAgent: 'Test Agent',
requestPath: '/admin',
requestMethod: 'POST',
userId: 'user-a',
reason: 'Matched a blocked payload signature',
);
self::assertSame(SuspiciousActivityEvent::class, $event->getName());
self::assertSame('203.0.113.20', $event->getIpAddress());
self::assertSame('payload-signature', $event->getDetector());
self::assertSame(['score' => 98], $event->getDetectionData());
self::assertSame(['detector' => 'payload-signature', 'score' => 98], $event->getData());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
self::assertSame('device-a', $event->getDeviceFingerprint());
self::assertSame('Test Agent', $event->getUserAgent());
self::assertSame('/admin', $event->getRequestPath());
self::assertSame('POST', $event->getRequestMethod());
self::assertSame('user-a', $event->getUserId());
self::assertSame('Matched a blocked payload signature', $event->getReason());
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
}
#[Test]
#[TestDox('Suspicious activity rejects incomplete or conflicting detection context')]
public function rejectsInvalidDetectionContext(): void
{
foreach ([
['', 'payload-signature', []],
['203.0.113.20', '', []],
['203.0.113.20', 'payload-signature', ['detector' => 'replacement']],
] as $arguments) {
try {
new SuspiciousActivityEvent(...$arguments);
self::fail('Invalid suspicious-activity context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Models\Firewall;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Firewall\FirewallRuleObject;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class FirewallLogObjectTest extends TestCase
{
#[TestDox('Rule ID and scope survive firewall log serialization')]
public function testRuleContextSerialization(): void
{
$log = (new FirewallLogObject())
->setEventId('event-123')
->setRuleId('rule-123')
->setRuleScope(FirewallRuleObject::SCOPE_SYSTEM);
$restored = (new FirewallLogObject())->jsonDeserialize($log->jsonSerialize());
self::assertSame('rule-123', $restored->getRuleId());
self::assertSame('event-123', $restored->getEventId());
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $restored->getRuleScope());
}
#[TestDox('Unknown rule scopes are rejected from firewall logs')]
public function testRuleScopeValidation(): void
{
$this->expectException(\InvalidArgumentException::class);
(new FirewallLogObject())->setRuleScope('unknown');
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Models\Firewall;
use KTXC\Models\Firewall\FirewallRuleObject;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class FirewallRuleObjectTest extends TestCase
{
#[TestDox('Persisted rules without an explicit scope are rejected')]
public function testMissingScope(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('explicit scope');
(new FirewallRuleObject())->jsonDeserialize([
'tenantId' => 'tenant-a',
'type' => FirewallRuleObject::TYPE_IP,
'action' => FirewallRuleObject::ACTION_BLOCK,
'value' => '203.0.113.10',
]);
}
#[TestDox('Unknown rule scopes are rejected')]
public function testUnknownScope(): void
{
$this->expectException(\InvalidArgumentException::class);
(new FirewallRuleObject())->setScope('unknown');
}
#[TestDox('Tenant rules require a tenant ID')]
public function testTenantOwnership(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('require a tenant ID');
(new FirewallRuleObject())->assertValidScopeOwnership();
}
#[TestDox('System rules cannot have a tenant ID')]
public function testSystemOwnership(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('cannot have a tenant ID');
(new FirewallRuleObject())
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setTenantId('tenant-a')
->assertValidScopeOwnership();
}
}
+68 -4
View File
@@ -4,12 +4,31 @@ 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 KTXC\Event\EventListenerRegistry;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
@@ -26,12 +45,37 @@ final class CoreModuleTest extends TestCase
$module->boot();
$definitions = $registry->definitions();
self::assertCount(5, $definitions);
self::assertCount(12, $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,
$registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service,
);
self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred));
$successListeners = $registry->listeners(
AuthenticationSucceededEvent::class,
DeliveryMode::Deferred,
);
self::assertCount(1, $successListeners);
self::assertSame(FirewallService::class, $successListeners[0]->service);
self::assertSame('logAuthenticationSuccess', $successListeners[0]->method);
foreach ([
AccessDeniedEvent::class,
BruteForceDetectedEvent::class,
RateLimitExceededEvent::class,
SuspiciousActivityEvent::class,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class,
] 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());
}
@@ -42,5 +86,25 @@ 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);
}
}
+45 -1
View File
@@ -20,6 +20,7 @@ use KTXC\Http\Middleware\RequestHandlerInterface;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedResponse;
use KTXC\KernelInterface;
@@ -78,6 +79,31 @@ final class HttpRuntimeTest extends TestCase
self::assertTrue($kernel->outcome?->successful);
}
#[Test]
#[TestDox('HTTP requests are available only for the active runtime execution')]
public function scopesRequestContext(): void
{
$requestContext = new RequestContext();
$observer = new RequestContextMiddleware($requestContext);
$kernel = new RuntimeKernel(
new RuntimeContainer([
RequestContext::class => $requestContext,
TenantMiddleware::class => new PassMiddleware(),
FirewallMiddleware::class => new PassMiddleware(),
AuthenticationMiddleware::class => new PassMiddleware(),
RouterMiddleware::class => $observer,
]),
new TenantContext($this->createStub(TenantService::class)),
new IdentityContext(),
);
$request = Request::create('/auth/verify', 'POST');
(new HttpRuntime($kernel, false))->run($request, send: false);
self::assertSame($request, $observer->observed);
self::assertNull($requestContext->current());
}
#[Test]
#[TestDox('HTTP exceptions render a response and still terminate')]
public function fails(): void
@@ -198,9 +224,12 @@ final class RuntimeKernel implements KernelInterface
final class RuntimeContainer implements ContainerInterface
{
private readonly array $services;
public function __construct(
private readonly array $services,
array $services,
) {
$this->services = [RequestContext::class => new RequestContext(), ...$services];
}
public function get(string $id): mixed
@@ -250,6 +279,21 @@ final class ResponseMiddleware extends PassMiddleware
}
}
final class RequestContextMiddleware extends PassMiddleware
{
public ?Request $observed = null;
public function __construct(
private readonly RequestContext $requestContext,
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$this->observed = $this->requestContext->current();
return new Response('ok');
}
}
final class ThrowingMiddleware extends PassMiddleware
{
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Security;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\AuthenticationManager;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
use KTXF\Security\Authentication\AuthenticationSession;
use KTXF\Security\Authentication\ProviderContext;
use KTXF\Security\Authentication\ProviderResult;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AuthenticationManagerTest extends TestCase
{
#[Test]
public function completedAuthenticationPublishesAuthenticationSucceededEvent(): void
{
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('identifier')->willReturn('tenant-a');
$tenant->method('configuration')->willReturn(new TenantConfiguration());
$session = new AuthenticationSession(
id: 'auth-session',
state: AuthenticationSession::STATE_IDENTIFIED,
tenantIdentifier: 'tenant-a',
userIdentifier: 'user-a',
userIdentity: 'person@example.com',
methodsAvailable: ['password'],
createdAt: time(),
expiresAt: time() + 300,
);
$cache = $this->createMock(EphemeralCacheInterface::class);
$cache->expects($this->once())
->method('get')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn($session);
$cache->expects($this->once())->method('set')->willReturn(true);
$cache->expects($this->once())
->method('delete')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn(true);
$provider = new SuccessfulAuthenticationProvider();
$providers = $this->createStub(ProviderManager::class);
$providers->method('resolve')->willReturn($provider);
$users = $this->createMock(UserAccountsService::class);
$users->expects($this->once())
->method('fetchByIdentifier')
->with('user-a')
->willReturn([
'uid' => 'user-a',
'identity' => 'person@example.com',
'label' => 'Person',
'permissions' => [],
]);
$tokens = $this->createStub(TokenService::class);
$tokens->method('createToken')->willReturn('token');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with(self::callback(static fn($event): bool =>
$event instanceof AuthenticationSucceededEvent
&& $event->getUserId() === 'user-a'
&& $event->getTenantId() === 'tenant-a'
));
$manager = new AuthenticationManager(
$tenant,
$cache,
$providers,
$tokens,
$users,
$events,
);
$response = $manager->handle(AuthenticationRequest::verify(
'auth-session',
'password',
'correct',
));
self::assertTrue($response->isSuccess());
}
#[Test]
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
{
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('configuration')->willReturn(new TenantConfiguration());
$session = new AuthenticationSession(
id: 'auth-session',
state: AuthenticationSession::STATE_IDENTIFIED,
tenantIdentifier: 'tenant-a',
userIdentifier: 'user-a',
userIdentity: 'person@example.com',
methodsAvailable: ['password'],
createdAt: time(),
expiresAt: time() + 300,
);
$cache = $this->createMock(EphemeralCacheInterface::class);
$cache->expects($this->once())
->method('get')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn($session);
$cache->expects($this->once())
->method('set')
->willReturn(true);
$provider = new class implements AuthenticationProviderInterface {
public int $verificationCount = 0;
public function type(): string
{
return 'authentication';
}
public function identifier(): string
{
return 'password';
}
public function label(): string
{
return 'Password';
}
public function description(): string
{
return 'Test provider';
}
public function method(): string
{
return self::METHOD_CREDENTIAL;
}
public function icon(): string
{
return '';
}
public function verify(ProviderContext $context, string $secret): ProviderResult
{
$this->verificationCount++;
return ProviderResult::failed(ProviderResult::ERROR_INVALID_FACTOR);
}
public function beginChallenge(ProviderContext $context): ProviderResult
{
return ProviderResult::failed();
}
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
{
return ProviderResult::failed();
}
public function beginRedirect(
ProviderContext $context,
string $callbackUrl,
?string $returnUrl = null,
): ProviderResult {
return ProviderResult::failed();
}
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
{
return ProviderResult::failed();
}
};
$providers = $this->createMock(ProviderManager::class);
$providers->method('resolve')->with('authentication', 'password')->willReturn($provider);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with(self::callback(static function ($event): bool {
return $event instanceof AuthenticationFailedEvent
&& $event->getUserId() === 'user-a'
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
&& $event->getTenantId() === 'tenant-a';
}));
$manager = new AuthenticationManager(
$tenant,
$cache,
$providers,
$this->createStub(TokenService::class),
$this->createStub(UserAccountsService::class),
$events,
);
$response = $manager->handle(AuthenticationRequest::verify(
'auth-session',
'password',
'incorrect',
));
self::assertTrue($response->isFailed());
self::assertSame(1, $provider->verificationCount);
}
}
final class SuccessfulAuthenticationProvider implements AuthenticationProviderInterface
{
public function type(): string
{
return 'authentication';
}
public function identifier(): string
{
return 'password';
}
public function label(): string
{
return 'Password';
}
public function description(): string
{
return 'Successful test provider';
}
public function method(): string
{
return self::METHOD_CREDENTIAL;
}
public function icon(): string
{
return '';
}
public function verify(ProviderContext $context, string $secret): ProviderResult
{
return ProviderResult::success();
}
public function beginChallenge(ProviderContext $context): ProviderResult
{
return ProviderResult::failed();
}
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
{
return ProviderResult::failed();
}
public function beginRedirect(
ProviderContext $context,
string $callbackUrl,
?string $returnUrl = null,
): ProviderResult {
return ProviderResult::failed();
}
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
{
return ProviderResult::failed();
}
}
@@ -0,0 +1,95 @@
<?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, []);
}
}
@@ -0,0 +1,498 @@
<?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 KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
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')
->willReturnCallback(static function (FirewallRuleObject $rule): FirewallRuleObject {
static $sequence = 0;
return $rule->setId('rule-' . ++$sequence);
});
$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')
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('rule-confirmed')
);
$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')->willReturnCallback(
static fn(FirewallRuleObject $rule): FirewallRuleObject => $rule->setId('rule-cache')
);
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[FirewallRuleCreatedEvent::class];
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'));
self::assertInstanceOf(IpBlockedEvent::class, $events[IpBlockedEvent::class]);
self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress());
}
#[TestDox('IP allowance and device blocking publish dedicated policy events')]
public function testSubjectPolicyEvents(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnCallback(
static function (FirewallRuleObject $rule): FirewallRuleObject {
static $sequence = 0;
return $rule->setId('policy-rule-' . ++$sequence);
}
);
$events = [];
$this->events->expects($this->exactly(4))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[] = $event;
});
$this->manager->allowIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.11',
'Trusted service',
'admin-a',
);
$this->manager->blockDevice(
FirewallRuleScope::tenant('tenant-a'),
'device-a',
'Compromised device',
'admin-a',
);
self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof IpAllowedEvent));
self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof DeviceBlockedEvent));
}
#[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([
FirewallRuleDisabledEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleRemovedEvent::class,
], 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 instanceof FirewallRuleExtendedEvent
&& $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
));
}
}
@@ -0,0 +1,160 @@
<?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'
))
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('tenant-rule')
);
$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
))
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('system-rule')
);
$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);
}
}
@@ -0,0 +1,693 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
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 RequestContext $requestContext;
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->requestContext = new RequestContext();
$this->requestContext->initialize(Request::create(
'/login',
'POST',
server: [
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_USER_AGENT' => 'Test Agent',
'HTTP_X_DEVICE_FINGERPRINT' => 'device-a',
],
));
$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,
$this->requestContext,
);
}
#[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')
->with(self::callback(static function ($event): bool {
return $event instanceof AccessDeniedEvent
&& $event->getIpAddress() === '203.0.113.10'
&& $event->getRuleId() === 'system-block'
&& $event->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
&& $event->getReason() === 'system-block';
}));
$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 = new AccessDeniedEvent(
ipAddress: '203.0.113.10',
ruleId: 'tenant-rule',
ruleScope: FirewallRuleObject::SCOPE_TENANT,
reason: 'Tenant block',
tenantId: '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 = new AccessDeniedEvent(
ipAddress: '203.0.113.10',
ruleId: 'system-rule',
ruleScope: FirewallRuleObject::SCOPE_SYSTEM,
reason: '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(
new AuthenticationFailedEvent()
);
}
#[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 = new RateLimitExceededEvent(
'203.0.113.10',
101,
60,
'/login',
'tenant-a',
);
$this->service->logSecurityEvent($event);
}
#[TestDox('Successful authentication maps to an allowed access log')]
public function testAuthenticationSuccessAudit(): void
{
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getEventType() === FirewallLogObject::EVENT_ACCESS_CHECK
&& $log->getResult() === FirewallLogObject::RESULT_ALLOWED
&& $log->getIpAddress() === '203.0.113.10'
&& $log->getIdentityId() === 'user-a';
}))
->willReturnArgument(0);
$this->service->logAuthenticationSuccess(new AuthenticationSucceededEvent(
'user-a',
'tenant-a',
));
}
#[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 = new SuspiciousActivityEvent(
ipAddress: '203.0.113.20',
detector: 'payload-signature',
tenantId: 'tenant-a',
requestPath: '/admin',
requestMethod: '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 = FirewallRuleDisabledEvent::fromRule(
(new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]),
'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 FirewallSettingsUpdatedEvent(
changeReason: 'Tighten controls',
previous: ['maxAuthFailures' => 5],
current: ['maxAuthFailures' => 3],
tenantId: 'tenant-a',
actorId: '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 = new AuthenticationFailedEvent(tenantId: 'tenant-a');
$this->service->handleAuthFailure($event);
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
}
#[TestDox('Authentication failures combine event facts with the current request')]
public function testAuthenticationFailureRequestContext(): void
{
$this->store->expects($this->once())
->method('createLogOnce')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getIpAddress() === '203.0.113.10'
&& $log->getDeviceFingerprint() === 'device-a'
&& $log->getUserAgent() === 'Test Agent'
&& $log->getRequestPath() === '/login'
&& $log->getRequestMethod() === 'POST'
&& $log->getIdentityId() === 'user-a'
&& $log->getMetadata()['reason'] === 'invalid_credentials';
}))
->willReturn(true);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
userId: 'user-a',
reason: 'invalid_credentials',
tenantId: 'tenant-a',
));
}
#[TestDox('Authentication failures outside HTTP request context are ignored')]
public function testAuthenticationFailureWithoutRequestContext(): void
{
$this->requestContext->clear();
$this->store->expects($this->never())->method('createLogOnce');
$this->store->expects($this->never())->method('countRecentFailures');
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
reason: 'invalid_credentials',
tenantId: 'tenant-a',
));
}
#[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 = new AuthenticationFailedEvent(tenantId: '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;
}))
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('automatic-rule')
);
$publishedTenants = [];
$lifecycleOrigin = null;
$bruteForceEvent = null;
$this->events->expects($this->exactly(3))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
&$publishedTenants,
&$lifecycleOrigin,
&$bruteForceEvent,
): void {
$publishedTenants[] = $event->getTenantId();
if ($event instanceof BruteForceDetectedEvent) {
$bruteForceEvent = $event;
}
if ($event instanceof FirewallRuleCreatedEvent) {
$lifecycleOrigin = $event->get('origin');
}
});
$event = new AuthenticationFailedEvent(tenantId: 'tenant-event');
$this->service->handleAuthFailure($event);
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
self::assertInstanceOf(BruteForceDetectedEvent::class, $bruteForceEvent);
self::assertSame('203.0.113.10', $bruteForceEvent->getIpAddress());
self::assertSame(5, $bruteForceEvent->getFailureCount());
self::assertSame(300, $bruteForceEvent->getWindowSeconds());
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(
new AuthenticationFailedEvent()
);
}
#[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(
new AuthenticationFailedEvent()
);
}
#[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(
new AuthenticationFailedEvent()
);
}
#[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 = new AuthenticationFailedEvent();
$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],
]);
}
}
@@ -0,0 +1,86 @@
<?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 KTXC\Security\Event\FirewallSettingsUpdatedEvent;
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(FirewallSettingsUpdatedEvent $event): bool =>
$event->getName() === FirewallSettingsUpdatedEvent::class
&& $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'
));
}
}
@@ -0,0 +1,121 @@
<?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)
);
}
}