Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb657a1f64 | |||
| fb39aa57fd | |||
| d5ca89e160 | |||
| b06c18d38e | |||
| 6700ff145d | |||
| d919b70a2e | |||
| 74696bbeb3 |
@@ -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;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user