7 Commits

Author SHA1 Message Date
Sebastian fb657a1f64 chore(deps): update dependency vuetify to v4.1.7
Build Test / build (pull_request) Successful in 17s
JS Unit Tests / test (pull_request) Successful in 15s
PHP Unit Tests / test (pull_request) Failing after 1m17s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m20s
2026-08-04 03:01:49 +00: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
28 changed files with 2542 additions and 44 deletions
+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);
}
}
}
@@ -23,8 +23,10 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
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;
+36
View File
@@ -5,8 +5,12 @@ namespace KTXC\Module;
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
use KTXC\Console\Firewall\FirewallSetupCommand;
use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
@@ -44,8 +48,10 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) {
$this->events->listen(
'core',
@@ -135,6 +141,21 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'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',
@@ -145,6 +166,21 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'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)',
+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.");
}
}
}
@@ -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);
}
}
+250 -15
View File
@@ -8,9 +8,12 @@ use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils;
final class FirewallRuleManager
{
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
public const MAX_QUERY_LIMIT = 100;
public const ORIGIN_MANUAL = 'manual';
public const ORIGIN_AUTOMATIC = 'automatic';
@@ -28,6 +31,120 @@ final class FirewallRuleManager
: $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,
@@ -144,33 +261,149 @@ final class FirewallRuleManager
return $rule;
}
public function disable(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
public function disableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return false;
return null;
}
if ($rule->isEnabled()) {
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_DISABLED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_DISABLED, $rule, $actorId);
return true;
return $rule;
}
public function remove(FirewallRuleScope $scope, string $ruleId, ?string $actorId = null): bool
{
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 false;
return null;
}
if (
!$confirmCurrentIp
&& $currentIp !== null
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
) {
throw new FirewallRuleConflictException(
'current_ip_confirmation_required',
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
);
}
if (!$rule->isEnabled()) {
$rule->setEnabled(true);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_ENABLED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
return $rule;
}
public function extendManual(
FirewallRuleScope $scope,
string $ruleId,
int $durationSeconds,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
FirewallRuleValidator::duration($durationSeconds);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$previousExpiry = $rule->getExpiresAt();
if ($previousExpiry === null) {
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
}
$now = new \DateTimeImmutable();
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
->modify("+{$durationSeconds} seconds");
$metadata = $rule->getMetadata() ?? [];
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
$extensions[] = [
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
'origin' => self::ORIGIN_MANUAL,
'actorId' => $actorId,
'reason' => $reason,
];
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_EXTENDED,
$rule,
$actorId,
[
'changeReason' => $reason,
'changeOrigin' => self::ORIGIN_MANUAL,
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
]
);
return $rule;
}
public function removeManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$this->store->destroyRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_REMOVED, $rule, $actorId);
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_REMOVED,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
return true;
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(
@@ -274,7 +507,8 @@ final class FirewallRuleManager
private function publishLifecycleEvent(
string $name,
FirewallRuleObject $rule,
?string $actorId = null
?string $actorId = null,
array $change = []
): void
{
$event = new SecurityEvent($name, [
@@ -287,6 +521,7 @@ final class FirewallRuleManager
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
...$change,
]);
$event->setTenantId($rule->getTenantId())
->setIdentityId($actorId ?? $rule->getCreatedBy());
+5 -1
View File
@@ -269,8 +269,10 @@ class FirewallService
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
@@ -285,8 +287,10 @@ class FirewallService
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
final class FirewallSettingsService
{
public function __construct(
private readonly TenantService $tenants,
private readonly EventDispatcherInterface $events,
) {
}
public function update(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason,
?string $actorId
): ?array {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures');
self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window');
self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration');
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
return null;
}
$previous = $tenant->getConfiguration()->firewall()->jsonSerialize();
$current = [
'enabled' => $enabled,
'maxAuthFailures' => $maxAuthFailures,
'authFailureWindow' => $authFailureWindow,
'autoBlockDuration' => $autoBlockDuration,
];
$configuration = (new TenantConfiguration())->jsonDeserialize([
...$tenant->getConfiguration()->jsonSerialize(),
'firewall' => $current,
]);
$tenant->setConfiguration($configuration);
$this->tenants->deposit($tenant);
$event = new SecurityEvent(SecurityEvent::FIREWALL_SETTINGS_UPDATED, [
'changeReason' => $reason,
'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL,
'previous' => $previous,
'current' => $current,
]);
$event->setTenantId($tenantId)->setIdentityId($actorId);
$this->events->dispatch($event);
return $current;
}
private static function bounded(int $value, int $minimum, int $maximum, string $label): void
{
if ($value < $minimum || $value > $maximum) {
throw new \InvalidArgumentException(
"{$label} must be between {$minimum} and {$maximum}."
);
}
}
}
@@ -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);
}
}
+70 -9
View File
@@ -24,6 +24,46 @@ final class SystemFirewallRuleService
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);
@@ -63,23 +103,44 @@ final class SystemFirewallRuleService
);
}
public function disableRule(string $ruleId): bool
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disable(
FirewallRuleScope::system(),
$ruleId,
$this->identity->identifier()
return $this->rules->disableManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId): bool
{
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->remove(
return $this->rules->enableManual(
FirewallRuleScope::system(),
$ruleId,
$this->identity->identifier()
$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()
);
}
@@ -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);
}
}
+75 -4
View File
@@ -26,6 +26,52 @@ final class TenantFirewallRuleService
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);
@@ -57,16 +103,41 @@ final class TenantFirewallRuleService
);
}
public function disableRule(string $ruleId): bool
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disable($this->scope(), $ruleId, $this->identity->identifier());
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
public function removeRule(string $ruleId): bool
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->remove($this->scope(), $ruleId, $this->identity->identifier());
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
@@ -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}");
}
}
}
+138
View File
@@ -46,6 +46,10 @@ class FirewallStore
['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']
@@ -62,6 +66,14 @@ class FirewallStore
['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]
@@ -73,6 +85,61 @@ class FirewallStore
// 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
*/
@@ -298,6 +365,62 @@ 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
*/
@@ -453,6 +576,21 @@ class FirewallStore
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
*/
+2
View File
@@ -28,8 +28,10 @@ class SecurityEvent extends Event
public const DEVICE_BLOCKED = 'security.device.blocked';
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
+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());
}
}
@@ -220,6 +220,48 @@ class FirewallStoreTest extends TestCase
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
{
@@ -452,6 +494,126 @@ class FirewallStoreTest extends TestCase
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,
@@ -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);
}
}
}
+13 -1
View File
@@ -10,6 +10,10 @@ use KTXC\Console\Event\EventsDebugCommand;
use KTXC\Module\Module;
use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallRuleService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
@@ -30,7 +34,7 @@ final class CoreModuleTest extends TestCase
$module->boot();
$definitions = $registry->definitions();
self::assertCount(10, $definitions);
self::assertCount(12, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame(
FirewallService::class,
@@ -43,7 +47,9 @@ final class CoreModuleTest extends TestCase
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) {
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
self::assertCount(1, $listeners);
@@ -74,5 +80,11 @@ final class CoreModuleTest extends TestCase
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);
}
}
@@ -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, []);
}
}
@@ -6,6 +6,7 @@ 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;
@@ -77,6 +78,96 @@ class FirewallRuleManagerTest extends TestCase
);
}
#[TestDox('Manual rule creation requires a reason before persistence')]
public function testManualReason(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
' ',
'admin-a'
);
}
#[TestDox('Blocking the current exact IP requires explicit confirmation')]
public function testCurrentIpSafeguard(): void
{
$this->store->expects(self::never())->method('depositRule');
try {
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'2001:db8::1',
'Confirmed abuse',
'admin-a',
currentIp: '2001:0db8:0:0:0:0:0:1'
);
self::fail('Current IP block should require confirmation.');
} catch (FirewallRuleConflictException $error) {
self::assertSame('current_ip_confirmation_required', $error->conflictCode);
}
}
#[TestDox('CIDR rules covering the current IP require explicit confirmation')]
public function testCurrentIpCidrSafeguard(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(FirewallRuleConflictException::class);
$this->manager->createManualRule(
FirewallRuleScope::system(),
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.0/24',
'Network abuse',
'admin-a',
currentIp: '203.0.113.10'
);
}
#[TestDox('Confirmed current-IP blocks retain manual audit context')]
public function testConfirmedCurrentIpBlock(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
$rule = $this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
'203.0.113.10',
'Emergency lockout',
'admin-a',
currentIp: '203.0.113.10',
confirmCurrentIp: true
);
self::assertSame('Emergency lockout', $rule->getReason());
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $rule->getMetadata()['origin']);
}
#[TestDox('Manual creation rejects unsupported type and action combinations')]
public function testUnsupportedManualRule(): void
{
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_ALLOW,
'device-123',
'Trusted device',
'admin-a'
);
}
#[TestDox('Rule lifecycle operations cannot cross scope ownership')]
public function testOwnership(): void
{
@@ -87,8 +178,65 @@ class FirewallRuleManagerTest extends TestCase
$this->store->method('fetchRule')->willReturn($tenantRule);
$this->store->expects($this->never())->method('destroyRule');
self::assertFalse($this->manager->remove(FirewallRuleScope::system(), 'tenant-rule'));
self::assertFalse($this->manager->remove(FirewallRuleScope::tenant('tenant-b'), 'tenant-rule'));
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')]
@@ -142,30 +290,82 @@ class FirewallRuleManagerTest extends TestCase
self::assertNotNull($audit->get('expiresAt'));
}
#[TestDox('Disable and remove audits identify the acting administrator')]
public function testLifecycleActors(): void
#[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)
->setTenantId(null)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setCreatedBy('creator');
->setEnabled(true)
->setExpiresAt($originalExpiry)
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
$this->store->method('fetchRule')->willReturn($rule);
$events = [];
$this->events->expects($this->exactly(2))
$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 (&$events): void {
$events[$event->getName()] = $event;
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$audits): void {
$audits[] = $event;
});
self::assertTrue($this->manager->disable(FirewallRuleScope::system(), 'rule-123', 'operator'));
self::assertTrue($this->manager->remove(FirewallRuleScope::system(), 'rule-123', 'operator'));
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::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
self::assertCount(1, $extended->getMetadata()['extensions']);
self::assertSame([
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_REMOVED,
], array_map(static fn($event): string => $event->getName(), $audits));
self::assertSame(
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
array_map(static fn($event): string => $event->get('changeReason'), $audits)
);
self::assertSame(['operator'], array_values(array_unique(array_map(
static fn($event): ?string => $event->getIdentityId(),
$audits
))));
}
#[TestDox('Manual lifecycle changes require reasons before reading or writing rules')]
public function testLifecycleReason(): void
{
$this->store->expects(self::never())->method('fetchRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->disableManual(FirewallRuleScope::system(), 'rule-123', ' ', 'operator');
}
#[TestDox('Permanent rules cannot be extended')]
public function testPermanentExtension(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM);
$this->store->method('fetchRule')->willReturn($rule);
$this->store->expects(self::never())->method('depositRule');
$this->expectException(\InvalidArgumentException::class);
$this->manager->extendManual(
FirewallRuleScope::system(), 'rule-123', 300, 'Continue monitoring', 'operator'
);
}
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
@@ -57,6 +57,39 @@ class FirewallRuleServicesTest extends TestCase
$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
{
@@ -89,6 +122,23 @@ class FirewallRuleServicesTest extends TestCase
$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);
@@ -336,6 +336,28 @@ class FirewallServiceTest extends TestCase
$this->service->logSecurityEvent($event);
}
#[TestDox('Settings changes map to recorded tenant audit entries')]
public function testSettingsAudit(): void
{
$this->store->expects(self::once())
->method('createLog')
->with(self::callback(static fn(FirewallLogObject $log): bool =>
$log->getEventType() === FirewallLogObject::EVENT_SETTINGS_UPDATED
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
&& $log->getTenantId() === 'tenant-a'
&& $log->getIdentityId() === 'operator'
&& $log->getMetadata()['changeReason'] === 'Tighten controls'
))
->willReturnArgument(0);
$event = new \KTXF\Event\SecurityEvent(
\KTXF\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
['changeReason' => 'Tighten controls']
);
$event->setTenantId('tenant-a')->setIdentityId('operator');
$this->service->logSecurityEvent($event);
}
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
public function testFirewallConfiguration(): void
{
@@ -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 KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallSettingsServiceTest extends TestCase
{
#[TestDox('Settings updates preserve other tenant configuration and emit complete audit context')]
public function testUpdate(): void
{
$tenant = (new TenantObject())
->setId('507f1f77bcf86cd799439011')
->setIdentifier('tenant-a')
->setConfiguration((new TenantConfiguration())->jsonDeserialize([
'firewall' => ['enabled' => true, 'maxAuthFailures' => 5],
'security' => ['code' => 'preserved'],
]));
$tenants = $this->createMock(TenantService::class);
$tenants->method('fetchById')->with('tenant-a')->willReturn($tenant);
$tenants->expects(self::once())
->method('deposit')
->with(self::callback(static fn(TenantObject $updated): bool =>
$updated->getConfiguration()->firewall()->maxAuthFailures() === 8
&& $updated->getConfiguration()->security()->jsonSerialize()['code'] === 'preserved'
))
->willReturnArgument(0);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::callback(static fn(SecurityEvent $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_SETTINGS_UPDATED
&& $event->getTenantId() === 'tenant-a'
&& $event->getIdentityId() === 'admin-a'
&& $event->get('changeReason') === 'Tighten authentication controls'
&& $event->get('previous')['maxAuthFailures'] === 5
&& $event->get('current')['maxAuthFailures'] === 8
));
$result = (new FirewallSettingsService($tenants, $events))->update(
'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a'
);
self::assertSame([
'enabled' => false,
'maxAuthFailures' => 8,
'authFailureWindow' => 600,
'autoBlockDuration' => 7200,
], $result);
}
#[TestDox('Invalid settings are rejected before tenant reads or persistence')]
public function testValidation(): void
{
$tenants = $this->createMock(TenantService::class);
$tenants->expects(self::never())->method('fetchById');
$tenants->expects(self::never())->method('deposit');
$this->expectException(\InvalidArgumentException::class);
(new FirewallSettingsService(
$tenants,
$this->createStub(EventDispatcherInterface::class)
))->update('tenant-a', true, 0, 300, 3600, 'Invalid threshold', 'admin-a');
}
#[TestDox('Unknown tenants return no configuration without emitting audit events')]
public function testMissingTenant(): void
{
$tenants = $this->createStub(TenantService::class);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
self::assertNull((new FirewallSettingsService($tenants, $events))->update(
'missing', true, 5, 300, 3600, 'Apply defaults', 'admin-a'
));
}
}
@@ -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)
);
}
}