Compare commits
1 Commits
main
..
2c90aec64b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c90aec64b |
@@ -15,9 +15,6 @@ final readonly class TerminationReport
|
|||||||
public array $failures = [],
|
public array $failures = [],
|
||||||
public bool $deadlineExceeded = false,
|
public bool $deadlineExceeded = false,
|
||||||
public bool $limitExceeded = false,
|
public bool $limitExceeded = false,
|
||||||
public int $deferredListenerInvocations = 0,
|
|
||||||
public bool $deferredEventLimitExceeded = false,
|
|
||||||
public bool $deferredListenerInvocationLimitExceeded = false,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\Event;
|
namespace KTXC\Console\Event;
|
||||||
|
|
||||||
use KTXC\Event\EventListenerRegistry;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Console\Firewall;
|
|
||||||
|
|
||||||
use KTXC\Service\FirewallService;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
||||||
|
|
||||||
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
|
|
||||||
final class FirewallMaintenanceCommand extends Command
|
|
||||||
{
|
|
||||||
public function __construct(private readonly FirewallService $firewall)
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
||||||
{
|
|
||||||
$io = new SymfonyStyle($input, $output);
|
|
||||||
try {
|
|
||||||
$result = $this->firewall->cleanup();
|
|
||||||
} catch (\Throwable $error) {
|
|
||||||
$io->error('Firewall maintenance failed: '.$error->getMessage());
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->success(sprintf(
|
|
||||||
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
|
|
||||||
$result['expiredRules'],
|
|
||||||
$result['oldLogs'],
|
|
||||||
$result['expiredBruteForceClaims']
|
|
||||||
));
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Console\Firewall;
|
|
||||||
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
||||||
|
|
||||||
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
|
|
||||||
final class FirewallSetupCommand extends Command
|
|
||||||
{
|
|
||||||
public function __construct(private readonly FirewallStore $store)
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
|
||||||
{
|
|
||||||
$io = new SymfonyStyle($input, $output);
|
|
||||||
try {
|
|
||||||
$indexes = $this->store->ensureIndexes();
|
|
||||||
} catch (\Throwable $error) {
|
|
||||||
$io->error('Firewall database setup failed: '.$error->getMessage());
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
|
|
||||||
return Command::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\Tenant;
|
namespace KTXC\Console\Tenant;
|
||||||
|
|
||||||
use KTXC\Context\TenantContext;
|
|
||||||
use KTXC\Models\Tenant\DomainCollection;
|
use KTXC\Models\Tenant\DomainCollection;
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Service\TenantService;
|
||||||
use KTXC\Service\UserAccountsService;
|
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use KTXC\Stores\UserRolesStore;
|
use KTXC\Stores\UserRolesStore;
|
||||||
use KTXF\Utile\UUID;
|
use KTXF\Utile\UUID;
|
||||||
@@ -37,8 +35,6 @@ class TenantCreateCommand extends Command
|
|||||||
private readonly TenantService $tenantService,
|
private readonly TenantService $tenantService,
|
||||||
private readonly UserRolesStore $rolesStore,
|
private readonly UserRolesStore $rolesStore,
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore,
|
||||||
private readonly UserAccountsService $userService,
|
|
||||||
private readonly TenantContext $tenantContext,
|
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -101,10 +97,6 @@ class TenantCreateCommand extends Command
|
|||||||
$io->error('Failed to create tenant.');
|
$io->error('Failed to create tenant.');
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
if (!$this->tenantContext->resolveIdentifier($identifier)) {
|
|
||||||
throw new \RuntimeException("Failed to initialize tenant context for '{$identifier}'.");
|
|
||||||
}
|
|
||||||
$identifier = $this->tenantContext->requireIdentifier();
|
|
||||||
|
|
||||||
$this->logger->info('Tenant created via console', [
|
$this->logger->info('Tenant created via console', [
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
@@ -142,7 +134,7 @@ class TenantCreateCommand extends Command
|
|||||||
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
|
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
|
||||||
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
|
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
|
||||||
} else {
|
} else {
|
||||||
$this->userService->createUser([
|
$this->userStore->createUser($identifier, [
|
||||||
'identity' => $adminIdentity,
|
'identity' => $adminIdentity,
|
||||||
'label' => 'Administrator',
|
'label' => 'Administrator',
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\User;
|
namespace KTXC\Console\User;
|
||||||
|
|
||||||
use KTXC\Context\TenantContext;
|
use KTXC\Service\TenantService;
|
||||||
use KTXC\Service\UserAccountsService;
|
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use KTXC\Stores\UserRolesStore;
|
use KTXC\Stores\UserRolesStore;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -29,9 +28,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class UserCreateCommand extends Command
|
class UserCreateCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContext $tenantContext,
|
private readonly TenantService $tenantService,
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore,
|
||||||
private readonly UserAccountsService $userService,
|
|
||||||
private readonly UserRolesStore $rolesStore,
|
private readonly UserRolesStore $rolesStore,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
@@ -61,11 +59,11 @@ class UserCreateCommand extends Command
|
|||||||
$io->title('Create User');
|
$io->title('Create User');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!$this->tenantContext->resolveIdentifier($tenant)) {
|
// Ensure the tenant exists
|
||||||
|
if (!$this->tenantService->fetchById($tenant)) {
|
||||||
$io->error("Tenant '{$tenant}' not found.");
|
$io->error("Tenant '{$tenant}' not found.");
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
$tenant = $this->tenantContext->requireIdentifier();
|
|
||||||
|
|
||||||
// Ensure identity is unique within the tenant
|
// Ensure identity is unique within the tenant
|
||||||
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
|
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
|
||||||
@@ -97,7 +95,7 @@ class UserCreateCommand extends Command
|
|||||||
$userData['uid'] = $input->getOption('uid');
|
$userData['uid'] = $input->getOption('uid');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->userService->createUser($userData);
|
$user = $this->userStore->createUser($tenant, $userData);
|
||||||
|
|
||||||
$this->logger->info('User created via console', [
|
$this->logger->info('User created via console', [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\User;
|
namespace KTXC\Console\User;
|
||||||
|
|
||||||
use KTXC\Context\TenantContext;
|
|
||||||
use KTXC\Service\UserAccountsService;
|
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
@@ -28,9 +26,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class UserDeleteCommand extends Command
|
class UserDeleteCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContext $tenantContext,
|
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore,
|
||||||
private readonly UserAccountsService $userService,
|
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -56,12 +52,6 @@ class UserDeleteCommand extends Command
|
|||||||
$io->title('Delete User');
|
$io->title('Delete User');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!$this->tenantContext->resolveIdentifier($tenant)) {
|
|
||||||
$io->error("Tenant '{$tenant}' not found.");
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
$tenant = $this->tenantContext->requireIdentifier();
|
|
||||||
|
|
||||||
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
@@ -74,7 +64,7 @@ class UserDeleteCommand extends Command
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->userService->deleteUser($user['uid'])) {
|
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
|
||||||
$io->error("Failed to delete user '{$identity}'.");
|
$io->error("Failed to delete user '{$identity}'.");
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
$request = AuthenticationRequest::verify($session, $method, $response);
|
$request = AuthenticationRequest::verify($session, $method, $response);
|
||||||
$response = $this->authManager->handle($request);
|
$authResponse = $this->authManager->handle($request);
|
||||||
|
|
||||||
return $this->buildJsonResponse($response);
|
return $this->buildJsonResponse($authResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
$host = $request->getHost();
|
$host = $request->getHost();
|
||||||
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
||||||
|
|
||||||
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||||
$response = $this->authManager->handle($request);
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
return $this->buildJsonResponse($response);
|
return $this->buildJsonResponse($response);
|
||||||
}
|
}
|
||||||
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
return $this->redirectWithError('Missing state parameter');
|
return $this->redirectWithError('Missing state parameter');
|
||||||
}
|
}
|
||||||
|
|
||||||
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
|
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||||
$response = $this->authManager->handle($request);
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
if ($response->isSuccess()) {
|
if ($response->isSuccess()) {
|
||||||
$returnUrl = $response->returnUrl ?? '/';
|
$returnUrl = $response->returnUrl ?? '/';
|
||||||
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request = AuthenticationRequest::status($sessionId);
|
$authRequest = AuthenticationRequest::status($sessionId);
|
||||||
$response = $this->authManager->handle($request);
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
return $this->buildJsonResponse($response);
|
return $this->buildJsonResponse($response);
|
||||||
}
|
}
|
||||||
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
$sessionId = $request->query->get('session', '');
|
$sessionId = $request->query->get('session', '');
|
||||||
|
|
||||||
$request = AuthenticationRequest::cancel($sessionId);
|
$authRequest = AuthenticationRequest::cancel($sessionId);
|
||||||
$this->authManager->handle($request);
|
$this->authManager->handle($authRequest);
|
||||||
|
|
||||||
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request = AuthenticationRequest::refresh($refreshToken);
|
$authRequest = AuthenticationRequest::refresh($refreshToken);
|
||||||
$response = $this->authManager->handle($request);
|
$response = $this->authManager->handle($authRequest);
|
||||||
|
|
||||||
if ($response->isFailed()) {
|
if ($response->isFailed()) {
|
||||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||||
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
$token = $request->cookies->get('accessToken');
|
$token = $request->cookies->get('accessToken');
|
||||||
|
|
||||||
$request = AuthenticationRequest::logout($token, false);
|
$authRequest = AuthenticationRequest::logout($token, false);
|
||||||
$this->authManager->handle($request);
|
$this->authManager->handle($authRequest);
|
||||||
|
|
||||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
||||||
return $this->clearTokenCookies($response);
|
return $this->clearTokenCookies($response);
|
||||||
@@ -274,8 +274,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
$token = $request->cookies->get('accessToken');
|
$token = $request->cookies->get('accessToken');
|
||||||
|
|
||||||
$request = AuthenticationRequest::logout($token, true);
|
$authRequest = AuthenticationRequest::logout($token, true);
|
||||||
$this->authManager->handle($request);
|
$this->authManager->handle($authRequest);
|
||||||
|
|
||||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
||||||
return $this->clearTokenCookies($response);
|
return $this->clearTokenCookies($response);
|
||||||
|
|||||||
@@ -1,465 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Controllers;
|
|
||||||
|
|
||||||
use KTXC\Http\Request\Request;
|
|
||||||
use KTXC\Http\Response\JsonResponse;
|
|
||||||
use KTXC\Service\FirewallRuleConflictException;
|
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
|
||||||
use KTXC\Service\SystemFirewallStatusService;
|
|
||||||
use KTXC\Service\TenantFirewallLogService;
|
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
|
||||||
use KTXC\Service\TenantFirewallStatusService;
|
|
||||||
use KTXF\Controller\ControllerAbstract;
|
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
|
||||||
|
|
||||||
final class FirewallController extends ControllerAbstract
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly TenantFirewallRuleService $tenantRules,
|
|
||||||
private readonly SystemFirewallRuleService $systemRules,
|
|
||||||
private readonly TenantFirewallLogService $tenantLogs,
|
|
||||||
private readonly SystemFirewallLogService $systemLogs,
|
|
||||||
private readonly TenantFirewallStatusService $tenantStatus,
|
|
||||||
private readonly SystemFirewallStatusService $systemStatus,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/rules',
|
|
||||||
name: 'firewall.tenant.rules.list',
|
|
||||||
permissions: [TenantFirewallRuleService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function tenantRules(
|
|
||||||
string $status = 'active',
|
|
||||||
?string $type = null,
|
|
||||||
?string $action = null,
|
|
||||||
string $limit = '50',
|
|
||||||
string $offset = '0'
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->queryResponse(
|
|
||||||
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantRules->queryRules(
|
|
||||||
$status,
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$parsedLimit,
|
|
||||||
$parsedOffset
|
|
||||||
),
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/rules/{ruleId}',
|
|
||||||
name: 'firewall.tenant.rules.fetch',
|
|
||||||
permissions: [TenantFirewallRuleService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function tenantRule(string $ruleId): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->ruleResponse($this->tenantRules->fetchRule($ruleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/effective-policy',
|
|
||||||
name: 'firewall.tenant.policy.effective',
|
|
||||||
permissions: [TenantFirewallRuleService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function effectivePolicy(): JsonResponse
|
|
||||||
{
|
|
||||||
return new JsonResponse($this->tenantRules->effectivePolicy());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/rules',
|
|
||||||
name: 'firewall.system.rules.list',
|
|
||||||
permissions: [SystemFirewallRuleService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function systemRules(
|
|
||||||
string $status = 'active',
|
|
||||||
?string $type = null,
|
|
||||||
?string $action = null,
|
|
||||||
string $limit = '50',
|
|
||||||
string $offset = '0'
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->queryResponse(
|
|
||||||
fn(int $parsedLimit, int $parsedOffset): array => $this->systemRules->queryRules(
|
|
||||||
$status,
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$parsedLimit,
|
|
||||||
$parsedOffset
|
|
||||||
),
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/rules/{ruleId}',
|
|
||||||
name: 'firewall.system.rules.fetch',
|
|
||||||
permissions: [SystemFirewallRuleService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function systemRule(string $ruleId): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/rules',
|
|
||||||
name: 'firewall.tenant.rules.create',
|
|
||||||
methods: ['POST'],
|
|
||||||
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
|
||||||
)]
|
|
||||||
public function createTenantRule(
|
|
||||||
Request $request,
|
|
||||||
string $type,
|
|
||||||
string $action,
|
|
||||||
string $value,
|
|
||||||
string $reason,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->mutationResponse(fn() => $this->tenantRules->createRule(
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$value,
|
|
||||||
$reason,
|
|
||||||
$durationSeconds,
|
|
||||||
$request->getClientIp(),
|
|
||||||
$confirmCurrentIp
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/rules',
|
|
||||||
name: 'firewall.system.rules.create',
|
|
||||||
methods: ['POST'],
|
|
||||||
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
|
||||||
)]
|
|
||||||
public function createSystemRule(
|
|
||||||
Request $request,
|
|
||||||
string $type,
|
|
||||||
string $action,
|
|
||||||
string $value,
|
|
||||||
string $reason,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->mutationResponse(fn() => $this->systemRules->createRule(
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$value,
|
|
||||||
$reason,
|
|
||||||
$durationSeconds,
|
|
||||||
$request->getClientIp(),
|
|
||||||
$confirmCurrentIp
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/rules/{ruleId}',
|
|
||||||
name: 'firewall.tenant.rules.update',
|
|
||||||
methods: ['PATCH'],
|
|
||||||
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
|
||||||
)]
|
|
||||||
public function updateTenantRule(
|
|
||||||
Request $request,
|
|
||||||
string $ruleId,
|
|
||||||
string $operation,
|
|
||||||
string $reason,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->lifecycleResponse(fn() => match ($operation) {
|
|
||||||
'disable' => $this->tenantRules->disableRule($ruleId, $reason),
|
|
||||||
'enable' => $this->tenantRules->enableRule(
|
|
||||||
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
|
|
||||||
),
|
|
||||||
'extend' => $this->tenantRules->extendRule(
|
|
||||||
$ruleId,
|
|
||||||
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
|
|
||||||
$reason
|
|
||||||
),
|
|
||||||
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/rules/{ruleId}',
|
|
||||||
name: 'firewall.system.rules.update',
|
|
||||||
methods: ['PATCH'],
|
|
||||||
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
|
||||||
)]
|
|
||||||
public function updateSystemRule(
|
|
||||||
Request $request,
|
|
||||||
string $ruleId,
|
|
||||||
string $operation,
|
|
||||||
string $reason,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->lifecycleResponse(fn() => match ($operation) {
|
|
||||||
'disable' => $this->systemRules->disableRule($ruleId, $reason),
|
|
||||||
'enable' => $this->systemRules->enableRule(
|
|
||||||
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
|
|
||||||
),
|
|
||||||
'extend' => $this->systemRules->extendRule(
|
|
||||||
$ruleId,
|
|
||||||
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
|
|
||||||
$reason
|
|
||||||
),
|
|
||||||
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/rules/{ruleId}',
|
|
||||||
name: 'firewall.tenant.rules.delete',
|
|
||||||
methods: ['DELETE'],
|
|
||||||
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
|
||||||
)]
|
|
||||||
public function deleteTenantRule(string $ruleId, string $reason): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->lifecycleResponse(fn() => $this->tenantRules->removeRule($ruleId, $reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/rules/{ruleId}',
|
|
||||||
name: 'firewall.system.rules.delete',
|
|
||||||
methods: ['DELETE'],
|
|
||||||
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
|
||||||
)]
|
|
||||||
public function deleteSystemRule(string $ruleId, string $reason): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->lifecycleResponse(fn() => $this->systemRules->removeRule($ruleId, $reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/logs',
|
|
||||||
name: 'firewall.tenant.logs.list',
|
|
||||||
permissions: [TenantFirewallLogService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function tenantLogs(
|
|
||||||
?string $ipAddress = null,
|
|
||||||
?string $eventType = null,
|
|
||||||
?string $result = null,
|
|
||||||
?string $ruleId = null,
|
|
||||||
?string $ruleScope = null,
|
|
||||||
?string $from = null,
|
|
||||||
?string $to = null,
|
|
||||||
string $limit = '50',
|
|
||||||
string $offset = '0'
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->queryResponse(
|
|
||||||
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query(
|
|
||||||
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
|
|
||||||
$parsedLimit,
|
|
||||||
$parsedOffset
|
|
||||||
),
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/logs',
|
|
||||||
name: 'firewall.system.logs.list',
|
|
||||||
permissions: [SystemFirewallLogService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function systemLogs(
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $ipAddress = null,
|
|
||||||
?string $eventType = null,
|
|
||||||
?string $result = null,
|
|
||||||
?string $ruleId = null,
|
|
||||||
?string $ruleScope = null,
|
|
||||||
?string $from = null,
|
|
||||||
?string $to = null,
|
|
||||||
string $limit = '50',
|
|
||||||
string $offset = '0'
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->queryResponse(
|
|
||||||
fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query(
|
|
||||||
$tenantId,
|
|
||||||
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
|
|
||||||
$parsedLimit,
|
|
||||||
$parsedOffset
|
|
||||||
),
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/metrics',
|
|
||||||
name: 'firewall.tenant.metrics.read',
|
|
||||||
permissions: [TenantFirewallLogService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function tenantMetrics(?string $since = null): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->readResponse(fn(): array => $this->tenantStatus->metrics($since));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/configuration',
|
|
||||||
name: 'firewall.tenant.configuration.read',
|
|
||||||
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_READ],
|
|
||||||
)]
|
|
||||||
public function tenantConfiguration(): JsonResponse
|
|
||||||
{
|
|
||||||
return new JsonResponse($this->tenantStatus->configuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/configuration',
|
|
||||||
name: 'firewall.tenant.configuration.update',
|
|
||||||
methods: ['PUT'],
|
|
||||||
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
|
|
||||||
)]
|
|
||||||
public function updateTenantConfiguration(
|
|
||||||
bool $enabled,
|
|
||||||
int $maxAuthFailures,
|
|
||||||
int $authFailureWindow,
|
|
||||||
int $autoBlockDuration,
|
|
||||||
string $reason
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration(
|
|
||||||
$enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/tenants/{tenantId}/configuration',
|
|
||||||
name: 'firewall.system.tenant.configuration.update',
|
|
||||||
methods: ['PUT'],
|
|
||||||
permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
|
|
||||||
)]
|
|
||||||
public function updateSystemTenantConfiguration(
|
|
||||||
string $tenantId,
|
|
||||||
bool $enabled,
|
|
||||||
int $maxAuthFailures,
|
|
||||||
int $authFailureWindow,
|
|
||||||
int $autoBlockDuration,
|
|
||||||
string $reason
|
|
||||||
): JsonResponse {
|
|
||||||
return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration(
|
|
||||||
$tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/metrics',
|
|
||||||
name: 'firewall.system.metrics.read',
|
|
||||||
permissions: [SystemFirewallLogService::PERMISSION_READ],
|
|
||||||
)]
|
|
||||||
public function systemMetrics(?string $tenantId = null, ?string $since = null): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->readResponse(fn(): array => $this->systemStatus->metrics($tenantId, $since));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[AuthenticatedRoute(
|
|
||||||
'/firewall/system/maintenance',
|
|
||||||
name: 'firewall.system.maintenance.read',
|
|
||||||
permissions: [SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ],
|
|
||||||
)]
|
|
||||||
public function maintenanceStatus(): JsonResponse
|
|
||||||
{
|
|
||||||
return new JsonResponse($this->systemStatus->maintenanceStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queryResponse(callable $query, string $limit, string $offset): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if (!ctype_digit($limit) || !ctype_digit($offset)) {
|
|
||||||
throw new \InvalidArgumentException('Pagination values must be non-negative integers.');
|
|
||||||
}
|
|
||||||
return new JsonResponse($query((int)$limit, (int)$offset));
|
|
||||||
} catch (\InvalidArgumentException $error) {
|
|
||||||
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ruleResponse(?\JsonSerializable $rule): JsonResponse
|
|
||||||
{
|
|
||||||
if ($rule === null) {
|
|
||||||
return new JsonResponse(['error' => 'Firewall rule not found.'], JsonResponse::HTTP_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse($rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readResponse(callable $read): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return new JsonResponse($read());
|
|
||||||
} catch (\InvalidArgumentException $error) {
|
|
||||||
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function mutationResponse(callable $mutation): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED);
|
|
||||||
} catch (FirewallRuleConflictException $error) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => $error->conflictCode,
|
|
||||||
'message' => $error->getMessage(),
|
|
||||||
]], JsonResponse::HTTP_CONFLICT);
|
|
||||||
} catch (\InvalidArgumentException $error) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => 'invalid_firewall_rule',
|
|
||||||
'message' => $error->getMessage(),
|
|
||||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function lifecycleResponse(callable $mutation): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$rule = $mutation();
|
|
||||||
if ($rule === null) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => 'firewall_rule_not_found',
|
|
||||||
'message' => 'Firewall rule not found.',
|
|
||||||
]], JsonResponse::HTTP_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(['rule' => $rule]);
|
|
||||||
} catch (FirewallRuleConflictException $error) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => $error->conflictCode,
|
|
||||||
'message' => $error->getMessage(),
|
|
||||||
]], JsonResponse::HTTP_CONFLICT);
|
|
||||||
} catch (\InvalidArgumentException $error) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => 'invalid_firewall_rule',
|
|
||||||
'message' => $error->getMessage(),
|
|
||||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function settingsResponse(callable $mutation): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$configuration = $mutation();
|
|
||||||
if ($configuration === null) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => 'tenant_not_found',
|
|
||||||
'message' => 'Tenant not found.',
|
|
||||||
]], JsonResponse::HTTP_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(['configuration' => $configuration]);
|
|
||||||
} catch (\InvalidArgumentException $error) {
|
|
||||||
return new JsonResponse(['error' => [
|
|
||||||
'code' => 'invalid_firewall_configuration',
|
|
||||||
'message' => $error->getMessage(),
|
|
||||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,6 +66,6 @@ class ObjectId
|
|||||||
*/
|
*/
|
||||||
public static function isValid(string $id): bool
|
public static function isValid(string $id): bool
|
||||||
{
|
{
|
||||||
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
|
return MongoObjectId::isValid($id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\DeliveryMode;
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXF\Event\FailurePolicy;
|
|
||||||
use Psr\Container\ContainerInterface;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
|
||||||
{
|
|
||||||
private const DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS = 300.0;
|
|
||||||
private const DEFAULT_MAX_DEFERRED_EVENTS = 1000;
|
|
||||||
private const DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS = 50000;
|
|
||||||
|
|
||||||
/** @var array<string, list<Event>> */
|
|
||||||
private array $deferred = [];
|
|
||||||
private ?string $activeExecution = null;
|
|
||||||
private int $dispatchDepth = 0;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly EventListenerRegistry $registry,
|
|
||||||
private readonly ContainerInterface $container,
|
|
||||||
private readonly LoggerInterface $logger,
|
|
||||||
private readonly float $deferredProcessingTimeoutSeconds = self::DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS,
|
|
||||||
private readonly int $maxDeferredEvents = self::DEFAULT_MAX_DEFERRED_EVENTS,
|
|
||||||
private readonly int $maxDeferredListenerInvocations = self::DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS,
|
|
||||||
) {
|
|
||||||
if ($this->deferredProcessingTimeoutSeconds <= 0) {
|
|
||||||
throw new \InvalidArgumentException('The deferred processing timeout must be greater than zero.');
|
|
||||||
}
|
|
||||||
if ($this->maxDeferredEvents <= 0) {
|
|
||||||
throw new \InvalidArgumentException('The deferred event limit must be greater than zero.');
|
|
||||||
}
|
|
||||||
if ($this->maxDeferredListenerInvocations <= 0) {
|
|
||||||
throw new \InvalidArgumentException('The deferred listener invocation limit must be greater than zero.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dispatch(Event $event): void
|
|
||||||
{
|
|
||||||
if (++$this->dispatchDepth > 32) {
|
|
||||||
--$this->dispatchDepth;
|
|
||||||
throw new \RuntimeException('Event dispatch recursion limit exceeded.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->invoke($event, DeliveryMode::Immediate);
|
|
||||||
if ($this->registry->listeners($event->label(), DeliveryMode::Deferred) !== []) {
|
|
||||||
if ($this->activeExecution === null) {
|
|
||||||
throw new \LogicException('Deferred events require an active execution scope.');
|
|
||||||
}
|
|
||||||
$this->deferred[$this->activeExecution][] = $event;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
--$this->dispatchDepth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginExecution(string $executionId): void
|
|
||||||
{
|
|
||||||
if ($this->activeExecution !== null) {
|
|
||||||
throw new \LogicException('An event execution scope is already active.');
|
|
||||||
}
|
|
||||||
$this->activeExecution = $executionId;
|
|
||||||
$this->deferred[$executionId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function processDeferred(string $executionId): DeferredProcessingResult
|
|
||||||
{
|
|
||||||
if ($this->activeExecution !== $executionId) {
|
|
||||||
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$processedEvents = 0;
|
|
||||||
$listenerInvocations = 0;
|
|
||||||
$deadline = microtime(true) + $this->deferredProcessingTimeoutSeconds;
|
|
||||||
$deadlineExceeded = false;
|
|
||||||
$eventLimitExceeded = false;
|
|
||||||
$listenerInvocationLimitExceeded = false;
|
|
||||||
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
|
||||||
if ($processedEvents >= $this->maxDeferredEvents) {
|
|
||||||
$eventLimitExceeded = true;
|
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (microtime(true) >= $deadline) {
|
|
||||||
$deadlineExceeded = true;
|
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$eventListenerCount = count($this->registry->listeners(
|
|
||||||
$event->label(),
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
));
|
|
||||||
if ($listenerInvocations + $eventListenerCount > $this->maxDeferredListenerInvocations) {
|
|
||||||
$listenerInvocationLimitExceeded = true;
|
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$listenerInvocations += $this->invoke($event, DeliveryMode::Deferred);
|
|
||||||
$processedEvents++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DeferredProcessingResult(
|
|
||||||
processed: $processedEvents,
|
|
||||||
remaining: count($this->deferred[$executionId]),
|
|
||||||
deadlineExceeded: $deadlineExceeded,
|
|
||||||
limitExceeded: $eventLimitExceeded || $listenerInvocationLimitExceeded,
|
|
||||||
listenerInvocations: $listenerInvocations,
|
|
||||||
eventLimitExceeded: $eventLimitExceeded,
|
|
||||||
listenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
$this->discardDeferred($executionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function discardDeferred(string $executionId): void
|
|
||||||
{
|
|
||||||
unset($this->deferred[$executionId]);
|
|
||||||
if ($this->activeExecution === $executionId) {
|
|
||||||
$this->activeExecution = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invoke(Event $event, DeliveryMode $delivery): int
|
|
||||||
{
|
|
||||||
$processed = 0;
|
|
||||||
foreach ($this->registry->listeners($event->label(), $delivery) as $listener) {
|
|
||||||
if ($event->isPropagationStopped()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$processed++;
|
|
||||||
try {
|
|
||||||
$service = $this->container->get($listener->service);
|
|
||||||
$service->{$listener->method}($event);
|
|
||||||
} catch (\Throwable $error) {
|
|
||||||
$this->logger->error('Event listener failed.', [
|
|
||||||
'event' => $event->label(),
|
|
||||||
'module' => $listener->module,
|
|
||||||
'listener' => $listener->service . '::' . $listener->method,
|
|
||||||
'exception' => $error,
|
|
||||||
]);
|
|
||||||
if ($listener->failurePolicy === FailurePolicy::Propagate) {
|
|
||||||
throw $error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $processed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Http\Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds the HTTP request for the duration of the current runtime execution.
|
|
||||||
*/
|
|
||||||
final class RequestContext
|
|
||||||
{
|
|
||||||
private ?Request $request = null;
|
|
||||||
|
|
||||||
public function initialize(Request $request): void
|
|
||||||
{
|
|
||||||
if ($this->request !== null) {
|
|
||||||
throw new \LogicException('The request context has already been initialized.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->request = $request;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function current(): ?Request
|
|
||||||
{
|
|
||||||
return $this->request;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clear(): void
|
|
||||||
{
|
|
||||||
$this->request = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-14
@@ -27,11 +27,10 @@ use KTXC\Module\ModuleManager;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use KTXC\Logger\LoggerFactory;
|
use KTXC\Logger\LoggerFactory;
|
||||||
use KTXC\Logger\TenantAwareLogger;
|
use KTXC\Logger\TenantAwareLogger;
|
||||||
use KTXC\Event\DeferredEventProcessorInterface;
|
use KTXF\Event\DeferredEventProcessorInterface;
|
||||||
use KTXC\Event\EventDispatcher;
|
use KTXF\Event\EventDispatcher;
|
||||||
use KTXC\Event\EventListenerRegistry;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\Event\EventListenerRegistrarInterface;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
use KTXF\Cache\PersistentCacheInterface;
|
use KTXF\Cache\PersistentCacheInterface;
|
||||||
use KTXF\Cache\BlobCacheInterface;
|
use KTXF\Cache\BlobCacheInterface;
|
||||||
@@ -238,9 +237,6 @@ class Kernel implements KernelInterface
|
|||||||
$remaining = 0;
|
$remaining = 0;
|
||||||
$deadlineExceeded = false;
|
$deadlineExceeded = false;
|
||||||
$limitExceeded = false;
|
$limitExceeded = false;
|
||||||
$listenerInvocations = 0;
|
|
||||||
$eventLimitExceeded = false;
|
|
||||||
$listenerInvocationLimitExceeded = false;
|
|
||||||
$failures = [];
|
$failures = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -252,9 +248,6 @@ class Kernel implements KernelInterface
|
|||||||
$remaining = $result->remaining;
|
$remaining = $result->remaining;
|
||||||
$deadlineExceeded = $result->deadlineExceeded;
|
$deadlineExceeded = $result->deadlineExceeded;
|
||||||
$limitExceeded = $result->limitExceeded;
|
$limitExceeded = $result->limitExceeded;
|
||||||
$listenerInvocations = $result->listenerInvocations;
|
|
||||||
$eventLimitExceeded = $result->eventLimitExceeded;
|
|
||||||
$listenerInvocationLimitExceeded = $result->listenerInvocationLimitExceeded;
|
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$failures[] = $e;
|
$failures[] = $e;
|
||||||
@@ -293,9 +286,6 @@ class Kernel implements KernelInterface
|
|||||||
failures: $failures,
|
failures: $failures,
|
||||||
deadlineExceeded: $deadlineExceeded,
|
deadlineExceeded: $deadlineExceeded,
|
||||||
limitExceeded: $limitExceeded,
|
limitExceeded: $limitExceeded,
|
||||||
deferredListenerInvocations: $listenerInvocations,
|
|
||||||
deferredEventLimitExceeded: $eventLimitExceeded,
|
|
||||||
deferredListenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +410,6 @@ class Kernel implements KernelInterface
|
|||||||
|
|
||||||
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
|
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
|
||||||
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
|
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
|
||||||
EventListenerRegistrarInterface::class => \DI\get(EventListenerRegistry::class),
|
|
||||||
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
|
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
|
||||||
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
||||||
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
|
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
{
|
{
|
||||||
public const RESULT_ALLOWED = 'allowed';
|
public const RESULT_ALLOWED = 'allowed';
|
||||||
public const RESULT_BLOCKED = 'blocked';
|
public const RESULT_BLOCKED = 'blocked';
|
||||||
public const RESULT_RECORDED = 'recorded';
|
|
||||||
|
|
||||||
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
||||||
public const EVENT_RATE_LIMIT = 'rate_limit';
|
public const EVENT_RATE_LIMIT = 'rate_limit';
|
||||||
@@ -21,15 +20,8 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
public const EVENT_SUSPICIOUS = 'suspicious';
|
public const EVENT_SUSPICIOUS = 'suspicious';
|
||||||
public const EVENT_RULE_MATCH = 'rule_match';
|
public const EVENT_RULE_MATCH = 'rule_match';
|
||||||
public const EVENT_ACCESS_CHECK = 'access_check';
|
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 $id = null;
|
||||||
private ?string $eventId = null;
|
|
||||||
private ?string $tenantId = null;
|
private ?string $tenantId = null;
|
||||||
private ?string $ipAddress = null;
|
private ?string $ipAddress = null;
|
||||||
private ?string $deviceFingerprint = null;
|
private ?string $deviceFingerprint = null;
|
||||||
@@ -39,7 +31,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
private ?string $eventType = null;
|
private ?string $eventType = null;
|
||||||
private ?string $result = null; // allowed, blocked
|
private ?string $result = null; // allowed, blocked
|
||||||
private ?string $ruleId = null; // Which rule triggered (if any)
|
private ?string $ruleId = null; // Which rule triggered (if any)
|
||||||
private ?string $ruleScope = null; // tenant or system
|
|
||||||
private ?string $identityId = null; // User ID if authenticated
|
private ?string $identityId = null; // User ID if authenticated
|
||||||
private ?\DateTimeImmutable $timestamp = null;
|
private ?\DateTimeImmutable $timestamp = null;
|
||||||
private ?array $metadata = null; // Additional context
|
private ?array $metadata = null; // Additional context
|
||||||
@@ -59,9 +50,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
if (array_key_exists('tenantId', $data)) {
|
if (array_key_exists('tenantId', $data)) {
|
||||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('eventId', $data)) {
|
|
||||||
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
|
|
||||||
}
|
|
||||||
if (array_key_exists('ipAddress', $data)) {
|
if (array_key_exists('ipAddress', $data)) {
|
||||||
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||||
}
|
}
|
||||||
@@ -86,14 +74,13 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
if (array_key_exists('ruleId', $data)) {
|
if (array_key_exists('ruleId', $data)) {
|
||||||
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('ruleScope', $data)) {
|
|
||||||
$this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null;
|
|
||||||
}
|
|
||||||
if (array_key_exists('identityId', $data)) {
|
if (array_key_exists('identityId', $data)) {
|
||||||
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('timestamp', $data)) {
|
if (array_key_exists('timestamp', $data)) {
|
||||||
$this->timestamp = self::deserializeDate($data['timestamp']);
|
$this->timestamp = $data['timestamp'] !== null
|
||||||
|
? new \DateTimeImmutable($data['timestamp'])
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('metadata', $data)) {
|
if (array_key_exists('metadata', $data)) {
|
||||||
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||||
@@ -106,7 +93,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'eventId' => $this->eventId,
|
|
||||||
'tenantId' => $this->tenantId,
|
'tenantId' => $this->tenantId,
|
||||||
'ipAddress' => $this->ipAddress,
|
'ipAddress' => $this->ipAddress,
|
||||||
'deviceFingerprint' => $this->deviceFingerprint,
|
'deviceFingerprint' => $this->deviceFingerprint,
|
||||||
@@ -116,31 +102,12 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
'eventType' => $this->eventType,
|
'eventType' => $this->eventType,
|
||||||
'result' => $this->result,
|
'result' => $this->result,
|
||||||
'ruleId' => $this->ruleId,
|
'ruleId' => $this->ruleId,
|
||||||
'ruleScope' => $this->ruleScope,
|
|
||||||
'identityId' => $this->identityId,
|
'identityId' => $this->identityId,
|
||||||
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
||||||
'metadata' => $this->metadata,
|
'metadata' => $this->metadata,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
|
||||||
return \DateTimeImmutable::createFromMutable($value->toDateTime());
|
|
||||||
}
|
|
||||||
if ($value instanceof \DateTimeImmutable) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
if ($value instanceof \DateTimeInterface) {
|
|
||||||
return \DateTimeImmutable::createFromInterface($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new \DateTimeImmutable((string)$value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters and setters
|
// Getters and setters
|
||||||
|
|
||||||
public function getId(): ?string
|
public function getId(): ?string
|
||||||
@@ -154,17 +121,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEventId(): ?string
|
|
||||||
{
|
|
||||||
return $this->eventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEventId(?string $eventId): self
|
|
||||||
{
|
|
||||||
$this->eventId = $eventId;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTenantId(): ?string
|
public function getTenantId(): ?string
|
||||||
{
|
{
|
||||||
return $this->tenantId;
|
return $this->tenantId;
|
||||||
@@ -264,24 +220,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRuleScope(): ?string
|
|
||||||
{
|
|
||||||
return $this->ruleScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setRuleScope(?string $ruleScope): self
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
$ruleScope !== null
|
|
||||||
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
|
|
||||||
) {
|
|
||||||
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->ruleScope = $ruleScope;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIdentityId(): ?string
|
public function getIdentityId(): ?string
|
||||||
{
|
{
|
||||||
return $this->identityId;
|
return $this->identityId;
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ use KTXF\Json\JsonDeserializable;
|
|||||||
*/
|
*/
|
||||||
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||||
{
|
{
|
||||||
public const SCOPE_TENANT = 'tenant';
|
|
||||||
public const SCOPE_SYSTEM = 'system';
|
|
||||||
|
|
||||||
public const TYPE_IP = 'ip';
|
public const TYPE_IP = 'ip';
|
||||||
public const TYPE_IP_RANGE = 'ip_range';
|
public const TYPE_IP_RANGE = 'ip_range';
|
||||||
public const TYPE_DEVICE = 'device';
|
public const TYPE_DEVICE = 'device';
|
||||||
@@ -22,7 +19,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
public const ACTION_BLOCK = 'block';
|
public const ACTION_BLOCK = 'block';
|
||||||
|
|
||||||
private ?string $id = null;
|
private ?string $id = null;
|
||||||
private string $scope = self::SCOPE_TENANT;
|
|
||||||
private ?string $tenantId = null;
|
private ?string $tenantId = null;
|
||||||
private ?string $type = null; // ip, ip_range, device
|
private ?string $type = null; // ip, ip_range, device
|
||||||
private ?string $action = null; // allow, block
|
private ?string $action = null; // allow, block
|
||||||
@@ -46,10 +42,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!array_key_exists('scope', $data)) {
|
|
||||||
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
|
|
||||||
}
|
|
||||||
$this->setScope((string)$data['scope']);
|
|
||||||
if (array_key_exists('tenantId', $data)) {
|
if (array_key_exists('tenantId', $data)) {
|
||||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||||
}
|
}
|
||||||
@@ -69,10 +61,14 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
|
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('createdAt', $data)) {
|
if (array_key_exists('createdAt', $data)) {
|
||||||
$this->createdAt = self::deserializeDate($data['createdAt']);
|
$this->createdAt = $data['createdAt'] !== null
|
||||||
|
? new \DateTimeImmutable($data['createdAt'])
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('expiresAt', $data)) {
|
if (array_key_exists('expiresAt', $data)) {
|
||||||
$this->expiresAt = self::deserializeDate($data['expiresAt']);
|
$this->expiresAt = $data['expiresAt'] !== null
|
||||||
|
? new \DateTimeImmutable($data['expiresAt'])
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
if (array_key_exists('enabled', $data)) {
|
if (array_key_exists('enabled', $data)) {
|
||||||
$this->enabled = (bool)$data['enabled'];
|
$this->enabled = (bool)$data['enabled'];
|
||||||
@@ -88,7 +84,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'scope' => $this->scope,
|
|
||||||
'tenantId' => $this->tenantId,
|
'tenantId' => $this->tenantId,
|
||||||
'type' => $this->type,
|
'type' => $this->type,
|
||||||
'action' => $this->action,
|
'action' => $this->action,
|
||||||
@@ -102,24 +97,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
|
||||||
return \DateTimeImmutable::createFromMutable($value->toDateTime());
|
|
||||||
}
|
|
||||||
if ($value instanceof \DateTimeImmutable) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
if ($value instanceof \DateTimeInterface) {
|
|
||||||
return \DateTimeImmutable::createFromInterface($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new \DateTimeImmutable((string)$value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if this rule has expired
|
* Check if this rule has expired
|
||||||
*/
|
*/
|
||||||
@@ -157,42 +134,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
|||||||
return $this->tenantId;
|
return $this->tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getScope(): string
|
|
||||||
{
|
|
||||||
return $this->scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setScope(string $scope): self
|
|
||||||
{
|
|
||||||
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
|
|
||||||
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->scope = $scope;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isTenantScoped(): bool
|
|
||||||
{
|
|
||||||
return $this->scope === self::SCOPE_TENANT;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isSystemScoped(): bool
|
|
||||||
{
|
|
||||||
return $this->scope === self::SCOPE_SYSTEM;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assertValidScopeOwnership(): void
|
|
||||||
{
|
|
||||||
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
|
|
||||||
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isSystemScoped() && $this->tenantId !== null) {
|
|
||||||
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setTenantId(?string $tenantId): self
|
public function setTenantId(?string $tenantId): self
|
||||||
{
|
{
|
||||||
$this->tenantId = $tenantId;
|
$this->tenantId = $tenantId;
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
{
|
{
|
||||||
protected TenantAuthentication $authentication;
|
protected TenantAuthentication $authentication;
|
||||||
protected TenantSecurity $security;
|
protected TenantSecurity $security;
|
||||||
protected TenantFirewall $firewall;
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->authentication = new TenantAuthentication();
|
$this->authentication = new TenantAuthentication();
|
||||||
$this->security = new TenantSecurity();
|
$this->security = new TenantSecurity();
|
||||||
$this->firewall = new TenantFirewall();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function authentication(): TenantAuthentication {
|
public function authentication(): TenantAuthentication {
|
||||||
@@ -28,8 +26,4 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
return $this->security;
|
return $this->security;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function firewall(): TenantFirewall {
|
|
||||||
return $this->firewall;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Models\Tenant;
|
|
||||||
|
|
||||||
use KTXF\Json\JsonSerializableObject;
|
|
||||||
|
|
||||||
class TenantFirewall extends JsonSerializableObject
|
|
||||||
{
|
|
||||||
protected bool $enabled = true;
|
|
||||||
protected int $maxAuthFailures = 5;
|
|
||||||
protected int $authFailureWindow = 300;
|
|
||||||
protected int $autoBlockDuration = 3600;
|
|
||||||
|
|
||||||
public function enabled(): bool
|
|
||||||
{
|
|
||||||
return $this->enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function maxAuthFailures(): int
|
|
||||||
{
|
|
||||||
return $this->maxAuthFailures;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function authFailureWindow(): int
|
|
||||||
{
|
|
||||||
return $this->authFailureWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function autoBlockDuration(): int
|
|
||||||
{
|
|
||||||
return $this->autoBlockDuration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,29 +2,10 @@
|
|||||||
|
|
||||||
namespace KTXC\Module;
|
namespace KTXC\Module;
|
||||||
|
|
||||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
|
||||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
|
||||||
use KTXC\Service\SystemFirewallStatusService;
|
|
||||||
use KTXC\Service\TenantFirewallLogService;
|
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
|
||||||
use KTXC\Service\TenantFirewallStatusService;
|
|
||||||
use KTXC\Security\Event\AccessDeniedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
|
||||||
use KTXC\Security\Event\BruteForceDetectedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
|
||||||
use KTXC\Security\Event\RateLimitExceededEvent;
|
|
||||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
use KTXF\Event\EventListenerRegistrarInterface;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
|
use KTXF\Event\SecurityEvent;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
use KTXF\Module\ModuleConsoleInterface;
|
use KTXF\Module\ModuleConsoleInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
@@ -37,7 +18,7 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EventListenerRegistrarInterface $events,
|
private readonly EventListenerRegistry $events,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,32 +26,17 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
{
|
{
|
||||||
$this->events->listen(
|
$this->events->listen(
|
||||||
'core',
|
'core',
|
||||||
AuthenticationFailedEvent::class,
|
SecurityEvent::AUTH_FAILURE,
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
'handleAuthFailure',
|
'handleAuthFailure',
|
||||||
DeliveryMode::Immediate,
|
|
||||||
priority: 100,
|
priority: 100,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->events->listen(
|
|
||||||
'core',
|
|
||||||
AuthenticationSucceededEvent::class,
|
|
||||||
FirewallService::class,
|
|
||||||
'logAuthenticationSuccess',
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
AccessDeniedEvent::class,
|
SecurityEvent::AUTH_FAILURE,
|
||||||
BruteForceDetectedEvent::class,
|
SecurityEvent::AUTH_SUCCESS,
|
||||||
RateLimitExceededEvent::class,
|
SecurityEvent::ACCESS_DENIED,
|
||||||
SuspiciousActivityEvent::class,
|
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||||
FirewallRuleCreatedEvent::class,
|
|
||||||
FirewallRuleExtendedEvent::class,
|
|
||||||
FirewallRuleEnabledEvent::class,
|
|
||||||
FirewallRuleDisabledEvent::class,
|
|
||||||
FirewallRuleRemovedEvent::class,
|
|
||||||
FirewallSettingsUpdatedEvent::class,
|
|
||||||
] as $event) {
|
] as $event) {
|
||||||
$this->events->listen(
|
$this->events->listen(
|
||||||
'core',
|
'core',
|
||||||
@@ -149,57 +115,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
'group' => 'Module Management'
|
'group' => 'Module Management'
|
||||||
],
|
],
|
||||||
|
|
||||||
// Firewall Management
|
// System Administration
|
||||||
TenantFirewallRuleService::PERMISSION_READ => [
|
|
||||||
'label' => 'View Tenant Firewall Rules',
|
|
||||||
'description' => 'View firewall rules owned by the current tenant',
|
|
||||||
'group' => 'Firewall Management'
|
|
||||||
],
|
|
||||||
TenantFirewallRuleService::PERMISSION_MANAGE => [
|
|
||||||
'label' => 'Manage Tenant Firewall Rules',
|
|
||||||
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
|
|
||||||
'group' => 'Firewall Management'
|
|
||||||
],
|
|
||||||
TenantFirewallLogService::PERMISSION_READ => [
|
|
||||||
'label' => 'View Tenant Firewall Logs',
|
|
||||||
'description' => 'View firewall security and audit logs owned by the current tenant',
|
|
||||||
'group' => 'Firewall Management'
|
|
||||||
],
|
|
||||||
TenantFirewallStatusService::PERMISSION_SETTINGS_READ => [
|
|
||||||
'label' => 'View Tenant Firewall Settings',
|
|
||||||
'description' => 'View effective firewall settings for the current tenant',
|
|
||||||
'group' => 'Firewall Management'
|
|
||||||
],
|
|
||||||
TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
|
|
||||||
'label' => 'Manage Tenant Firewall Settings',
|
|
||||||
'description' => 'Update firewall settings for the current tenant',
|
|
||||||
'group' => 'Firewall Management'
|
|
||||||
],
|
|
||||||
SystemFirewallRuleService::PERMISSION_READ => [
|
|
||||||
'label' => 'View System Firewall Rules',
|
|
||||||
'description' => 'View firewall rules that apply to every tenant',
|
|
||||||
'group' => 'System Administration'
|
|
||||||
],
|
|
||||||
SystemFirewallRuleService::PERMISSION_MANAGE => [
|
|
||||||
'label' => 'Manage System Firewall Rules',
|
|
||||||
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
|
|
||||||
'group' => 'System Administration'
|
|
||||||
],
|
|
||||||
SystemFirewallLogService::PERMISSION_READ => [
|
|
||||||
'label' => 'View System Firewall Logs',
|
|
||||||
'description' => 'View firewall security and audit logs across tenants',
|
|
||||||
'group' => 'System Administration'
|
|
||||||
],
|
|
||||||
SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ => [
|
|
||||||
'label' => 'View Firewall Maintenance Status',
|
|
||||||
'description' => 'View the last firewall cleanup result and operational status',
|
|
||||||
'group' => 'System Administration'
|
|
||||||
],
|
|
||||||
SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
|
|
||||||
'label' => 'Manage Tenant Firewall Settings System-Wide',
|
|
||||||
'description' => 'Update firewall settings for any tenant',
|
|
||||||
'group' => 'System Administration'
|
|
||||||
],
|
|
||||||
'system.admin' => [
|
'system.admin' => [
|
||||||
'label' => 'System Administrator',
|
'label' => 'System Administrator',
|
||||||
'description' => 'Full system access (superuser)',
|
'description' => 'Full system access (superuser)',
|
||||||
@@ -216,8 +132,6 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
public function registerCI(): array
|
public function registerCI(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
FirewallSetupCommand::class,
|
|
||||||
FirewallMaintenanceCommand::class,
|
|
||||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||||
\KTXC\Console\Module\ModuleListCommand::class,
|
\KTXC\Console\Module\ModuleListCommand::class,
|
||||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
\KTXC\Console\Module\ModuleEnableCommand::class,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use KTXC\Http\Middleware\MiddlewarePipeline;
|
|||||||
use KTXC\Http\Middleware\RouterMiddleware;
|
use KTXC\Http\Middleware\RouterMiddleware;
|
||||||
use KTXC\Http\Middleware\TenantMiddleware;
|
use KTXC\Http\Middleware\TenantMiddleware;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Request\RequestContext;
|
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
|
|
||||||
@@ -26,34 +25,26 @@ final class HttpRuntime
|
|||||||
public function run(?Request $request = null, bool $send = true): Response
|
public function run(?Request $request = null, bool $send = true): Response
|
||||||
{
|
{
|
||||||
$request ??= Request::createFromGlobals();
|
$request ??= Request::createFromGlobals();
|
||||||
$requestContext = null;
|
|
||||||
|
|
||||||
try {
|
return $this->kernel->executionRunner()->execute(
|
||||||
return $this->kernel->executionRunner()->execute(
|
ExecutionDescriptor::http(),
|
||||||
ExecutionDescriptor::http(),
|
function () use ($request, $send): Response {
|
||||||
function () use ($request, $send, &$requestContext): Response {
|
$response = $this->pipeline()->handle($request);
|
||||||
$requestContext = $this->kernel->container()->get(RequestContext::class);
|
if ($send) {
|
||||||
$requestContext->initialize($request);
|
$response->send();
|
||||||
|
}
|
||||||
|
|
||||||
$response = $this->pipeline()->handle($request);
|
return $response;
|
||||||
if ($send) {
|
},
|
||||||
$response->send();
|
function (\Throwable $error) use ($send): Response {
|
||||||
}
|
$response = $this->errorResponse($error);
|
||||||
|
if ($send) {
|
||||||
|
$response->send();
|
||||||
|
}
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
},
|
},
|
||||||
function (\Throwable $error) use ($send): Response {
|
);
|
||||||
$response = $this->errorResponse($error);
|
|
||||||
if ($send) {
|
|
||||||
$response->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
$requestContext?->clear();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pipeline(): MiddlewarePipeline
|
private function pipeline(): MiddlewarePipeline
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ use KTXC\Models\Identity\User;
|
|||||||
use KTXC\Resource\ProviderManager;
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
|
||||||
use KTXC\Service\TokenService;
|
use KTXC\Service\TokenService;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXF\Cache\CacheScope;
|
use KTXF\Cache\CacheScope;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
use KTXF\Security\Authentication\AuthenticationSession;
|
use KTXF\Security\Authentication\AuthenticationSession;
|
||||||
use KTXF\Security\Authentication\ProviderContext;
|
use KTXF\Security\Authentication\ProviderContext;
|
||||||
@@ -34,7 +31,6 @@ class AuthenticationManager
|
|||||||
private readonly ProviderManager $providerManager,
|
private readonly ProviderManager $providerManager,
|
||||||
private readonly TokenService $tokenService,
|
private readonly TokenService $tokenService,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
private readonly EventDispatcherInterface $events,
|
|
||||||
) {
|
) {
|
||||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||||
}
|
}
|
||||||
@@ -191,10 +187,6 @@ class AuthenticationManager
|
|||||||
|
|
||||||
if (!$result->isSuccess()) {
|
if (!$result->isSuccess()) {
|
||||||
$this->saveSession($session);
|
$this->saveSession($session);
|
||||||
$this->publishAuthenticationFailure(
|
|
||||||
$session,
|
|
||||||
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
|
||||||
);
|
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
'Authentication failed. If you haven\'t set up this method, try another option.',
|
'Authentication failed. If you haven\'t set up this method, try another option.',
|
||||||
@@ -397,10 +389,6 @@ class AuthenticationManager
|
|||||||
$result = $provider->completeRedirect($context, $request->params);
|
$result = $provider->completeRedirect($context, $request->params);
|
||||||
|
|
||||||
if ($result->isFailed()) {
|
if ($result->isFailed()) {
|
||||||
$this->publishAuthenticationFailure(
|
|
||||||
$session,
|
|
||||||
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
|
||||||
);
|
|
||||||
$this->deleteSession($session->id);
|
$this->deleteSession($session->id);
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
@@ -578,17 +566,6 @@ class AuthenticationManager
|
|||||||
// Helper Methods
|
// Helper Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private function publishAuthenticationFailure(
|
|
||||||
AuthenticationSession $session,
|
|
||||||
string $reason,
|
|
||||||
): void {
|
|
||||||
$this->events->dispatch(new AuthenticationFailedEvent(
|
|
||||||
userId: $session->userIdentifier,
|
|
||||||
reason: $reason,
|
|
||||||
tenantId: $session->tenantIdentifier,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build provider context from session
|
* Build provider context from session
|
||||||
*/
|
*/
|
||||||
@@ -617,16 +594,7 @@ class AuthenticationManager
|
|||||||
*/
|
*/
|
||||||
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
||||||
{
|
{
|
||||||
$userId = $session->userIdentifier;
|
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
|
||||||
if ($userId === null) {
|
|
||||||
return AuthenticationResponse::failed(
|
|
||||||
AuthenticationResponse::ERROR_INVALID_SESSION,
|
|
||||||
'Authenticated user is missing',
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$userData = $this->userService->fetchByIdentifier($userId);
|
|
||||||
|
|
||||||
if ($userData === null) {
|
if ($userData === null) {
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
@@ -643,11 +611,6 @@ class AuthenticationManager
|
|||||||
|
|
||||||
$this->deleteSession($session->id);
|
$this->deleteSession($session->id);
|
||||||
|
|
||||||
$this->events->dispatch(new AuthenticationSucceededEvent(
|
|
||||||
$userId,
|
|
||||||
$session->tenantIdentifier,
|
|
||||||
));
|
|
||||||
|
|
||||||
return AuthenticationResponse::success(
|
return AuthenticationResponse::success(
|
||||||
$this->buildUserData($user),
|
$this->buildUserData($user),
|
||||||
$tokens
|
$tokens
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly string $ruleId,
|
|
||||||
private readonly string $ruleScope,
|
|
||||||
private readonly ?string $deviceFingerprint = null,
|
|
||||||
private readonly ?string $reason = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
) {
|
|
||||||
if ($ipAddress === '') {
|
|
||||||
throw new \InvalidArgumentException('Access denial requires an IP address.');
|
|
||||||
}
|
|
||||||
if ($ruleId === '') {
|
|
||||||
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
|
|
||||||
}
|
|
||||||
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
|
|
||||||
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleId(): string
|
|
||||||
{
|
|
||||||
return $this->ruleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleScope(): string
|
|
||||||
{
|
|
||||||
return $this->ruleScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return $this->deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::WARNING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ?string $userId = null,
|
|
||||||
private readonly ?string $reason = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
) {
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
['userId' => $userId, 'reason' => $reason],
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return $this->userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::WARNING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $userId,
|
|
||||||
?string $tenantId = null,
|
|
||||||
) {
|
|
||||||
if ($userId === '') {
|
|
||||||
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
['userId' => $userId],
|
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): string
|
|
||||||
{
|
|
||||||
return $this->userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::INFO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
|
|
||||||
{
|
|
||||||
private readonly string $reason;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly int $failureCount,
|
|
||||||
private readonly int $windowSeconds,
|
|
||||||
?string $tenantId = null,
|
|
||||||
) {
|
|
||||||
if ($ipAddress === '') {
|
|
||||||
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
|
|
||||||
}
|
|
||||||
if ($failureCount < 1) {
|
|
||||||
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
|
|
||||||
}
|
|
||||||
if ($windowSeconds < 1) {
|
|
||||||
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->reason = sprintf(
|
|
||||||
'%d failed attempts in %d seconds',
|
|
||||||
$failureCount,
|
|
||||||
$windowSeconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
|
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFailureCount(): int
|
|
||||||
{
|
|
||||||
return $this->failureCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWindowSeconds(): int
|
|
||||||
{
|
|
||||||
return $this->windowSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::CRITICAL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $deviceFingerprint,
|
|
||||||
private readonly ?string $reason = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
) {
|
|
||||||
if ($deviceFingerprint === '') {
|
|
||||||
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
['device' => $deviceFingerprint, 'reason' => $reason],
|
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIpAddress(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): string
|
|
||||||
{
|
|
||||||
return $this->deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::CRITICAL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
|
|
||||||
{
|
|
||||||
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
|
|
||||||
|
|
||||||
final public function __construct(
|
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly ?string $reason = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
) {
|
|
||||||
if ($ipAddress === '') {
|
|
||||||
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
static::class,
|
|
||||||
['ip' => $ipAddress, 'reason' => $reason],
|
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return static::SEVERITY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
|
|
||||||
{
|
|
||||||
final protected function __construct(
|
|
||||||
private readonly string $ruleId,
|
|
||||||
private readonly string $ruleScope,
|
|
||||||
private readonly string $ruleType,
|
|
||||||
private readonly string $ruleAction,
|
|
||||||
private readonly string $ruleValue,
|
|
||||||
private readonly ?string $reason,
|
|
||||||
private readonly string $origin,
|
|
||||||
private readonly ?string $expiresAt,
|
|
||||||
private readonly array $details,
|
|
||||||
?string $tenantId,
|
|
||||||
?string $identityId,
|
|
||||||
) {
|
|
||||||
if ($ruleId === '') {
|
|
||||||
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
|
|
||||||
}
|
|
||||||
foreach ([
|
|
||||||
'scope' => $ruleScope,
|
|
||||||
'type' => $ruleType,
|
|
||||||
'action' => $ruleAction,
|
|
||||||
'value' => $ruleValue,
|
|
||||||
'origin' => $origin,
|
|
||||||
] as $field => $value) {
|
|
||||||
if ($value === '') {
|
|
||||||
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ([
|
|
||||||
'scope' => $ruleScope,
|
|
||||||
'type' => $ruleType,
|
|
||||||
'action' => $ruleAction,
|
|
||||||
'value' => $ruleValue,
|
|
||||||
'origin' => $origin,
|
|
||||||
] as $field => $value) {
|
|
||||||
if ($value === '') {
|
|
||||||
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
static::class,
|
|
||||||
[
|
|
||||||
'ruleId' => $ruleId,
|
|
||||||
'ruleScope' => $ruleScope,
|
|
||||||
'ruleType' => $ruleType,
|
|
||||||
'ruleAction' => $ruleAction,
|
|
||||||
'ruleValue' => $ruleValue,
|
|
||||||
'reason' => $reason,
|
|
||||||
'origin' => $origin,
|
|
||||||
'expiresAt' => $expiresAt,
|
|
||||||
...$details,
|
|
||||||
],
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromRule(
|
|
||||||
FirewallRuleObject $rule,
|
|
||||||
?string $actorId = null,
|
|
||||||
array $change = [],
|
|
||||||
): static {
|
|
||||||
$metadata = $rule->getMetadata() ?? [];
|
|
||||||
$details = [...$metadata, ...$change];
|
|
||||||
foreach ([
|
|
||||||
'ruleId',
|
|
||||||
'ruleScope',
|
|
||||||
'ruleType',
|
|
||||||
'ruleAction',
|
|
||||||
'ruleValue',
|
|
||||||
'reason',
|
|
||||||
'origin',
|
|
||||||
'expiresAt',
|
|
||||||
] as $reservedKey) {
|
|
||||||
unset($details[$reservedKey]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new static(
|
|
||||||
ruleId: (string) $rule->getId(),
|
|
||||||
ruleScope: (string) $rule->getScope(),
|
|
||||||
ruleType: (string) $rule->getType(),
|
|
||||||
ruleAction: (string) $rule->getAction(),
|
|
||||||
ruleValue: (string) $rule->getValue(),
|
|
||||||
reason: $rule->getReason(),
|
|
||||||
origin: (string) ($metadata['origin'] ?? 'manual'),
|
|
||||||
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
|
||||||
details: $details,
|
|
||||||
tenantId: $rule->getTenantId(),
|
|
||||||
identityId: $actorId ?? $rule->getCreatedBy(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleId(): string
|
|
||||||
{
|
|
||||||
return $this->ruleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleScope(): string
|
|
||||||
{
|
|
||||||
return $this->ruleScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleType(): string
|
|
||||||
{
|
|
||||||
return $this->ruleType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleAction(): string
|
|
||||||
{
|
|
||||||
return $this->ruleAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRuleValue(): string
|
|
||||||
{
|
|
||||||
return $this->ruleValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getOrigin(): string
|
|
||||||
{
|
|
||||||
return $this->origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getExpiresAt(): ?string
|
|
||||||
{
|
|
||||||
return $this->expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDetails(): array
|
|
||||||
{
|
|
||||||
return $this->details;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::INFO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $changeReason,
|
|
||||||
private readonly array $previous,
|
|
||||||
private readonly array $current,
|
|
||||||
string $tenantId,
|
|
||||||
?string $actorId = null,
|
|
||||||
private readonly string $changeOrigin = 'manual',
|
|
||||||
) {
|
|
||||||
if ($changeReason === '') {
|
|
||||||
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
|
|
||||||
}
|
|
||||||
if ($tenantId === '') {
|
|
||||||
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
|
|
||||||
}
|
|
||||||
if ($changeOrigin === '') {
|
|
||||||
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
[
|
|
||||||
'changeReason' => $changeReason,
|
|
||||||
'changeOrigin' => $changeOrigin,
|
|
||||||
'previous' => $previous,
|
|
||||||
'current' => $current,
|
|
||||||
],
|
|
||||||
$tenantId,
|
|
||||||
$actorId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getChangeReason(): string
|
|
||||||
{
|
|
||||||
return $this->changeReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPrevious(): array
|
|
||||||
{
|
|
||||||
return $this->previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCurrent(): array
|
|
||||||
{
|
|
||||||
return $this->current;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getChangeOrigin(): string
|
|
||||||
{
|
|
||||||
return $this->changeOrigin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): string
|
|
||||||
{
|
|
||||||
return $this->changeReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::INFO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class IpAllowedEvent extends FirewallIpEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
final class IpBlockedEvent extends FirewallIpEvent
|
|
||||||
{
|
|
||||||
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
|
|
||||||
{
|
|
||||||
private readonly string $reason;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly int $requestCount,
|
|
||||||
private readonly int $windowSeconds,
|
|
||||||
private readonly ?string $endpoint = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
) {
|
|
||||||
if ($ipAddress === '') {
|
|
||||||
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
|
|
||||||
}
|
|
||||||
if ($requestCount < 1) {
|
|
||||||
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
|
|
||||||
}
|
|
||||||
if ($windowSeconds < 1) {
|
|
||||||
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
|
|
||||||
}
|
|
||||||
if ($endpoint === '') {
|
|
||||||
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->reason = sprintf(
|
|
||||||
'%d requests in %d seconds',
|
|
||||||
$requestCount,
|
|
||||||
$windowSeconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
[
|
|
||||||
'requestCount' => $requestCount,
|
|
||||||
'windowSeconds' => $windowSeconds,
|
|
||||||
'endpoint' => $endpoint,
|
|
||||||
],
|
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestCount(): int
|
|
||||||
{
|
|
||||||
return $this->requestCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWindowSeconds(): int
|
|
||||||
{
|
|
||||||
return $this->windowSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEndpoint(): ?string
|
|
||||||
{
|
|
||||||
return $this->endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return $this->endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
interface SecurityEventInterface
|
|
||||||
{
|
|
||||||
public function label(): string;
|
|
||||||
|
|
||||||
public function get(string $key, mixed $default = null): mixed;
|
|
||||||
|
|
||||||
public function context(): array;
|
|
||||||
|
|
||||||
public function identifier(): string;
|
|
||||||
|
|
||||||
public function tenantIdentifier(): ?string;
|
|
||||||
|
|
||||||
public function actorIdentity(): ?string;
|
|
||||||
|
|
||||||
public function getUserId(): ?string;
|
|
||||||
|
|
||||||
public function getReason(): ?string;
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
enum SecurityEventSeverity: int
|
|
||||||
{
|
|
||||||
case DEBUG = 0;
|
|
||||||
case INFO = 1;
|
|
||||||
case WARNING = 2;
|
|
||||||
case ERROR = 3;
|
|
||||||
case CRITICAL = 4;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
interface SecurityRequestEventInterface extends SecurityEventInterface
|
|
||||||
{
|
|
||||||
public function getIpAddress(): ?string;
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string;
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string;
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string;
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string;
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly string $detector,
|
|
||||||
private readonly array $detectionData = [],
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
private readonly ?string $deviceFingerprint = null,
|
|
||||||
private readonly ?string $userAgent = null,
|
|
||||||
private readonly ?string $requestPath = null,
|
|
||||||
private readonly ?string $requestMethod = null,
|
|
||||||
private readonly ?string $userId = null,
|
|
||||||
private readonly ?string $reason = null,
|
|
||||||
) {
|
|
||||||
if ($ipAddress === '') {
|
|
||||||
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
|
|
||||||
}
|
|
||||||
if ($detector === '') {
|
|
||||||
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
|
|
||||||
}
|
|
||||||
if (array_key_exists('detector', $detectionData)) {
|
|
||||||
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
|
||||||
}
|
|
||||||
if (array_key_exists('detector', $detectionData)) {
|
|
||||||
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
|
||||||
}
|
|
||||||
if ($requestPath === '') {
|
|
||||||
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
|
|
||||||
}
|
|
||||||
if ($requestMethod === '') {
|
|
||||||
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct(
|
|
||||||
self::class,
|
|
||||||
['detector' => $detector] + $detectionData,
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDetector(): string
|
|
||||||
{
|
|
||||||
return $this->detector;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDetectionData(): array
|
|
||||||
{
|
|
||||||
return $this->detectionData;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return $this->deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return $this->userAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return $this->userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): SecurityEventSeverity
|
|
||||||
{
|
|
||||||
return SecurityEventSeverity::ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
|
|
||||||
final class FirewallLogService
|
|
||||||
{
|
|
||||||
public const MAX_LIMIT = 100;
|
|
||||||
|
|
||||||
private const EVENT_TYPES = [
|
|
||||||
FirewallLogObject::EVENT_AUTH_FAILURE,
|
|
||||||
FirewallLogObject::EVENT_RATE_LIMIT,
|
|
||||||
FirewallLogObject::EVENT_BRUTE_FORCE,
|
|
||||||
FirewallLogObject::EVENT_SUSPICIOUS,
|
|
||||||
FirewallLogObject::EVENT_RULE_MATCH,
|
|
||||||
FirewallLogObject::EVENT_ACCESS_CHECK,
|
|
||||||
FirewallLogObject::EVENT_RULE_CREATED,
|
|
||||||
FirewallLogObject::EVENT_RULE_EXTENDED,
|
|
||||||
FirewallLogObject::EVENT_RULE_ENABLED,
|
|
||||||
FirewallLogObject::EVENT_RULE_DISABLED,
|
|
||||||
FirewallLogObject::EVENT_RULE_REMOVED,
|
|
||||||
FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(private readonly FirewallStore $store)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(string $tenantId, array $filters, int $limit, int $offset): array
|
|
||||||
{
|
|
||||||
return $this->store->queryTenantLogs(
|
|
||||||
$tenantId,
|
|
||||||
$this->validate($filters, $limit, $offset),
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function system(?string $tenantId, array $filters, int $limit, int $offset): array
|
|
||||||
{
|
|
||||||
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid tenant filter.');
|
|
||||||
}
|
|
||||||
return $this->store->querySystemLogs(
|
|
||||||
$tenantId,
|
|
||||||
$this->validate($filters, $limit, $offset),
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validate(array $filters, int $limit, int $offset): array
|
|
||||||
{
|
|
||||||
if ($limit < 1 || $limit > self::MAX_LIMIT || $offset < 0) {
|
|
||||||
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
|
|
||||||
}
|
|
||||||
$ipAddress = self::nullableString($filters, 'ipAddress');
|
|
||||||
if ($ipAddress !== null && filter_var($ipAddress, FILTER_VALIDATE_IP) === false) {
|
|
||||||
throw new \InvalidArgumentException('Invalid IP address filter.');
|
|
||||||
}
|
|
||||||
$eventType = self::nullableString($filters, 'eventType');
|
|
||||||
if ($eventType !== null && !in_array($eventType, self::EVENT_TYPES, true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid firewall event type filter.');
|
|
||||||
}
|
|
||||||
$result = self::nullableString($filters, 'result');
|
|
||||||
if ($result !== null && !in_array($result, [
|
|
||||||
FirewallLogObject::RESULT_ALLOWED,
|
|
||||||
FirewallLogObject::RESULT_BLOCKED,
|
|
||||||
FirewallLogObject::RESULT_RECORDED,
|
|
||||||
], true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid firewall result filter.');
|
|
||||||
}
|
|
||||||
$ruleScope = self::nullableString($filters, 'ruleScope');
|
|
||||||
if ($ruleScope !== null && !in_array($ruleScope, [
|
|
||||||
FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
FirewallRuleObject::SCOPE_SYSTEM,
|
|
||||||
], true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid rule scope filter.');
|
|
||||||
}
|
|
||||||
$from = self::date($filters, 'from');
|
|
||||||
$to = self::date($filters, 'to');
|
|
||||||
if ($from !== null && $to !== null && $from > $to) {
|
|
||||||
throw new \InvalidArgumentException('The from date must not be later than the to date.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ipAddress' => $ipAddress,
|
|
||||||
'eventType' => $eventType,
|
|
||||||
'result' => $result,
|
|
||||||
'ruleId' => self::nullableString($filters, 'ruleId'),
|
|
||||||
'ruleScope' => $ruleScope,
|
|
||||||
'from' => $from,
|
|
||||||
'to' => $to,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function nullableString(array $filters, string $key): ?string
|
|
||||||
{
|
|
||||||
$value = $filters[$key] ?? null;
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!is_string($value) || $value === '' || strlen($value) > 255) {
|
|
||||||
throw new \InvalidArgumentException("Invalid {$key} filter.");
|
|
||||||
}
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function date(array $filters, string $key): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
$value = self::nullableString($filters, $key);
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new \DateTimeImmutable($value);
|
|
||||||
} catch (\Exception) {
|
|
||||||
throw new \InvalidArgumentException("Invalid {$key} date filter.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
|
|
||||||
final class FirewallRuleCache
|
|
||||||
{
|
|
||||||
/** @var array<string, FirewallRuleObject[]> */
|
|
||||||
private array $tenantRules = [];
|
|
||||||
|
|
||||||
/** @var FirewallRuleObject[]|null */
|
|
||||||
private ?array $systemRules = null;
|
|
||||||
|
|
||||||
public function __construct(private readonly FirewallStore $store)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return FirewallRuleObject[] */
|
|
||||||
public function tenant(string $tenantId): array
|
|
||||||
{
|
|
||||||
return $this->tenantRules[$tenantId] ??= $this->store->listRules($tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return FirewallRuleObject[] */
|
|
||||||
public function system(): array
|
|
||||||
{
|
|
||||||
return $this->systemRules ??= $this->store->listSystemRules();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function invalidate(): void
|
|
||||||
{
|
|
||||||
$this->tenantRules = [];
|
|
||||||
$this->systemRules = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
final class FirewallRuleConflictException extends \RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $conflictCode,
|
|
||||||
string $message
|
|
||||||
) {
|
|
||||||
parent::__construct($message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
|
||||||
use KTXC\Security\Event\DeviceBlockedEvent;
|
|
||||||
use KTXC\Security\Event\IpAllowedEvent;
|
|
||||||
use KTXC\Security\Event\IpBlockedEvent;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXF\IpUtils;
|
|
||||||
|
|
||||||
final class FirewallRuleManager
|
|
||||||
{
|
|
||||||
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
|
|
||||||
public const MAX_QUERY_LIMIT = 100;
|
|
||||||
public const ORIGIN_MANUAL = 'manual';
|
|
||||||
public const ORIGIN_AUTOMATIC = 'automatic';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallStore $store,
|
|
||||||
private readonly FirewallRuleCache $cache,
|
|
||||||
private readonly EventDispatcherInterface $events,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function list(FirewallRuleScope $scope, bool $activeOnly = true): array
|
|
||||||
{
|
|
||||||
return $scope->scope === FirewallRuleObject::SCOPE_SYSTEM
|
|
||||||
? $this->store->listSystemRules($activeOnly)
|
|
||||||
: $this->store->listRules($scope->tenantId, $activeOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $status = 'active',
|
|
||||||
?string $type = null,
|
|
||||||
?string $action = null,
|
|
||||||
int $limit = 50,
|
|
||||||
int $offset = 0
|
|
||||||
): array {
|
|
||||||
if (!in_array($status, self::QUERY_STATUSES, true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid rule status filter.');
|
|
||||||
}
|
|
||||||
if ($type !== null && !in_array($type, [
|
|
||||||
FirewallRuleObject::TYPE_IP,
|
|
||||||
FirewallRuleObject::TYPE_IP_RANGE,
|
|
||||||
FirewallRuleObject::TYPE_DEVICE,
|
|
||||||
], true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid rule type filter.');
|
|
||||||
}
|
|
||||||
if ($action !== null && !in_array($action, [
|
|
||||||
FirewallRuleObject::ACTION_ALLOW,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
], true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid rule action filter.');
|
|
||||||
}
|
|
||||||
if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) {
|
|
||||||
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->store->queryRules(
|
|
||||||
$scope->scope,
|
|
||||||
$scope->tenantId,
|
|
||||||
$status,
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$limit,
|
|
||||||
$offset
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
return $this->ownedRule($scope, $ruleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */
|
|
||||||
public function effectivePolicy(string $tenantId): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'],
|
|
||||||
'system' => $this->store->listSystemRules(),
|
|
||||||
'tenant' => $this->store->listRules($tenantId),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createManualRule(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $type,
|
|
||||||
string $action,
|
|
||||||
string $value,
|
|
||||||
string $reason,
|
|
||||||
?string $createdBy,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
?string $currentIp = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$reason = trim($reason);
|
|
||||||
if ($reason === '' || strlen($reason) > 1000) {
|
|
||||||
throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.');
|
|
||||||
}
|
|
||||||
if ($currentIp !== null) {
|
|
||||||
$currentIp = FirewallRuleValidator::ipAddress($currentIp);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!$confirmCurrentIp
|
|
||||||
&& $currentIp !== null
|
|
||||||
&& $action === FirewallRuleObject::ACTION_BLOCK
|
|
||||||
&& $this->matchesIp($type, $value, $currentIp)
|
|
||||||
) {
|
|
||||||
throw new FirewallRuleConflictException(
|
|
||||||
'current_ip_confirmation_required',
|
|
||||||
'This rule would block your current IP address. Explicit confirmation is required.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ([$type, $action]) {
|
|
||||||
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] =>
|
|
||||||
$this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds),
|
|
||||||
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] =>
|
|
||||||
$durationSeconds === null
|
|
||||||
? $this->allowIp($scope, $value, $reason, $createdBy)
|
|
||||||
: throw new \InvalidArgumentException('Temporary allow rules are not supported.'),
|
|
||||||
[FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] =>
|
|
||||||
$durationSeconds === null
|
|
||||||
? $this->blockIpRange($scope, $value, $reason, $createdBy)
|
|
||||||
: throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'),
|
|
||||||
[FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] =>
|
|
||||||
$this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds),
|
|
||||||
default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function matchesIp(string $type, string $value, string $currentIp): bool
|
|
||||||
{
|
|
||||||
if ($type === FirewallRuleObject::TYPE_IP) {
|
|
||||||
$value = FirewallRuleValidator::ipAddress($value);
|
|
||||||
return inet_pton($value) === inet_pton($currentIp);
|
|
||||||
}
|
|
||||||
if ($type === FirewallRuleObject::TYPE_IP_RANGE) {
|
|
||||||
return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value));
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockIp(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $ipAddress,
|
|
||||||
?string $reason,
|
|
||||||
?string $createdBy,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
string $origin = self::ORIGIN_MANUAL,
|
|
||||||
array $metadata = []
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
|
||||||
FirewallRuleValidator::duration($durationSeconds);
|
|
||||||
|
|
||||||
$existing = $this->store->findExactIpRule(
|
|
||||||
$scope->tenantId,
|
|
||||||
$ipAddress,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
$scope->scope
|
|
||||||
);
|
|
||||||
if ($existing) {
|
|
||||||
if (
|
|
||||||
$origin === self::ORIGIN_AUTOMATIC
|
|
||||||
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
|
|
||||||
&& $durationSeconds !== null
|
|
||||||
) {
|
|
||||||
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rule = $this->create(
|
|
||||||
$scope,
|
|
||||||
FirewallRuleObject::TYPE_IP,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
$ipAddress,
|
|
||||||
$reason ?? 'Blocked by administrator',
|
|
||||||
$createdBy,
|
|
||||||
$durationSeconds,
|
|
||||||
$origin,
|
|
||||||
$metadata
|
|
||||||
);
|
|
||||||
$this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function allowIp(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $ipAddress,
|
|
||||||
?string $reason,
|
|
||||||
?string $createdBy,
|
|
||||||
string $origin = self::ORIGIN_MANUAL
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
|
||||||
$rule = $this->create(
|
|
||||||
$scope,
|
|
||||||
FirewallRuleObject::TYPE_IP,
|
|
||||||
FirewallRuleObject::ACTION_ALLOW,
|
|
||||||
$ipAddress,
|
|
||||||
$reason ?? 'Allowed by administrator',
|
|
||||||
$createdBy,
|
|
||||||
null,
|
|
||||||
$origin
|
|
||||||
);
|
|
||||||
$this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockIpRange(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $cidr,
|
|
||||||
?string $reason,
|
|
||||||
?string $createdBy,
|
|
||||||
string $origin = self::ORIGIN_MANUAL
|
|
||||||
): FirewallRuleObject {
|
|
||||||
return $this->create(
|
|
||||||
$scope,
|
|
||||||
FirewallRuleObject::TYPE_IP_RANGE,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
FirewallRuleValidator::cidr($cidr),
|
|
||||||
$reason ?? 'Range blocked by administrator',
|
|
||||||
$createdBy,
|
|
||||||
null,
|
|
||||||
$origin
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockDevice(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $fingerprint,
|
|
||||||
?string $reason,
|
|
||||||
?string $createdBy,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
string $origin = self::ORIGIN_MANUAL
|
|
||||||
): FirewallRuleObject {
|
|
||||||
FirewallRuleValidator::duration($durationSeconds);
|
|
||||||
$fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint);
|
|
||||||
$rule = $this->create(
|
|
||||||
$scope,
|
|
||||||
FirewallRuleObject::TYPE_DEVICE,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
$fingerprint,
|
|
||||||
$reason ?? 'Device blocked by administrator',
|
|
||||||
$createdBy,
|
|
||||||
$durationSeconds,
|
|
||||||
$origin
|
|
||||||
);
|
|
||||||
|
|
||||||
$event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId);
|
|
||||||
$this->events->dispatch($event);
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function disableManual(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $ruleId,
|
|
||||||
string $reason,
|
|
||||||
?string $actorId
|
|
||||||
): ?FirewallRuleObject {
|
|
||||||
$reason = self::manualReason($reason);
|
|
||||||
$rule = $this->ownedRule($scope, $ruleId);
|
|
||||||
if (!$rule) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($rule->isEnabled()) {
|
|
||||||
$rule->setEnabled(false);
|
|
||||||
$this->store->depositRule($rule);
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(
|
|
||||||
FirewallRuleDisabledEvent::class,
|
|
||||||
$rule,
|
|
||||||
$actorId,
|
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enableManual(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $ruleId,
|
|
||||||
string $reason,
|
|
||||||
?string $actorId,
|
|
||||||
?string $currentIp = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): ?FirewallRuleObject {
|
|
||||||
$reason = self::manualReason($reason);
|
|
||||||
$rule = $this->ownedRule($scope, $ruleId);
|
|
||||||
if (!$rule) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!$confirmCurrentIp
|
|
||||||
&& $currentIp !== null
|
|
||||||
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
|
|
||||||
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
|
|
||||||
) {
|
|
||||||
throw new FirewallRuleConflictException(
|
|
||||||
'current_ip_confirmation_required',
|
|
||||||
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!$rule->isEnabled()) {
|
|
||||||
$rule->setEnabled(true);
|
|
||||||
$this->store->depositRule($rule);
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(
|
|
||||||
FirewallRuleEnabledEvent::class,
|
|
||||||
$rule,
|
|
||||||
$actorId,
|
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function extendManual(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $ruleId,
|
|
||||||
int $durationSeconds,
|
|
||||||
string $reason,
|
|
||||||
?string $actorId
|
|
||||||
): ?FirewallRuleObject {
|
|
||||||
$reason = self::manualReason($reason);
|
|
||||||
FirewallRuleValidator::duration($durationSeconds);
|
|
||||||
$rule = $this->ownedRule($scope, $ruleId);
|
|
||||||
if (!$rule) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$previousExpiry = $rule->getExpiresAt();
|
|
||||||
if ($previousExpiry === null) {
|
|
||||||
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
|
|
||||||
}
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
|
|
||||||
->modify("+{$durationSeconds} seconds");
|
|
||||||
$metadata = $rule->getMetadata() ?? [];
|
|
||||||
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
|
|
||||||
$extensions[] = [
|
|
||||||
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
|
|
||||||
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
|
|
||||||
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
|
|
||||||
'origin' => self::ORIGIN_MANUAL,
|
|
||||||
'actorId' => $actorId,
|
|
||||||
'reason' => $reason,
|
|
||||||
];
|
|
||||||
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
|
|
||||||
$this->store->depositRule($rule);
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(
|
|
||||||
FirewallRuleExtendedEvent::class,
|
|
||||||
$rule,
|
|
||||||
$actorId,
|
|
||||||
[
|
|
||||||
'changeReason' => $reason,
|
|
||||||
'changeOrigin' => self::ORIGIN_MANUAL,
|
|
||||||
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeManual(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $ruleId,
|
|
||||||
string $reason,
|
|
||||||
?string $actorId
|
|
||||||
): ?FirewallRuleObject {
|
|
||||||
$reason = self::manualReason($reason);
|
|
||||||
$rule = $this->ownedRule($scope, $ruleId);
|
|
||||||
if (!$rule) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$this->store->destroyRule($rule);
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(
|
|
||||||
FirewallRuleRemovedEvent::class,
|
|
||||||
$rule,
|
|
||||||
$actorId,
|
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function manualReason(string $reason): string
|
|
||||||
{
|
|
||||||
$reason = trim($reason);
|
|
||||||
if ($reason === '' || strlen($reason) > 1000) {
|
|
||||||
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function create(
|
|
||||||
FirewallRuleScope $scope,
|
|
||||||
string $type,
|
|
||||||
string $action,
|
|
||||||
string $value,
|
|
||||||
string $reason,
|
|
||||||
?string $createdBy,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
string $origin = self::ORIGIN_MANUAL,
|
|
||||||
array $metadata = []
|
|
||||||
): FirewallRuleObject {
|
|
||||||
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
|
|
||||||
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$rule = (new FirewallRuleObject())
|
|
||||||
->setScope($scope->scope)
|
|
||||||
->setTenantId($scope->tenantId)
|
|
||||||
->setType($type)
|
|
||||||
->setAction($action)
|
|
||||||
->setValue($value)
|
|
||||||
->setReason($reason)
|
|
||||||
->setCreatedBy($createdBy)
|
|
||||||
->setCreatedAt(new \DateTimeImmutable())
|
|
||||||
->setEnabled(true);
|
|
||||||
|
|
||||||
if ($durationSeconds !== null) {
|
|
||||||
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
|
|
||||||
}
|
|
||||||
$metadata = [...$metadata, 'origin' => $origin];
|
|
||||||
if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) {
|
|
||||||
$metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM);
|
|
||||||
$metadata['extensions'] = [];
|
|
||||||
}
|
|
||||||
$rule->setMetadata($metadata);
|
|
||||||
|
|
||||||
$rule = $this->store->depositRule($rule)
|
|
||||||
?? throw new \RuntimeException('Failed to persist firewall rule.');
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extendAutomaticBlock(
|
|
||||||
FirewallRuleObject $rule,
|
|
||||||
int $durationSeconds,
|
|
||||||
array $policy
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$previousExpiry = $rule->getExpiresAt();
|
|
||||||
$newExpiry = $now->modify("+{$durationSeconds} seconds");
|
|
||||||
if ($previousExpiry !== null && $newExpiry <= $previousExpiry) {
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = $rule->getMetadata() ?? [];
|
|
||||||
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
|
|
||||||
$extensions[] = [
|
|
||||||
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
|
|
||||||
'previousExpiresAt' => $previousExpiry?->format(\DateTimeInterface::ATOM),
|
|
||||||
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
|
|
||||||
'failureCount' => $policy['lastFailureCount'] ?? null,
|
|
||||||
];
|
|
||||||
$rule->setExpiresAt($newExpiry)->setMetadata([
|
|
||||||
...$metadata,
|
|
||||||
...$policy,
|
|
||||||
'origin' => self::ORIGIN_AUTOMATIC,
|
|
||||||
'originalExpiresAt' => $metadata['originalExpiresAt']
|
|
||||||
?? $previousExpiry?->format(\DateTimeInterface::ATOM),
|
|
||||||
'extensions' => $extensions,
|
|
||||||
'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->store->depositRule($rule);
|
|
||||||
$this->cache->invalidate();
|
|
||||||
$this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
|
|
||||||
|
|
||||||
return $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$rule = $this->store->fetchRule($ruleId);
|
|
||||||
|
|
||||||
return $rule && $scope->owns($rule) ? $rule : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param class-string<FirewallRuleEvent> $eventClass
|
|
||||||
*/
|
|
||||||
private function publishLifecycleEvent(
|
|
||||||
string $eventClass,
|
|
||||||
FirewallRuleObject $rule,
|
|
||||||
?string $actorId = null,
|
|
||||||
array $change = []
|
|
||||||
): void
|
|
||||||
{
|
|
||||||
$event = $eventClass::fromRule($rule, $actorId, $change);
|
|
||||||
$this->events->dispatch($event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
|
|
||||||
final class FirewallRuleScope
|
|
||||||
{
|
|
||||||
private function __construct(
|
|
||||||
public readonly string $scope,
|
|
||||||
public readonly ?string $tenantId,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function tenant(string $tenantId): self
|
|
||||||
{
|
|
||||||
if ($tenantId === '') {
|
|
||||||
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function system(): self
|
|
||||||
{
|
|
||||||
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function owns(FirewallRuleObject $rule): bool
|
|
||||||
{
|
|
||||||
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
final class FirewallRuleValidator
|
|
||||||
{
|
|
||||||
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
|
|
||||||
|
|
||||||
private function __construct()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function ipAddress(string $ipAddress): string
|
|
||||||
{
|
|
||||||
$ipAddress = trim($ipAddress);
|
|
||||||
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
|
|
||||||
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function cidr(string $cidr): string
|
|
||||||
{
|
|
||||||
$cidr = trim($cidr);
|
|
||||||
if (substr_count($cidr, '/') !== 1) {
|
|
||||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[$address, $prefix] = explode('/', $cidr, 2);
|
|
||||||
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
|
|
||||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
|
|
||||||
if ((int)$prefix > $maximumPrefix) {
|
|
||||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cidr;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function deviceFingerprint(string $fingerprint): string
|
|
||||||
{
|
|
||||||
$fingerprint = trim($fingerprint);
|
|
||||||
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function duration(?int $durationSeconds): void
|
|
||||||
{
|
|
||||||
if ($durationSeconds !== null && $durationSeconds < 1) {
|
|
||||||
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,26 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Request\RequestContext;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXC\Security\Event\AccessDeniedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
|
||||||
use KTXC\Security\Event\BruteForceDetectedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
|
||||||
use KTXC\Security\Event\RateLimitExceededEvent;
|
|
||||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventInterface;
|
|
||||||
use KTXC\Security\Event\SecurityRequestEventInterface;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
|
use KTXF\Event\SecurityEvent;
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,9 +29,6 @@ class FirewallService
|
|||||||
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
||||||
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
||||||
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
|
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
|
||||||
private const MAX_AUTH_FAILURES = 1000;
|
|
||||||
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
|
|
||||||
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
|
|
||||||
|
|
||||||
// Configuration keys
|
// Configuration keys
|
||||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||||
@@ -53,13 +36,13 @@ class FirewallService
|
|||||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||||
|
|
||||||
|
/** @var FirewallRuleObject[]|null */
|
||||||
|
private ?array $rulesCache = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FirewallStore $store,
|
private readonly FirewallStore $store,
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly TenantContextInterface $tenantContext,
|
||||||
private readonly EventDispatcherInterface $events,
|
private readonly EventDispatcherInterface $events,
|
||||||
private readonly FirewallRuleManager $rules,
|
|
||||||
private readonly FirewallRuleCache $ruleCache,
|
|
||||||
private readonly RequestContext $requestContext,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,33 +71,36 @@ class FirewallService
|
|||||||
string $ipAddress,
|
string $ipAddress,
|
||||||
?string $deviceFingerprint = null
|
?string $deviceFingerprint = null
|
||||||
): FirewallAnalyzeResult {
|
): FirewallAnalyzeResult {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
// Check if firewall is enabled for this tenant
|
||||||
$ruleGroups = [
|
if (!$this->isEnabled()) {
|
||||||
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK],
|
return new FirewallAnalyzeResult(true);
|
||||||
];
|
|
||||||
|
|
||||||
if ($tenantId !== null && $this->isEnabled()) {
|
|
||||||
$tenantRules = $this->ruleCache->tenant($tenantId);
|
|
||||||
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
|
|
||||||
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
return new FirewallAnalyzeResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($ruleGroups as [$rules, $action]) {
|
$rules = $this->getActiveRules();
|
||||||
foreach ($rules as $rule) {
|
|
||||||
if ($rule->getAction() !== $action) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
// First check for explicit allow rules (whitelist takes precedence)
|
||||||
continue;
|
foreach ($rules as $rule) {
|
||||||
}
|
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
|
||||||
|
continue;
|
||||||
if ($action === FirewallRuleObject::ACTION_ALLOW) {
|
}
|
||||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
|
||||||
}
|
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||||
|
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check for block rules
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||||
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
|
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
|
||||||
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
||||||
}
|
}
|
||||||
@@ -145,31 +131,23 @@ class FirewallService
|
|||||||
/**
|
/**
|
||||||
* Handle authentication failure event
|
* Handle authentication failure event
|
||||||
*/
|
*/
|
||||||
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
public function handleAuthFailure(SecurityEvent $event): void
|
||||||
{
|
{
|
||||||
$request = $this->requestContext->current();
|
$ipAddress = $event->getIpAddress();
|
||||||
$ipAddress = $request?->getClientIp();
|
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||||
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
|
||||||
|
|
||||||
if (!$ipAddress || !$tenantId) {
|
if (!$ipAddress || !$tenantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$log = $this->securityLog($event, $request);
|
|
||||||
if ($log === null || !$this->store->createLogOnce($log)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for brute force
|
// Check for brute force
|
||||||
$windowSeconds = $this->getBoundedIntegerConfig(
|
$windowSeconds = $this->getConfig(
|
||||||
self::CONFIG_FAILURE_WINDOW,
|
self::CONFIG_FAILURE_WINDOW,
|
||||||
self::DEFAULT_AUTH_FAILURE_WINDOW,
|
self::DEFAULT_AUTH_FAILURE_WINDOW
|
||||||
self::MAX_AUTH_FAILURE_WINDOW
|
|
||||||
);
|
);
|
||||||
$maxFailures = $this->getBoundedIntegerConfig(
|
$maxFailures = $this->getConfig(
|
||||||
self::CONFIG_MAX_FAILURES,
|
self::CONFIG_MAX_FAILURES,
|
||||||
self::DEFAULT_MAX_AUTH_FAILURES,
|
self::DEFAULT_MAX_AUTH_FAILURES
|
||||||
self::MAX_AUTH_FAILURES
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$failureCount = $this->store->countRecentFailures(
|
$failureCount = $this->store->countRecentFailures(
|
||||||
@@ -178,24 +156,11 @@ class FirewallService
|
|||||||
$windowSeconds
|
$windowSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($failureCount >= $maxFailures) {
|
// Include current failure in count
|
||||||
$blockDuration = $this->getBoundedIntegerConfig(
|
$failureCount++;
|
||||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
|
||||||
self::DEFAULT_AUTO_BLOCK_DURATION,
|
|
||||||
self::MAX_AUTO_BLOCK_DURATION
|
|
||||||
);
|
|
||||||
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
|
|
||||||
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->handleBruteForce(
|
if ($failureCount >= $maxFailures) {
|
||||||
$tenantId,
|
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
|
||||||
$ipAddress,
|
|
||||||
$failureCount,
|
|
||||||
$windowSeconds,
|
|
||||||
$blockDuration
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,91 +168,53 @@ class FirewallService
|
|||||||
* Handle detected brute force attack
|
* Handle detected brute force attack
|
||||||
*/
|
*/
|
||||||
private function handleBruteForce(
|
private function handleBruteForce(
|
||||||
string $tenantId,
|
|
||||||
string $ipAddress,
|
string $ipAddress,
|
||||||
int $failureCount,
|
int $failureCount,
|
||||||
int $windowSeconds,
|
int $windowSeconds
|
||||||
int $blockDuration
|
|
||||||
): void {
|
): void {
|
||||||
// Publish brute force event
|
// Publish brute force event
|
||||||
$event = new BruteForceDetectedEvent(
|
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||||
$ipAddress,
|
$event->setTenantId($this->tenantContext->identifier());
|
||||||
$failureCount,
|
|
||||||
$windowSeconds,
|
|
||||||
$tenantId,
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
$this->rules->blockIp(
|
// Auto-block the IP
|
||||||
FirewallRuleScope::tenant($tenantId),
|
$blockDuration = $this->getConfig(
|
||||||
|
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||||
|
self::DEFAULT_AUTO_BLOCK_DURATION
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->blockIp(
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||||
null, // System-created
|
null, // System-created
|
||||||
$blockDuration,
|
$blockDuration
|
||||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
|
||||||
[
|
|
||||||
'failureThreshold' => $this->getBoundedIntegerConfig(
|
|
||||||
self::CONFIG_MAX_FAILURES,
|
|
||||||
self::DEFAULT_MAX_AUTH_FAILURES,
|
|
||||||
self::MAX_AUTH_FAILURES
|
|
||||||
),
|
|
||||||
'failureWindowSeconds' => $windowSeconds,
|
|
||||||
'lastFailureCount' => $failureCount,
|
|
||||||
'blockDurationSeconds' => $blockDuration,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log security event to firewall logs
|
* Log security event to firewall logs
|
||||||
*/
|
*/
|
||||||
public function logSecurityEvent(SecurityEventInterface $event): void
|
public function logSecurityEvent(SecurityEvent $event): void
|
||||||
{
|
{
|
||||||
$log = $this->securityLog($event);
|
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||||
if ($log !== null) {
|
if (!$tenantId) {
|
||||||
$this->store->createLog($log);
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
|
|
||||||
{
|
|
||||||
$log = $this->securityLog($event, $this->requestContext->current());
|
|
||||||
if ($log !== null) {
|
|
||||||
$this->store->createLog($log);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function securityLog(
|
|
||||||
SecurityEventInterface $event,
|
|
||||||
?Request $request = null,
|
|
||||||
): ?FirewallLogObject
|
|
||||||
{
|
|
||||||
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
|
||||||
$ruleScope = $event->get('ruleScope');
|
|
||||||
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
|
|
||||||
|
|
||||||
$log = new FirewallLogObject();
|
$log = new FirewallLogObject();
|
||||||
return $log->setEventId($event->identifier())
|
$log->setTenantId($tenantId)
|
||||||
->setTenantId($tenantId)
|
->setIpAddress($event->getIpAddress())
|
||||||
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
|
->setDeviceFingerprint($event->getDeviceFingerprint())
|
||||||
->setDeviceFingerprint(
|
->setUserAgent($event->getUserAgent())
|
||||||
$request?->headers->get('X-Device-Fingerprint')
|
->setRequestPath($event->getRequestPath())
|
||||||
?? $requestEvent?->getDeviceFingerprint()
|
->setRequestMethod($event->getRequestMethod())
|
||||||
)
|
->setEventType($this->mapEventToLogType($event->getName()))
|
||||||
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
|
->setResult($this->mapEventToResult($event->getName()))
|
||||||
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
|
->setIdentityId($event->getUserId())
|
||||||
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
|
|
||||||
->setEventType($this->mapEventToLogType($event->label()))
|
|
||||||
->setResult($this->mapEventToResult($event))
|
|
||||||
->setRuleId($event->get('ruleId'))
|
|
||||||
->setRuleScope($ruleScope)
|
|
||||||
->setIdentityId($event->getUserId() ?? $event->actorIdentity())
|
|
||||||
->setTimestamp(new \DateTimeImmutable())
|
->setTimestamp(new \DateTimeImmutable())
|
||||||
->setMetadata($event->context());
|
->setMetadata($event->getData());
|
||||||
|
|
||||||
|
$this->store->createLog($log);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -296,18 +223,12 @@ class FirewallService
|
|||||||
private function mapEventToLogType(string $eventName): string
|
private function mapEventToLogType(string $eventName): string
|
||||||
{
|
{
|
||||||
return match ($eventName) {
|
return match ($eventName) {
|
||||||
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
|
SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||||
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK,
|
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE,
|
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||||
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
|
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||||
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
|
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
||||||
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
|
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||||
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
|
|
||||||
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
|
|
||||||
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
|
|
||||||
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
|
|
||||||
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
|
|
||||||
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
|
||||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -315,16 +236,11 @@ class FirewallService
|
|||||||
/**
|
/**
|
||||||
* Map security event to result
|
* Map security event to result
|
||||||
*/
|
*/
|
||||||
private function mapEventToResult(SecurityEventInterface $event): string
|
private function mapEventToResult(string $eventName): string
|
||||||
{
|
{
|
||||||
return match ($event->label()) {
|
return match ($eventName) {
|
||||||
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
|
SecurityEvent::AUTH_SUCCESS,
|
||||||
FirewallRuleCreatedEvent::class,
|
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||||
FirewallRuleExtendedEvent::class,
|
|
||||||
FirewallRuleEnabledEvent::class,
|
|
||||||
FirewallRuleDisabledEvent::class,
|
|
||||||
FirewallRuleRemovedEvent::class,
|
|
||||||
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
|
|
||||||
default => FirewallLogObject::RESULT_BLOCKED,
|
default => FirewallLogObject::RESULT_BLOCKED,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -337,17 +253,272 @@ class FirewallService
|
|||||||
?string $deviceFingerprint,
|
?string $deviceFingerprint,
|
||||||
FirewallRuleObject $rule
|
FirewallRuleObject $rule
|
||||||
): void {
|
): void {
|
||||||
$event = new AccessDeniedEvent(
|
$event = SecurityEvent::accessDenied(
|
||||||
ipAddress: $ipAddress,
|
$ipAddress,
|
||||||
ruleId: $rule->getId(),
|
$deviceFingerprint,
|
||||||
ruleScope: $rule->getScope(),
|
$rule->getId(),
|
||||||
deviceFingerprint: $deviceFingerprint,
|
$rule->getReason()
|
||||||
reason: $rule->getReason(),
|
|
||||||
tenantId: $this->tenantContext->identifier(),
|
|
||||||
);
|
);
|
||||||
|
$event->setTenantId($this->tenantContext->identifier());
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Rule Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block an IP address
|
||||||
|
*/
|
||||||
|
public function blockIp(
|
||||||
|
string $ipAddress,
|
||||||
|
?string $reason = null,
|
||||||
|
?string $createdBy = null,
|
||||||
|
?int $durationSeconds = null
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already blocked
|
||||||
|
$existing = $this->store->findExactIpRule(
|
||||||
|
$tenantId,
|
||||||
|
$ipAddress,
|
||||||
|
FirewallRuleObject::ACTION_BLOCK
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = new FirewallRuleObject();
|
||||||
|
$rule->setTenantId($tenantId)
|
||||||
|
->setType(FirewallRuleObject::TYPE_IP)
|
||||||
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
|
->setValue($ipAddress)
|
||||||
|
->setReason($reason ?? 'Blocked by administrator')
|
||||||
|
->setCreatedBy($createdBy)
|
||||||
|
->setCreatedAt(new \DateTimeImmutable())
|
||||||
|
->setEnabled(true);
|
||||||
|
|
||||||
|
if ($durationSeconds !== null) {
|
||||||
|
$rule->setExpiresAt(
|
||||||
|
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->store->depositRule($rule);
|
||||||
|
$this->clearRulesCache();
|
||||||
|
|
||||||
|
// Publish event
|
||||||
|
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||||
|
$event->setIpAddress($ipAddress)
|
||||||
|
->setReason($reason)
|
||||||
|
->setTenantId($tenantId);
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow an IP address (whitelist)
|
||||||
|
*/
|
||||||
|
public function allowIp(
|
||||||
|
string $ipAddress,
|
||||||
|
?string $reason = null,
|
||||||
|
?string $createdBy = null
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = new FirewallRuleObject();
|
||||||
|
$rule->setTenantId($tenantId)
|
||||||
|
->setType(FirewallRuleObject::TYPE_IP)
|
||||||
|
->setAction(FirewallRuleObject::ACTION_ALLOW)
|
||||||
|
->setValue($ipAddress)
|
||||||
|
->setReason($reason ?? 'Allowed by administrator')
|
||||||
|
->setCreatedBy($createdBy)
|
||||||
|
->setCreatedAt(new \DateTimeImmutable())
|
||||||
|
->setEnabled(true);
|
||||||
|
|
||||||
|
$this->store->depositRule($rule);
|
||||||
|
$this->clearRulesCache();
|
||||||
|
|
||||||
|
// Publish event
|
||||||
|
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||||
|
$event->setIpAddress($ipAddress)
|
||||||
|
->setReason($reason)
|
||||||
|
->setTenantId($tenantId);
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block an IP range (CIDR notation)
|
||||||
|
*/
|
||||||
|
public function blockIpRange(
|
||||||
|
string $cidr,
|
||||||
|
?string $reason = null,
|
||||||
|
?string $createdBy = null
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = new FirewallRuleObject();
|
||||||
|
$rule->setTenantId($tenantId)
|
||||||
|
->setType(FirewallRuleObject::TYPE_IP_RANGE)
|
||||||
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
|
->setValue($cidr)
|
||||||
|
->setReason($reason ?? 'Range blocked by administrator')
|
||||||
|
->setCreatedBy($createdBy)
|
||||||
|
->setCreatedAt(new \DateTimeImmutable())
|
||||||
|
->setEnabled(true);
|
||||||
|
|
||||||
|
$this->store->depositRule($rule);
|
||||||
|
$this->clearRulesCache();
|
||||||
|
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a device fingerprint
|
||||||
|
*/
|
||||||
|
public function blockDevice(
|
||||||
|
string $fingerprint,
|
||||||
|
?string $reason = null,
|
||||||
|
?string $createdBy = null,
|
||||||
|
?int $durationSeconds = null
|
||||||
|
): FirewallRuleObject {
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule = new FirewallRuleObject();
|
||||||
|
$rule->setTenantId($tenantId)
|
||||||
|
->setType(FirewallRuleObject::TYPE_DEVICE)
|
||||||
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
|
->setValue($fingerprint)
|
||||||
|
->setReason($reason ?? 'Device blocked by administrator')
|
||||||
|
->setCreatedBy($createdBy)
|
||||||
|
->setCreatedAt(new \DateTimeImmutable())
|
||||||
|
->setEnabled(true);
|
||||||
|
|
||||||
|
if ($durationSeconds !== null) {
|
||||||
|
$rule->setExpiresAt(
|
||||||
|
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->store->depositRule($rule);
|
||||||
|
$this->clearRulesCache();
|
||||||
|
|
||||||
|
// Publish event
|
||||||
|
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
|
||||||
|
$event->setDeviceFingerprint($fingerprint)
|
||||||
|
->setReason($reason)
|
||||||
|
->setTenantId($tenantId);
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a rule by ID
|
||||||
|
*/
|
||||||
|
public function removeRule(string $ruleId): bool
|
||||||
|
{
|
||||||
|
$rule = $this->store->fetchRule($ruleId);
|
||||||
|
if (!$rule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenant ownership
|
||||||
|
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->store->destroyRule($rule);
|
||||||
|
$this->clearRulesCache();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a rule (soft delete)
|
||||||
|
*/
|
||||||
|
public function disableRule(string $ruleId): bool
|
||||||
|
{
|
||||||
|
$rule = $this->store->fetchRule($ruleId);
|
||||||
|
if (!$rule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenant ownership
|
||||||
|
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rule->setEnabled(false);
|
||||||
|
$this->store->depositRule($rule);
|
||||||
|
$this->clearRulesCache();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all rules for current tenant
|
||||||
|
*/
|
||||||
|
public function listRules(bool $activeOnly = true): array
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->store->listRules($tenantId, $activeOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get firewall logs for current tenant
|
||||||
|
*/
|
||||||
|
public function getLogs(
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $eventType = null,
|
||||||
|
?string $result = null,
|
||||||
|
int $limit = 100
|
||||||
|
): array {
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get blocked requests count
|
||||||
|
*/
|
||||||
|
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantContext->identifier();
|
||||||
|
if (!$tenantId) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->store->countBlockedRequests($tenantId, $since);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Helpers
|
||||||
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if firewall is enabled for current tenant
|
* Check if firewall is enabled for current tenant
|
||||||
*/
|
*/
|
||||||
@@ -362,9 +533,6 @@ class FirewallService
|
|||||||
private function getConfig(string $key, mixed $default = null): mixed
|
private function getConfig(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$config = $this->tenantContext->configuration();
|
$config = $this->tenantContext->configuration();
|
||||||
if ($config instanceof \JsonSerializable) {
|
|
||||||
$config = $config->jsonSerialize();
|
|
||||||
}
|
|
||||||
$parts = explode('.', $key);
|
$parts = explode('.', $key);
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
foreach ($parts as $part) {
|
||||||
@@ -377,14 +545,27 @@ class FirewallService
|
|||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
|
/**
|
||||||
|
* Get active rules (cached)
|
||||||
|
* @return FirewallRuleObject[]
|
||||||
|
*/
|
||||||
|
private function getActiveRules(): array
|
||||||
{
|
{
|
||||||
$value = $this->getConfig($key, $default);
|
if ($this->rulesCache === null) {
|
||||||
if (!is_int($value) || $value < 1 || $value > $maximum) {
|
$tenantId = $this->tenantContext->identifier();
|
||||||
return $default;
|
$this->rulesCache = $tenantId
|
||||||
|
? $this->store->listRules($tenantId, true)
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
return $this->rulesCache;
|
||||||
|
}
|
||||||
|
|
||||||
return $value;
|
/**
|
||||||
|
* Clear rules cache
|
||||||
|
*/
|
||||||
|
private function clearRulesCache(): void
|
||||||
|
{
|
||||||
|
$this->rulesCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -392,31 +573,13 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function cleanup(): array
|
public function cleanup(): array
|
||||||
{
|
{
|
||||||
$startedAt = new \DateTimeImmutable();
|
$expiredRules = $this->store->cleanupExpiredRules();
|
||||||
|
$oldLogs = $this->store->cleanupOldLogs(30);
|
||||||
|
|
||||||
try {
|
return [
|
||||||
$result = [
|
'expiredRules' => $expiredRules,
|
||||||
'expiredRules' => $this->store->cleanupExpiredRules(),
|
'oldLogs' => $oldLogs,
|
||||||
'oldLogs' => $this->store->cleanupOldLogs(30),
|
];
|
||||||
'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(),
|
|
||||||
];
|
|
||||||
$this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result);
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
} catch (\Throwable $error) {
|
|
||||||
try {
|
|
||||||
$this->store->recordMaintenanceStatus(
|
|
||||||
$startedAt,
|
|
||||||
new \DateTimeImmutable(),
|
|
||||||
'failed',
|
|
||||||
[],
|
|
||||||
$error->getMessage()
|
|
||||||
);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Preserve the cleanup failure when the status store is also unavailable.
|
|
||||||
}
|
|
||||||
throw $error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
|
||||||
|
|
||||||
final class FirewallSettingsService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly TenantService $tenants,
|
|
||||||
private readonly EventDispatcherInterface $events,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(
|
|
||||||
string $tenantId,
|
|
||||||
bool $enabled,
|
|
||||||
int $maxAuthFailures,
|
|
||||||
int $authFailureWindow,
|
|
||||||
int $autoBlockDuration,
|
|
||||||
string $reason,
|
|
||||||
?string $actorId
|
|
||||||
): ?array {
|
|
||||||
$reason = trim($reason);
|
|
||||||
if ($reason === '' || strlen($reason) > 1000) {
|
|
||||||
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
|
|
||||||
}
|
|
||||||
self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures');
|
|
||||||
self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window');
|
|
||||||
self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration');
|
|
||||||
|
|
||||||
$tenant = $this->tenants->fetchById($tenantId);
|
|
||||||
if ($tenant === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $tenant->getConfiguration()->firewall()->jsonSerialize();
|
|
||||||
$current = [
|
|
||||||
'enabled' => $enabled,
|
|
||||||
'maxAuthFailures' => $maxAuthFailures,
|
|
||||||
'authFailureWindow' => $authFailureWindow,
|
|
||||||
'autoBlockDuration' => $autoBlockDuration,
|
|
||||||
];
|
|
||||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
|
||||||
...$tenant->getConfiguration()->jsonSerialize(),
|
|
||||||
'firewall' => $current,
|
|
||||||
]);
|
|
||||||
$tenant->setConfiguration($configuration);
|
|
||||||
$this->tenants->deposit($tenant);
|
|
||||||
|
|
||||||
$event = new FirewallSettingsUpdatedEvent(
|
|
||||||
changeReason: $reason,
|
|
||||||
previous: $previous,
|
|
||||||
current: $current,
|
|
||||||
tenantId: $tenantId,
|
|
||||||
actorId: $actorId,
|
|
||||||
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
|
||||||
|
|
||||||
return $current;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function bounded(int $value, int $minimum, int $maximum, string $label): void
|
|
||||||
{
|
|
||||||
if ($value < $minimum || $value > $maximum) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
"{$label} must be between {$minimum} and {$maximum}."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
|
|
||||||
final class FirewallStatusService
|
|
||||||
{
|
|
||||||
public function __construct(private readonly FirewallStore $store)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantMetrics(string $tenantId, ?string $since = null): array
|
|
||||||
{
|
|
||||||
$sinceDate = self::date($since);
|
|
||||||
return [
|
|
||||||
'tenantId' => $tenantId,
|
|
||||||
'blockedRequests' => $this->store->countBlockedRequests($tenantId, $sinceDate),
|
|
||||||
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function systemMetrics(?string $tenantId = null, ?string $since = null): array
|
|
||||||
{
|
|
||||||
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid tenant filter.');
|
|
||||||
}
|
|
||||||
$sinceDate = self::date($since);
|
|
||||||
return [
|
|
||||||
'tenantId' => $tenantId,
|
|
||||||
'blockedRequests' => $this->store->countSystemBlockedRequests($tenantId, $sinceDate),
|
|
||||||
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function maintenanceStatus(): array
|
|
||||||
{
|
|
||||||
return $this->store->maintenanceStatus() ?? [
|
|
||||||
'status' => 'never_run',
|
|
||||||
'startedAt' => null,
|
|
||||||
'completedAt' => null,
|
|
||||||
'result' => null,
|
|
||||||
'error' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function date(?string $value): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($value === '' || strlen($value) > 255) {
|
|
||||||
throw new \InvalidArgumentException('Invalid since date filter.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new \DateTimeImmutable($value);
|
|
||||||
} catch (\Exception) {
|
|
||||||
throw new \InvalidArgumentException('Invalid since date filter.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
|
|
||||||
final class SystemFirewallLogService
|
|
||||||
{
|
|
||||||
public const PERMISSION_READ = 'firewall.system.logs.read';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallLogService $logs,
|
|
||||||
private readonly IdentityContextInterface $identity,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(?string $tenantId, array $filters, int $limit = 50, int $offset = 0): array
|
|
||||||
{
|
|
||||||
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
|
|
||||||
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
|
|
||||||
}
|
|
||||||
return $this->logs->system($tenantId, $filters, $limit, $offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
|
|
||||||
final class SystemFirewallRuleService
|
|
||||||
{
|
|
||||||
public const PERMISSION_READ = 'firewall.system.rules.read';
|
|
||||||
public const PERMISSION_MANAGE = 'firewall.system.rules.manage';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallRuleManager $rules,
|
|
||||||
private readonly IdentityContextInterface $identity,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listRules(bool $activeOnly = true): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->list(FirewallRuleScope::system(), $activeOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function queryRules(
|
|
||||||
string $status = 'active',
|
|
||||||
?string $type = null,
|
|
||||||
?string $action = null,
|
|
||||||
int $limit = 50,
|
|
||||||
int $offset = 0
|
|
||||||
): array {
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->query(FirewallRuleScope::system(), $status, $type, $action, $limit, $offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetchRule(string $ruleId): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->fetch(FirewallRuleScope::system(), $ruleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createRule(
|
|
||||||
string $type,
|
|
||||||
string $action,
|
|
||||||
string $value,
|
|
||||||
string $reason,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
?string $currentIp = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->createManualRule(
|
|
||||||
FirewallRuleScope::system(),
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$value,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier(),
|
|
||||||
$durationSeconds,
|
|
||||||
$currentIp,
|
|
||||||
$confirmCurrentIp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->blockIp(
|
|
||||||
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->allowIp(
|
|
||||||
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->blockIpRange(
|
|
||||||
FirewallRuleScope::system(), $cidr, $reason, $this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockDevice(
|
|
||||||
string $fingerprint,
|
|
||||||
?string $reason = null,
|
|
||||||
?int $durationSeconds = null
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->blockDevice(
|
|
||||||
FirewallRuleScope::system(),
|
|
||||||
$fingerprint,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier(),
|
|
||||||
$durationSeconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->disableManual(
|
|
||||||
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enableRule(
|
|
||||||
string $ruleId,
|
|
||||||
string $reason,
|
|
||||||
?string $currentIp = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): ?FirewallRuleObject {
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->enableManual(
|
|
||||||
FirewallRuleScope::system(),
|
|
||||||
$ruleId,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier(),
|
|
||||||
$currentIp,
|
|
||||||
$confirmCurrentIp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->extendManual(
|
|
||||||
FirewallRuleScope::system(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->removeManual(
|
|
||||||
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requirePermission(string $permission): void
|
|
||||||
{
|
|
||||||
if (!$this->identity->hasPermission($permission)) {
|
|
||||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
|
|
||||||
final class SystemFirewallStatusService
|
|
||||||
{
|
|
||||||
public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read';
|
|
||||||
public const PERMISSION_SETTINGS_MANAGE = 'firewall.system.settings.manage';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallStatusService $status,
|
|
||||||
private readonly IdentityContextInterface $identity,
|
|
||||||
private readonly FirewallSettingsService $settings,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function metrics(?string $tenantId = null, ?string $since = null): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(SystemFirewallLogService::PERMISSION_READ);
|
|
||||||
return $this->status->systemMetrics($tenantId, $since);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function maintenanceStatus(): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MAINTENANCE_READ);
|
|
||||||
return $this->status->maintenanceStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateTenantConfiguration(
|
|
||||||
string $tenantId,
|
|
||||||
bool $enabled,
|
|
||||||
int $maxAuthFailures,
|
|
||||||
int $authFailureWindow,
|
|
||||||
int $autoBlockDuration,
|
|
||||||
string $reason
|
|
||||||
): ?array {
|
|
||||||
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
|
|
||||||
return $this->settings->update(
|
|
||||||
$tenantId,
|
|
||||||
$enabled,
|
|
||||||
$maxAuthFailures,
|
|
||||||
$authFailureWindow,
|
|
||||||
$autoBlockDuration,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requirePermission(string $permission): void
|
|
||||||
{
|
|
||||||
if (!$this->identity->hasPermission($permission)) {
|
|
||||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
|
|
||||||
final class TenantFirewallLogService
|
|
||||||
{
|
|
||||||
public const PERMISSION_READ = 'firewall.tenant.logs.read';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallLogService $logs,
|
|
||||||
private readonly TenantContextInterface $tenant,
|
|
||||||
private readonly IdentityContextInterface $identity,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(array $filters, int $limit = 50, int $offset = 0): array
|
|
||||||
{
|
|
||||||
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
|
|
||||||
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
|
|
||||||
}
|
|
||||||
return $this->logs->tenant($this->tenant->requireIdentifier(), $filters, $limit, $offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
|
|
||||||
final class TenantFirewallRuleService
|
|
||||||
{
|
|
||||||
public const PERMISSION_READ = 'firewall.tenant.rules.read';
|
|
||||||
public const PERMISSION_MANAGE = 'firewall.tenant.rules.manage';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallRuleManager $rules,
|
|
||||||
private readonly TenantContextInterface $tenant,
|
|
||||||
private readonly IdentityContextInterface $identity,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function listRules(bool $activeOnly = true): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->list($this->scope(), $activeOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function queryRules(
|
|
||||||
string $status = 'active',
|
|
||||||
?string $type = null,
|
|
||||||
?string $action = null,
|
|
||||||
int $limit = 50,
|
|
||||||
int $offset = 0
|
|
||||||
): array {
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->query($this->scope(), $status, $type, $action, $limit, $offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetchRule(string $ruleId): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->fetch($this->scope(), $ruleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createRule(
|
|
||||||
string $type,
|
|
||||||
string $action,
|
|
||||||
string $value,
|
|
||||||
string $reason,
|
|
||||||
?int $durationSeconds = null,
|
|
||||||
?string $currentIp = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->createManualRule(
|
|
||||||
$this->scope(),
|
|
||||||
$type,
|
|
||||||
$action,
|
|
||||||
$value,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier(),
|
|
||||||
$durationSeconds,
|
|
||||||
$currentIp,
|
|
||||||
$confirmCurrentIp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function effectivePolicy(): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_READ);
|
|
||||||
return $this->rules->effectivePolicy($this->tenant->requireIdentifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->blockIp(
|
|
||||||
$this->scope(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->allowIp($this->scope(), $ip, $reason, $this->identity->identifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->blockIpRange($this->scope(), $cidr, $reason, $this->identity->identifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function blockDevice(
|
|
||||||
string $fingerprint,
|
|
||||||
?string $reason = null,
|
|
||||||
?int $durationSeconds = null
|
|
||||||
): FirewallRuleObject {
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->blockDevice(
|
|
||||||
$this->scope(), $fingerprint, $reason, $this->identity->identifier(), $durationSeconds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enableRule(
|
|
||||||
string $ruleId,
|
|
||||||
string $reason,
|
|
||||||
?string $currentIp = null,
|
|
||||||
bool $confirmCurrentIp = false
|
|
||||||
): ?FirewallRuleObject {
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->enableManual(
|
|
||||||
$this->scope(),
|
|
||||||
$ruleId,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier(),
|
|
||||||
$currentIp,
|
|
||||||
$confirmCurrentIp
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->extendManual(
|
|
||||||
$this->scope(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
|
||||||
return $this->rules->removeManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function scope(): FirewallRuleScope
|
|
||||||
{
|
|
||||||
return FirewallRuleScope::tenant($this->tenant->requireIdentifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requirePermission(string $permission): void
|
|
||||||
{
|
|
||||||
if (!$this->identity->hasPermission($permission)) {
|
|
||||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
|
|
||||||
final class TenantFirewallStatusService
|
|
||||||
{
|
|
||||||
public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read';
|
|
||||||
public const PERMISSION_SETTINGS_MANAGE = 'firewall.tenant.settings.manage';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FirewallStatusService $status,
|
|
||||||
private readonly TenantContextInterface $tenant,
|
|
||||||
private readonly IdentityContextInterface $identity,
|
|
||||||
private readonly FirewallSettingsService $settings,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function metrics(?string $since = null): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(TenantFirewallLogService::PERMISSION_READ);
|
|
||||||
return $this->status->tenantMetrics($this->tenant->requireIdentifier(), $since);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configuration(): array
|
|
||||||
{
|
|
||||||
$this->requirePermission(self::PERMISSION_SETTINGS_READ);
|
|
||||||
return $this->tenant->configuration()?->firewall()->jsonSerialize()
|
|
||||||
?? [
|
|
||||||
'enabled' => true,
|
|
||||||
'maxAuthFailures' => 5,
|
|
||||||
'authFailureWindow' => 300,
|
|
||||||
'autoBlockDuration' => 3600,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateConfiguration(
|
|
||||||
bool $enabled,
|
|
||||||
int $maxAuthFailures,
|
|
||||||
int $authFailureWindow,
|
|
||||||
int $autoBlockDuration,
|
|
||||||
string $reason
|
|
||||||
): ?array {
|
|
||||||
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
|
|
||||||
return $this->settings->update(
|
|
||||||
$this->tenant->requireIdentifier(),
|
|
||||||
$enabled,
|
|
||||||
$maxAuthFailures,
|
|
||||||
$authFailureWindow,
|
|
||||||
$autoBlockDuration,
|
|
||||||
$reason,
|
|
||||||
$this->identity->identifier()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function requirePermission(string $permission): void
|
|
||||||
{
|
|
||||||
if (!$this->identity->hasPermission($permission)) {
|
|
||||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,14 @@ use KTXC\Models\Identity\User;
|
|||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\Context\IdentityContextInterface;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use KTXC\User\Event\UserCreatedEvent;
|
|
||||||
use KTXC\User\Event\UserDeletingEvent;
|
|
||||||
use KTXC\User\Event\UserUpdatedEvent;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
|
|
||||||
class UserAccountsService
|
class UserAccountsService
|
||||||
{
|
{
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly TenantContextInterface $tenantContext,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly IdentityContextInterface $identityContext,
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore
|
||||||
private readonly EventDispatcherInterface $events,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,53 +65,17 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function createUser(array $userData): array
|
public function createUser(array $userData): array
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantContext->requireIdentifier();
|
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
|
||||||
$user = $this->userStore->createUser($tenantId, $userData);
|
|
||||||
$this->events->dispatch(UserCreatedEvent::fromUser(
|
|
||||||
$user,
|
|
||||||
$tenantId,
|
|
||||||
$this->identityContext->identifier(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateUser(string $userId, array $updates): bool
|
public function updateUser(string $uid, array $updates): bool
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantContext->requireIdentifier();
|
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
|
||||||
if (!$this->userStore->updateUser($tenantId, $userId, $updates)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
|
|
||||||
if ($user === null) {
|
|
||||||
throw new \RuntimeException("Updated user '{$userId}' could not be retrieved.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->events->dispatch(UserUpdatedEvent::fromUser(
|
|
||||||
$user,
|
|
||||||
$tenantId,
|
|
||||||
$this->identityContext->identifier(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteUser(string $userId): bool
|
public function deleteUser(string $uid): bool
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantContext->requireIdentifier();
|
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
|
||||||
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
|
|
||||||
if ($user === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->events->dispatch(UserDeletingEvent::fromUser(
|
|
||||||
$user,
|
|
||||||
$tenantId,
|
|
||||||
$this->identityContext->identifier(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return $this->userStore->deleteUser($tenantId, $userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -167,6 +126,10 @@ class UserAccountsService
|
|||||||
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
|
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a profile field is editable by the user
|
* Check if a profile field is editable by the user
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Stores;
|
namespace KTXC\Stores;
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
use KTXC\Db\DataStore;
|
||||||
use KTXC\Db\ObjectId;
|
|
||||||
use KTXC\Db\UTCDateTime;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
|
|
||||||
@@ -17,147 +15,28 @@ class FirewallStore
|
|||||||
{
|
{
|
||||||
protected const RULES_COLLECTION = 'firewall_rules';
|
protected const RULES_COLLECTION = 'firewall_rules';
|
||||||
protected const LOGS_COLLECTION = 'firewall_logs';
|
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||||
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
|
||||||
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected readonly DataStore $dataStore
|
protected readonly DataStore $dataStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Install the indexes used by firewall enforcement, audit queries, and expiry.
|
|
||||||
*
|
|
||||||
* MongoDB createIndex is idempotent when the name and specification match.
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
public function ensureIndexes(): array
|
|
||||||
{
|
|
||||||
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
|
|
||||||
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
|
|
||||||
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
|
||||||
|
|
||||||
return [
|
|
||||||
$rules->createIndex(
|
|
||||||
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
|
|
||||||
['name' => 'rules_by_scope_tenant_active']
|
|
||||||
),
|
|
||||||
$rules->createIndex(
|
|
||||||
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
|
|
||||||
['name' => 'rules_exact_lookup']
|
|
||||||
),
|
|
||||||
$rules->createIndex(
|
|
||||||
['scope' => 1, 'tenantId' => 1, 'createdAt' => -1],
|
|
||||||
['name' => 'rules_browse']
|
|
||||||
),
|
|
||||||
$logs->createIndex(
|
|
||||||
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
|
|
||||||
['name' => 'logs_auth_failures']
|
|
||||||
),
|
|
||||||
$logs->createIndex(
|
|
||||||
['tenantId' => 1, 'timestamp' => -1],
|
|
||||||
['name' => 'logs_tenant_timeline']
|
|
||||||
),
|
|
||||||
$logs->createIndex(
|
|
||||||
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
|
|
||||||
['name' => 'logs_blocked_counts']
|
|
||||||
),
|
|
||||||
$logs->createIndex(
|
|
||||||
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
|
|
||||||
['name' => 'logs_event_type']
|
|
||||||
),
|
|
||||||
$logs->createIndex(
|
|
||||||
['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1],
|
|
||||||
['name' => 'logs_rule']
|
|
||||||
),
|
|
||||||
$logs->createIndex(
|
|
||||||
['timestamp' => -1],
|
|
||||||
['name' => 'logs_global_timeline']
|
|
||||||
),
|
|
||||||
$claims->createIndex(
|
|
||||||
['expiresAt' => 1],
|
|
||||||
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Rule Operations
|
// 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
|
* List all rules for a tenant
|
||||||
*/
|
*/
|
||||||
public function listRules(string $tenantId, bool $activeOnly = true): array
|
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||||
{
|
{
|
||||||
$filter = [
|
$filter = ['tenantId' => $tenantId];
|
||||||
'tenantId' => $tenantId,
|
|
||||||
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($activeOnly) {
|
if ($activeOnly) {
|
||||||
$filter['enabled'] = true;
|
$filter['enabled'] = true;
|
||||||
$filter['$and'] = [[
|
$filter['$or'] = [
|
||||||
'$or' => [
|
|
||||||
['expiresAt' => null],
|
['expiresAt' => null],
|
||||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
],
|
];
|
||||||
]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||||
@@ -171,26 +50,6 @@ class FirewallStore
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listSystemRules(bool $activeOnly = true): array
|
|
||||||
{
|
|
||||||
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
|
|
||||||
if ($activeOnly) {
|
|
||||||
$filter['enabled'] = true;
|
|
||||||
$filter['$or'] = [
|
|
||||||
['expiresAt' => null],
|
|
||||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
|
||||||
$list = [];
|
|
||||||
foreach ($cursor as $entry) {
|
|
||||||
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find rules by IP address
|
* Find rules by IP address
|
||||||
*/
|
*/
|
||||||
@@ -202,7 +61,7 @@ class FirewallStore
|
|||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'$or' => [
|
'$or' => [
|
||||||
['expiresAt' => null],
|
['expiresAt' => null],
|
||||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -229,7 +88,7 @@ class FirewallStore
|
|||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'$or' => [
|
'$or' => [
|
||||||
['expiresAt' => null],
|
['expiresAt' => null],
|
||||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -249,7 +108,7 @@ class FirewallStore
|
|||||||
*/
|
*/
|
||||||
public function fetchRule(string $id): ?FirewallRuleObject
|
public function fetchRule(string $id): ?FirewallRuleObject
|
||||||
{
|
{
|
||||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
|
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
|
||||||
if (!$entry) {
|
if (!$entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -259,33 +118,15 @@ class FirewallStore
|
|||||||
/**
|
/**
|
||||||
* Check if exact IP rule exists
|
* Check if exact IP rule exists
|
||||||
*/
|
*/
|
||||||
public function findExactIpRule(
|
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
|
||||||
?string $tenantId,
|
|
||||||
string $ipAddress,
|
|
||||||
string $action,
|
|
||||||
string $scope = FirewallRuleObject::SCOPE_TENANT
|
|
||||||
): ?FirewallRuleObject
|
|
||||||
{
|
{
|
||||||
$filter = [
|
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
||||||
|
'tenantId' => $tenantId,
|
||||||
'type' => FirewallRuleObject::TYPE_IP,
|
'type' => FirewallRuleObject::TYPE_IP,
|
||||||
'value' => $ipAddress,
|
'value' => $ipAddress,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'$or' => [
|
]);
|
||||||
['expiresAt' => null],
|
|
||||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($scope === FirewallRuleObject::SCOPE_SYSTEM) {
|
|
||||||
$filter['scope'] = FirewallRuleObject::SCOPE_SYSTEM;
|
|
||||||
$filter['tenantId'] = null;
|
|
||||||
} else {
|
|
||||||
$filter['tenantId'] = $tenantId;
|
|
||||||
$filter['scope'] = FirewallRuleObject::SCOPE_TENANT;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne($filter);
|
|
||||||
|
|
||||||
if (!$entry) {
|
if (!$entry) {
|
||||||
return null;
|
return null;
|
||||||
@@ -298,8 +139,6 @@ class FirewallStore
|
|||||||
*/
|
*/
|
||||||
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||||
{
|
{
|
||||||
$rule->assertValidScopeOwnership();
|
|
||||||
|
|
||||||
if ($rule->getId()) {
|
if ($rule->getId()) {
|
||||||
return $this->updateRule($rule);
|
return $this->updateRule($rule);
|
||||||
} else {
|
} else {
|
||||||
@@ -309,7 +148,7 @@ class FirewallStore
|
|||||||
|
|
||||||
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||||
{
|
{
|
||||||
$data = self::ruleDocument($rule);
|
$data = $rule->jsonSerialize();
|
||||||
unset($data['id']); // Remove id for insert
|
unset($data['id']); // Remove id for insert
|
||||||
|
|
||||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
||||||
@@ -324,11 +163,11 @@ class FirewallStore
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = self::ruleDocument($rule);
|
$data = $rule->jsonSerialize();
|
||||||
unset($data['id']);
|
unset($data['id']);
|
||||||
|
|
||||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||||
self::ruleIdFilter($id),
|
['_id' => $id],
|
||||||
['$set' => $data]
|
['$set' => $data]
|
||||||
);
|
);
|
||||||
return $rule;
|
return $rule;
|
||||||
@@ -343,7 +182,7 @@ class FirewallStore
|
|||||||
if (!$id) {
|
if (!$id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,10 +191,8 @@ class FirewallStore
|
|||||||
public function cleanupExpiredRules(): int
|
public function cleanupExpiredRules(): int
|
||||||
{
|
{
|
||||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
||||||
'expiresAt' => [
|
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
|
||||||
'$lt' => self::bsonDate(new \DateTimeImmutable()),
|
'expiresAt' => ['$ne' => null]
|
||||||
'$ne' => null,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $result->getDeletedCount();
|
return $result->getDeletedCount();
|
||||||
@@ -365,68 +202,12 @@ class FirewallStore
|
|||||||
// Log Operations
|
// 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
|
* Log a firewall event
|
||||||
*/
|
*/
|
||||||
public function createLog(FirewallLogObject $log): FirewallLogObject
|
public function createLog(FirewallLogObject $log): FirewallLogObject
|
||||||
{
|
{
|
||||||
$data = self::logDocument($log);
|
$data = $log->jsonSerialize();
|
||||||
unset($data['id']);
|
unset($data['id']);
|
||||||
|
|
||||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
||||||
@@ -434,30 +215,6 @@ class FirewallStore
|
|||||||
return $log;
|
return $log;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
|
|
||||||
*/
|
|
||||||
public function createLogOnce(FirewallLogObject $log): bool
|
|
||||||
{
|
|
||||||
$eventId = $log->getEventId();
|
|
||||||
if ($eventId === null || $eventId === '') {
|
|
||||||
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = self::logDocument($log);
|
|
||||||
unset($data['id']);
|
|
||||||
$data['_id'] = $eventId;
|
|
||||||
|
|
||||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
|
|
||||||
['_id' => $eventId],
|
|
||||||
['$setOnInsert' => $data],
|
|
||||||
['upsert' => true]
|
|
||||||
);
|
|
||||||
$log->setId($eventId);
|
|
||||||
|
|
||||||
return $result->getUpsertedCount() === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get logs for a tenant with optional filters
|
* Get logs for a tenant with optional filters
|
||||||
*/
|
*/
|
||||||
@@ -513,50 +270,10 @@ class FirewallStore
|
|||||||
'tenantId' => $tenantId,
|
'tenantId' => $tenantId,
|
||||||
'ipAddress' => $ipAddress,
|
'ipAddress' => $ipAddress,
|
||||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||||
'timestamp' => ['$gte' => self::bsonDate($since)]
|
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
|
|
||||||
*/
|
|
||||||
public function claimBruteForce(
|
|
||||||
string $tenantId,
|
|
||||||
string $ipAddress,
|
|
||||||
int $claimDurationSeconds
|
|
||||||
): bool {
|
|
||||||
if ($claimDurationSeconds < 1) {
|
|
||||||
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
|
|
||||||
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
|
||||||
|
|
||||||
$collection->deleteOne([
|
|
||||||
'_id' => $claimId,
|
|
||||||
'expiresAt' => ['$lte' => self::bsonDate($now)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$collection->insertOne([
|
|
||||||
'_id' => $claimId,
|
|
||||||
'tenantId' => $tenantId,
|
|
||||||
'ipAddress' => $ipAddress,
|
|
||||||
'createdAt' => self::bsonDate($now),
|
|
||||||
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
|
|
||||||
]);
|
|
||||||
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
|
|
||||||
if ($error->getCode() === 11000) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get blocked requests count for dashboard
|
* Get blocked requests count for dashboard
|
||||||
*/
|
*/
|
||||||
@@ -570,27 +287,12 @@ class FirewallStore
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($since !== null) {
|
if ($since !== null) {
|
||||||
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
|
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
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
|
* Clean up old logs
|
||||||
*/
|
*/
|
||||||
@@ -599,79 +301,9 @@ class FirewallStore
|
|||||||
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
||||||
|
|
||||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
||||||
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
|
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $result->getDeletedCount();
|
return $result->getDeletedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cleanupExpiredBruteForceClaims(): int
|
|
||||||
{
|
|
||||||
$result = $this->dataStore
|
|
||||||
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
|
|
||||||
->deleteMany([
|
|
||||||
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $result->getDeletedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function recordMaintenanceStatus(
|
|
||||||
\DateTimeImmutable $startedAt,
|
|
||||||
\DateTimeImmutable $completedAt,
|
|
||||||
string $status,
|
|
||||||
array $result,
|
|
||||||
?string $error = null
|
|
||||||
): void {
|
|
||||||
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
|
|
||||||
['_id' => 'cleanup'],
|
|
||||||
['$set' => [
|
|
||||||
'startedAt' => self::bsonDate($startedAt),
|
|
||||||
'completedAt' => self::bsonDate($completedAt),
|
|
||||||
'status' => $status,
|
|
||||||
'result' => $result,
|
|
||||||
'error' => $error,
|
|
||||||
]],
|
|
||||||
['upsert' => true]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function maintenanceStatus(): ?array
|
|
||||||
{
|
|
||||||
return $this->dataStore
|
|
||||||
->selectCollection(self::MAINTENANCE_COLLECTION)
|
|
||||||
->findOne(['_id' => 'cleanup']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function ruleDocument(FirewallRuleObject $rule): array
|
|
||||||
{
|
|
||||||
$data = $rule->jsonSerialize();
|
|
||||||
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
|
|
||||||
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function logDocument(FirewallLogObject $log): array
|
|
||||||
{
|
|
||||||
$data = $log->jsonSerialize();
|
|
||||||
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
|
|
||||||
{
|
|
||||||
return $date === null ? null : self::bsonDate($date);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
|
|
||||||
{
|
|
||||||
return UTCDateTime::fromDateTime($date);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function ruleIdFilter(string $id): array
|
|
||||||
{
|
|
||||||
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\User\Event;
|
|
||||||
|
|
||||||
final class UserCreatedEvent extends UserEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\User\Event;
|
|
||||||
|
|
||||||
final class UserDeletingEvent extends UserEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\User\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
abstract class UserEvent extends Event
|
|
||||||
{
|
|
||||||
final public function __construct(
|
|
||||||
private readonly string $userIdentifier,
|
|
||||||
private readonly string $userIdentity,
|
|
||||||
private readonly string $userLabel,
|
|
||||||
private readonly bool $userEnabled,
|
|
||||||
private readonly array $userRoles,
|
|
||||||
private readonly string $tenantIdentifier,
|
|
||||||
private readonly ?string $actorIdentifier = null,
|
|
||||||
) {
|
|
||||||
if ($userIdentifier === '') {
|
|
||||||
throw new \InvalidArgumentException('User lifecycle events require a user ID.');
|
|
||||||
}
|
|
||||||
if ($userIdentity === '') {
|
|
||||||
throw new \InvalidArgumentException('User lifecycle events require a user identity.');
|
|
||||||
}
|
|
||||||
if ($tenantIdentifier === '') {
|
|
||||||
throw new \InvalidArgumentException('User lifecycle events require a tenant ID.');
|
|
||||||
}
|
|
||||||
parent::__construct(
|
|
||||||
static::class,
|
|
||||||
[
|
|
||||||
'identifier' => $userIdentifier,
|
|
||||||
'identity' => $userIdentity,
|
|
||||||
'label' => $userLabel,
|
|
||||||
'roles' => $userRoles,
|
|
||||||
'enabled' => $userEnabled,
|
|
||||||
],
|
|
||||||
$tenantIdentifier,
|
|
||||||
$actorIdentifier,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromUser(
|
|
||||||
array $user,
|
|
||||||
string $tenantIdentifier,
|
|
||||||
?string $actorIdentifier = null,
|
|
||||||
): static {
|
|
||||||
return new static(
|
|
||||||
(string) ($user['uid'] ?? ''),
|
|
||||||
(string) ($user['identity'] ?? ''),
|
|
||||||
(string) ($user['label'] ?? $user['identity'] ?? ''),
|
|
||||||
(bool) ($user['enabled'] ?? true),
|
|
||||||
array_values((array) ($user['roles'] ?? [])),
|
|
||||||
$tenantIdentifier,
|
|
||||||
$actorIdentifier,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userIdentifier(): string
|
|
||||||
{
|
|
||||||
return $this->userIdentifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantIdentifier(): string
|
|
||||||
{
|
|
||||||
return $this->tenantIdentifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userIdentity(): string
|
|
||||||
{
|
|
||||||
return $this->userIdentity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userLabel(): string
|
|
||||||
{
|
|
||||||
return $this->userLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userRoles(): array
|
|
||||||
{
|
|
||||||
return $this->userRoles;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function userEnabled(): bool
|
|
||||||
{
|
|
||||||
return $this->userEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actorIdentifier(): ?string
|
|
||||||
{
|
|
||||||
return $this->actorIdentifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\User\Event;
|
|
||||||
|
|
||||||
final class UserUpdatedEvent extends UserEvent
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useUserStore } from '@KTXC/stores/userStore';
|
import { useUserStore } from '@KTXC/stores/userStore';
|
||||||
import { authenticationService } from '@KTXC/services/authenticationService';
|
import { authenticationService } from '@KTXC/services/authenticationService';
|
||||||
@@ -15,7 +15,6 @@ type LoginPhase = 'identity' | 'method' | 'mfa';
|
|||||||
// Form state
|
// Form state
|
||||||
const identity = ref('');
|
const identity = ref('');
|
||||||
const authResponse = ref(''); // password, code, etc.
|
const authResponse = ref(''); // password, code, etc.
|
||||||
const authInput = ref<{ focus: () => void } | null>(null);
|
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const rememberMe = ref(false);
|
const rememberMe = ref(false);
|
||||||
|
|
||||||
@@ -61,19 +60,21 @@ const pageTitle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Input label/type based on selected method
|
// Input label/type based on selected method
|
||||||
const isPasswordMethod = computed(() => selectedMethod.value?.id === 'password');
|
|
||||||
|
|
||||||
const authInputLabel = computed(() => {
|
const authInputLabel = computed(() => {
|
||||||
return isPasswordMethod.value ? 'Password' : 'Verification Code';
|
if (!selectedMethod.value) return 'Password';
|
||||||
|
return selectedMethod.value.method === 'credential' ? 'Password' : 'Verification Code';
|
||||||
});
|
});
|
||||||
|
|
||||||
const authInputType = computed(() => {
|
const authInputType = computed(() => {
|
||||||
return isPasswordMethod.value ? 'password' : 'text';
|
if (!selectedMethod.value) return 'password';
|
||||||
|
return selectedMethod.value.method === 'credential' ? 'password' : 'text';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const identityRules = [
|
const identityRules = [
|
||||||
(v: string) => !!v.trim() || 'Login ID is required',
|
(v: string) => !!v.trim() || 'Email is required',
|
||||||
|
(v: string) => !/\s/.test(v.trim()) || 'Email must not contain spaces',
|
||||||
|
(v: string) => /.+@.+\..+/.test(v.trim()) || 'Email must be valid'
|
||||||
];
|
];
|
||||||
|
|
||||||
const authResponseRules = [
|
const authResponseRules = [
|
||||||
@@ -116,9 +117,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Watch for method selection changes (for challenge-based methods)
|
// Watch for method selection changes (for challenge-based methods)
|
||||||
watch(selectedMethod, async (newMethod) => {
|
watch(selectedMethod, async (newMethod) => {
|
||||||
await nextTick();
|
|
||||||
authInput.value?.focus();
|
|
||||||
|
|
||||||
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
|
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
|
||||||
// Initiate challenge for methods that need it (SMS, email, TOTP)
|
// Initiate challenge for methods that need it (SMS, email, TOTP)
|
||||||
await initiateChallenge(newMethod.id);
|
await initiateChallenge(newMethod.id);
|
||||||
@@ -315,16 +313,7 @@ function backToIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMethodIcon(method: AuthenticationMethod): string {
|
function getMethodIcon(method: AuthenticationMethod): string {
|
||||||
if (method.icon?.startsWith('mdi-') || method.icon?.startsWith('$')) {
|
if (method.icon) return method.icon;
|
||||||
return method.icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication providers may return a bare Material Design icon name
|
|
||||||
// (for example, "lock" or "mail") instead of Vuetify's "mdi-*" form.
|
|
||||||
if (method.icon && /^[a-z0-9-]+$/i.test(method.icon)) {
|
|
||||||
return `mdi-${method.icon}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (method.method) {
|
switch (method.method) {
|
||||||
case 'credential': return 'mdi-key';
|
case 'credential': return 'mdi-key';
|
||||||
case 'challenge': return 'mdi-shield-check';
|
case 'challenge': return 'mdi-shield-check';
|
||||||
@@ -362,15 +351,16 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
v-slot="{ errors, isSubmitting }"
|
v-slot="{ errors, isSubmitting }"
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
<v-label>Email Address</v-label>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="identity"
|
v-model="identity"
|
||||||
:rules="identityRules"
|
:rules="identityRules"
|
||||||
aria-label="Login ID"
|
class="mt-2"
|
||||||
required
|
required
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
autocomplete="username"
|
autocomplete="email"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,7 +391,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
size="large"
|
size="large"
|
||||||
@click="initiateSsoLogin(method.id)"
|
@click="initiateSsoLogin(method.id)"
|
||||||
>
|
>
|
||||||
<v-icon start>{{ getMethodIcon(method) }}</v-icon>
|
<v-icon v-if="method.icon" start>{{ method.icon }}</v-icon>
|
||||||
{{ method.label }}
|
{{ method.label }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,18 +445,18 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
v-slot="{ errors, isSubmitting }"
|
v-slot="{ errors, isSubmitting }"
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
<v-label>{{ authInputLabel }}</v-label>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
ref="authInput"
|
|
||||||
v-model="authResponse"
|
v-model="authResponse"
|
||||||
:rules="authResponseRules"
|
:rules="authResponseRules"
|
||||||
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
|
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
|
||||||
:aria-label="authInputLabel"
|
class="mt-2"
|
||||||
required
|
required
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
:autocomplete="isPasswordMethod ? 'current-password' : 'off'"
|
:autocomplete="selectedMethod?.method === 'credential' ? 'current-password' : 'one-time-code'"
|
||||||
:inputmode="isPasswordMethod ? undefined : 'numeric'"
|
:inputmode="selectedMethod?.method !== 'credential' ? 'numeric' : undefined"
|
||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
||||||
@@ -480,7 +470,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
</v-text-field>
|
</v-text-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isPasswordMethod" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
<div v-if="selectedMethod?.method === 'credential'" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="rememberMe"
|
v-model="rememberMe"
|
||||||
label="Keep me logged in"
|
label="Keep me logged in"
|
||||||
@@ -503,7 +493,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
size="large"
|
size="large"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{{ isPasswordMethod ? 'Login' : 'Verify' }}
|
{{ selectedMethod?.method === 'credential' ? 'Login' : 'Verify' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -544,7 +534,6 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-label>Verification Code</v-label>
|
<v-label>Verification Code</v-label>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
ref="authInput"
|
|
||||||
v-model="authResponse"
|
v-model="authResponse"
|
||||||
:rules="authResponseRules"
|
:rules="authResponseRules"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -553,7 +542,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
autocomplete="off"
|
autocomplete="one-time-code"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Install in /etc/cron.d/ktrix-firewall after adjusting the user/path if needed.
|
|
||||||
*/15 * * * * www-data cd /var/www/ktrix/main && bin/console firewall:maintenance
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[program:ktrix-mail-daemon]
|
||||||
|
command=/usr/bin/php /var/www/ktrix/main/bin/console mail:queue:daemon
|
||||||
|
directory=/var/www/ktrix/main
|
||||||
|
user=www-data
|
||||||
|
numprocs=1
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=5
|
||||||
|
startretries=3
|
||||||
|
exitcodes=0
|
||||||
|
stopsignal=TERM
|
||||||
|
stopwaitsecs=30
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/www/ktrix/main/var/log/mail-daemon.log
|
||||||
|
stdout_logfile_maxbytes=10MB
|
||||||
|
stdout_logfile_backups=5
|
||||||
|
environment=PHP_INI_SCAN_DIR="/etc/php/8.2/cli/conf.d"
|
||||||
|
|
||||||
|
; Process name for easier identification
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ktrix Mail Queue Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/ktrix/main
|
||||||
|
ExecStart=/usr/bin/php bin/console mail:queue:daemon
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:/var/www/ktrix/main/var/log/mail-daemon.log
|
||||||
|
StandardError=append:/var/www/ktrix/main/var/log/mail-daemon.log
|
||||||
|
|
||||||
|
# Process management
|
||||||
|
KillMode=process
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/www/ktrix/main/storage
|
||||||
|
ReadWritePaths=/var/www/ktrix/main/var
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -33,30 +33,6 @@ require_command()
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cron runs with a bare PATH, so nvm-installed npm (added to PATH only by
|
|
||||||
# .bashrc sourcing nvm.sh in an interactive shell) is invisible here even
|
|
||||||
# though it works fine when this script is run by hand. Resolve nvm's
|
|
||||||
# current npm via bash (nvm.sh is not POSIX sh compatible) and prepend it,
|
|
||||||
# so a later `nvm use`/`nvm install` doesn't require updating this script
|
|
||||||
# or the crontab.
|
|
||||||
ensure_npm_on_path()
|
|
||||||
{
|
|
||||||
command -v npm >/dev/null 2>&1 && return 0
|
|
||||||
|
|
||||||
nvm_dir=${NVM_DIR:-${HOME:-/root}/.nvm}
|
|
||||||
[ -s "$nvm_dir/nvm.sh" ] || return 0
|
|
||||||
command -v bash >/dev/null 2>&1 || return 0
|
|
||||||
|
|
||||||
npm_path=$(bash -c ". \"\$1/nvm.sh\" >/dev/null 2>&1 && command -v npm" _ "$nvm_dir" 2>/dev/null) || return 0
|
|
||||||
[ -n "$npm_path" ] || return 0
|
|
||||||
|
|
||||||
PATH=$(dirname -- "$npm_path"):$PATH
|
|
||||||
export PATH
|
|
||||||
log "Resolved npm via nvm: $npm_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_npm_on_path
|
|
||||||
|
|
||||||
git_in()
|
git_in()
|
||||||
{
|
{
|
||||||
repository=$1
|
repository=$1
|
||||||
|
|||||||
Generated
+24
-24
@@ -642,14 +642,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
|
||||||
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
|
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/devtools-types": "11.4.7",
|
"@intlify/devtools-types": "11.4.8",
|
||||||
"@intlify/message-compiler": "11.4.7",
|
"@intlify/message-compiler": "11.4.8",
|
||||||
"@intlify/shared": "11.4.7"
|
"@intlify/shared": "11.4.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -659,13 +659,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/devtools-types": {
|
"node_modules/@intlify/devtools-types": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
|
||||||
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
|
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.7",
|
"@intlify/core-base": "11.4.8",
|
||||||
"@intlify/shared": "11.4.7"
|
"@intlify/shared": "11.4.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -675,12 +675,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/message-compiler": {
|
"node_modules/@intlify/message-compiler": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
|
||||||
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
|
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/shared": "11.4.7",
|
"@intlify/shared": "11.4.8",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -691,9 +691,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/shared": {
|
"node_modules/@intlify/shared": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
|
||||||
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
|
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22"
|
"node": ">= 22"
|
||||||
@@ -6537,14 +6537,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.4.7",
|
"version": "11.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
|
||||||
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
|
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.4.7",
|
"@intlify/core-base": "11.4.8",
|
||||||
"@intlify/devtools-types": "11.4.7",
|
"@intlify/devtools-types": "11.4.8",
|
||||||
"@intlify/shared": "11.4.7",
|
"@intlify/shared": "11.4.8",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Event;
|
namespace KTXF\Event;
|
||||||
|
|
||||||
interface DeferredEventProcessorInterface
|
interface DeferredEventProcessorInterface
|
||||||
{
|
{
|
||||||
+1
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Event;
|
namespace KTXF\Event;
|
||||||
|
|
||||||
final readonly class DeferredProcessingResult
|
final readonly class DeferredProcessingResult
|
||||||
{
|
{
|
||||||
@@ -11,9 +11,6 @@ final readonly class DeferredProcessingResult
|
|||||||
public int $remaining,
|
public int $remaining,
|
||||||
public bool $deadlineExceeded,
|
public bool $deadlineExceeded,
|
||||||
public bool $limitExceeded = false,
|
public bool $limitExceeded = false,
|
||||||
public int $listenerInvocations = 0,
|
|
||||||
public bool $eventLimitExceeded = false,
|
|
||||||
public bool $listenerInvocationLimitExceeded = false,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+64
-47
@@ -10,76 +10,76 @@ namespace KTXF\Event;
|
|||||||
class Event
|
class Event
|
||||||
{
|
{
|
||||||
private bool $propagationStopped = false;
|
private bool $propagationStopped = false;
|
||||||
private readonly array $context;
|
private array $data = [];
|
||||||
private readonly float $timestamp;
|
private float $timestamp;
|
||||||
private readonly string $identifier;
|
private ?string $tenantId = null;
|
||||||
|
private ?string $identityId = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $label,
|
private readonly string $name,
|
||||||
array $context = [],
|
array $data = []
|
||||||
private readonly ?string $tenantIdentifier = null,
|
|
||||||
private readonly ?string $actorIdentity = null,
|
|
||||||
) {
|
) {
|
||||||
self::validateContext($context);
|
$this->data = $data;
|
||||||
|
|
||||||
$this->context = $context;
|
|
||||||
$this->timestamp = microtime(true);
|
$this->timestamp = microtime(true);
|
||||||
$this->identifier = bin2hex(random_bytes(16));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event label
|
* Get the event name
|
||||||
*/
|
*/
|
||||||
public function label(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
return $this->label;
|
return $this->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a context value by key
|
* Get a data value by key
|
||||||
*/
|
*/
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->context[$key] ?? $default;
|
return $this->data[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a context key exists
|
* Set a data value
|
||||||
|
*/
|
||||||
|
public function set(string $key, mixed $value): self
|
||||||
|
{
|
||||||
|
$this->data[$key] = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a data key exists
|
||||||
*/
|
*/
|
||||||
public function has(string $key): bool
|
public function has(string $key): bool
|
||||||
{
|
{
|
||||||
return array_key_exists($key, $this->context);
|
return array_key_exists($key, $this->data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event context
|
* Get all data
|
||||||
*/
|
*/
|
||||||
public function context(): array
|
public function getData(): array
|
||||||
{
|
{
|
||||||
return $this->context;
|
return $this->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all event context
|
* Alias for getData() for backward compatibility
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function all(): array
|
||||||
{
|
{
|
||||||
return $this->context;
|
return $this->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event timestamp
|
* Get the event timestamp
|
||||||
*/
|
*/
|
||||||
public function timestamp(): float
|
public function getTimestamp(): float
|
||||||
{
|
{
|
||||||
return $this->timestamp;
|
return $this->timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function identifier(): string
|
|
||||||
{
|
|
||||||
return $this->identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop event propagation to subsequent listeners
|
* Stop event propagation to subsequent listeners
|
||||||
*/
|
*/
|
||||||
@@ -99,31 +99,48 @@ class Event
|
|||||||
/**
|
/**
|
||||||
* Get tenant ID for multi-tenant context
|
* Get tenant ID for multi-tenant context
|
||||||
*/
|
*/
|
||||||
public function tenantIdentifier(): ?string
|
public function getTenantId(): ?string
|
||||||
{
|
{
|
||||||
return $this->tenantIdentifier;
|
return $this->tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the identity of the actor who triggered the event
|
* Set tenant ID for multi-tenant context
|
||||||
*/
|
*/
|
||||||
public function actorIdentity(): ?string
|
public function setTenantId(?string $tenantId): self
|
||||||
{
|
{
|
||||||
return $this->actorIdentity;
|
$this->tenantId = $tenantId;
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function validateContext(array $context): void
|
/**
|
||||||
|
* Get identity ID (user who triggered the event)
|
||||||
|
*/
|
||||||
|
public function getIdentityId(): ?string
|
||||||
{
|
{
|
||||||
foreach ($context as $value) {
|
return $this->identityId;
|
||||||
if (is_array($value)) {
|
|
||||||
self::validateContext($value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($value !== null && !is_scalar($value)) {
|
|
||||||
throw new \InvalidArgumentException(
|
|
||||||
'Event context must contain only scalar, null, or array values.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Set identity ID
|
||||||
|
*/
|
||||||
|
public function setIdentityId(?string $identityId): self
|
||||||
|
{
|
||||||
|
$this->identityId = $identityId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert event to array for serialization/logging
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->name,
|
||||||
|
'data' => $this->data,
|
||||||
|
'timestamp' => $this->timestamp,
|
||||||
|
'tenantId' => $this->tenantId,
|
||||||
|
'identityId' => $this->identityId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Event;
|
||||||
|
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
||||||
|
{
|
||||||
|
/** @var array<string, list<Event>> */
|
||||||
|
private array $deferred = [];
|
||||||
|
private ?string $activeExecution = null;
|
||||||
|
private int $dispatchDepth = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EventListenerRegistry $registry,
|
||||||
|
private readonly ContainerInterface $container,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(Event $event): void
|
||||||
|
{
|
||||||
|
if (++$this->dispatchDepth > 32) {
|
||||||
|
--$this->dispatchDepth;
|
||||||
|
throw new \RuntimeException('Event dispatch recursion limit exceeded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->invoke($event, DeliveryMode::Immediate);
|
||||||
|
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
|
||||||
|
if ($this->activeExecution === null) {
|
||||||
|
throw new \LogicException('Deferred events require an active execution scope.');
|
||||||
|
}
|
||||||
|
$this->deferred[$this->activeExecution][] = $event;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
--$this->dispatchDepth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginExecution(string $executionId): void
|
||||||
|
{
|
||||||
|
if ($this->activeExecution !== null) {
|
||||||
|
throw new \LogicException('An event execution scope is already active.');
|
||||||
|
}
|
||||||
|
$this->activeExecution = $executionId;
|
||||||
|
$this->deferred[$executionId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processDeferred(string $executionId): DeferredProcessingResult
|
||||||
|
{
|
||||||
|
if ($this->activeExecution !== $executionId) {
|
||||||
|
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$deadline = microtime(true) + 1.0;
|
||||||
|
$deadlineExceeded = false;
|
||||||
|
$limitExceeded = false;
|
||||||
|
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
||||||
|
if ($processed >= 1000) {
|
||||||
|
$limitExceeded = true;
|
||||||
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (microtime(true) >= $deadline) {
|
||||||
|
$deadlineExceeded = true;
|
||||||
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$processed += $this->invoke($event, DeliveryMode::Deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = count($this->deferred[$executionId]);
|
||||||
|
unset($this->deferred[$executionId]);
|
||||||
|
$this->activeExecution = null;
|
||||||
|
|
||||||
|
return new DeferredProcessingResult(
|
||||||
|
$processed,
|
||||||
|
$remaining,
|
||||||
|
$deadlineExceeded,
|
||||||
|
$limitExceeded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function discardDeferred(string $executionId): void
|
||||||
|
{
|
||||||
|
unset($this->deferred[$executionId]);
|
||||||
|
if ($this->activeExecution === $executionId) {
|
||||||
|
$this->activeExecution = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invoke(Event $event, DeliveryMode $delivery): int
|
||||||
|
{
|
||||||
|
$processed = 0;
|
||||||
|
foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) {
|
||||||
|
if ($event->isPropagationStopped()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = $this->container->get($listener->service);
|
||||||
|
$service->{$listener->method}($event);
|
||||||
|
$processed++;
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$this->logger->error('Event listener failed.', [
|
||||||
|
'event' => $event->getName(),
|
||||||
|
'module' => $listener->module,
|
||||||
|
'listener' => $listener->service . '::' . $listener->method,
|
||||||
|
'exception' => $error,
|
||||||
|
]);
|
||||||
|
if ($listener->failurePolicy === FailurePolicy::Propagate) {
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $processed;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-4
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Event;
|
namespace KTXF\Event;
|
||||||
|
|
||||||
use KTXF\Event\DeliveryMode;
|
|
||||||
use KTXF\Event\FailurePolicy;
|
|
||||||
|
|
||||||
final readonly class EventListenerDefinition
|
final readonly class EventListenerDefinition
|
||||||
{
|
{
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
interface EventListenerRegistrarInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param class-string $service
|
|
||||||
*/
|
|
||||||
public function listen(
|
|
||||||
string $module,
|
|
||||||
string $event,
|
|
||||||
string $service,
|
|
||||||
string $method,
|
|
||||||
DeliveryMode $delivery = DeliveryMode::Immediate,
|
|
||||||
int $priority = 0,
|
|
||||||
FailurePolicy $failurePolicy = FailurePolicy::Continue,
|
|
||||||
): void;
|
|
||||||
}
|
|
||||||
+2
-5
@@ -2,14 +2,11 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Event;
|
namespace KTXF\Event;
|
||||||
|
|
||||||
use KTXF\Event\DeliveryMode;
|
|
||||||
use KTXF\Event\EventListenerRegistrarInterface;
|
|
||||||
use KTXF\Event\FailurePolicy;
|
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
final class EventListenerRegistry implements EventListenerRegistrarInterface
|
final class EventListenerRegistry
|
||||||
{
|
{
|
||||||
/** @var array<string, list<EventListenerDefinition>> */
|
/** @var array<string, list<EventListenerDefinition>> */
|
||||||
private array $listeners = [];
|
private array $listeners = [];
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security-specific event for authentication and access control events
|
||||||
|
*/
|
||||||
|
class SecurityEvent extends Event
|
||||||
|
{
|
||||||
|
// Event names
|
||||||
|
public const AUTH_SUCCESS = 'security.auth.success';
|
||||||
|
public const AUTH_FAILURE = 'security.auth.failure';
|
||||||
|
public const AUTH_LOGOUT = 'security.auth.logout';
|
||||||
|
public const TOKEN_REFRESH = 'security.token.refresh';
|
||||||
|
public const TOKEN_REVOKED = 'security.token.revoked';
|
||||||
|
|
||||||
|
public const ACCESS_DENIED = 'security.access.denied';
|
||||||
|
public const ACCESS_GRANTED = 'security.access.granted';
|
||||||
|
|
||||||
|
public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected';
|
||||||
|
public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded';
|
||||||
|
public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity';
|
||||||
|
|
||||||
|
public const IP_BLOCKED = 'security.ip.blocked';
|
||||||
|
public const IP_ALLOWED = 'security.ip.allowed';
|
||||||
|
public const DEVICE_BLOCKED = 'security.device.blocked';
|
||||||
|
|
||||||
|
private ?string $ipAddress = null;
|
||||||
|
private ?string $deviceFingerprint = null;
|
||||||
|
private ?string $userAgent = null;
|
||||||
|
private ?string $requestPath = null;
|
||||||
|
private ?string $requestMethod = null;
|
||||||
|
private ?string $userId = null;
|
||||||
|
private ?string $reason = null;
|
||||||
|
private int $severity = self::SEVERITY_INFO;
|
||||||
|
|
||||||
|
// Severity levels
|
||||||
|
public const SEVERITY_DEBUG = 0;
|
||||||
|
public const SEVERITY_INFO = 1;
|
||||||
|
public const SEVERITY_WARNING = 2;
|
||||||
|
public const SEVERITY_ERROR = 3;
|
||||||
|
public const SEVERITY_CRITICAL = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a security event with common parameters
|
||||||
|
*/
|
||||||
|
public static function create(
|
||||||
|
string $name,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $deviceFingerprint = null,
|
||||||
|
array $data = []
|
||||||
|
): self {
|
||||||
|
$event = new self($name, $data);
|
||||||
|
$event->ipAddress = $ipAddress;
|
||||||
|
$event->deviceFingerprint = $deviceFingerprint;
|
||||||
|
|
||||||
|
// Set default severity based on event type
|
||||||
|
$event->severity = self::getSeverityForEvent($name);
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an authentication failure event
|
||||||
|
*/
|
||||||
|
public static function authFailure(
|
||||||
|
string $ipAddress,
|
||||||
|
?string $deviceFingerprint = null,
|
||||||
|
?string $userId = null,
|
||||||
|
?string $reason = null
|
||||||
|
): self {
|
||||||
|
$event = self::create(self::AUTH_FAILURE, $ipAddress, $deviceFingerprint, [
|
||||||
|
'userId' => $userId,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
$event->userId = $userId;
|
||||||
|
$event->reason = $reason;
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an authentication success event
|
||||||
|
*/
|
||||||
|
public static function authSuccess(
|
||||||
|
string $ipAddress,
|
||||||
|
?string $deviceFingerprint = null,
|
||||||
|
string $userId = null
|
||||||
|
): self {
|
||||||
|
$event = self::create(self::AUTH_SUCCESS, $ipAddress, $deviceFingerprint, [
|
||||||
|
'userId' => $userId,
|
||||||
|
]);
|
||||||
|
$event->userId = $userId;
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a brute force detection event
|
||||||
|
*/
|
||||||
|
public static function bruteForceDetected(
|
||||||
|
string $ipAddress,
|
||||||
|
int $failureCount,
|
||||||
|
int $windowSeconds
|
||||||
|
): self {
|
||||||
|
$event = self::create(self::BRUTE_FORCE_DETECTED, $ipAddress, null, [
|
||||||
|
'failureCount' => $failureCount,
|
||||||
|
'windowSeconds' => $windowSeconds,
|
||||||
|
]);
|
||||||
|
$event->reason = sprintf(
|
||||||
|
'%d failed attempts in %d seconds',
|
||||||
|
$failureCount,
|
||||||
|
$windowSeconds
|
||||||
|
);
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a rate limit exceeded event
|
||||||
|
*/
|
||||||
|
public static function rateLimitExceeded(
|
||||||
|
string $ipAddress,
|
||||||
|
int $requestCount,
|
||||||
|
int $windowSeconds,
|
||||||
|
?string $endpoint = null
|
||||||
|
): self {
|
||||||
|
$event = self::create(self::RATE_LIMIT_EXCEEDED, $ipAddress, null, [
|
||||||
|
'requestCount' => $requestCount,
|
||||||
|
'windowSeconds' => $windowSeconds,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
]);
|
||||||
|
$event->requestPath = $endpoint;
|
||||||
|
$event->reason = sprintf(
|
||||||
|
'%d requests in %d seconds',
|
||||||
|
$requestCount,
|
||||||
|
$windowSeconds
|
||||||
|
);
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an access denied event
|
||||||
|
*/
|
||||||
|
public static function accessDenied(
|
||||||
|
string $ipAddress,
|
||||||
|
?string $deviceFingerprint = null,
|
||||||
|
?string $ruleId = null,
|
||||||
|
?string $reason = null
|
||||||
|
): self {
|
||||||
|
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [
|
||||||
|
'ruleId' => $ruleId,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
$event->reason = $reason;
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default severity for event types
|
||||||
|
*/
|
||||||
|
private static function getSeverityForEvent(string $eventName): int
|
||||||
|
{
|
||||||
|
return match ($eventName) {
|
||||||
|
self::AUTH_SUCCESS,
|
||||||
|
self::ACCESS_GRANTED,
|
||||||
|
self::TOKEN_REFRESH => self::SEVERITY_INFO,
|
||||||
|
|
||||||
|
self::AUTH_FAILURE,
|
||||||
|
self::ACCESS_DENIED,
|
||||||
|
self::AUTH_LOGOUT,
|
||||||
|
self::TOKEN_REVOKED => self::SEVERITY_WARNING,
|
||||||
|
|
||||||
|
self::RATE_LIMIT_EXCEEDED,
|
||||||
|
self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR,
|
||||||
|
|
||||||
|
self::BRUTE_FORCE_DETECTED,
|
||||||
|
self::IP_BLOCKED,
|
||||||
|
self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL,
|
||||||
|
|
||||||
|
default => self::SEVERITY_INFO,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIpAddress(?string $ipAddress): self
|
||||||
|
{
|
||||||
|
$this->ipAddress = $ipAddress;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeviceFingerprint(?string $deviceFingerprint): self
|
||||||
|
{
|
||||||
|
$this->deviceFingerprint = $deviceFingerprint;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserAgent(?string $userAgent): self
|
||||||
|
{
|
||||||
|
$this->userAgent = $userAgent;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRequestPath(?string $requestPath): self
|
||||||
|
{
|
||||||
|
$this->requestPath = $requestPath;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRequestMethod(?string $requestMethod): self
|
||||||
|
{
|
||||||
|
$this->requestMethod = $requestMethod;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserId(?string $userId): self
|
||||||
|
{
|
||||||
|
$this->userId = $userId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setReason(?string $reason): self
|
||||||
|
{
|
||||||
|
$this->reason = $reason;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): int
|
||||||
|
{
|
||||||
|
return $this->severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSeverity(int $severity): self
|
||||||
|
{
|
||||||
|
$this->severity = $severity;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverityLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->severity) {
|
||||||
|
self::SEVERITY_DEBUG => 'DEBUG',
|
||||||
|
self::SEVERITY_INFO => 'INFO',
|
||||||
|
self::SEVERITY_WARNING => 'WARNING',
|
||||||
|
self::SEVERITY_ERROR => 'ERROR',
|
||||||
|
self::SEVERITY_CRITICAL => 'CRITICAL',
|
||||||
|
default => 'UNKNOWN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override toArray to include security-specific fields
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::toArray(), [
|
||||||
|
'ipAddress' => $this->ipAddress,
|
||||||
|
'deviceFingerprint' => $this->deviceFingerprint,
|
||||||
|
'userAgent' => $this->userAgent,
|
||||||
|
'requestPath' => $this->requestPath,
|
||||||
|
'requestMethod' => $this->requestMethod,
|
||||||
|
'userId' => $this->userId,
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'severity' => $this->severity,
|
||||||
|
'severityLabel' => $this->getSeverityLabel(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,6 @@ class IpUtils
|
|||||||
if ($netmask < 0 || $netmask > 32) {
|
if ($netmask < 0 || $netmask > 32) {
|
||||||
return self::setCacheResult($cacheKey, false);
|
return self::setCacheResult($cacheKey, false);
|
||||||
}
|
}
|
||||||
$netmask = (int)$netmask;
|
|
||||||
} else {
|
} else {
|
||||||
$address = $ip;
|
$address = $ip;
|
||||||
$netmask = 32;
|
$netmask = 32;
|
||||||
@@ -150,7 +149,6 @@ class IpUtils
|
|||||||
if ($netmask < 1 || $netmask > 128) {
|
if ($netmask < 1 || $netmask > 128) {
|
||||||
return self::setCacheResult($cacheKey, false);
|
return self::setCacheResult($cacheKey, false);
|
||||||
}
|
}
|
||||||
$netmask = (int)$netmask;
|
|
||||||
} else {
|
} else {
|
||||||
if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
|
||||||
return self::setCacheResult($cacheKey, false);
|
return self::setCacheResult($cacheKey, false);
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Integration\Service;
|
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use KTXC\Models\Tenant\DomainCollection;
|
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
|
||||||
use KTXC\Service\FirewallSettingsService;
|
|
||||||
use KTXC\Service\TenantService;
|
|
||||||
use KTXC\Stores\TenantStore;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class FirewallSettingsServiceTest extends TestCase
|
|
||||||
{
|
|
||||||
private DataStore $dataStore;
|
|
||||||
private bool $databaseAvailable = false;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$system = require dirname(__DIR__, 4).'/config/system.php';
|
|
||||||
$database = $system['database'];
|
|
||||||
$database['database'] = sprintf('ktrix_firewall_settings_test_%d', getmypid());
|
|
||||||
$this->dataStore = new DataStore($database);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->dataStore->getDatabase()->drop();
|
|
||||||
$this->databaseAvailable = true;
|
|
||||||
} catch (\MongoDB\Driver\Exception\Exception $error) {
|
|
||||||
self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
if ($this->databaseAvailable) {
|
|
||||||
$this->dataStore->getDatabase()->drop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Firewall settings updates persist without replacing unrelated tenant configuration')]
|
|
||||||
public function testPersistence(): void
|
|
||||||
{
|
|
||||||
$tenants = new TenantService(new TenantStore($this->dataStore));
|
|
||||||
$tenants->deposit((new TenantObject())
|
|
||||||
->setIdentifier('tenant-a')
|
|
||||||
->setEnabled(true)
|
|
||||||
->setLabel('Tenant A')
|
|
||||||
->setDomains(new DomainCollection(['tenant-a.example.test']))
|
|
||||||
->setConfiguration((new TenantConfiguration())->jsonDeserialize([
|
|
||||||
'security' => ['code' => 'keep-me'],
|
|
||||||
])));
|
|
||||||
$settings = new FirewallSettingsService(
|
|
||||||
$tenants,
|
|
||||||
$this->createStub(EventDispatcherInterface::class)
|
|
||||||
);
|
|
||||||
|
|
||||||
$settings->update(
|
|
||||||
'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a'
|
|
||||||
);
|
|
||||||
$stored = $tenants->fetchById('tenant-a')->getConfiguration();
|
|
||||||
|
|
||||||
self::assertFalse($stored->firewall()->enabled());
|
|
||||||
self::assertSame(8, $stored->firewall()->maxAuthFailures());
|
|
||||||
self::assertSame(600, $stored->firewall()->authFailureWindow());
|
|
||||||
self::assertSame(7200, $stored->firewall()->autoBlockDuration());
|
|
||||||
self::assertSame('keep-me', $stored->security()->code());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,649 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Integration\Stores;
|
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
|
||||||
use KTXC\Db\UTCDateTime;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
|
||||||
use KTXC\Service\FirewallRuleCache;
|
|
||||||
use KTXC\Service\FirewallRuleManager;
|
|
||||||
use KTXC\Service\FirewallRuleScope;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
#[AllowMockObjectsWithoutExpectations]
|
|
||||||
class FirewallStoreTest extends TestCase
|
|
||||||
{
|
|
||||||
private DataStore $dataStore;
|
|
||||||
private FirewallStore $store;
|
|
||||||
private bool $databaseAvailable = false;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$system = require dirname(__DIR__, 4).'/config/system.php';
|
|
||||||
$database = $system['database'];
|
|
||||||
$database['database'] = sprintf('ktrix_firewall_test_%d', getmypid());
|
|
||||||
|
|
||||||
$this->dataStore = new DataStore($database);
|
|
||||||
try {
|
|
||||||
$this->dataStore->getDatabase()->drop();
|
|
||||||
$this->databaseAvailable = true;
|
|
||||||
} catch (\MongoDB\Driver\Exception\Exception $error) {
|
|
||||||
self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage());
|
|
||||||
}
|
|
||||||
$this->store = new FirewallStore($this->dataStore);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
if ($this->databaseAvailable) {
|
|
||||||
$this->dataStore->getDatabase()->drop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('System and tenant rule sets remain independently scoped')]
|
|
||||||
public function testApplicableScopes(): void
|
|
||||||
{
|
|
||||||
$tenantA = $this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a');
|
|
||||||
$tenantB = $this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b');
|
|
||||||
$system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM);
|
|
||||||
$expiredSystem = $this->rule('expired-system', FirewallRuleObject::SCOPE_SYSTEM)
|
|
||||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'));
|
|
||||||
$disabledSystem = $this->rule('disabled-system', FirewallRuleObject::SCOPE_SYSTEM)
|
|
||||||
->setEnabled(false);
|
|
||||||
|
|
||||||
foreach ([$tenantA, $tenantB, $system, $expiredSystem, $disabledSystem] as $rule) {
|
|
||||||
$this->store->depositRule($rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rules = array_merge(
|
|
||||||
$this->store->listSystemRules(),
|
|
||||||
$this->store->listRules('tenant-a')
|
|
||||||
);
|
|
||||||
$reasons = array_map(static fn(FirewallRuleObject $rule): ?string => $rule->getReason(), $rules);
|
|
||||||
sort($reasons);
|
|
||||||
|
|
||||||
self::assertSame(['system', 'tenant-a'], $reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Tenant rule listings cannot expose system or other tenant rules')]
|
|
||||||
public function testTenantListingIsolation(): void
|
|
||||||
{
|
|
||||||
$this->store->depositRule($this->rule('tenant-a', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
|
|
||||||
$this->store->depositRule($this->rule('tenant-b', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'));
|
|
||||||
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
|
|
||||||
|
|
||||||
$rules = $this->store->listRules('tenant-a');
|
|
||||||
|
|
||||||
self::assertCount(1, $rules);
|
|
||||||
self::assertSame('tenant-a', $rules[0]->getReason());
|
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $rules[0]->getScope());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Expired exact IP rules do not suppress a replacement block')]
|
|
||||||
public function testExpiredBlockReplacement(): void
|
|
||||||
{
|
|
||||||
$expired = $this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
|
||||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'));
|
|
||||||
$this->store->depositRule($expired);
|
|
||||||
|
|
||||||
self::assertNull($this->store->findExactIpRule(
|
|
||||||
'tenant-a',
|
|
||||||
'203.0.113.10',
|
|
||||||
FirewallRuleObject::ACTION_BLOCK
|
|
||||||
));
|
|
||||||
|
|
||||||
$events = $this->createMock(EventDispatcherInterface::class);
|
|
||||||
$cache = new FirewallRuleCache($this->store);
|
|
||||||
$manager = new FirewallRuleManager($this->store, $cache, $events);
|
|
||||||
$replacement = $manager->blockIp(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'),
|
|
||||||
'203.0.113.10',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
300
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertFalse($replacement->isExpired());
|
|
||||||
self::assertNotSame($expired->getId(), $replacement->getId());
|
|
||||||
self::assertSame(
|
|
||||||
$replacement->getId(),
|
|
||||||
$this->store->findExactIpRule(
|
|
||||||
'tenant-a',
|
|
||||||
'203.0.113.10',
|
|
||||||
FirewallRuleObject::ACTION_BLOCK
|
|
||||||
)?->getId()
|
|
||||||
);
|
|
||||||
self::assertCount(2, $this->store->listRules('tenant-a', false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Exact IP lookups respect tenant and system scope')]
|
|
||||||
public function testExactLookupScope(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a');
|
|
||||||
$system = $this->rule('system', FirewallRuleObject::SCOPE_SYSTEM);
|
|
||||||
$this->store->depositRule($tenant);
|
|
||||||
$this->store->depositRule($system);
|
|
||||||
|
|
||||||
self::assertSame(
|
|
||||||
$tenant->getId(),
|
|
||||||
$this->store->findExactIpRule(
|
|
||||||
'tenant-a',
|
|
||||||
'203.0.113.10',
|
|
||||||
FirewallRuleObject::ACTION_BLOCK
|
|
||||||
)?->getId()
|
|
||||||
);
|
|
||||||
self::assertSame(
|
|
||||||
$system->getId(),
|
|
||||||
$this->store->findExactIpRule(
|
|
||||||
null,
|
|
||||||
'203.0.113.10',
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
FirewallRuleObject::SCOPE_SYSTEM
|
|
||||||
)?->getId()
|
|
||||||
);
|
|
||||||
self::assertNull($this->store->findExactIpRule(
|
|
||||||
'tenant-b',
|
|
||||||
'203.0.113.10',
|
|
||||||
FirewallRuleObject::ACTION_BLOCK
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('System rule listings exclude tenant, disabled, and expired rules')]
|
|
||||||
public function testSystemListing(): void
|
|
||||||
{
|
|
||||||
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
|
|
||||||
$this->store->depositRule(
|
|
||||||
$this->rule('disabled', FirewallRuleObject::SCOPE_SYSTEM)->setEnabled(false)
|
|
||||||
);
|
|
||||||
$this->store->depositRule(
|
|
||||||
$this->rule('expired', FirewallRuleObject::SCOPE_SYSTEM)
|
|
||||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
|
|
||||||
);
|
|
||||||
$this->store->depositRule(
|
|
||||||
$this->rule('tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
|
||||||
);
|
|
||||||
|
|
||||||
$rules = $this->store->listSystemRules();
|
|
||||||
|
|
||||||
self::assertCount(1, $rules);
|
|
||||||
self::assertSame('system', $rules[0]->getReason());
|
|
||||||
self::assertTrue($rules[0]->isSystemScoped());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Firewall log persistence retains matched rule context')]
|
|
||||||
public function testRuleAuditPersistence(): void
|
|
||||||
{
|
|
||||||
$log = (new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setIpAddress('203.0.113.10')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
|
||||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
|
||||||
->setRuleId('rule-123')
|
|
||||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
|
||||||
->setTimestamp(new \DateTimeImmutable());
|
|
||||||
$this->store->createLog($log);
|
|
||||||
|
|
||||||
$logs = $this->store->listLogs('tenant-a');
|
|
||||||
|
|
||||||
self::assertCount(1, $logs);
|
|
||||||
self::assertSame('rule-123', $logs[0]->getRuleId());
|
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $logs[0]->getRuleScope());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Rule lifecycle audit persistence retains actor and origin')]
|
|
||||||
public function testLifecycleAuditPersistence(): void
|
|
||||||
{
|
|
||||||
$log = (new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_RULE_CREATED)
|
|
||||||
->setResult(FirewallLogObject::RESULT_RECORDED)
|
|
||||||
->setRuleId('rule-456')
|
|
||||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
|
||||||
->setIdentityId('admin-a')
|
|
||||||
->setTimestamp(new \DateTimeImmutable())
|
|
||||||
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
|
|
||||||
$this->store->createLog($log);
|
|
||||||
|
|
||||||
$logs = $this->store->listLogs('tenant-a');
|
|
||||||
|
|
||||||
self::assertCount(1, $logs);
|
|
||||||
self::assertSame(FirewallLogObject::EVENT_RULE_CREATED, $logs[0]->getEventType());
|
|
||||||
self::assertSame(FirewallLogObject::RESULT_RECORDED, $logs[0]->getResult());
|
|
||||||
self::assertSame('admin-a', $logs[0]->getIdentityId());
|
|
||||||
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $logs[0]->getMetadata()['origin']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Manual rule lifecycle changes persist through disable, enable, extension, and deletion')]
|
|
||||||
public function testManualRuleLifecyclePersistence(): void
|
|
||||||
{
|
|
||||||
$manager = new FirewallRuleManager(
|
|
||||||
$this->store,
|
|
||||||
new FirewallRuleCache($this->store),
|
|
||||||
$this->createStub(EventDispatcherInterface::class)
|
|
||||||
);
|
|
||||||
$rule = $manager->createManualRule(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'),
|
|
||||||
FirewallRuleObject::TYPE_IP,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
'203.0.113.10',
|
|
||||||
'Repeated abuse',
|
|
||||||
'admin-a',
|
|
||||||
300
|
|
||||||
);
|
|
||||||
|
|
||||||
$manager->disableManual(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Investigating', 'admin-a'
|
|
||||||
);
|
|
||||||
self::assertFalse($this->store->fetchRule($rule->getId())->isEnabled());
|
|
||||||
|
|
||||||
$manager->enableManual(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Threat confirmed', 'admin-a'
|
|
||||||
);
|
|
||||||
self::assertTrue($this->store->fetchRule($rule->getId())->isEnabled());
|
|
||||||
|
|
||||||
$previousExpiry = $rule->getExpiresAt();
|
|
||||||
$manager->extendManual(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 300, 'Continue monitoring', 'admin-a'
|
|
||||||
);
|
|
||||||
$stored = $this->store->fetchRule($rule->getId());
|
|
||||||
self::assertGreaterThan($previousExpiry, $stored->getExpiresAt());
|
|
||||||
self::assertSame('Continue monitoring', $stored->getMetadata()['extensions'][0]['reason']);
|
|
||||||
|
|
||||||
$manager->removeManual(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'), $rule->getId(), 'Case closed', 'admin-a'
|
|
||||||
);
|
|
||||||
self::assertNull($this->store->fetchRule($rule->getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Event-backed firewall logs are inserted exactly once')]
|
|
||||||
public function testIdempotentLogPersistence(): void
|
|
||||||
{
|
|
||||||
$log = (new FirewallLogObject())
|
|
||||||
->setEventId('event-123')
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setIpAddress('203.0.113.10')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
|
|
||||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
|
||||||
->setTimestamp(new \DateTimeImmutable());
|
|
||||||
|
|
||||||
self::assertTrue($this->store->createLogOnce($log));
|
|
||||||
self::assertFalse($this->store->createLogOnce($log));
|
|
||||||
|
|
||||||
$logs = $this->store->listLogs('tenant-a');
|
|
||||||
self::assertCount(1, $logs);
|
|
||||||
self::assertSame('event-123', $logs[0]->getEventId());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Only one worker can claim a tenant and IP brute-force response')]
|
|
||||||
public function testBruteForceClaim(): void
|
|
||||||
{
|
|
||||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
|
||||||
self::assertFalse($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
|
||||||
self::assertTrue($this->store->claimBruteForce('tenant-b', '203.0.113.10', 3600));
|
|
||||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.11', 3600));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Expired brute-force claims can be acquired again and cleaned up')]
|
|
||||||
public function testExpiredBruteForceClaim(): void
|
|
||||||
{
|
|
||||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
|
||||||
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
|
|
||||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
|
||||||
['_id' => $claimId],
|
|
||||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
|
||||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
|
||||||
['_id' => $claimId],
|
|
||||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
|
||||||
);
|
|
||||||
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Firewall dates use BSON date storage and indexes are installed idempotently')]
|
|
||||||
public function testBsonDatesAndIndexes(): void
|
|
||||||
{
|
|
||||||
$rule = $this->rule('bson-date', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
|
||||||
->setExpiresAt(new \DateTimeImmutable('+1 hour'));
|
|
||||||
$this->store->depositRule($rule);
|
|
||||||
|
|
||||||
$log = (new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
|
|
||||||
->setResult(FirewallLogObject::RESULT_ALLOWED)
|
|
||||||
->setTimestamp(new \DateTimeImmutable());
|
|
||||||
$this->store->createLog($log);
|
|
||||||
|
|
||||||
$storedRule = $this->dataStore->selectCollection('firewall_rules')
|
|
||||||
->getMongoCollection()
|
|
||||||
->findOne(['_id' => new \MongoDB\BSON\ObjectId($rule->getId())]);
|
|
||||||
$storedLog = $this->dataStore->selectCollection('firewall_logs')
|
|
||||||
->getMongoCollection()
|
|
||||||
->findOne(['_id' => new \MongoDB\BSON\ObjectId($log->getId())]);
|
|
||||||
|
|
||||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['createdAt']);
|
|
||||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['expiresAt']);
|
|
||||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedLog['timestamp']);
|
|
||||||
|
|
||||||
$expected = $this->store->ensureIndexes();
|
|
||||||
self::assertSame($expected, $this->store->ensureIndexes());
|
|
||||||
|
|
||||||
$claimIndex = null;
|
|
||||||
foreach ($this->dataStore->selectCollection('firewall_brute_force_claims')->getMongoCollection()->listIndexes() as $index) {
|
|
||||||
if ($index->getName() === 'claims_expiry') {
|
|
||||||
$claimIndex = $index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self::assertNotNull($claimIndex);
|
|
||||||
self::assertSame(0, $claimIndex['expireAfterSeconds']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Automatic block extensions persist policy and expiration history')]
|
|
||||||
public function testAutomaticBlockExtensionPersistence(): void
|
|
||||||
{
|
|
||||||
$events = $this->createMock(EventDispatcherInterface::class);
|
|
||||||
$manager = new FirewallRuleManager($this->store, new FirewallRuleCache($this->store), $events);
|
|
||||||
$scope = FirewallRuleScope::tenant('tenant-a');
|
|
||||||
$rule = $manager->blockIp(
|
|
||||||
$scope,
|
|
||||||
'203.0.113.10',
|
|
||||||
'Initial attack',
|
|
||||||
null,
|
|
||||||
60,
|
|
||||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
|
||||||
[
|
|
||||||
'failureThreshold' => 5,
|
|
||||||
'failureWindowSeconds' => 300,
|
|
||||||
'lastFailureCount' => 5,
|
|
||||||
'blockDurationSeconds' => 60,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
$originalExpiry = $rule->getExpiresAt();
|
|
||||||
|
|
||||||
$extended = $manager->blockIp(
|
|
||||||
$scope,
|
|
||||||
'203.0.113.10',
|
|
||||||
'Continued attack',
|
|
||||||
null,
|
|
||||||
3600,
|
|
||||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
|
||||||
[
|
|
||||||
'failureThreshold' => 5,
|
|
||||||
'failureWindowSeconds' => 300,
|
|
||||||
'lastFailureCount' => 9,
|
|
||||||
'blockDurationSeconds' => 3600,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
$persisted = $this->store->fetchRule($rule->getId());
|
|
||||||
|
|
||||||
self::assertSame($rule->getId(), $extended->getId());
|
|
||||||
self::assertNotNull($persisted);
|
|
||||||
self::assertGreaterThan($originalExpiry, $persisted->getExpiresAt());
|
|
||||||
self::assertSame(9, $persisted->getMetadata()['lastFailureCount']);
|
|
||||||
self::assertCount(1, $persisted->getMetadata()['extensions']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Cleanup removes only expired rules, old logs, and expired claims')]
|
|
||||||
public function testCleanupBoundaries(): void
|
|
||||||
{
|
|
||||||
$this->store->depositRule(
|
|
||||||
$this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
|
||||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
|
|
||||||
);
|
|
||||||
$this->store->depositRule(
|
|
||||||
$this->rule('active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
|
||||||
->setExpiresAt(new \DateTimeImmutable('+1 hour'))
|
|
||||||
);
|
|
||||||
foreach (['-31 days', '-29 days'] as $age) {
|
|
||||||
$this->store->createLog(
|
|
||||||
(new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
|
|
||||||
->setResult(FirewallLogObject::RESULT_ALLOWED)
|
|
||||||
->setTimestamp(new \DateTimeImmutable($age))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
|
||||||
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
|
|
||||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
|
||||||
['_id' => $claimId],
|
|
||||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(1, $this->store->cleanupExpiredRules());
|
|
||||||
self::assertSame(1, $this->store->cleanupOldLogs());
|
|
||||||
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
|
|
||||||
self::assertCount(1, $this->store->listRules('tenant-a'));
|
|
||||||
self::assertCount(1, $this->store->listLogs('tenant-a'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Maintenance outcomes are persisted with BSON timestamps')]
|
|
||||||
public function testMaintenanceStatusPersistence(): void
|
|
||||||
{
|
|
||||||
$this->store->recordMaintenanceStatus(
|
|
||||||
new \DateTimeImmutable('-1 second'),
|
|
||||||
new \DateTimeImmutable(),
|
|
||||||
'success',
|
|
||||||
['expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4]
|
|
||||||
);
|
|
||||||
|
|
||||||
$status = $this->store->maintenanceStatus();
|
|
||||||
$raw = $this->dataStore->selectCollection('firewall_maintenance')
|
|
||||||
->getMongoCollection()
|
|
||||||
->findOne(['_id' => 'cleanup']);
|
|
||||||
|
|
||||||
self::assertSame('success', $status['status']);
|
|
||||||
self::assertSame(3, $status['result']['oldLogs']);
|
|
||||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['startedAt']);
|
|
||||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['completedAt']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Firewall query shapes select their intended indexes')]
|
|
||||||
public function testIndexedQueryShapes(): void
|
|
||||||
{
|
|
||||||
$this->store->ensureIndexes();
|
|
||||||
$this->store->depositRule($this->rule('indexed', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
|
|
||||||
$this->store->createLog(
|
|
||||||
(new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setIpAddress('203.0.113.10')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
|
|
||||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
|
||||||
->setTimestamp(new \DateTimeImmutable())
|
|
||||||
);
|
|
||||||
|
|
||||||
$database = $this->dataStore->getDatabase()->getMongoDatabase();
|
|
||||||
$now = new \MongoDB\BSON\UTCDateTime();
|
|
||||||
$rulePlan = $database->command([
|
|
||||||
'explain' => [
|
|
||||||
'find' => 'firewall_rules',
|
|
||||||
'filter' => [
|
|
||||||
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
'tenantId' => 'tenant-a',
|
|
||||||
'type' => FirewallRuleObject::TYPE_IP,
|
|
||||||
'value' => '203.0.113.10',
|
|
||||||
'action' => FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
'enabled' => true,
|
|
||||||
'$or' => [['expiresAt' => null], ['expiresAt' => ['$gt' => $now]]],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'verbosity' => 'queryPlanner',
|
|
||||||
])->toArray()[0];
|
|
||||||
$failurePlan = $database->command([
|
|
||||||
'explain' => [
|
|
||||||
'find' => 'firewall_logs',
|
|
||||||
'filter' => [
|
|
||||||
'tenantId' => 'tenant-a',
|
|
||||||
'ipAddress' => '203.0.113.10',
|
|
||||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
|
||||||
'timestamp' => ['$gte' => new \MongoDB\BSON\UTCDateTime(0)],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'verbosity' => 'queryPlanner',
|
|
||||||
])->toArray()[0];
|
|
||||||
|
|
||||||
self::assertContains('rules_exact_lookup', self::indexNames($rulePlan));
|
|
||||||
self::assertContains('logs_auth_failures', self::indexNames($failurePlan));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Administrative rule queries filter, paginate, and preserve scope')]
|
|
||||||
public function testAdministrativeRuleQuery(): void
|
|
||||||
{
|
|
||||||
$this->store->depositRule($this->rule('tenant-active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
|
|
||||||
$this->store->depositRule(
|
|
||||||
$this->rule('tenant-disabled', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')->setEnabled(false)
|
|
||||||
);
|
|
||||||
$this->store->depositRule($this->rule('other-tenant', FirewallRuleObject::SCOPE_TENANT, 'tenant-b'));
|
|
||||||
$this->store->depositRule($this->rule('system', FirewallRuleObject::SCOPE_SYSTEM));
|
|
||||||
|
|
||||||
$active = $this->store->queryRules(
|
|
||||||
FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
'tenant-a',
|
|
||||||
'active',
|
|
||||||
FirewallRuleObject::TYPE_IP,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
$disabled = $this->store->queryRules(
|
|
||||||
FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
'tenant-a',
|
|
||||||
'disabled',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
50,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(1, $active['total']);
|
|
||||||
self::assertCount(1, $active['items']);
|
|
||||||
self::assertSame('tenant-active', $active['items'][0]->getReason());
|
|
||||||
self::assertSame(1, $disabled['total']);
|
|
||||||
self::assertSame('tenant-disabled', $disabled['items'][0]->getReason());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Administrative creation persists tenant and system ownership')]
|
|
||||||
public function testAdministrativeRuleCreation(): void
|
|
||||||
{
|
|
||||||
$manager = new FirewallRuleManager(
|
|
||||||
$this->store,
|
|
||||||
new FirewallRuleCache($this->store),
|
|
||||||
$this->createStub(EventDispatcherInterface::class)
|
|
||||||
);
|
|
||||||
$tenantRule = $manager->createManualRule(
|
|
||||||
FirewallRuleScope::tenant('tenant-a'),
|
|
||||||
FirewallRuleObject::TYPE_DEVICE,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
'device-123',
|
|
||||||
'Compromised device',
|
|
||||||
'admin-a',
|
|
||||||
3600
|
|
||||||
);
|
|
||||||
$systemRule = $manager->createManualRule(
|
|
||||||
FirewallRuleScope::system(),
|
|
||||||
FirewallRuleObject::TYPE_IP_RANGE,
|
|
||||||
FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
'198.51.100.0/24',
|
|
||||||
'Malicious network',
|
|
||||||
'system-admin',
|
|
||||||
currentIp: '203.0.113.10'
|
|
||||||
);
|
|
||||||
|
|
||||||
$persistedTenant = $this->store->fetchRule($tenantRule->getId());
|
|
||||||
$persistedSystem = $this->store->fetchRule($systemRule->getId());
|
|
||||||
self::assertSame('tenant-a', $persistedTenant->getTenantId());
|
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $persistedSystem->getScope());
|
|
||||||
self::assertNull($persistedSystem->getTenantId());
|
|
||||||
self::assertSame('system-admin', $persistedSystem->getCreatedBy());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Administrative log queries enforce tenant scope and supported filters')]
|
|
||||||
public function testAdministrativeLogQuery(): void
|
|
||||||
{
|
|
||||||
$matching = (new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setIpAddress('203.0.113.10')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
|
||||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
|
||||||
->setRuleId('rule-123')
|
|
||||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
|
||||||
->setTimestamp(new \DateTimeImmutable('-1 hour'));
|
|
||||||
$otherTenant = (new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-b')
|
|
||||||
->setIpAddress('203.0.113.10')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
|
||||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
|
||||||
->setRuleId('rule-123')
|
|
||||||
->setRuleScope(FirewallRuleObject::SCOPE_TENANT)
|
|
||||||
->setTimestamp(new \DateTimeImmutable('-1 hour'));
|
|
||||||
$tooOld = (new FirewallLogObject())
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setEventType(FirewallLogObject::EVENT_RULE_MATCH)
|
|
||||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
|
||||||
->setRuleId('rule-123')
|
|
||||||
->setTimestamp(new \DateTimeImmutable('-3 days'));
|
|
||||||
foreach ([$matching, $otherTenant, $tooOld] as $log) {
|
|
||||||
$this->store->createLog($log);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $this->store->queryTenantLogs('tenant-a', [
|
|
||||||
'ipAddress' => '203.0.113.10',
|
|
||||||
'eventType' => FirewallLogObject::EVENT_RULE_MATCH,
|
|
||||||
'result' => FirewallLogObject::RESULT_BLOCKED,
|
|
||||||
'ruleId' => 'rule-123',
|
|
||||||
'ruleScope' => FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
'from' => new \DateTimeImmutable('-1 day'),
|
|
||||||
'to' => new \DateTimeImmutable(),
|
|
||||||
], 50, 0);
|
|
||||||
$system = $this->store->querySystemLogs(null, [], 2, 0);
|
|
||||||
|
|
||||||
self::assertSame(1, $tenant['total']);
|
|
||||||
self::assertSame('tenant-a', $tenant['items'][0]->getTenantId());
|
|
||||||
self::assertSame(3, $system['total']);
|
|
||||||
self::assertCount(2, $system['items']);
|
|
||||||
self::assertSame(2, $this->store->countSystemBlockedRequests('tenant-a'));
|
|
||||||
self::assertSame(3, $this->store->countSystemBlockedRequests());
|
|
||||||
self::assertSame(2, $this->store->countSystemBlockedRequests(null, new \DateTimeImmutable('-1 day')));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function rule(
|
|
||||||
string $reason,
|
|
||||||
string $scope,
|
|
||||||
?string $tenantId = null
|
|
||||||
): FirewallRuleObject {
|
|
||||||
return (new FirewallRuleObject())
|
|
||||||
->setScope($scope)
|
|
||||||
->setTenantId($tenantId)
|
|
||||||
->setType(FirewallRuleObject::TYPE_IP)
|
|
||||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
|
||||||
->setValue('203.0.113.10')
|
|
||||||
->setReason($reason)
|
|
||||||
->setCreatedAt(new \DateTimeImmutable())
|
|
||||||
->setEnabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function indexNames(mixed $value): array
|
|
||||||
{
|
|
||||||
if (is_object($value)) {
|
|
||||||
$value = (array)$value;
|
|
||||||
}
|
|
||||||
if (!is_array($value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$names = isset($value['indexName']) ? [(string)$value['indexName']] : [];
|
|
||||||
foreach ($value as $child) {
|
|
||||||
$names = [...$names, ...self::indexNames($child)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($names));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,9 @@ use KTXC\Service\TenantService;
|
|||||||
use KTXF\Cache\BlobCacheInterface;
|
use KTXF\Cache\BlobCacheInterface;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
use KTXF\Cache\PersistentCacheInterface;
|
use KTXF\Cache\PersistentCacheInterface;
|
||||||
use KTXC\Event\DeferredEventProcessorInterface;
|
use KTXF\Event\DeferredEventProcessorInterface;
|
||||||
use KTXC\Event\DeferredProcessingResult;
|
use KTXF\Event\DeferredProcessingResult;
|
||||||
use KTXC\Event\EventListenerRegistry;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Console\Firewall;
|
|
||||||
|
|
||||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
|
||||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
|
||||||
use KTXC\Service\FirewallService;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
|
||||||
|
|
||||||
final class FirewallCommandsTest extends TestCase
|
|
||||||
{
|
|
||||||
#[TestDox('Setup verifies every firewall database index')]
|
|
||||||
public function testSetup(): void
|
|
||||||
{
|
|
||||||
$store = $this->createMock(FirewallStore::class);
|
|
||||||
$store->expects(self::once())->method('ensureIndexes')->willReturn(array_fill(0, 7, 'index'));
|
|
||||||
$tester = new CommandTester(new FirewallSetupCommand($store));
|
|
||||||
|
|
||||||
self::assertSame(Command::SUCCESS, $tester->execute([]));
|
|
||||||
self::assertStringContainsString('7 indexes verified', $tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Maintenance reports cleanup counts for schedulers')]
|
|
||||||
public function testMaintenance(): void
|
|
||||||
{
|
|
||||||
$firewall = $this->createMock(FirewallService::class);
|
|
||||||
$firewall->expects(self::once())->method('cleanup')->willReturn([
|
|
||||||
'expiredRules' => 2,
|
|
||||||
'oldLogs' => 3,
|
|
||||||
'expiredBruteForceClaims' => 4,
|
|
||||||
]);
|
|
||||||
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
|
|
||||||
|
|
||||||
self::assertSame(Command::SUCCESS, $tester->execute([]));
|
|
||||||
self::assertStringContainsString('2 expired rules, 3 old logs', $tester->getDisplay());
|
|
||||||
self::assertStringContainsString('4 expired', $tester->getDisplay());
|
|
||||||
self::assertStringContainsString('claims removed', $tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Maintenance returns failure to its scheduler')]
|
|
||||||
public function testMaintenanceFailure(): void
|
|
||||||
{
|
|
||||||
$firewall = $this->createStub(FirewallService::class);
|
|
||||||
$firewall->method('cleanup')->willThrowException(new \RuntimeException('database unavailable'));
|
|
||||||
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
|
|
||||||
|
|
||||||
self::assertSame(Command::FAILURE, $tester->execute([]));
|
|
||||||
self::assertStringContainsString('database unavailable', $tester->getDisplay());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace KTXT\Unit\Console\Tenant;
|
namespace KTXT\Unit\Console\Tenant;
|
||||||
|
|
||||||
use KTXC\Console\Tenant\TenantCreateCommand;
|
use KTXC\Console\Tenant\TenantCreateCommand;
|
||||||
use KTXC\Context\TenantContext;
|
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Service\TenantService;
|
||||||
use KTXC\Service\UserAccountsService;
|
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use KTXC\Stores\UserRolesStore;
|
use KTXC\Stores\UserRolesStore;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
@@ -24,7 +22,6 @@ class TenantCreateCommandTest extends TestCase
|
|||||||
private TenantService&MockObject $tenantService;
|
private TenantService&MockObject $tenantService;
|
||||||
private UserRolesStore $rolesStore;
|
private UserRolesStore $rolesStore;
|
||||||
private UserAccountsStore $userStore;
|
private UserAccountsStore $userStore;
|
||||||
private UserAccountsService $userService;
|
|
||||||
private CommandTester $tester;
|
private CommandTester $tester;
|
||||||
private ?TenantObject $deposited = null;
|
private ?TenantObject $deposited = null;
|
||||||
|
|
||||||
@@ -34,21 +31,12 @@ class TenantCreateCommandTest extends TestCase
|
|||||||
$this->rolesStore = $this->createStub(UserRolesStore::class);
|
$this->rolesStore = $this->createStub(UserRolesStore::class);
|
||||||
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
|
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
|
||||||
$this->userStore = $this->createStub(UserAccountsStore::class);
|
$this->userStore = $this->createStub(UserAccountsStore::class);
|
||||||
$this->userService = $this->createStub(UserAccountsService::class);
|
$this->userStore->method('createUser')->willReturn(['uid' => 'admin']);
|
||||||
$this->userService->method('createUser')->willReturn(['uid' => 'admin']);
|
|
||||||
$contextTenantService = $this->createStub(TenantService::class);
|
|
||||||
$contextTenantService->method('fetchById')->willReturnCallback(
|
|
||||||
fn(string $identifier): ?TenantObject => $this->deposited?->getIdentifier() === $identifier
|
|
||||||
? $this->deposited
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
$this->tester = new CommandTester(
|
$this->tester = new CommandTester(
|
||||||
new TenantCreateCommand(
|
new TenantCreateCommand(
|
||||||
$this->tenantService,
|
$this->tenantService,
|
||||||
$this->rolesStore,
|
$this->rolesStore,
|
||||||
$this->userStore,
|
$this->userStore,
|
||||||
$this->userService,
|
|
||||||
new TenantContext($contextTenantService),
|
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Controllers;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
use KTXC\Controllers\FirewallController;
|
|
||||||
use KTXC\Http\Request\Request;
|
|
||||||
use KTXC\Service\FirewallRuleCache;
|
|
||||||
use KTXC\Service\FirewallRuleManager;
|
|
||||||
use KTXC\Service\FirewallSettingsService;
|
|
||||||
use KTXC\Service\FirewallStatusService;
|
|
||||||
use KTXC\Service\FirewallLogService;
|
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
|
||||||
use KTXC\Service\SystemFirewallStatusService;
|
|
||||||
use KTXC\Service\TenantFirewallLogService;
|
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
|
||||||
use KTXC\Service\TenantFirewallStatusService;
|
|
||||||
use KTXC\Service\TenantService;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
|
||||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
#[AllowMockObjectsWithoutExpectations]
|
|
||||||
final class FirewallControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
private FirewallStore $store;
|
|
||||||
private FirewallController $controller;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->store = $this->createMock(FirewallStore::class);
|
|
||||||
$tenant = $this->createMock(TenantContextInterface::class);
|
|
||||||
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
|
||||||
$identity = $this->createMock(IdentityContextInterface::class);
|
|
||||||
$identity->method('hasPermission')->willReturn(true);
|
|
||||||
$manager = new FirewallRuleManager(
|
|
||||||
$this->store,
|
|
||||||
new FirewallRuleCache($this->store),
|
|
||||||
$this->createStub(EventDispatcherInterface::class)
|
|
||||||
);
|
|
||||||
$settings = new FirewallSettingsService(
|
|
||||||
$this->createStub(TenantService::class),
|
|
||||||
$this->createStub(EventDispatcherInterface::class)
|
|
||||||
);
|
|
||||||
$this->controller = new FirewallController(
|
|
||||||
new TenantFirewallRuleService($manager, $tenant, $identity),
|
|
||||||
new SystemFirewallRuleService($manager, $identity),
|
|
||||||
new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity),
|
|
||||||
new SystemFirewallLogService(new FirewallLogService($this->store), $identity),
|
|
||||||
new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity, $settings),
|
|
||||||
new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity, $settings)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Tenant rule endpoint returns bounded paginated results')]
|
|
||||||
public function testTenantRules(): void
|
|
||||||
{
|
|
||||||
$this->store->expects(self::once())
|
|
||||||
->method('queryRules')
|
|
||||||
->with('tenant', 'tenant-a', 'active', null, null, 25, 10)
|
|
||||||
->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]);
|
|
||||||
|
|
||||||
$response = $this->controller->tenantRules(limit: '25', offset: '10');
|
|
||||||
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
self::assertSame(200, $response->getStatusCode());
|
|
||||||
self::assertSame(25, $data['limit']);
|
|
||||||
self::assertSame(10, $data['offset']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Rule endpoints reject malformed and excessive pagination')]
|
|
||||||
public function testPaginationValidation(): void
|
|
||||||
{
|
|
||||||
$this->store->expects(self::never())->method('queryRules');
|
|
||||||
|
|
||||||
self::assertSame(400, $this->controller->tenantRules(limit: 'invalid')->getStatusCode());
|
|
||||||
self::assertSame(400, $this->controller->systemRules(limit: '101')->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Metric endpoints return scoped counts and stable validation errors')]
|
|
||||||
public function testMetrics(): void
|
|
||||||
{
|
|
||||||
$this->store->method('countBlockedRequests')->willReturn(4);
|
|
||||||
|
|
||||||
$response = $this->controller->tenantMetrics();
|
|
||||||
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
self::assertSame(4, $data['blockedRequests']);
|
|
||||||
self::assertSame(400, $this->controller->systemMetrics(since: 'not-a-date')->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Current-IP blocks return a structured confirmation conflict')]
|
|
||||||
public function testCurrentIpConflict(): void
|
|
||||||
{
|
|
||||||
$this->store->expects(self::never())->method('depositRule');
|
|
||||||
$response = $this->controller->createTenantRule(
|
|
||||||
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
|
|
||||||
'ip',
|
|
||||||
'block',
|
|
||||||
'203.0.113.10',
|
|
||||||
'Suspected abuse'
|
|
||||||
);
|
|
||||||
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
self::assertSame(409, $response->getStatusCode());
|
|
||||||
self::assertSame('current_ip_confirmation_required', $data['error']['code']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Invalid manual rules return a stable validation response')]
|
|
||||||
public function testMutationValidation(): void
|
|
||||||
{
|
|
||||||
$response = $this->controller->createSystemRule(
|
|
||||||
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
|
|
||||||
'device',
|
|
||||||
'allow',
|
|
||||||
'device-123',
|
|
||||||
'Trusted device'
|
|
||||||
);
|
|
||||||
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
self::assertSame(400, $response->getStatusCode());
|
|
||||||
self::assertSame('invalid_firewall_rule', $data['error']['code']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Re-enabling a current-IP block requires explicit confirmation')]
|
|
||||||
public function testEnableSafeguard(): void
|
|
||||||
{
|
|
||||||
$rule = (new \KTXC\Models\Firewall\FirewallRuleObject())
|
|
||||||
->setId('rule-123')
|
|
||||||
->setScope('tenant')
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setType('ip')
|
|
||||||
->setAction('block')
|
|
||||||
->setValue('203.0.113.10')
|
|
||||||
->setEnabled(false);
|
|
||||||
$this->store->method('fetchRule')->willReturn($rule);
|
|
||||||
|
|
||||||
$response = $this->controller->updateTenantRule(
|
|
||||||
new Request(server: ['REMOTE_ADDR' => '203.0.113.10']),
|
|
||||||
'rule-123',
|
|
||||||
'enable',
|
|
||||||
'Threat returned'
|
|
||||||
);
|
|
||||||
$data = json_decode($response->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
self::assertSame(409, $response->getStatusCode());
|
|
||||||
self::assertSame('current_ip_confirmation_required', $data['error']['code']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Lifecycle endpoints return stable validation and not-found responses')]
|
|
||||||
public function testLifecycleResponses(): void
|
|
||||||
{
|
|
||||||
$request = new Request(server: ['REMOTE_ADDR' => '203.0.113.10']);
|
|
||||||
|
|
||||||
self::assertSame(400, $this->controller->updateSystemRule(
|
|
||||||
$request, 'rule-123', 'extend', 'More time required'
|
|
||||||
)->getStatusCode());
|
|
||||||
self::assertSame(404, $this->controller->deleteTenantRule(
|
|
||||||
'missing-rule', 'No longer required'
|
|
||||||
)->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Every rule endpoint declares its scope-specific read permission')]
|
|
||||||
public function testRoutePermissions(): void
|
|
||||||
{
|
|
||||||
$expected = [
|
|
||||||
'tenantRules' => TenantFirewallRuleService::PERMISSION_READ,
|
|
||||||
'tenantRule' => TenantFirewallRuleService::PERMISSION_READ,
|
|
||||||
'effectivePolicy' => TenantFirewallRuleService::PERMISSION_READ,
|
|
||||||
'systemRules' => SystemFirewallRuleService::PERMISSION_READ,
|
|
||||||
'systemRule' => SystemFirewallRuleService::PERMISSION_READ,
|
|
||||||
'tenantLogs' => TenantFirewallLogService::PERMISSION_READ,
|
|
||||||
'systemLogs' => SystemFirewallLogService::PERMISSION_READ,
|
|
||||||
'tenantMetrics' => TenantFirewallLogService::PERMISSION_READ,
|
|
||||||
'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ,
|
|
||||||
'systemMetrics' => SystemFirewallLogService::PERMISSION_READ,
|
|
||||||
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
|
|
||||||
'updateTenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
|
|
||||||
'updateSystemTenantConfiguration' => SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
|
|
||||||
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
|
||||||
'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
|
|
||||||
'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
|
||||||
'updateSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
|
|
||||||
'deleteTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
|
||||||
'deleteSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($expected as $method => $permission) {
|
|
||||||
$attributes = (new \ReflectionMethod(FirewallController::class, $method))
|
|
||||||
->getAttributes(AuthenticatedRoute::class);
|
|
||||||
self::assertCount(1, $attributes);
|
|
||||||
self::assertSame([$permission], $attributes[0]->newInstance()->permissions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXC\Security\Event\AccessDeniedEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class AccessDeniedEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Access-denial state is typed and complete at construction')]
|
|
||||||
public function constructsTypedState(): void
|
|
||||||
{
|
|
||||||
$event = new AccessDeniedEvent(
|
|
||||||
'203.0.113.10',
|
|
||||||
'rule-a',
|
|
||||||
FirewallRuleObject::SCOPE_TENANT,
|
|
||||||
'device-a',
|
|
||||||
'Blocked by policy',
|
|
||||||
'tenant-a',
|
|
||||||
'identity-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(AccessDeniedEvent::class, $event->label());
|
|
||||||
self::assertSame('203.0.113.10', $event->getIpAddress());
|
|
||||||
self::assertSame('device-a', $event->getDeviceFingerprint());
|
|
||||||
self::assertSame('rule-a', $event->getRuleId());
|
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
|
|
||||||
self::assertSame('Blocked by policy', $event->getReason());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('identity-a', $event->actorIdentity());
|
|
||||||
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Access denial rejects incomplete rule context')]
|
|
||||||
public function rejectsIncompleteRuleContext(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['', 'rule-a', FirewallRuleObject::SCOPE_TENANT],
|
|
||||||
['203.0.113.10', '', FirewallRuleObject::SCOPE_TENANT],
|
|
||||||
['203.0.113.10', 'rule-a', 'unknown'],
|
|
||||||
] as $arguments) {
|
|
||||||
try {
|
|
||||||
new AccessDeniedEvent(...$arguments);
|
|
||||||
self::fail('Incomplete access-denial context was accepted.');
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
$this->addToAssertionCount(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use KTXC\Security\Event\SecurityRequestEventInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class AuthenticationFailedEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Authentication failure state is typed and complete at construction')]
|
|
||||||
public function constructsTypedState(): void
|
|
||||||
{
|
|
||||||
$event = new AuthenticationFailedEvent(
|
|
||||||
'user-a',
|
|
||||||
'Invalid credentials',
|
|
||||||
'tenant-a',
|
|
||||||
'identity-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(AuthenticationFailedEvent::class, $event->label());
|
|
||||||
self::assertSame('user-a', $event->getUserId());
|
|
||||||
self::assertSame('Invalid credentials', $event->getReason());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('identity-a', $event->actorIdentity());
|
|
||||||
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
|
|
||||||
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use KTXC\Security\Event\SecurityRequestEventInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class AuthenticationSucceededEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Successful-authentication state is typed and complete at construction')]
|
|
||||||
public function constructsTypedState(): void
|
|
||||||
{
|
|
||||||
$event = new AuthenticationSucceededEvent(
|
|
||||||
'user-a',
|
|
||||||
'tenant-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(AuthenticationSucceededEvent::class, $event->label());
|
|
||||||
self::assertSame('user-a', $event->getUserId());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame(['userId' => 'user-a'], $event->context());
|
|
||||||
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
|
||||||
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Successful authentication requires a user ID')]
|
|
||||||
public function rejectsIncompleteAuthenticationContext(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
|
|
||||||
new AuthenticationSucceededEvent('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\BruteForceDetectedEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class BruteForceDetectedEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Brute-force detection state is typed and complete at construction')]
|
|
||||||
public function constructsTypedState(): void
|
|
||||||
{
|
|
||||||
$event = new BruteForceDetectedEvent(
|
|
||||||
'203.0.113.10',
|
|
||||||
5,
|
|
||||||
300,
|
|
||||||
'tenant-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(BruteForceDetectedEvent::class, $event->label());
|
|
||||||
self::assertSame('203.0.113.10', $event->getIpAddress());
|
|
||||||
self::assertSame(5, $event->getFailureCount());
|
|
||||||
self::assertSame(300, $event->getWindowSeconds());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('5 failed attempts in 300 seconds', $event->getReason());
|
|
||||||
self::assertSame(
|
|
||||||
['failureCount' => 5, 'windowSeconds' => 300],
|
|
||||||
$event->context(),
|
|
||||||
);
|
|
||||||
self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Brute-force detection rejects invalid measurements')]
|
|
||||||
public function rejectsInvalidMeasurements(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['', 5, 300],
|
|
||||||
['203.0.113.10', 0, 300],
|
|
||||||
['203.0.113.10', 5, 0],
|
|
||||||
] as $arguments) {
|
|
||||||
try {
|
|
||||||
new BruteForceDetectedEvent(...$arguments);
|
|
||||||
self::fail('Invalid brute-force measurements were accepted.');
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
$this->addToAssertionCount(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ namespace KTXT\Unit\Event;
|
|||||||
|
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
use KTXF\Event\Event;
|
use KTXF\Event\Event;
|
||||||
use KTXC\Event\EventDispatcher;
|
use KTXF\Event\EventDispatcher;
|
||||||
use KTXC\Event\EventListenerRegistry;
|
use KTXF\Event\EventListenerRegistry;
|
||||||
use KTXF\Event\FailurePolicy;
|
use KTXF\Event\FailurePolicy;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
@@ -167,43 +167,6 @@ final class EventDispatcherTest extends TestCase
|
|||||||
$dispatcher->dispatch(new Event('test.event'));
|
$dispatcher->dispatch(new Event('test.event'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Propagated deferred failures close their execution scope')]
|
|
||||||
public function recoversFromDeferredFailure(): void
|
|
||||||
{
|
|
||||||
$registry = new EventListenerRegistry();
|
|
||||||
$registry->listen(
|
|
||||||
'test',
|
|
||||||
'test.event',
|
|
||||||
FailingListener::class,
|
|
||||||
'fail',
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
failurePolicy: FailurePolicy::Propagate,
|
|
||||||
);
|
|
||||||
$registry->freeze();
|
|
||||||
|
|
||||||
$dispatcher = new EventDispatcher(
|
|
||||||
$registry,
|
|
||||||
new RecordingContainer([FailingListener::class => new FailingListener()]),
|
|
||||||
new NullLogger(),
|
|
||||||
);
|
|
||||||
$dispatcher->beginExecution('failed');
|
|
||||||
$dispatcher->dispatch(new Event('test.event'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
$dispatcher->processDeferred('failed');
|
|
||||||
self::fail('Expected deferred listener failure to propagate.');
|
|
||||||
} catch (\RuntimeException $error) {
|
|
||||||
self::assertSame('Listener failed.', $error->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$dispatcher->beginExecution('next');
|
|
||||||
$result = $dispatcher->processDeferred('next');
|
|
||||||
|
|
||||||
self::assertSame(0, $result->processed);
|
|
||||||
self::assertSame(0, $result->remaining);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Deferred processing stops at its configured count limit')]
|
#[TestDox('Deferred processing stops at its configured count limit')]
|
||||||
public function boundsDeferredWork(): void
|
public function boundsDeferredWork(): void
|
||||||
@@ -230,57 +193,9 @@ final class EventDispatcherTest extends TestCase
|
|||||||
$result = $dispatcher->processDeferred('test');
|
$result = $dispatcher->processDeferred('test');
|
||||||
|
|
||||||
self::assertSame(1000, $result->processed);
|
self::assertSame(1000, $result->processed);
|
||||||
self::assertSame(1000, $result->listenerInvocations);
|
|
||||||
self::assertSame(1, $result->remaining);
|
self::assertSame(1, $result->remaining);
|
||||||
self::assertFalse($result->deadlineExceeded);
|
self::assertFalse($result->deadlineExceeded);
|
||||||
self::assertTrue($result->limitExceeded);
|
self::assertTrue($result->limitExceeded);
|
||||||
self::assertTrue($result->eventLimitExceeded);
|
|
||||||
self::assertFalse($result->listenerInvocationLimitExceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Deferred event and listener invocation limits are tracked separately')]
|
|
||||||
public function boundsDeferredListenerInvocations(): void
|
|
||||||
{
|
|
||||||
$recursive = new RecursiveListener();
|
|
||||||
$recording = new RecordingListener();
|
|
||||||
$registry = new EventListenerRegistry();
|
|
||||||
$registry->listen(
|
|
||||||
'test',
|
|
||||||
'test.event',
|
|
||||||
RecursiveListener::class,
|
|
||||||
'deferred',
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
);
|
|
||||||
$registry->listen(
|
|
||||||
'test',
|
|
||||||
'test.event',
|
|
||||||
RecordingListener::class,
|
|
||||||
'deferred',
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
);
|
|
||||||
$registry->freeze();
|
|
||||||
$dispatcher = new EventDispatcher(
|
|
||||||
$registry,
|
|
||||||
new RecordingContainer([
|
|
||||||
RecursiveListener::class => $recursive,
|
|
||||||
RecordingListener::class => $recording,
|
|
||||||
]),
|
|
||||||
new NullLogger(),
|
|
||||||
maxDeferredListenerInvocations: 3,
|
|
||||||
);
|
|
||||||
$recursive->dispatcher = $dispatcher;
|
|
||||||
$dispatcher->beginExecution('test');
|
|
||||||
$dispatcher->dispatch(new Event('test.event'));
|
|
||||||
|
|
||||||
$result = $dispatcher->processDeferred('test');
|
|
||||||
|
|
||||||
self::assertSame(1, $result->processed);
|
|
||||||
self::assertSame(2, $result->listenerInvocations);
|
|
||||||
self::assertSame(1, $result->remaining);
|
|
||||||
self::assertTrue($result->limitExceeded);
|
|
||||||
self::assertFalse($result->eventLimitExceeded);
|
|
||||||
self::assertTrue($result->listenerInvocationLimitExceeded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class EventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Event data and context are supplied during construction')]
|
|
||||||
public function constructsCompleteEventState(): void
|
|
||||||
{
|
|
||||||
$event = new Event(
|
|
||||||
'test.event',
|
|
||||||
['nested' => ['value' => 'original']],
|
|
||||||
'tenant-a',
|
|
||||||
'identity-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
$copy = $event->context();
|
|
||||||
$copy['nested']['value'] = 'changed';
|
|
||||||
|
|
||||||
self::assertSame('original', $event->get('nested')['value']);
|
|
||||||
self::assertSame('test.event', $event->label());
|
|
||||||
self::assertNotSame('', $event->identifier());
|
|
||||||
self::assertGreaterThan(0, $event->timestamp());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('identity-a', $event->actorIdentity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Event data rejects mutable object references')]
|
|
||||||
public function rejectsMutablePayloadValues(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
|
|
||||||
new Event('test.event', ['mutable' => new \stdClass()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\DeviceBlockedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
|
||||||
use KTXC\Security\Event\IpAllowedEvent;
|
|
||||||
use KTXC\Security\Event\IpBlockedEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class FirewallPolicyEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('IP and device policy events expose typed immutable state')]
|
|
||||||
public function constructsSubjectPolicyEvents(): void
|
|
||||||
{
|
|
||||||
$blocked = new IpBlockedEvent('203.0.113.10', 'Repeated abuse', 'tenant-a');
|
|
||||||
$allowed = new IpAllowedEvent('203.0.113.11', 'Trusted service', 'tenant-a');
|
|
||||||
$device = new DeviceBlockedEvent('device-a', 'Compromised device', 'tenant-a');
|
|
||||||
|
|
||||||
self::assertSame(IpBlockedEvent::class, $blocked->label());
|
|
||||||
self::assertSame('203.0.113.10', $blocked->getIpAddress());
|
|
||||||
self::assertSame('Repeated abuse', $blocked->getReason());
|
|
||||||
self::assertSame(SecurityEventSeverity::CRITICAL, $blocked->getSeverity());
|
|
||||||
self::assertSame(IpAllowedEvent::class, $allowed->label());
|
|
||||||
self::assertSame('203.0.113.11', $allowed->getIpAddress());
|
|
||||||
self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity());
|
|
||||||
self::assertSame(DeviceBlockedEvent::class, $device->label());
|
|
||||||
self::assertSame('device-a', $device->getDeviceFingerprint());
|
|
||||||
self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Firewall settings events capture the complete configuration transition')]
|
|
||||||
public function constructsSettingsEvent(): void
|
|
||||||
{
|
|
||||||
$event = new FirewallSettingsUpdatedEvent(
|
|
||||||
'Tighten controls',
|
|
||||||
['maxAuthFailures' => 5],
|
|
||||||
['maxAuthFailures' => 3],
|
|
||||||
'tenant-a',
|
|
||||||
'operator-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->label());
|
|
||||||
self::assertSame('Tighten controls', $event->getChangeReason());
|
|
||||||
self::assertSame(['maxAuthFailures' => 5], $event->getPrevious());
|
|
||||||
self::assertSame(['maxAuthFailures' => 3], $event->getCurrent());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('operator-a', $event->actorIdentity());
|
|
||||||
self::assertSame('manual', $event->getChangeOrigin());
|
|
||||||
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Firewall policy events reject missing subject and ownership context')]
|
|
||||||
public function rejectsIncompletePolicyContext(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
static fn() => new IpBlockedEvent(''),
|
|
||||||
static fn() => new IpAllowedEvent(''),
|
|
||||||
static fn() => new DeviceBlockedEvent(''),
|
|
||||||
static fn() => new FirewallSettingsUpdatedEvent('', [], [], 'tenant-a'),
|
|
||||||
static fn() => new FirewallSettingsUpdatedEvent('Reason', [], [], ''),
|
|
||||||
] as $construction) {
|
|
||||||
try {
|
|
||||||
$construction();
|
|
||||||
self::fail('Incomplete firewall policy context was accepted.');
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
$this->addToAssertionCount(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class FirewallRuleEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Firewall rule events capture immutable rule snapshots')]
|
|
||||||
public function capturesRuleSnapshot(): void
|
|
||||||
{
|
|
||||||
$expiresAt = new \DateTimeImmutable('+5 minutes');
|
|
||||||
$rule = (new FirewallRuleObject())
|
|
||||||
->setId('rule-a')
|
|
||||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->setType(FirewallRuleObject::TYPE_IP)
|
|
||||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
|
||||||
->setValue('203.0.113.10')
|
|
||||||
->setReason('Repeated abuse')
|
|
||||||
->setCreatedBy('creator-a')
|
|
||||||
->setExpiresAt($expiresAt)
|
|
||||||
->setMetadata(['origin' => 'automatic', 'failureCount' => 5]);
|
|
||||||
|
|
||||||
$event = FirewallRuleExtendedEvent::fromRule(
|
|
||||||
$rule,
|
|
||||||
'operator-a',
|
|
||||||
['changeReason' => 'Continue monitoring'],
|
|
||||||
);
|
|
||||||
$rule->setReason('Mutated after publication');
|
|
||||||
|
|
||||||
self::assertSame(FirewallRuleExtendedEvent::class, $event->label());
|
|
||||||
self::assertSame('rule-a', $event->getRuleId());
|
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
|
|
||||||
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
|
|
||||||
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $event->getRuleAction());
|
|
||||||
self::assertSame('203.0.113.10', $event->getRuleValue());
|
|
||||||
self::assertSame('Repeated abuse', $event->getReason());
|
|
||||||
self::assertSame('automatic', $event->getOrigin());
|
|
||||||
self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('operator-a', $event->actorIdentity());
|
|
||||||
self::assertSame(5, $event->get('failureCount'));
|
|
||||||
self::assertSame('Continue monitoring', $event->get('changeReason'));
|
|
||||||
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Each firewall rule lifecycle operation has a dedicated event key')]
|
|
||||||
public function exposesDedicatedLifecycleKeys(): void
|
|
||||||
{
|
|
||||||
$rule = (new FirewallRuleObject())
|
|
||||||
->setId('rule-a')
|
|
||||||
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
|
||||||
->setType(FirewallRuleObject::TYPE_IP)
|
|
||||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
|
||||||
->setValue('203.0.113.10');
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
FirewallRuleCreatedEvent::class,
|
|
||||||
FirewallRuleExtendedEvent::class,
|
|
||||||
FirewallRuleEnabledEvent::class,
|
|
||||||
FirewallRuleDisabledEvent::class,
|
|
||||||
FirewallRuleRemovedEvent::class,
|
|
||||||
] as $eventClass) {
|
|
||||||
self::assertSame($eventClass, $eventClass::fromRule($rule)->label());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\RateLimitExceededEvent;
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class RateLimitExceededEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Rate-limit state is typed and complete at construction')]
|
|
||||||
public function constructsTypedState(): void
|
|
||||||
{
|
|
||||||
$event = new RateLimitExceededEvent(
|
|
||||||
'203.0.113.10',
|
|
||||||
101,
|
|
||||||
60,
|
|
||||||
'/login',
|
|
||||||
'tenant-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(RateLimitExceededEvent::class, $event->label());
|
|
||||||
self::assertSame('203.0.113.10', $event->getIpAddress());
|
|
||||||
self::assertSame(101, $event->getRequestCount());
|
|
||||||
self::assertSame(60, $event->getWindowSeconds());
|
|
||||||
self::assertSame('/login', $event->getEndpoint());
|
|
||||||
self::assertSame('/login', $event->getRequestPath());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('101 requests in 60 seconds', $event->getReason());
|
|
||||||
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Rate-limit detection rejects invalid measurements')]
|
|
||||||
public function rejectsInvalidMeasurements(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['', 101, 60, '/login'],
|
|
||||||
['203.0.113.10', 0, 60, '/login'],
|
|
||||||
['203.0.113.10', 101, 0, '/login'],
|
|
||||||
['203.0.113.10', 101, 60, ''],
|
|
||||||
] as $arguments) {
|
|
||||||
try {
|
|
||||||
new RateLimitExceededEvent(...$arguments);
|
|
||||||
self::fail('Invalid rate-limit measurements were accepted.');
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
$this->addToAssertionCount(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class SecurityEventSeverityTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Security severity levels expose stable numeric values')]
|
|
||||||
public function exposesStableNumericValues(): void
|
|
||||||
{
|
|
||||||
self::assertSame(0, SecurityEventSeverity::DEBUG->value);
|
|
||||||
self::assertSame(1, SecurityEventSeverity::INFO->value);
|
|
||||||
self::assertSame(2, SecurityEventSeverity::WARNING->value);
|
|
||||||
self::assertSame(3, SecurityEventSeverity::ERROR->value);
|
|
||||||
self::assertSame(4, SecurityEventSeverity::CRITICAL->value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\SecurityEventSeverity;
|
|
||||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class SuspiciousActivityEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Suspicious-activity state is typed and complete at construction')]
|
|
||||||
public function constructsTypedState(): void
|
|
||||||
{
|
|
||||||
$event = new SuspiciousActivityEvent(
|
|
||||||
ipAddress: '203.0.113.20',
|
|
||||||
detector: 'payload-signature',
|
|
||||||
detectionData: ['score' => 98],
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
identityId: 'identity-a',
|
|
||||||
deviceFingerprint: 'device-a',
|
|
||||||
userAgent: 'Test Agent',
|
|
||||||
requestPath: '/admin',
|
|
||||||
requestMethod: 'POST',
|
|
||||||
userId: 'user-a',
|
|
||||||
reason: 'Matched a blocked payload signature',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(SuspiciousActivityEvent::class, $event->label());
|
|
||||||
self::assertSame('203.0.113.20', $event->getIpAddress());
|
|
||||||
self::assertSame('payload-signature', $event->getDetector());
|
|
||||||
self::assertSame(['score' => 98], $event->getDetectionData());
|
|
||||||
self::assertSame(['detector' => 'payload-signature', 'score' => 98], $event->context());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('identity-a', $event->actorIdentity());
|
|
||||||
self::assertSame('device-a', $event->getDeviceFingerprint());
|
|
||||||
self::assertSame('Test Agent', $event->getUserAgent());
|
|
||||||
self::assertSame('/admin', $event->getRequestPath());
|
|
||||||
self::assertSame('POST', $event->getRequestMethod());
|
|
||||||
self::assertSame('user-a', $event->getUserId());
|
|
||||||
self::assertSame('Matched a blocked payload signature', $event->getReason());
|
|
||||||
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Suspicious activity rejects incomplete or conflicting detection context')]
|
|
||||||
public function rejectsInvalidDetectionContext(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['', 'payload-signature', []],
|
|
||||||
['203.0.113.20', '', []],
|
|
||||||
['203.0.113.20', 'payload-signature', ['detector' => 'replacement']],
|
|
||||||
] as $arguments) {
|
|
||||||
try {
|
|
||||||
new SuspiciousActivityEvent(...$arguments);
|
|
||||||
self::fail('Invalid suspicious-activity context was accepted.');
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
$this->addToAssertionCount(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\User\Event\UserCreatedEvent;
|
|
||||||
use KTXC\User\Event\UserDeletingEvent;
|
|
||||||
use KTXC\User\Event\UserEvent;
|
|
||||||
use KTXC\User\Event\UserUpdatedEvent;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class UserEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('User lifecycle events contain an immutable user snapshot')]
|
|
||||||
public function containsUserSnapshot(): void
|
|
||||||
{
|
|
||||||
$event = UserCreatedEvent::fromUser(
|
|
||||||
[
|
|
||||||
'uid' => 'user-a',
|
|
||||||
'identity' => 'person@example.test',
|
|
||||||
'label' => 'Person',
|
|
||||||
'enabled' => true,
|
|
||||||
'roles' => ['member'],
|
|
||||||
],
|
|
||||||
'tenant-a',
|
|
||||||
'actor-a',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(UserCreatedEvent::class, $event->label());
|
|
||||||
self::assertSame('user-a', $event->userIdentifier());
|
|
||||||
self::assertSame('person@example.test', $event->userIdentity());
|
|
||||||
self::assertSame('Person', $event->userLabel());
|
|
||||||
self::assertTrue($event->userEnabled());
|
|
||||||
self::assertSame(['member'], $event->userRoles());
|
|
||||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
|
||||||
self::assertSame('actor-a', $event->actorIdentifier());
|
|
||||||
self::assertSame('actor-a', $event->actorIdentity());
|
|
||||||
self::assertSame('string', (string) (new \ReflectionMethod(UserEvent::class, 'tenantIdentifier'))->getReturnType());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Created, updated, and deleting users have distinct event names')]
|
|
||||||
public function distinguishesLifecycleStage(): void
|
|
||||||
{
|
|
||||||
$user = ['uid' => 'user-a', 'identity' => 'person@example.test'];
|
|
||||||
|
|
||||||
self::assertSame(UserCreatedEvent::class, UserCreatedEvent::fromUser($user, 'tenant-a')->label());
|
|
||||||
self::assertSame(UserUpdatedEvent::class, UserUpdatedEvent::fromUser($user, 'tenant-a')->label());
|
|
||||||
self::assertSame(UserDeletingEvent::class, UserDeletingEvent::fromUser($user, 'tenant-a')->label());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('User lifecycle events reject incomplete snapshots')]
|
|
||||||
public function rejectsIncompleteSnapshot(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
|
|
||||||
UserCreatedEvent::fromUser(['identity' => 'person@example.test'], 'tenant-a');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Models\Firewall;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class FirewallLogObjectTest extends TestCase
|
|
||||||
{
|
|
||||||
#[TestDox('Rule ID and scope survive firewall log serialization')]
|
|
||||||
public function testRuleContextSerialization(): void
|
|
||||||
{
|
|
||||||
$log = (new FirewallLogObject())
|
|
||||||
->setEventId('event-123')
|
|
||||||
->setRuleId('rule-123')
|
|
||||||
->setRuleScope(FirewallRuleObject::SCOPE_SYSTEM);
|
|
||||||
|
|
||||||
$restored = (new FirewallLogObject())->jsonDeserialize($log->jsonSerialize());
|
|
||||||
|
|
||||||
self::assertSame('rule-123', $restored->getRuleId());
|
|
||||||
self::assertSame('event-123', $restored->getEventId());
|
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_SYSTEM, $restored->getRuleScope());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Unknown rule scopes are rejected from firewall logs')]
|
|
||||||
public function testRuleScopeValidation(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
|
|
||||||
(new FirewallLogObject())->setRuleScope('unknown');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Models\Firewall;
|
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class FirewallRuleObjectTest extends TestCase
|
|
||||||
{
|
|
||||||
#[TestDox('Persisted rules without an explicit scope are rejected')]
|
|
||||||
public function testMissingScope(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
$this->expectExceptionMessage('explicit scope');
|
|
||||||
|
|
||||||
(new FirewallRuleObject())->jsonDeserialize([
|
|
||||||
'tenantId' => 'tenant-a',
|
|
||||||
'type' => FirewallRuleObject::TYPE_IP,
|
|
||||||
'action' => FirewallRuleObject::ACTION_BLOCK,
|
|
||||||
'value' => '203.0.113.10',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Unknown rule scopes are rejected')]
|
|
||||||
public function testUnknownScope(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
|
|
||||||
(new FirewallRuleObject())->setScope('unknown');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Tenant rules require a tenant ID')]
|
|
||||||
public function testTenantOwnership(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
$this->expectExceptionMessage('require a tenant ID');
|
|
||||||
|
|
||||||
(new FirewallRuleObject())->assertValidScopeOwnership();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('System rules cannot have a tenant ID')]
|
|
||||||
public function testSystemOwnership(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
$this->expectExceptionMessage('cannot have a tenant ID');
|
|
||||||
|
|
||||||
(new FirewallRuleObject())
|
|
||||||
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
|
||||||
->setTenantId('tenant-a')
|
|
||||||
->assertValidScopeOwnership();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,31 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXT\Unit\Module;
|
namespace KTXT\Unit\Module;
|
||||||
|
|
||||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
|
||||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
|
||||||
use KTXC\Console\Event\EventsDebugCommand;
|
use KTXC\Console\Event\EventsDebugCommand;
|
||||||
use KTXC\Module\Module;
|
use KTXC\Module\Module;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
|
||||||
use KTXC\Service\TenantFirewallLogService;
|
|
||||||
use KTXC\Service\TenantFirewallStatusService;
|
|
||||||
use KTXC\Service\SystemFirewallStatusService;
|
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
|
||||||
use KTXC\Event\EventListenerRegistry;
|
|
||||||
use KTXC\Security\Event\AccessDeniedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
|
||||||
use KTXC\Security\Event\BruteForceDetectedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
|
||||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
|
||||||
use KTXC\Security\Event\RateLimitExceededEvent;
|
|
||||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
|
use KTXF\Event\EventListenerRegistry;
|
||||||
|
use KTXF\Event\SecurityEvent;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -45,37 +26,12 @@ final class CoreModuleTest extends TestCase
|
|||||||
$module->boot();
|
$module->boot();
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
|
|
||||||
self::assertCount(12, $definitions);
|
self::assertCount(5, $definitions);
|
||||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
$registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service,
|
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
|
||||||
);
|
);
|
||||||
self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred));
|
|
||||||
$successListeners = $registry->listeners(
|
|
||||||
AuthenticationSucceededEvent::class,
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
);
|
|
||||||
self::assertCount(1, $successListeners);
|
|
||||||
self::assertSame(FirewallService::class, $successListeners[0]->service);
|
|
||||||
self::assertSame('logAuthenticationSuccess', $successListeners[0]->method);
|
|
||||||
foreach ([
|
|
||||||
AccessDeniedEvent::class,
|
|
||||||
BruteForceDetectedEvent::class,
|
|
||||||
RateLimitExceededEvent::class,
|
|
||||||
SuspiciousActivityEvent::class,
|
|
||||||
FirewallRuleCreatedEvent::class,
|
|
||||||
FirewallRuleExtendedEvent::class,
|
|
||||||
FirewallRuleDisabledEvent::class,
|
|
||||||
FirewallRuleEnabledEvent::class,
|
|
||||||
FirewallRuleRemovedEvent::class,
|
|
||||||
FirewallSettingsUpdatedEvent::class,
|
|
||||||
] as $event) {
|
|
||||||
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
|
||||||
self::assertCount(1, $listeners);
|
|
||||||
self::assertSame(FirewallService::class, $listeners[0]->service);
|
|
||||||
self::assertSame('logSecurityEvent', $listeners[0]->method);
|
|
||||||
}
|
|
||||||
self::assertFalse($registry->frozen());
|
self::assertFalse($registry->frozen());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,25 +42,5 @@ final class CoreModuleTest extends TestCase
|
|||||||
$module = new Module(new EventListenerRegistry());
|
$module = new Module(new EventListenerRegistry());
|
||||||
|
|
||||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||||
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
|
|
||||||
self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Core registers dedicated system firewall permissions')]
|
|
||||||
public function registersSystemFirewallPermissions(): void
|
|
||||||
{
|
|
||||||
$permissions = (new Module(new EventListenerRegistry()))->permissions();
|
|
||||||
|
|
||||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
|
||||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
|
||||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_READ, $permissions);
|
|
||||||
self::assertArrayHasKey(TenantFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
|
||||||
self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions);
|
|
||||||
self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions);
|
|
||||||
self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_READ, $permissions);
|
|
||||||
self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
|
|
||||||
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions);
|
|
||||||
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use KTXC\Http\Middleware\RequestHandlerInterface;
|
|||||||
use KTXC\Http\Middleware\RouterMiddleware;
|
use KTXC\Http\Middleware\RouterMiddleware;
|
||||||
use KTXC\Http\Middleware\TenantMiddleware;
|
use KTXC\Http\Middleware\TenantMiddleware;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Request\RequestContext;
|
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Http\Response\StreamedResponse;
|
use KTXC\Http\Response\StreamedResponse;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
@@ -79,31 +78,6 @@ final class HttpRuntimeTest extends TestCase
|
|||||||
self::assertTrue($kernel->outcome?->successful);
|
self::assertTrue($kernel->outcome?->successful);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('HTTP requests are available only for the active runtime execution')]
|
|
||||||
public function scopesRequestContext(): void
|
|
||||||
{
|
|
||||||
$requestContext = new RequestContext();
|
|
||||||
$observer = new RequestContextMiddleware($requestContext);
|
|
||||||
$kernel = new RuntimeKernel(
|
|
||||||
new RuntimeContainer([
|
|
||||||
RequestContext::class => $requestContext,
|
|
||||||
TenantMiddleware::class => new PassMiddleware(),
|
|
||||||
FirewallMiddleware::class => new PassMiddleware(),
|
|
||||||
AuthenticationMiddleware::class => new PassMiddleware(),
|
|
||||||
RouterMiddleware::class => $observer,
|
|
||||||
]),
|
|
||||||
new TenantContext($this->createStub(TenantService::class)),
|
|
||||||
new IdentityContext(),
|
|
||||||
);
|
|
||||||
$request = Request::create('/auth/verify', 'POST');
|
|
||||||
|
|
||||||
(new HttpRuntime($kernel, false))->run($request, send: false);
|
|
||||||
|
|
||||||
self::assertSame($request, $observer->observed);
|
|
||||||
self::assertNull($requestContext->current());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('HTTP exceptions render a response and still terminate')]
|
#[TestDox('HTTP exceptions render a response and still terminate')]
|
||||||
public function fails(): void
|
public function fails(): void
|
||||||
@@ -224,12 +198,9 @@ final class RuntimeKernel implements KernelInterface
|
|||||||
|
|
||||||
final class RuntimeContainer implements ContainerInterface
|
final class RuntimeContainer implements ContainerInterface
|
||||||
{
|
{
|
||||||
private readonly array $services;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
array $services,
|
private readonly array $services,
|
||||||
) {
|
) {
|
||||||
$this->services = [RequestContext::class => new RequestContext(), ...$services];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $id): mixed
|
public function get(string $id): mixed
|
||||||
@@ -279,21 +250,6 @@ final class ResponseMiddleware extends PassMiddleware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class RequestContextMiddleware extends PassMiddleware
|
|
||||||
{
|
|
||||||
public ?Request $observed = null;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly RequestContext $requestContext,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
|
||||||
{
|
|
||||||
$this->observed = $this->requestContext->current();
|
|
||||||
return new Response('ok');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ThrowingMiddleware extends PassMiddleware
|
final class ThrowingMiddleware extends PassMiddleware
|
||||||
{
|
{
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Security;
|
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
|
||||||
use KTXC\Resource\ProviderManager;
|
|
||||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
|
||||||
use KTXC\Security\AuthenticationManager;
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
|
||||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
|
||||||
use KTXC\Service\TokenService;
|
|
||||||
use KTXC\Service\UserAccountsService;
|
|
||||||
use KTXF\Cache\CacheScope;
|
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
|
||||||
use KTXF\Security\Authentication\AuthenticationSession;
|
|
||||||
use KTXF\Security\Authentication\ProviderContext;
|
|
||||||
use KTXF\Security\Authentication\ProviderResult;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class AuthenticationManagerTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
public function completedAuthenticationPublishesAuthenticationSucceededEvent(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createStub(TenantContextInterface::class);
|
|
||||||
$tenant->method('identifier')->willReturn('tenant-a');
|
|
||||||
$tenant->method('configuration')->willReturn(new TenantConfiguration());
|
|
||||||
|
|
||||||
$session = new AuthenticationSession(
|
|
||||||
id: 'auth-session',
|
|
||||||
state: AuthenticationSession::STATE_IDENTIFIED,
|
|
||||||
tenantIdentifier: 'tenant-a',
|
|
||||||
userIdentifier: 'user-a',
|
|
||||||
userIdentity: 'person@example.com',
|
|
||||||
methodsAvailable: ['password'],
|
|
||||||
createdAt: time(),
|
|
||||||
expiresAt: time() + 300,
|
|
||||||
);
|
|
||||||
|
|
||||||
$cache = $this->createMock(EphemeralCacheInterface::class);
|
|
||||||
$cache->expects($this->once())
|
|
||||||
->method('get')
|
|
||||||
->with('auth-session', CacheScope::Tenant, 'auth')
|
|
||||||
->willReturn($session);
|
|
||||||
$cache->expects($this->once())->method('set')->willReturn(true);
|
|
||||||
$cache->expects($this->once())
|
|
||||||
->method('delete')
|
|
||||||
->with('auth-session', CacheScope::Tenant, 'auth')
|
|
||||||
->willReturn(true);
|
|
||||||
|
|
||||||
$provider = new SuccessfulAuthenticationProvider();
|
|
||||||
$providers = $this->createStub(ProviderManager::class);
|
|
||||||
$providers->method('resolve')->willReturn($provider);
|
|
||||||
|
|
||||||
$users = $this->createMock(UserAccountsService::class);
|
|
||||||
$users->expects($this->once())
|
|
||||||
->method('fetchByIdentifier')
|
|
||||||
->with('user-a')
|
|
||||||
->willReturn([
|
|
||||||
'uid' => 'user-a',
|
|
||||||
'identity' => 'person@example.com',
|
|
||||||
'label' => 'Person',
|
|
||||||
'permissions' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tokens = $this->createStub(TokenService::class);
|
|
||||||
$tokens->method('createToken')->willReturn('token');
|
|
||||||
|
|
||||||
$events = $this->createMock(EventDispatcherInterface::class);
|
|
||||||
$events->expects($this->once())
|
|
||||||
->method('dispatch')
|
|
||||||
->with(self::callback(static fn($event): bool =>
|
|
||||||
$event instanceof AuthenticationSucceededEvent
|
|
||||||
&& $event->getUserId() === 'user-a'
|
|
||||||
&& $event->tenantIdentifier() === 'tenant-a'
|
|
||||||
));
|
|
||||||
|
|
||||||
$manager = new AuthenticationManager(
|
|
||||||
$tenant,
|
|
||||||
$cache,
|
|
||||||
$providers,
|
|
||||||
$tokens,
|
|
||||||
$users,
|
|
||||||
$events,
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $manager->handle(AuthenticationRequest::verify(
|
|
||||||
'auth-session',
|
|
||||||
'password',
|
|
||||||
'correct',
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertTrue($response->isSuccess());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
|
|
||||||
{
|
|
||||||
$tenant = $this->createStub(TenantContextInterface::class);
|
|
||||||
$tenant->method('configuration')->willReturn(new TenantConfiguration());
|
|
||||||
|
|
||||||
$session = new AuthenticationSession(
|
|
||||||
id: 'auth-session',
|
|
||||||
state: AuthenticationSession::STATE_IDENTIFIED,
|
|
||||||
tenantIdentifier: 'tenant-a',
|
|
||||||
userIdentifier: 'user-a',
|
|
||||||
userIdentity: 'person@example.com',
|
|
||||||
methodsAvailable: ['password'],
|
|
||||||
createdAt: time(),
|
|
||||||
expiresAt: time() + 300,
|
|
||||||
);
|
|
||||||
|
|
||||||
$cache = $this->createMock(EphemeralCacheInterface::class);
|
|
||||||
$cache->expects($this->once())
|
|
||||||
->method('get')
|
|
||||||
->with('auth-session', CacheScope::Tenant, 'auth')
|
|
||||||
->willReturn($session);
|
|
||||||
$cache->expects($this->once())
|
|
||||||
->method('set')
|
|
||||||
->willReturn(true);
|
|
||||||
|
|
||||||
$provider = new class implements AuthenticationProviderInterface {
|
|
||||||
public int $verificationCount = 0;
|
|
||||||
|
|
||||||
public function type(): string
|
|
||||||
{
|
|
||||||
return 'authentication';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function identifier(): string
|
|
||||||
{
|
|
||||||
return 'password';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return 'Password';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): string
|
|
||||||
{
|
|
||||||
return 'Test provider';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function method(): string
|
|
||||||
{
|
|
||||||
return self::METHOD_CREDENTIAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function icon(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function verify(ProviderContext $context, string $secret): ProviderResult
|
|
||||||
{
|
|
||||||
$this->verificationCount++;
|
|
||||||
return ProviderResult::failed(ProviderResult::ERROR_INVALID_FACTOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginChallenge(ProviderContext $context): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginRedirect(
|
|
||||||
ProviderContext $context,
|
|
||||||
string $callbackUrl,
|
|
||||||
?string $returnUrl = null,
|
|
||||||
): ProviderResult {
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$providers = $this->createMock(ProviderManager::class);
|
|
||||||
$providers->method('resolve')->with('authentication', 'password')->willReturn($provider);
|
|
||||||
|
|
||||||
$events = $this->createMock(EventDispatcherInterface::class);
|
|
||||||
$events->expects($this->once())
|
|
||||||
->method('dispatch')
|
|
||||||
->with(self::callback(static function ($event): bool {
|
|
||||||
return $event instanceof AuthenticationFailedEvent
|
|
||||||
&& $event->getUserId() === 'user-a'
|
|
||||||
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
|
|
||||||
&& $event->tenantIdentifier() === 'tenant-a';
|
|
||||||
}));
|
|
||||||
|
|
||||||
$manager = new AuthenticationManager(
|
|
||||||
$tenant,
|
|
||||||
$cache,
|
|
||||||
$providers,
|
|
||||||
$this->createStub(TokenService::class),
|
|
||||||
$this->createStub(UserAccountsService::class),
|
|
||||||
$events,
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $manager->handle(AuthenticationRequest::verify(
|
|
||||||
'auth-session',
|
|
||||||
'password',
|
|
||||||
'incorrect',
|
|
||||||
));
|
|
||||||
|
|
||||||
self::assertTrue($response->isFailed());
|
|
||||||
self::assertSame(1, $provider->verificationCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class SuccessfulAuthenticationProvider implements AuthenticationProviderInterface
|
|
||||||
{
|
|
||||||
public function type(): string
|
|
||||||
{
|
|
||||||
return 'authentication';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function identifier(): string
|
|
||||||
{
|
|
||||||
return 'password';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return 'Password';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function description(): string
|
|
||||||
{
|
|
||||||
return 'Successful test provider';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function method(): string
|
|
||||||
{
|
|
||||||
return self::METHOD_CREDENTIAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function icon(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function verify(ProviderContext $context, string $secret): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::success();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginChallenge(ProviderContext $context): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginRedirect(
|
|
||||||
ProviderContext $context,
|
|
||||||
string $callbackUrl,
|
|
||||||
?string $returnUrl = null,
|
|
||||||
): ProviderResult {
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
|
||||||
{
|
|
||||||
return ProviderResult::failed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Service;
|
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
|
||||||
use KTXC\Service\FirewallLogService;
|
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
|
||||||
use KTXC\Service\TenantFirewallLogService;
|
|
||||||
use KTXC\Stores\FirewallStore;
|
|
||||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
#[AllowMockObjectsWithoutExpectations]
|
|
||||||
final class FirewallLogServicesTest extends TestCase
|
|
||||||
{
|
|
||||||
#[TestDox('Tenant log reads derive tenant ownership and validated filters')]
|
|
||||||
public function testTenantQuery(): void
|
|
||||||
{
|
|
||||||
$store = $this->createMock(FirewallStore::class);
|
|
||||||
$tenant = $this->createStub(TenantContextInterface::class);
|
|
||||||
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
|
||||||
$identity = $this->createStub(IdentityContextInterface::class);
|
|
||||||
$identity->method('hasPermission')->willReturn(true);
|
|
||||||
$store->expects(self::once())
|
|
||||||
->method('queryTenantLogs')
|
|
||||||
->with(
|
|
||||||
'tenant-a',
|
|
||||||
self::callback(static fn(array $filters): bool =>
|
|
||||||
$filters['eventType'] === FirewallLogObject::EVENT_AUTH_FAILURE
|
|
||||||
&& $filters['from'] instanceof \DateTimeImmutable
|
|
||||||
),
|
|
||||||
25,
|
|
||||||
10
|
|
||||||
)
|
|
||||||
->willReturn(['items' => [], 'total' => 0, 'limit' => 25, 'offset' => 10]);
|
|
||||||
$service = new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity);
|
|
||||||
|
|
||||||
self::assertSame(0, $service->query([
|
|
||||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
|
||||||
'from' => '2026-08-01T00:00:00+00:00',
|
|
||||||
], 25, 10)['total']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('System log reads may filter one tenant without changing ownership')]
|
|
||||||
public function testSystemQuery(): void
|
|
||||||
{
|
|
||||||
$store = $this->createMock(FirewallStore::class);
|
|
||||||
$identity = $this->createStub(IdentityContextInterface::class);
|
|
||||||
$identity->method('hasPermission')->willReturn(true);
|
|
||||||
$store->expects(self::once())
|
|
||||||
->method('querySystemLogs')
|
|
||||||
->with('tenant-a', self::isArray(), 50, 0)
|
|
||||||
->willReturn(['items' => [], 'total' => 0, 'limit' => 50, 'offset' => 0]);
|
|
||||||
$service = new SystemFirewallLogService(new FirewallLogService($store), $identity);
|
|
||||||
|
|
||||||
self::assertSame(0, $service->query('tenant-a', [], 50, 0)['total']);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Log queries reject invalid filters before database access')]
|
|
||||||
public function testValidation(): void
|
|
||||||
{
|
|
||||||
$store = $this->createMock(FirewallStore::class);
|
|
||||||
$store->expects(self::never())->method('queryTenantLogs');
|
|
||||||
$query = new FirewallLogService($store);
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
|
|
||||||
$query->tenant('tenant-a', ['ipAddress' => 'not-an-ip'], 50, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[TestDox('Log boundaries enforce dedicated read permissions')]
|
|
||||||
public function testPermissions(): void
|
|
||||||
{
|
|
||||||
$store = $this->createMock(FirewallStore::class);
|
|
||||||
$identity = $this->createStub(IdentityContextInterface::class);
|
|
||||||
$identity->method('hasPermission')->willReturn(false);
|
|
||||||
$tenant = $this->createStub(TenantContextInterface::class);
|
|
||||||
$store->expects(self::never())->method('queryTenantLogs');
|
|
||||||
$store->expects(self::never())->method('querySystemLogs');
|
|
||||||
|
|
||||||
try {
|
|
||||||
(new TenantFirewallLogService(new FirewallLogService($store), $tenant, $identity))->query([]);
|
|
||||||
self::fail('Tenant log read should be rejected.');
|
|
||||||
} catch (\RuntimeException $error) {
|
|
||||||
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->expectExceptionMessage(SystemFirewallLogService::PERMISSION_READ);
|
|
||||||
(new SystemFirewallLogService(new FirewallLogService($store), $identity))->query(null, []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user