Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4b32a9a7 | |||
| 71ddd3442f | |||
| 0dd735045d | |||
| 5646591c74 | |||
| a5acea72c3 | |||
| 62b416f13e | |||
| c8e6efe203 | |||
| f147ffc5c7 | |||
| 985e4a6450 | |||
| b14bd302a3 | |||
| 5b3e8f6588 | |||
| 84eb0e2c21 | |||
| 3f9c2500d9 | |||
| 2494cef02f | |||
| a5be782c51 | |||
| f4df769b3c | |||
| 688afbe5a1 | |||
| 7c2a8dfbd3 | |||
| e223ae7543 | |||
| a8e29d0305 | |||
| a363a1a4bc | |||
| 5c65c8592c | |||
| 52afd35d6f | |||
| ba4deccea9 | |||
| 2012d67be7 | |||
| 649fa47c68 | |||
| a73ca3abd6 | |||
| 01ed0f3080 | |||
| fb39aa57fd | |||
| d5ca89e160 | |||
| b06c18d38e | |||
| 6700ff145d | |||
| d919b70a2e | |||
| 74696bbeb3 | |||
| 4ee91a7918 | |||
| 90d847ffae | |||
| ad6443bff3 | |||
| a5c10e9b9b | |||
| 7aa8a27b1b | |||
| 5ada4c0c45 | |||
| 12037da367 | |||
| abc5bfcccc | |||
| da81f1ddf1 | |||
| 266b364c93 | |||
| 3b9a5cd5ab | |||
| 63d91ca7fa | |||
| b8afd75b77 | |||
| eb99eb5a2e | |||
| 65c1b5fb75 |
+5
-5
@@ -7,21 +7,21 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use KTXC\Server;
|
||||
use KTXC\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
try {
|
||||
$server = new Server(dirname(__DIR__));
|
||||
exit($server->runConsole());
|
||||
$application = Application::create(dirname(__DIR__), $composerLoader);
|
||||
exit($application->runConsole());
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
|
||||
if (isset($server) && $server->debug()) {
|
||||
if (($application ?? null)?->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString()."\n");
|
||||
}
|
||||
exit(1);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use KTXC\Application\KernelOptions;
|
||||
use KTXC\Application\ProjectPaths;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
use KTXC\Runtime\Console\ConsoleRuntime;
|
||||
use KTXC\Runtime\Http\HttpRuntime;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class Application
|
||||
{
|
||||
private readonly ProjectPaths $paths;
|
||||
private readonly array $config;
|
||||
private readonly Kernel $kernel;
|
||||
private readonly HttpRuntime $http;
|
||||
private readonly ConsoleRuntime $console;
|
||||
|
||||
public function __construct(
|
||||
string $projectDir,
|
||||
?ClassLoader $composerLoader = null,
|
||||
?string $environment = null,
|
||||
?bool $debug = null,
|
||||
) {
|
||||
$this->paths = ProjectPaths::resolve($projectDir);
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
$environment ??= $this->config['environment'] ?? 'prod';
|
||||
$debug ??= (bool) ($this->config['debug'] ?? false);
|
||||
|
||||
$this->kernel = new Kernel(
|
||||
new KernelOptions(
|
||||
$this->paths,
|
||||
$environment,
|
||||
$debug,
|
||||
),
|
||||
$this->config,
|
||||
);
|
||||
|
||||
(new ModuleAutoloader(
|
||||
$this->moduleDir(),
|
||||
$composerLoader,
|
||||
))->register();
|
||||
|
||||
$this->http = new HttpRuntime($this->kernel, $debug);
|
||||
$this->console = new ConsoleRuntime($this->kernel);
|
||||
}
|
||||
|
||||
public static function create(
|
||||
string $projectDir,
|
||||
?ClassLoader $composerLoader = null,
|
||||
?string $environment = null,
|
||||
?bool $debug = null,
|
||||
): self {
|
||||
return new self($projectDir, $composerLoader, $environment, $debug);
|
||||
}
|
||||
|
||||
public function runHttp(): void
|
||||
{
|
||||
try {
|
||||
$this->http->run();
|
||||
} finally {
|
||||
$this->kernel->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
public function runHttpRequest(Request $request): Response
|
||||
{
|
||||
return $this->http->run($request, send: false);
|
||||
}
|
||||
|
||||
public function runConsole(): int
|
||||
{
|
||||
try {
|
||||
return $this->console->run();
|
||||
} finally {
|
||||
$this->kernel->shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
$this->kernel->shutdown();
|
||||
}
|
||||
|
||||
public function kernel(): KernelInterface
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
public function projectDir(): string
|
||||
{
|
||||
return $this->paths->project;
|
||||
}
|
||||
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->paths->modules();
|
||||
}
|
||||
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
$value = $this->config;
|
||||
foreach (explode('.', $key) as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function loadConfig(): array
|
||||
{
|
||||
$path = $this->paths->configuration() . '/system.php';
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = require $path;
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException("Configuration file must return an array: {$path}");
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class ExecutionContext
|
||||
{
|
||||
public function __construct(
|
||||
public RuntimeType $runtime,
|
||||
public string $executionId,
|
||||
public string $correlationId,
|
||||
public ?string $causationId = null,
|
||||
public ?string $requestId = null,
|
||||
public ?string $operationName = null,
|
||||
public array $traceMetadata = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDescriptor(ExecutionDescriptor $descriptor): self
|
||||
{
|
||||
return new self(
|
||||
$descriptor->runtime,
|
||||
$descriptor->executionId,
|
||||
$descriptor->correlationId,
|
||||
operationName: $descriptor->operationName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class ExecutionDescriptor
|
||||
{
|
||||
public function __construct(
|
||||
public RuntimeType $runtime,
|
||||
public string $executionId,
|
||||
public string $correlationId,
|
||||
public ?string $operationName = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function http(?string $operationName = null): self
|
||||
{
|
||||
$id = self::id();
|
||||
|
||||
return new self(RuntimeType::HTTP, $id, $id, $operationName);
|
||||
}
|
||||
|
||||
public static function cli(?string $operationName = null): self
|
||||
{
|
||||
$id = self::id();
|
||||
|
||||
return new self(RuntimeType::CLI, $id, $id, $operationName);
|
||||
}
|
||||
|
||||
private static function id(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class ExecutionOutcome
|
||||
{
|
||||
private function __construct(
|
||||
public bool $successful,
|
||||
public mixed $result = null,
|
||||
public ?\Throwable $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function success(mixed $result = null): self
|
||||
{
|
||||
return new self(true, $result);
|
||||
}
|
||||
|
||||
public static function failure(\Throwable $error, mixed $result = null): self
|
||||
{
|
||||
return new self(false, $result, $error);
|
||||
}
|
||||
|
||||
public static function incomplete(): self
|
||||
{
|
||||
return new self(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
use KTXC\KernelInterface;
|
||||
|
||||
final readonly class ExecutionRunner implements ExecutionRunnerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private KernelInterface $kernel,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(
|
||||
ExecutionDescriptor $descriptor,
|
||||
callable $execution,
|
||||
?callable $failure = null,
|
||||
): mixed {
|
||||
$scope = $this->kernel->beginExecution($descriptor);
|
||||
$outcome = ExecutionOutcome::incomplete();
|
||||
|
||||
try {
|
||||
$result = $execution($scope);
|
||||
$outcome = ExecutionOutcome::success($result);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $error) {
|
||||
if ($failure === null) {
|
||||
$outcome = ExecutionOutcome::failure($error);
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $failure($error, $scope);
|
||||
$outcome = ExecutionOutcome::failure($error, $result);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $failureError) {
|
||||
$outcome = ExecutionOutcome::failure($failureError);
|
||||
|
||||
throw $failureError;
|
||||
}
|
||||
} finally {
|
||||
$this->kernel->terminateExecution($scope, $outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
interface ExecutionRunnerInterface
|
||||
{
|
||||
/**
|
||||
* @template TResult
|
||||
*
|
||||
* @param callable(ExecutionScope): TResult $execution
|
||||
* @param null|callable(\Throwable, ExecutionScope): TResult $failure
|
||||
*
|
||||
* @return TResult
|
||||
*/
|
||||
public function execute(
|
||||
ExecutionDescriptor $descriptor,
|
||||
callable $execution,
|
||||
?callable $failure = null,
|
||||
): mixed;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
use KTXC\Context\IdentityContext;
|
||||
use KTXC\Context\TenantContext;
|
||||
|
||||
final class ExecutionScope
|
||||
{
|
||||
private bool $terminated = false;
|
||||
|
||||
public function __construct(
|
||||
public readonly ExecutionDescriptor $descriptor,
|
||||
public readonly ExecutionContext $context,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly IdentityContext $identityContext,
|
||||
) {
|
||||
$this->tenantContext->clear();
|
||||
$this->identityContext->clear();
|
||||
}
|
||||
|
||||
public function markTerminated(): void
|
||||
{
|
||||
if ($this->terminated) {
|
||||
throw new \LogicException('The execution scope has already been terminated.');
|
||||
}
|
||||
|
||||
$this->terminated = true;
|
||||
}
|
||||
|
||||
public function terminated(): bool
|
||||
{
|
||||
return $this->terminated;
|
||||
}
|
||||
|
||||
public function dispose(): void
|
||||
{
|
||||
$this->identityContext->clear();
|
||||
$this->tenantContext->clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
enum RuntimeType: string
|
||||
{
|
||||
case HTTP = 'http';
|
||||
case CLI = 'cli';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class TerminationReport
|
||||
{
|
||||
/**
|
||||
* @param list<\Throwable> $failures
|
||||
*/
|
||||
public function __construct(
|
||||
public int $deferredProcessed = 0,
|
||||
public int $deferredRemaining = 0,
|
||||
public array $failures = [],
|
||||
public bool $deadlineExceeded = false,
|
||||
public bool $limitExceeded = false,
|
||||
public int $deferredListenerInvocations = 0,
|
||||
public bool $deferredEventLimitExceeded = false,
|
||||
public bool $deferredListenerInvocationLimitExceeded = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application;
|
||||
|
||||
final readonly class KernelOptions
|
||||
{
|
||||
public function __construct(
|
||||
public ProjectPaths $paths,
|
||||
public string $environment = 'prod',
|
||||
public bool $debug = false,
|
||||
) {
|
||||
if ($this->environment === '') {
|
||||
throw new \InvalidArgumentException('Kernel environment cannot be empty.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application;
|
||||
|
||||
final readonly class ProjectPaths
|
||||
{
|
||||
private function __construct(
|
||||
public string $project,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function resolve(string $start): self
|
||||
{
|
||||
$directory = is_file($start) ? dirname($start) : $start;
|
||||
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
|
||||
|
||||
while ($directory !== dirname($directory)) {
|
||||
if (is_file($directory . '/composer.json')) {
|
||||
return new self(realpath($directory) ?: $directory);
|
||||
}
|
||||
$directory = dirname($directory);
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Unable to resolve project root from {$start}.");
|
||||
}
|
||||
|
||||
public function configuration(): string
|
||||
{
|
||||
return $this->project . '/config';
|
||||
}
|
||||
|
||||
public function modules(): string
|
||||
{
|
||||
return $this->project . '/modules';
|
||||
}
|
||||
|
||||
public function cache(string $environment): string
|
||||
{
|
||||
return $this->project . '/var/cache/' . $environment;
|
||||
}
|
||||
|
||||
public function logs(): string
|
||||
{
|
||||
return $this->project . '/var/log';
|
||||
}
|
||||
|
||||
public function runtime(): string
|
||||
{
|
||||
return $this->project . '/var';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Event;
|
||||
|
||||
use KTXC\Event\EventListenerRegistry;
|
||||
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: 'events:debug',
|
||||
description: 'Display the frozen event listener registry',
|
||||
)]
|
||||
final class EventsDebugCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventListenerRegistry $registry,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$rows = [];
|
||||
foreach ($this->registry->definitions() as $listener) {
|
||||
$rows[] = [
|
||||
$listener->module,
|
||||
$listener->event,
|
||||
$listener->service . '::' . $listener->method,
|
||||
$listener->delivery->value,
|
||||
$listener->priority,
|
||||
$listener->failurePolicy->value,
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Module', 'Event', 'Listener', 'Delivery', 'Priority', 'Failure'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Firewall;
|
||||
|
||||
use KTXC\Service\FirewallService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
|
||||
final class FirewallMaintenanceCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly FirewallService $firewall)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$result = $this->firewall->cleanup();
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Firewall maintenance failed: '.$error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
|
||||
$result['expiredRules'],
|
||||
$result['oldLogs'],
|
||||
$result['expiredBruteForceClaims']
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Firewall;
|
||||
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
|
||||
final class FirewallSetupCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
try {
|
||||
$indexes = $this->store->ensureIndexes();
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Firewall database setup failed: '.$error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXC\Models\Tenant\DomainCollection;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use KTXF\Utile\UUID;
|
||||
@@ -35,6 +37,8 @@ class TenantCreateCommand extends Command
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -97,6 +101,10 @@ class TenantCreateCommand extends Command
|
||||
$io->error('Failed to create tenant.');
|
||||
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', [
|
||||
'identifier' => $identifier,
|
||||
@@ -134,7 +142,7 @@ class TenantCreateCommand extends Command
|
||||
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
|
||||
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
|
||||
} else {
|
||||
$this->userStore->createUser($identifier, [
|
||||
$this->userService->createUser([
|
||||
'identity' => $adminIdentity,
|
||||
'label' => 'Administrator',
|
||||
'enabled' => true,
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\User;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -28,8 +29,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
class UserCreateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
@@ -59,11 +61,11 @@ class UserCreateCommand extends Command
|
||||
$io->title('Create User');
|
||||
|
||||
try {
|
||||
// Ensure the tenant exists
|
||||
if (!$this->tenantService->fetchById($tenant)) {
|
||||
if (!$this->tenantContext->resolveIdentifier($tenant)) {
|
||||
$io->error("Tenant '{$tenant}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$tenant = $this->tenantContext->requireIdentifier();
|
||||
|
||||
// Ensure identity is unique within the tenant
|
||||
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
|
||||
@@ -95,7 +97,7 @@ class UserCreateCommand extends Command
|
||||
$userData['uid'] = $input->getOption('uid');
|
||||
}
|
||||
|
||||
$user = $this->userStore->createUser($tenant, $userData);
|
||||
$user = $this->userService->createUser($userData);
|
||||
|
||||
$this->logger->info('User created via console', [
|
||||
'tenant' => $tenant,
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\User;
|
||||
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
@@ -26,7 +28,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
class UserDeleteCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -52,6 +56,12 @@ class UserDeleteCommand extends Command
|
||||
$io->title('Delete User');
|
||||
|
||||
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);
|
||||
|
||||
if (!$user) {
|
||||
@@ -64,7 +74,7 @@ class UserDeleteCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
|
||||
if (!$this->userService->deleteUser($user['uid'])) {
|
||||
$io->error("Failed to delete user '{$identity}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
|
||||
final class IdentityContext implements IdentityContextInterface
|
||||
{
|
||||
private ?User $identity = null;
|
||||
|
||||
public function initialize(User $identity): void
|
||||
{
|
||||
if ($this->identity !== null) {
|
||||
throw new \LogicException('The execution identity has already been initialized.');
|
||||
}
|
||||
|
||||
$this->identity = $identity;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->identity = null;
|
||||
}
|
||||
|
||||
public function present(): bool
|
||||
{
|
||||
return $this->identity !== null;
|
||||
}
|
||||
|
||||
public function identity(): ?User
|
||||
{
|
||||
return $this->identity;
|
||||
}
|
||||
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->identity?->getId();
|
||||
}
|
||||
|
||||
public function requireIdentifier(): string
|
||||
{
|
||||
return $this->identifier()
|
||||
?? throw new \LogicException('This operation requires an identity context.');
|
||||
}
|
||||
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->identity?->getLabel();
|
||||
}
|
||||
|
||||
public function mailAddress(): ?string
|
||||
{
|
||||
return $this->identity?->getIdentity();
|
||||
}
|
||||
|
||||
public function nameFirst(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function nameLast(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return $this->identity?->getPermissions() ?? [];
|
||||
}
|
||||
|
||||
public function roles(): array
|
||||
{
|
||||
return $this->identity?->getRoles() ?? [];
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
$permissions = $this->permissions();
|
||||
if (in_array($permission, $permissions, true) || in_array('*', $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($permissions as $userPermission) {
|
||||
if (str_ends_with($userPermission, '.*')) {
|
||||
$prefix = substr($userPermission, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roles(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
|
||||
interface IdentityContextInterface
|
||||
{
|
||||
public function present(): bool;
|
||||
public function identity(): ?User;
|
||||
public function identifier(): ?string;
|
||||
public function requireIdentifier(): string;
|
||||
public function label(): ?string;
|
||||
public function mailAddress(): ?string;
|
||||
public function nameFirst(): ?string;
|
||||
public function nameLast(): ?string;
|
||||
public function permissions(): array;
|
||||
public function roles(): array;
|
||||
public function hasPermission(string $permission): bool;
|
||||
public function hasRole(string $role): bool;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
|
||||
final class TenantContext implements TenantContextInterface
|
||||
{
|
||||
private ?TenantObject $tenant = null;
|
||||
private ?string $domain = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveDomain(string $domain): bool
|
||||
{
|
||||
$this->clear();
|
||||
$tenant = $this->tenantService->fetchByDomain($domain);
|
||||
if ($tenant === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->domain = $domain;
|
||||
$this->tenant = $tenant;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resolveIdentifier(string $identifier): bool
|
||||
{
|
||||
$this->clear();
|
||||
$tenant = $this->tenantService->fetchById($identifier);
|
||||
if ($tenant === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->domain = $identifier;
|
||||
$this->tenant = $tenant;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->tenant = null;
|
||||
$this->domain = null;
|
||||
}
|
||||
|
||||
public function present(): bool
|
||||
{
|
||||
return $this->tenant !== null;
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->present();
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->tenant?->getEnabled() ?? false;
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->tenant?->getIdentifier();
|
||||
}
|
||||
|
||||
public function requireIdentifier(): string
|
||||
{
|
||||
return $this->identifier()
|
||||
?? throw new \LogicException('This operation requires a tenant context.');
|
||||
}
|
||||
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->tenant?->getLabel();
|
||||
}
|
||||
|
||||
public function configuration(): ?TenantConfiguration
|
||||
{
|
||||
return $this->tenant?->getConfiguration();
|
||||
}
|
||||
|
||||
public function settings(): array
|
||||
{
|
||||
return $this->tenant?->getSettings() ?? [];
|
||||
}
|
||||
|
||||
public function identityProviders(): array
|
||||
{
|
||||
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
|
||||
}
|
||||
|
||||
public function identityProviderConfig(string $providerId): ?array
|
||||
{
|
||||
return $this->identityProviders()[$providerId] ?? null;
|
||||
}
|
||||
|
||||
public function isIdentityProviderEnabled(string $providerId): bool
|
||||
{
|
||||
$config = $this->identityProviderConfig($providerId);
|
||||
|
||||
return $config !== null && ($config['enabled'] ?? false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
|
||||
interface TenantContextInterface
|
||||
{
|
||||
public function present(): bool;
|
||||
public function configured(): bool;
|
||||
public function enabled(): bool;
|
||||
public function domain(): ?string;
|
||||
public function identifier(): ?string;
|
||||
public function requireIdentifier(): string;
|
||||
public function label(): ?string;
|
||||
public function configuration(): ?TenantConfiguration;
|
||||
public function settings(): array;
|
||||
public function identityProviders(): array;
|
||||
public function identityProviderConfig(string $providerId): ?array;
|
||||
public function isIdentityProviderEnabled(string $providerId): bool;
|
||||
}
|
||||
@@ -92,9 +92,9 @@ class AuthenticationController extends ControllerAbstract
|
||||
}
|
||||
|
||||
$request = AuthenticationRequest::verify($session, $method, $response);
|
||||
$authResponse = $this->authManager->handle($request);
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($authResponse);
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
$host = $request->getHost();
|
||||
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
||||
|
||||
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
return $this->redirectWithError('Missing state parameter');
|
||||
}
|
||||
|
||||
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
if ($response->isSuccess()) {
|
||||
$returnUrl = $response->returnUrl ?? '/';
|
||||
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
);
|
||||
}
|
||||
|
||||
$authRequest = AuthenticationRequest::status($sessionId);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::status($sessionId);
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
{
|
||||
$sessionId = $request->query->get('session', '');
|
||||
|
||||
$authRequest = AuthenticationRequest::cancel($sessionId);
|
||||
$this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::cancel($sessionId);
|
||||
$this->authManager->handle($request);
|
||||
|
||||
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
||||
}
|
||||
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
);
|
||||
}
|
||||
|
||||
$authRequest = AuthenticationRequest::refresh($refreshToken);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::refresh($refreshToken);
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
if ($response->isFailed()) {
|
||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
{
|
||||
$token = $request->cookies->get('accessToken');
|
||||
|
||||
$authRequest = AuthenticationRequest::logout($token, false);
|
||||
$this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::logout($token, false);
|
||||
$this->authManager->handle($request);
|
||||
|
||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
||||
return $this->clearTokenCookies($response);
|
||||
@@ -274,8 +274,8 @@ class AuthenticationController extends ControllerAbstract
|
||||
{
|
||||
$token = $request->cookies->get('accessToken');
|
||||
|
||||
$authRequest = AuthenticationRequest::logout($token, true);
|
||||
$this->authManager->handle($authRequest);
|
||||
$request = AuthenticationRequest::logout($token, true);
|
||||
$this->authManager->handle($request);
|
||||
|
||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
||||
return $this->clearTokenCookies($response);
|
||||
|
||||
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXC\Service\SecurityService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Http\Request\Request;
|
||||
|
||||
class DefaultController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityService $securityService,
|
||||
private readonly SessionIdentity $identity,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
) {}
|
||||
|
||||
@@ -25,7 +25,7 @@ class DefaultController extends ControllerAbstract
|
||||
public function home(Request $request): Response
|
||||
{
|
||||
// If an authenticated identity is available, serve the private app
|
||||
if ($this->identity->identifier()) {
|
||||
if ($this->identityContext->identifier()) {
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/private.html',
|
||||
Response::HTTP_OK,
|
||||
@@ -116,7 +116,7 @@ class DefaultController extends ControllerAbstract
|
||||
public function catchAll(Request $request, string $path = ''): Response
|
||||
{
|
||||
// If an authenticated identity is available, serve the private app
|
||||
if ($this->identity->identifier()) {
|
||||
if ($this->identityContext->identifier()) {
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/private.html',
|
||||
Response::HTTP_OK,
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\FirewallRuleConflictException;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\SystemFirewallStatusService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallStatusService;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
final class FirewallController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantFirewallRuleService $tenantRules,
|
||||
private readonly SystemFirewallRuleService $systemRules,
|
||||
private readonly TenantFirewallLogService $tenantLogs,
|
||||
private readonly SystemFirewallLogService $systemLogs,
|
||||
private readonly TenantFirewallStatusService $tenantStatus,
|
||||
private readonly SystemFirewallStatusService $systemStatus,
|
||||
) {
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/rules',
|
||||
name: 'firewall.tenant.rules.list',
|
||||
permissions: [TenantFirewallRuleService::PERMISSION_READ],
|
||||
)]
|
||||
public function tenantRules(
|
||||
string $status = 'active',
|
||||
?string $type = null,
|
||||
?string $action = null,
|
||||
string $limit = '50',
|
||||
string $offset = '0'
|
||||
): JsonResponse {
|
||||
return $this->queryResponse(
|
||||
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantRules->queryRules(
|
||||
$status,
|
||||
$type,
|
||||
$action,
|
||||
$parsedLimit,
|
||||
$parsedOffset
|
||||
),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/rules/{ruleId}',
|
||||
name: 'firewall.tenant.rules.fetch',
|
||||
permissions: [TenantFirewallRuleService::PERMISSION_READ],
|
||||
)]
|
||||
public function tenantRule(string $ruleId): JsonResponse
|
||||
{
|
||||
return $this->ruleResponse($this->tenantRules->fetchRule($ruleId));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/effective-policy',
|
||||
name: 'firewall.tenant.policy.effective',
|
||||
permissions: [TenantFirewallRuleService::PERMISSION_READ],
|
||||
)]
|
||||
public function effectivePolicy(): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this->tenantRules->effectivePolicy());
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/rules',
|
||||
name: 'firewall.system.rules.list',
|
||||
permissions: [SystemFirewallRuleService::PERMISSION_READ],
|
||||
)]
|
||||
public function systemRules(
|
||||
string $status = 'active',
|
||||
?string $type = null,
|
||||
?string $action = null,
|
||||
string $limit = '50',
|
||||
string $offset = '0'
|
||||
): JsonResponse {
|
||||
return $this->queryResponse(
|
||||
fn(int $parsedLimit, int $parsedOffset): array => $this->systemRules->queryRules(
|
||||
$status,
|
||||
$type,
|
||||
$action,
|
||||
$parsedLimit,
|
||||
$parsedOffset
|
||||
),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/rules/{ruleId}',
|
||||
name: 'firewall.system.rules.fetch',
|
||||
permissions: [SystemFirewallRuleService::PERMISSION_READ],
|
||||
)]
|
||||
public function systemRule(string $ruleId): JsonResponse
|
||||
{
|
||||
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/rules',
|
||||
name: 'firewall.tenant.rules.create',
|
||||
methods: ['POST'],
|
||||
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
||||
)]
|
||||
public function createTenantRule(
|
||||
Request $request,
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?int $durationSeconds = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): JsonResponse {
|
||||
return $this->mutationResponse(fn() => $this->tenantRules->createRule(
|
||||
$type,
|
||||
$action,
|
||||
$value,
|
||||
$reason,
|
||||
$durationSeconds,
|
||||
$request->getClientIp(),
|
||||
$confirmCurrentIp
|
||||
));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/rules',
|
||||
name: 'firewall.system.rules.create',
|
||||
methods: ['POST'],
|
||||
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
||||
)]
|
||||
public function createSystemRule(
|
||||
Request $request,
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?int $durationSeconds = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): JsonResponse {
|
||||
return $this->mutationResponse(fn() => $this->systemRules->createRule(
|
||||
$type,
|
||||
$action,
|
||||
$value,
|
||||
$reason,
|
||||
$durationSeconds,
|
||||
$request->getClientIp(),
|
||||
$confirmCurrentIp
|
||||
));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/rules/{ruleId}',
|
||||
name: 'firewall.tenant.rules.update',
|
||||
methods: ['PATCH'],
|
||||
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
||||
)]
|
||||
public function updateTenantRule(
|
||||
Request $request,
|
||||
string $ruleId,
|
||||
string $operation,
|
||||
string $reason,
|
||||
?int $durationSeconds = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): JsonResponse {
|
||||
return $this->lifecycleResponse(fn() => match ($operation) {
|
||||
'disable' => $this->tenantRules->disableRule($ruleId, $reason),
|
||||
'enable' => $this->tenantRules->enableRule(
|
||||
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
|
||||
),
|
||||
'extend' => $this->tenantRules->extendRule(
|
||||
$ruleId,
|
||||
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
|
||||
$reason
|
||||
),
|
||||
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
|
||||
});
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/rules/{ruleId}',
|
||||
name: 'firewall.system.rules.update',
|
||||
methods: ['PATCH'],
|
||||
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
||||
)]
|
||||
public function updateSystemRule(
|
||||
Request $request,
|
||||
string $ruleId,
|
||||
string $operation,
|
||||
string $reason,
|
||||
?int $durationSeconds = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): JsonResponse {
|
||||
return $this->lifecycleResponse(fn() => match ($operation) {
|
||||
'disable' => $this->systemRules->disableRule($ruleId, $reason),
|
||||
'enable' => $this->systemRules->enableRule(
|
||||
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
|
||||
),
|
||||
'extend' => $this->systemRules->extendRule(
|
||||
$ruleId,
|
||||
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
|
||||
$reason
|
||||
),
|
||||
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
|
||||
});
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/rules/{ruleId}',
|
||||
name: 'firewall.tenant.rules.delete',
|
||||
methods: ['DELETE'],
|
||||
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
|
||||
)]
|
||||
public function deleteTenantRule(string $ruleId, string $reason): JsonResponse
|
||||
{
|
||||
return $this->lifecycleResponse(fn() => $this->tenantRules->removeRule($ruleId, $reason));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/rules/{ruleId}',
|
||||
name: 'firewall.system.rules.delete',
|
||||
methods: ['DELETE'],
|
||||
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
|
||||
)]
|
||||
public function deleteSystemRule(string $ruleId, string $reason): JsonResponse
|
||||
{
|
||||
return $this->lifecycleResponse(fn() => $this->systemRules->removeRule($ruleId, $reason));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/logs',
|
||||
name: 'firewall.tenant.logs.list',
|
||||
permissions: [TenantFirewallLogService::PERMISSION_READ],
|
||||
)]
|
||||
public function tenantLogs(
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
?string $ruleId = null,
|
||||
?string $ruleScope = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
string $limit = '50',
|
||||
string $offset = '0'
|
||||
): JsonResponse {
|
||||
return $this->queryResponse(
|
||||
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query(
|
||||
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
|
||||
$parsedLimit,
|
||||
$parsedOffset
|
||||
),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/logs',
|
||||
name: 'firewall.system.logs.list',
|
||||
permissions: [SystemFirewallLogService::PERMISSION_READ],
|
||||
)]
|
||||
public function systemLogs(
|
||||
?string $tenantId = null,
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
?string $ruleId = null,
|
||||
?string $ruleScope = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
string $limit = '50',
|
||||
string $offset = '0'
|
||||
): JsonResponse {
|
||||
return $this->queryResponse(
|
||||
fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query(
|
||||
$tenantId,
|
||||
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
|
||||
$parsedLimit,
|
||||
$parsedOffset
|
||||
),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/metrics',
|
||||
name: 'firewall.tenant.metrics.read',
|
||||
permissions: [TenantFirewallLogService::PERMISSION_READ],
|
||||
)]
|
||||
public function tenantMetrics(?string $since = null): JsonResponse
|
||||
{
|
||||
return $this->readResponse(fn(): array => $this->tenantStatus->metrics($since));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/configuration',
|
||||
name: 'firewall.tenant.configuration.read',
|
||||
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_READ],
|
||||
)]
|
||||
public function tenantConfiguration(): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this->tenantStatus->configuration());
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/configuration',
|
||||
name: 'firewall.tenant.configuration.update',
|
||||
methods: ['PUT'],
|
||||
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
|
||||
)]
|
||||
public function updateTenantConfiguration(
|
||||
bool $enabled,
|
||||
int $maxAuthFailures,
|
||||
int $authFailureWindow,
|
||||
int $autoBlockDuration,
|
||||
string $reason
|
||||
): JsonResponse {
|
||||
return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration(
|
||||
$enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
|
||||
));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/tenants/{tenantId}/configuration',
|
||||
name: 'firewall.system.tenant.configuration.update',
|
||||
methods: ['PUT'],
|
||||
permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
|
||||
)]
|
||||
public function updateSystemTenantConfiguration(
|
||||
string $tenantId,
|
||||
bool $enabled,
|
||||
int $maxAuthFailures,
|
||||
int $authFailureWindow,
|
||||
int $autoBlockDuration,
|
||||
string $reason
|
||||
): JsonResponse {
|
||||
return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration(
|
||||
$tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
|
||||
));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/metrics',
|
||||
name: 'firewall.system.metrics.read',
|
||||
permissions: [SystemFirewallLogService::PERMISSION_READ],
|
||||
)]
|
||||
public function systemMetrics(?string $tenantId = null, ?string $since = null): JsonResponse
|
||||
{
|
||||
return $this->readResponse(fn(): array => $this->systemStatus->metrics($tenantId, $since));
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/firewall/system/maintenance',
|
||||
name: 'firewall.system.maintenance.read',
|
||||
permissions: [SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ],
|
||||
)]
|
||||
public function maintenanceStatus(): JsonResponse
|
||||
{
|
||||
return new JsonResponse($this->systemStatus->maintenanceStatus());
|
||||
}
|
||||
|
||||
private function queryResponse(callable $query, string $limit, string $offset): JsonResponse
|
||||
{
|
||||
try {
|
||||
if (!ctype_digit($limit) || !ctype_digit($offset)) {
|
||||
throw new \InvalidArgumentException('Pagination values must be non-negative integers.');
|
||||
}
|
||||
return new JsonResponse($query((int)$limit, (int)$offset));
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private function ruleResponse(?\JsonSerializable $rule): JsonResponse
|
||||
{
|
||||
if ($rule === null) {
|
||||
return new JsonResponse(['error' => 'Firewall rule not found.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResponse($rule);
|
||||
}
|
||||
|
||||
private function readResponse(callable $read): JsonResponse
|
||||
{
|
||||
try {
|
||||
return new JsonResponse($read());
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private function mutationResponse(callable $mutation): JsonResponse
|
||||
{
|
||||
try {
|
||||
return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED);
|
||||
} catch (FirewallRuleConflictException $error) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => $error->conflictCode,
|
||||
'message' => $error->getMessage(),
|
||||
]], JsonResponse::HTTP_CONFLICT);
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => 'invalid_firewall_rule',
|
||||
'message' => $error->getMessage(),
|
||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private function lifecycleResponse(callable $mutation): JsonResponse
|
||||
{
|
||||
try {
|
||||
$rule = $mutation();
|
||||
if ($rule === null) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => 'firewall_rule_not_found',
|
||||
'message' => 'Firewall rule not found.',
|
||||
]], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResponse(['rule' => $rule]);
|
||||
} catch (FirewallRuleConflictException $error) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => $error->conflictCode,
|
||||
'message' => $error->getMessage(),
|
||||
]], JsonResponse::HTTP_CONFLICT);
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => 'invalid_firewall_rule',
|
||||
'message' => $error->getMessage(),
|
||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private function settingsResponse(callable $mutation): JsonResponse
|
||||
{
|
||||
try {
|
||||
$configuration = $mutation();
|
||||
if ($configuration === null) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => 'tenant_not_found',
|
||||
'message' => 'Tenant not found.',
|
||||
]], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResponse(['configuration' => $configuration]);
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return new JsonResponse(['error' => [
|
||||
'code' => 'invalid_firewall_configuration',
|
||||
'message' => $error->getMessage(),
|
||||
]], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,16 @@ use KTXC\L10N\LocaleResolver;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class InitController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly PermissionChecker $permissionChecker,
|
||||
@@ -54,21 +54,21 @@ class InitController extends ControllerAbstract
|
||||
|
||||
// tenant
|
||||
$configuration['tenant'] = [
|
||||
'id' => $this->tenant->identifier(),
|
||||
'domain' => $this->tenant->domain(),
|
||||
'label' => $this->tenant->label(),
|
||||
'id' => $this->tenantContext->identifier(),
|
||||
'domain' => $this->tenantContext->domain(),
|
||||
'label' => $this->tenantContext->label(),
|
||||
];
|
||||
|
||||
// user
|
||||
$configuration['user'] = [
|
||||
'auth' => [
|
||||
'identifier' => $this->userIdentity->identifier(),
|
||||
'identity' => $this->userIdentity->identity()->getIdentity(),
|
||||
'label' => $this->userIdentity->label(),
|
||||
'roles' => $this->userIdentity->identity()->getRoles(),
|
||||
'permissions' => $this->userIdentity->identity()->getPermissions(),
|
||||
'identifier' => $this->identityContext->identifier(),
|
||||
'identity' => $this->identityContext->identity()->getIdentity(),
|
||||
'label' => $this->identityContext->label(),
|
||||
'roles' => $this->identityContext->identity()->getRoles(),
|
||||
'permissions' => $this->identityContext->identity()->getPermissions(),
|
||||
],
|
||||
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
|
||||
'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
|
||||
'settings' => $this->userService->fetchSettings([], true),
|
||||
];
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
@@ -19,7 +19,7 @@ use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
class TenantSettingsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly TenantService $tenantService,
|
||||
) {}
|
||||
|
||||
@@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
|
||||
$settings = $this->tenantService->fetchSettings($this->tenantContext->identifier());
|
||||
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
@@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
|
||||
$this->tenantService->storeSettings($this->tenantContext->identifier(), $data);
|
||||
|
||||
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
|
||||
$updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data));
|
||||
|
||||
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
|
||||
class UserAccountsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
{
|
||||
try {
|
||||
// Check admin permission
|
||||
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||
if (!$this->identityContext->hasPermission('user.admin')) {
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||
@@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userCreate(array $data): array
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.create')) {
|
||||
if (!$this->identityContext->hasPermission('user.create')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to create users');
|
||||
}
|
||||
|
||||
@@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract
|
||||
];
|
||||
|
||||
$this->logger->info('Creating user', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'identity' => $userData['identity'],
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
'actor' => $this->identityContext->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->createUser($userData);
|
||||
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userUpdate(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.update')) {
|
||||
if (!$this->identityContext->hasPermission('user.update')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to update users');
|
||||
}
|
||||
|
||||
@@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract
|
||||
}
|
||||
|
||||
$this->logger->info('Updating user', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
'actor' => $this->identityContext->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->updateUser($uid, $updates);
|
||||
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userDelete(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.delete')) {
|
||||
if (!$this->identityContext->hasPermission('user.delete')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to delete users');
|
||||
}
|
||||
|
||||
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
||||
|
||||
// Prevent self-deletion
|
||||
if ($uid === $this->userIdentity->identifier()) {
|
||||
if ($uid === $this->identityContext->identifier()) {
|
||||
throw new \InvalidArgumentException('Cannot delete your own account');
|
||||
}
|
||||
|
||||
$this->logger->info('Deleting user', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
'actor' => $this->identityContext->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->deleteUser($uid);
|
||||
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userProviderUnlink(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||
if (!$this->identityContext->hasPermission('user.admin')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions');
|
||||
}
|
||||
|
||||
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
|
||||
];
|
||||
|
||||
$this->logger->info('Unlinking provider', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
'actor' => $this->identityContext->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->updateUser($uid, $updates);
|
||||
|
||||
@@ -4,16 +4,16 @@ namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class UserProfileController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly UserAccountsService $userService
|
||||
) {}
|
||||
|
||||
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
$userId = $this->identityContext->identifier();
|
||||
|
||||
// Get profile with editability metadata
|
||||
$profile = $this->userService->getEditableFields($userId);
|
||||
@@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
$userId = $this->identityContext->identifier();
|
||||
|
||||
// storeProfile automatically filters out provider-managed fields
|
||||
$this->userService->storeProfile($userId, $data);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Service\UserRolesService;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
|
||||
class UserRolesController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly UserRolesService $roleService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
|
||||
{
|
||||
try {
|
||||
// Check role admin permission
|
||||
if (!$this->userIdentity->hasPermission('role.admin')) {
|
||||
if (!$this->identityContext->hasPermission('role.admin')) {
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||
@@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract
|
||||
*/
|
||||
private function roleCreate(array $data): array
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
if (!$this->identityContext->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to create roles');
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract
|
||||
*/
|
||||
private function roleUpdate(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
if (!$this->identityContext->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to update roles');
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract
|
||||
*/
|
||||
private function roleDelete(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
if (!$this->identityContext->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to delete roles');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ namespace KTXC\Controllers;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class UserSettingsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly UserAccountsService $userService
|
||||
) {}
|
||||
|
||||
|
||||
@@ -66,6 +66,6 @@ class ObjectId
|
||||
*/
|
||||
public static function isValid(string $id): bool
|
||||
{
|
||||
return MongoObjectId::isValid($id);
|
||||
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Event;
|
||||
|
||||
interface DeferredEventProcessorInterface
|
||||
{
|
||||
public function beginExecution(string $executionId): void;
|
||||
|
||||
public function processDeferred(string $executionId): DeferredProcessingResult;
|
||||
|
||||
public function discardDeferred(string $executionId): void;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Event;
|
||||
|
||||
final readonly class DeferredProcessingResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $processed,
|
||||
public int $remaining,
|
||||
public bool $deadlineExceeded,
|
||||
public bool $limitExceeded = false,
|
||||
public int $listenerInvocations = 0,
|
||||
public bool $eventLimitExceeded = false,
|
||||
public bool $listenerInvocationLimitExceeded = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Event;
|
||||
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\FailurePolicy;
|
||||
|
||||
final readonly class EventListenerDefinition
|
||||
{
|
||||
/**
|
||||
* @param class-string $service
|
||||
*/
|
||||
public function __construct(
|
||||
public string $module,
|
||||
public string $event,
|
||||
public string $service,
|
||||
public string $method,
|
||||
public DeliveryMode $delivery,
|
||||
public int $priority,
|
||||
public FailurePolicy $failurePolicy,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Event;
|
||||
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXF\Event\FailurePolicy;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class EventListenerRegistry implements EventListenerRegistrarInterface
|
||||
{
|
||||
/** @var array<string, list<EventListenerDefinition>> */
|
||||
private array $listeners = [];
|
||||
/** @var array<string, true> */
|
||||
private array $registrationIds = [];
|
||||
private bool $frozen = false;
|
||||
|
||||
/**
|
||||
* @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 {
|
||||
if ($this->frozen) {
|
||||
throw new \LogicException('The event listener registry is frozen.');
|
||||
}
|
||||
if ($module === '' || $event === '' || $service === '' || $method === '') {
|
||||
throw new \InvalidArgumentException('Event listener registrations require module, event, service, and method.');
|
||||
}
|
||||
if (!class_exists($service) || !method_exists($service, $method)) {
|
||||
throw new \InvalidArgumentException("Invalid event listener {$service}::{$method}.");
|
||||
}
|
||||
if ($priority < -10000 || $priority > 10000) {
|
||||
throw new \InvalidArgumentException('Event listener priority must be between -10000 and 10000.');
|
||||
}
|
||||
|
||||
$id = implode('|', [$module, $event, $service, $method, $delivery->value]);
|
||||
if (isset($this->registrationIds[$id])) {
|
||||
throw new \LogicException("Duplicate event listener registration: {$id}.");
|
||||
}
|
||||
$this->registrationIds[$id] = true;
|
||||
|
||||
$this->listeners[$event][] = new EventListenerDefinition(
|
||||
$module,
|
||||
$event,
|
||||
$service,
|
||||
$method,
|
||||
$delivery,
|
||||
$priority,
|
||||
$failurePolicy,
|
||||
);
|
||||
}
|
||||
|
||||
public function freeze(?ContainerInterface $container = null): void
|
||||
{
|
||||
foreach ($this->listeners as &$listeners) {
|
||||
if ($container !== null) {
|
||||
foreach ($listeners as $listener) {
|
||||
if (!$container->has($listener->service)) {
|
||||
throw new \LogicException(
|
||||
"Event listener service is not resolvable: {$listener->service}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
usort(
|
||||
$listeners,
|
||||
static fn(EventListenerDefinition $a, EventListenerDefinition $b): int =>
|
||||
$b->priority <=> $a->priority,
|
||||
);
|
||||
}
|
||||
unset($listeners);
|
||||
$this->frozen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<EventListenerDefinition>
|
||||
*/
|
||||
public function listeners(string $event, DeliveryMode $delivery): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->listeners[$event] ?? [],
|
||||
static fn(EventListenerDefinition $listener): bool => $listener->delivery === $delivery,
|
||||
));
|
||||
}
|
||||
|
||||
public function frozen(): bool
|
||||
{
|
||||
return $this->frozen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<EventListenerDefinition>
|
||||
*/
|
||||
public function definitions(): array
|
||||
{
|
||||
return array_merge(...array_values($this->listeners));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ namespace KTXC\Http\Middleware;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Service\SecurityService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Context\IdentityContext;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
@@ -19,7 +22,10 @@ class AuthenticationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityService $securityService,
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
private readonly IdentityContext $identityContext,
|
||||
private readonly EphemeralCacheInterface $ephemeralCache,
|
||||
private readonly PersistentCacheInterface $persistentCache,
|
||||
private readonly BlobCacheInterface $blobCache,
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
@@ -29,7 +35,11 @@ class AuthenticationMiddleware implements MiddlewareInterface
|
||||
|
||||
// Initialize session identity if authentication succeeded
|
||||
if ($identity) {
|
||||
$this->sessionIdentity->initialize($identity, true);
|
||||
$this->identityContext->initialize($identity);
|
||||
$identityId = $this->identityContext->identifier();
|
||||
$this->ephemeralCache->setUserContext($identityId);
|
||||
$this->persistentCache->setUserContext($identityId);
|
||||
$this->blobCache->setUserContext($identityId);
|
||||
}
|
||||
|
||||
// Continue to next middleware (authentication is optional at this stage)
|
||||
|
||||
@@ -6,7 +6,7 @@ use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Routing\Router;
|
||||
use KTXC\Routing\Route;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
|
||||
/**
|
||||
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Router $router,
|
||||
private readonly SessionIdentity $sessionIdentity,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly PermissionChecker $permissionChecker
|
||||
) {}
|
||||
|
||||
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
|
||||
if ($match->authenticated && $this->identityContext->identity() === null) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
|
||||
@@ -4,7 +4,10 @@ namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
|
||||
/**
|
||||
* Tenant resolution middleware
|
||||
@@ -13,16 +16,23 @@ use KTXC\SessionTenant;
|
||||
class TenantMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $sessionTenant
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly EphemeralCacheInterface $ephemeralCache,
|
||||
private readonly PersistentCacheInterface $persistentCache,
|
||||
private readonly BlobCacheInterface $blobCache,
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Configure tenant from request host
|
||||
$this->sessionTenant->configure($request->getHost());
|
||||
$this->tenantContext->resolveDomain($request->getHost());
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$this->ephemeralCache->setTenantContext($tenantId);
|
||||
$this->persistentCache->setTenantContext($tenantId);
|
||||
$this->blobCache->setTenantContext($tenantId);
|
||||
|
||||
// Check if tenant is configured and enabled
|
||||
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
|
||||
if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
/**
|
||||
* Holds the HTTP request for the duration of the current runtime execution.
|
||||
*/
|
||||
final class RequestContext
|
||||
{
|
||||
private ?Request $request = null;
|
||||
|
||||
public function initialize(Request $request): void
|
||||
{
|
||||
if ($this->request !== null) {
|
||||
throw new \LogicException('The request context has already been initialized.');
|
||||
}
|
||||
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
public function current(): ?Request
|
||||
{
|
||||
return $this->request;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->request = null;
|
||||
}
|
||||
}
|
||||
+170
-164
@@ -9,13 +9,17 @@
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Http\Middleware\MiddlewarePipeline;
|
||||
use KTXC\Http\Middleware\TenantMiddleware;
|
||||
use KTXC\Http\Middleware\FirewallMiddleware;
|
||||
use KTXC\Http\Middleware\AuthenticationMiddleware;
|
||||
use KTXC\Http\Middleware\RouterMiddleware;
|
||||
use KTXC\Application\KernelOptions;
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Application\Execution\ExecutionOutcome;
|
||||
use KTXC\Application\Execution\ExecutionRunner;
|
||||
use KTXC\Application\Execution\ExecutionRunnerInterface;
|
||||
use KTXC\Application\Execution\ExecutionScope;
|
||||
use KTXC\Application\Execution\TerminationReport;
|
||||
use KTXC\Context\IdentityContext;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Injection\Builder;
|
||||
use KTXC\Injection\Container;
|
||||
use Psr\Container\ContainerInterface;
|
||||
@@ -23,7 +27,11 @@ use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use KTXC\Logger\LoggerFactory;
|
||||
use KTXC\Logger\TenantAwareLogger;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXC\Event\DeferredEventProcessorInterface;
|
||||
use KTXC\Event\EventDispatcher;
|
||||
use KTXC\Event\EventListenerRegistry;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
@@ -31,7 +39,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
|
||||
use KTXF\Cache\Store\FilePersistentCache;
|
||||
use KTXF\Cache\Store\FileBlobCache;
|
||||
|
||||
class Kernel
|
||||
class Kernel implements KernelInterface
|
||||
{
|
||||
public const VERSION = '1.0.0';
|
||||
public const VERSION_ID = 10000;
|
||||
@@ -45,26 +53,17 @@ class Kernel
|
||||
protected ?float $startTime = null;
|
||||
protected ?ContainerInterface $container = null;
|
||||
protected ?LoggerInterface $logger = null;
|
||||
protected ?MiddlewarePipeline $pipeline = null;
|
||||
private bool $errorHandlerInstalled = false;
|
||||
private ?ExecutionScope $activeScope = null;
|
||||
private ?ExecutionRunnerInterface $runner = null;
|
||||
|
||||
private string $projectDir;
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
protected string $environment = 'prod',
|
||||
protected bool $debug = false,
|
||||
private readonly KernelOptions $options,
|
||||
array $config = [],
|
||||
?string $projectDir = null,
|
||||
) {
|
||||
if (!$environment) {
|
||||
throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this)));
|
||||
}
|
||||
|
||||
$this->config = $config;
|
||||
|
||||
if ($projectDir !== null) {
|
||||
$this->projectDir = $projectDir;
|
||||
}
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
@@ -72,15 +71,16 @@ class Kernel
|
||||
$this->initialized = false;
|
||||
$this->booted = false;
|
||||
$this->container = null;
|
||||
$this->runner = null;
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
|
||||
if ($this->debug) {
|
||||
if ($this->debug()) {
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
|
||||
if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
|
||||
if (\function_exists('putenv')) {
|
||||
putenv('SHELL_VERBOSITY=3');
|
||||
}
|
||||
@@ -89,7 +89,10 @@ class Kernel
|
||||
}
|
||||
|
||||
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
|
||||
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
|
||||
$this->logger = LoggerFactory::create(
|
||||
$this->config,
|
||||
$this->options->paths->project,
|
||||
);
|
||||
|
||||
$this->initializeErrorHandlers();
|
||||
|
||||
@@ -129,23 +132,7 @@ class Kernel
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
set_exception_handler(function (\Throwable $exception) {
|
||||
$this->logger->error('Exception caught: ' . $exception->getMessage(), [
|
||||
'exception' => $exception,
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
|
||||
if ($this->debug) {
|
||||
echo '<pre>Uncaught Exception: ' . $exception . '</pre>';
|
||||
} else {
|
||||
echo 'An unexpected error occurred. Please try again later.';
|
||||
}
|
||||
|
||||
exit(1);
|
||||
});
|
||||
$this->errorHandlerInstalled = true;
|
||||
|
||||
// Handle fatal errors
|
||||
register_shutdown_function(function () {
|
||||
@@ -161,11 +148,6 @@ class Kernel
|
||||
|
||||
$this->logger->error($message, $error);
|
||||
|
||||
if ($this->debug) {
|
||||
echo '<pre>' . $message . '</pre>';
|
||||
} else {
|
||||
echo 'A fatal error occurred. Please try again later.';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -180,14 +162,19 @@ class Kernel
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $this->container->get(ModuleManager::class);
|
||||
$moduleManager->modulesBoot();
|
||||
|
||||
// Build middleware pipeline
|
||||
$this->pipeline = $this->buildMiddlewarePipeline();
|
||||
$this->container
|
||||
->get(EventListenerRegistry::class)
|
||||
->freeze($this->container);
|
||||
|
||||
$this->booted = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function executionRunner(): ExecutionRunnerInterface
|
||||
{
|
||||
return $this->runner ??= new ExecutionRunner($this);
|
||||
}
|
||||
|
||||
public function reboot(): void
|
||||
{
|
||||
$this->shutdown();
|
||||
@@ -199,52 +186,117 @@ class Kernel
|
||||
if (false === $this->initialized) {
|
||||
return;
|
||||
}
|
||||
if ($this->activeScope !== null && !$this->activeScope->terminated()) {
|
||||
throw new \LogicException('Cannot shut down the kernel while an execution scope is active.');
|
||||
}
|
||||
|
||||
$this->initialized = false;
|
||||
$this->booted = false;
|
||||
$this->container = null;
|
||||
if ($this->errorHandlerInstalled) {
|
||||
restore_error_handler();
|
||||
$this->errorHandlerInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
|
||||
{
|
||||
if (!$this->booted) {
|
||||
$this->boot();
|
||||
}
|
||||
if ($this->activeScope !== null) {
|
||||
throw new \LogicException('The kernel already has an active execution scope.');
|
||||
}
|
||||
|
||||
// Use middleware pipeline to handle the request
|
||||
return $this->pipeline->handle($request);
|
||||
$scope = new ExecutionScope(
|
||||
$descriptor,
|
||||
\KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor),
|
||||
$this->container->get(TenantContext::class),
|
||||
$this->container->get(IdentityContext::class),
|
||||
);
|
||||
$this->container
|
||||
->get(DeferredEventProcessorInterface::class)
|
||||
->beginExecution($descriptor->executionId);
|
||||
$this->activeScope = $scope;
|
||||
|
||||
return $scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the middleware pipeline
|
||||
*/
|
||||
protected function buildMiddlewarePipeline(): MiddlewarePipeline
|
||||
public function terminateExecution(
|
||||
ExecutionScope $scope,
|
||||
ExecutionOutcome $outcome,
|
||||
): TerminationReport
|
||||
{
|
||||
$pipeline = new MiddlewarePipeline($this->container);
|
||||
|
||||
// Register middleware in execution order
|
||||
$pipeline->pipe(TenantMiddleware::class);
|
||||
$pipeline->pipe(FirewallMiddleware::class);
|
||||
$pipeline->pipe(AuthenticationMiddleware::class);
|
||||
$pipeline->pipe(RouterMiddleware::class);
|
||||
|
||||
return $pipeline;
|
||||
}
|
||||
if ($scope->terminated()) {
|
||||
return new TerminationReport();
|
||||
}
|
||||
if ($this->activeScope !== $scope) {
|
||||
throw new \LogicException('Cannot terminate a scope that is not active.');
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$remaining = 0;
|
||||
$deadlineExceeded = false;
|
||||
$limitExceeded = false;
|
||||
$listenerInvocations = 0;
|
||||
$eventLimitExceeded = false;
|
||||
$listenerInvocationLimitExceeded = false;
|
||||
$failures = [];
|
||||
|
||||
/**
|
||||
* Process deferred events at the end of the request
|
||||
*/
|
||||
public function processEvents(): void
|
||||
{
|
||||
try {
|
||||
if ($this->container && $this->container->has(EventBus::class)) {
|
||||
/** @var EventBus $eventBus */
|
||||
$eventBus = $this->container->get(EventBus::class);
|
||||
$eventBus->processDeferred();
|
||||
if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
|
||||
$result = $this->container
|
||||
->get(DeferredEventProcessorInterface::class)
|
||||
->processDeferred($scope->descriptor->executionId);
|
||||
$processed = $result->processed;
|
||||
$remaining = $result->remaining;
|
||||
$deadlineExceeded = $result->deadlineExceeded;
|
||||
$limitExceeded = $result->limitExceeded;
|
||||
$listenerInvocations = $result->listenerInvocations;
|
||||
$eventLimitExceeded = $result->eventLimitExceeded;
|
||||
$listenerInvocationLimitExceeded = $result->listenerInvocationLimitExceeded;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Event processing error: ' . $e->getMessage());
|
||||
$failures[] = $e;
|
||||
try {
|
||||
$this->container
|
||||
?->get(DeferredEventProcessorInterface::class)
|
||||
->discardDeferred($scope->descriptor->executionId);
|
||||
} catch (\Throwable $discardError) {
|
||||
$failures[] = $discardError;
|
||||
}
|
||||
$this->logger?->error('Deferred event processing failed.', [
|
||||
'exception' => $e,
|
||||
'execution_id' => $scope->descriptor->executionId,
|
||||
'runtime' => $scope->descriptor->runtime->value,
|
||||
]);
|
||||
} finally {
|
||||
if ($this->container !== null) {
|
||||
foreach ([
|
||||
EphemeralCacheInterface::class,
|
||||
PersistentCacheInterface::class,
|
||||
BlobCacheInterface::class,
|
||||
] as $cacheType) {
|
||||
$cache = $this->container->get($cacheType);
|
||||
$cache->setUserContext(null);
|
||||
$cache->setTenantContext(null);
|
||||
}
|
||||
}
|
||||
$scope->dispose();
|
||||
$scope->markTerminated();
|
||||
$this->activeScope = null;
|
||||
}
|
||||
|
||||
return new TerminationReport(
|
||||
deferredProcessed: $processed,
|
||||
deferredRemaining: $remaining,
|
||||
failures: $failures,
|
||||
deadlineExceeded: $deadlineExceeded,
|
||||
limitExceeded: $limitExceeded,
|
||||
deferredListenerInvocations: $listenerInvocations,
|
||||
deferredEventLimitExceeded: $eventLimitExceeded,
|
||||
deferredListenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,30 +306,34 @@ class Kernel
|
||||
*/
|
||||
protected function parameters(): array
|
||||
{
|
||||
$projectDir = $this->options->paths->project;
|
||||
$cacheDir = $this->options->paths->cache($this->environment());
|
||||
$logsDir = $this->options->paths->logs();
|
||||
|
||||
return [
|
||||
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
|
||||
'kernel.environment' => $this->environment,
|
||||
'kernel.project_dir' => realpath($projectDir) ?: $projectDir,
|
||||
'kernel.environment' => $this->environment(),
|
||||
'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
|
||||
'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%',
|
||||
'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
|
||||
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%',
|
||||
'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%',
|
||||
'kernel.debug' => $this->debug,
|
||||
'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(),
|
||||
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(),
|
||||
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(),
|
||||
'kernel.charset' => $this->getCharset(),
|
||||
'kernel.debug' => $this->debug(),
|
||||
'kernel.build_dir' => realpath($cacheDir) ?: $cacheDir,
|
||||
'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
|
||||
'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
|
||||
'kernel.charset' => 'UTF-8',
|
||||
];
|
||||
}
|
||||
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->environment;
|
||||
return $this->options->environment;
|
||||
}
|
||||
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->debug;
|
||||
return $this->options->debug;
|
||||
}
|
||||
|
||||
public function container(): ContainerInterface
|
||||
@@ -291,61 +347,7 @@ class Kernel
|
||||
|
||||
public function getStartTime(): float
|
||||
{
|
||||
return $this->debug && null !== $this->startTime ? $this->startTime : -\INF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the application root dir (path of the project's composer file).
|
||||
*/
|
||||
public function folderRoot(): string
|
||||
{
|
||||
if (!isset($this->projectDir)) {
|
||||
$r = new \ReflectionObject($this);
|
||||
|
||||
if (!is_file($dir = $r->getFileName())) {
|
||||
throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name));
|
||||
}
|
||||
|
||||
$dir = $rootDir = \dirname($dir);
|
||||
while (!is_file($dir.'/composer.json')) {
|
||||
if ($dir === \dirname($dir)) {
|
||||
return $this->projectDir = $rootDir;
|
||||
}
|
||||
$dir = \dirname($dir);
|
||||
}
|
||||
$this->projectDir = $dir;
|
||||
}
|
||||
|
||||
return $this->projectDir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the path to the configuration directory.
|
||||
*/
|
||||
private function getConfigDir(): string
|
||||
{
|
||||
return $this->folderRoot().'/config';
|
||||
}
|
||||
|
||||
public function getCacheDir(): string
|
||||
{
|
||||
return $this->folderRoot().'/var/cache/'.$this->environment;
|
||||
}
|
||||
|
||||
public function getBuildDir(): string
|
||||
{
|
||||
return $this->getCacheDir();
|
||||
}
|
||||
|
||||
public function getLogDir(): string
|
||||
{
|
||||
return $this->folderRoot().'/var/log';
|
||||
}
|
||||
|
||||
public function getCharset(): string
|
||||
{
|
||||
return 'UTF-8';
|
||||
return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,9 +382,9 @@ class Kernel
|
||||
protected function configureContainer(Builder $builder): void
|
||||
{
|
||||
// Service definitions
|
||||
$projectDir = $this->folderRoot();
|
||||
$projectDir = $this->options->paths->project;
|
||||
$moduleDir = $projectDir . '/modules';
|
||||
$environment = $this->environment;
|
||||
$environment = $this->environment();
|
||||
|
||||
$builder->addDefinitions([
|
||||
|
||||
@@ -395,6 +397,9 @@ class Kernel
|
||||
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
|
||||
Container::class => \DI\get(ContainerInterface::class),
|
||||
|
||||
TenantContextInterface::class => \DI\get(TenantContext::class),
|
||||
IdentityContextInterface::class => \DI\get(IdentityContext::class),
|
||||
|
||||
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
|
||||
$logConfig = $this->config['log'] ?? [];
|
||||
|
||||
@@ -405,7 +410,7 @@ class Kernel
|
||||
|
||||
return new TenantAwareLogger(
|
||||
$this->logger,
|
||||
$c->get(SessionTenant::class),
|
||||
$c->get(TenantContextInterface::class),
|
||||
$logDir,
|
||||
$channel,
|
||||
$level,
|
||||
@@ -413,8 +418,9 @@ class Kernel
|
||||
);
|
||||
},
|
||||
|
||||
// EventBus as singleton for consistent event handling
|
||||
EventBus::class => \DI\create(EventBus::class),
|
||||
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
|
||||
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
|
||||
EventListenerRegistrarInterface::class => \DI\get(EventListenerRegistry::class),
|
||||
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
|
||||
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
||||
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
|
||||
@@ -433,13 +439,13 @@ class Kernel
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
if ($c->has(TenantContextInterface::class)) {
|
||||
$tenantContext = $c->get(TenantContextInterface::class);
|
||||
$cache->setTenantContext($tenantContext->identifier());
|
||||
}
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
if ($c->has(IdentityContextInterface::class)) {
|
||||
$identityContext = $c->get(IdentityContextInterface::class);
|
||||
$cache->setUserContext($identityContext->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
@@ -462,13 +468,13 @@ class Kernel
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
if ($c->has(TenantContextInterface::class)) {
|
||||
$tenantContext = $c->get(TenantContextInterface::class);
|
||||
$cache->setTenantContext($tenantContext->identifier());
|
||||
}
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
if ($c->has(IdentityContextInterface::class)) {
|
||||
$identityContext = $c->get(IdentityContextInterface::class);
|
||||
$cache->setUserContext($identityContext->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
@@ -491,13 +497,13 @@ class Kernel
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
if ($c->has(TenantContextInterface::class)) {
|
||||
$tenantContext = $c->get(TenantContextInterface::class);
|
||||
$cache->setTenantContext($tenantContext->identifier());
|
||||
}
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
if ($c->has(IdentityContextInterface::class)) {
|
||||
$identityContext = $c->get(IdentityContextInterface::class);
|
||||
$cache->setUserContext($identityContext->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Application\Execution\ExecutionOutcome;
|
||||
use KTXC\Application\Execution\ExecutionRunnerInterface;
|
||||
use KTXC\Application\Execution\ExecutionScope;
|
||||
use KTXC\Application\Execution\TerminationReport;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
interface KernelInterface
|
||||
{
|
||||
public function boot(): void;
|
||||
|
||||
public function executionRunner(): ExecutionRunnerInterface;
|
||||
|
||||
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope;
|
||||
|
||||
public function terminateExecution(
|
||||
ExecutionScope $scope,
|
||||
ExecutionOutcome $outcome,
|
||||
): TerminationReport;
|
||||
|
||||
public function shutdown(): void;
|
||||
|
||||
public function container(): ContainerInterface;
|
||||
|
||||
public function environment(): string;
|
||||
|
||||
public function debug(): bool;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use Psr\Log\NullLogger;
|
||||
*
|
||||
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
|
||||
* DI container definition in Kernel::configureContainer(), because that
|
||||
* requires access to the SessionTenant singleton which lives in the container.
|
||||
* requires access to the TenantContextInterface singleton which lives in the container.
|
||||
*
|
||||
* Supported config keys inside $config['log']:
|
||||
*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ use Psr\Log\LoggerInterface;
|
||||
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
|
||||
* Messages that carry tenant "system" always go to the global logger.
|
||||
*
|
||||
* Both behaviours rely on a live SessionTenant reference that is populated lazily
|
||||
* Both behaviours rely on a live TenantContextInterface reference that is populated lazily
|
||||
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
|
||||
*/
|
||||
class TenantAwareLogger implements LoggerInterface
|
||||
@@ -30,7 +30,7 @@ class TenantAwareLogger implements LoggerInterface
|
||||
|
||||
/**
|
||||
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
|
||||
* @param SessionTenant $sessionTenant Live reference configured by TenantMiddleware.
|
||||
* @param TenantContextInterface $tenantContext Live reference configured by TenantMiddleware.
|
||||
* @param string $logDir Base log directory (e.g. /var/www/app/var/log).
|
||||
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
|
||||
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
|
||||
@@ -38,7 +38,7 @@ class TenantAwareLogger implements LoggerInterface
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $globalLogger,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly string $logDir,
|
||||
private readonly string $channel = 'app',
|
||||
private readonly string $minLevel = 'debug',
|
||||
@@ -60,8 +60,8 @@ class TenantAwareLogger implements LoggerInterface
|
||||
{
|
||||
// Resolve current tenant id; fall back to 'system' for CLI / boot phase.
|
||||
$tenantId = 'system';
|
||||
if ($this->sessionTenant->configured()) {
|
||||
$tenantId = $this->sessionTenant->identifier() ?? 'system';
|
||||
if ($this->tenantContext->configured()) {
|
||||
$tenantId = $this->tenantContext->identifier() ?? 'system';
|
||||
}
|
||||
|
||||
// Inject tenant id as a reserved context key that concrete loggers extract.
|
||||
|
||||
@@ -13,6 +13,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
public const RESULT_ALLOWED = 'allowed';
|
||||
public const RESULT_BLOCKED = 'blocked';
|
||||
public const RESULT_RECORDED = 'recorded';
|
||||
|
||||
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
||||
public const EVENT_RATE_LIMIT = 'rate_limit';
|
||||
@@ -20,8 +21,15 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
public const EVENT_SUSPICIOUS = 'suspicious';
|
||||
public const EVENT_RULE_MATCH = 'rule_match';
|
||||
public const EVENT_ACCESS_CHECK = 'access_check';
|
||||
public const EVENT_RULE_CREATED = 'rule_created';
|
||||
public const EVENT_RULE_EXTENDED = 'rule_extended';
|
||||
public const EVENT_RULE_ENABLED = 'rule_enabled';
|
||||
public const EVENT_RULE_DISABLED = 'rule_disabled';
|
||||
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||
public const EVENT_SETTINGS_UPDATED = 'settings_updated';
|
||||
|
||||
private ?string $id = null;
|
||||
private ?string $eventId = null;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $ipAddress = null;
|
||||
private ?string $deviceFingerprint = null;
|
||||
@@ -31,6 +39,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
private ?string $eventType = null;
|
||||
private ?string $result = null; // allowed, blocked
|
||||
private ?string $ruleId = null; // Which rule triggered (if any)
|
||||
private ?string $ruleScope = null; // tenant or system
|
||||
private ?string $identityId = null; // User ID if authenticated
|
||||
private ?\DateTimeImmutable $timestamp = null;
|
||||
private ?array $metadata = null; // Additional context
|
||||
@@ -50,6 +59,9 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
if (array_key_exists('eventId', $data)) {
|
||||
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
|
||||
}
|
||||
if (array_key_exists('ipAddress', $data)) {
|
||||
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||
}
|
||||
@@ -74,13 +86,14 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
if (array_key_exists('ruleId', $data)) {
|
||||
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
||||
}
|
||||
if (array_key_exists('ruleScope', $data)) {
|
||||
$this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null;
|
||||
}
|
||||
if (array_key_exists('identityId', $data)) {
|
||||
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
||||
}
|
||||
if (array_key_exists('timestamp', $data)) {
|
||||
$this->timestamp = $data['timestamp'] !== null
|
||||
? new \DateTimeImmutable($data['timestamp'])
|
||||
: null;
|
||||
$this->timestamp = self::deserializeDate($data['timestamp']);
|
||||
}
|
||||
if (array_key_exists('metadata', $data)) {
|
||||
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||
@@ -93,6 +106,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'eventId' => $this->eventId,
|
||||
'tenantId' => $this->tenantId,
|
||||
'ipAddress' => $this->ipAddress,
|
||||
'deviceFingerprint' => $this->deviceFingerprint,
|
||||
@@ -102,12 +116,31 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
'eventType' => $this->eventType,
|
||||
'result' => $this->result,
|
||||
'ruleId' => $this->ruleId,
|
||||
'ruleScope' => $this->ruleScope,
|
||||
'identityId' => $this->identityId,
|
||||
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
return \DateTimeImmutable::createFromMutable($value->toDateTime());
|
||||
}
|
||||
if ($value instanceof \DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return \DateTimeImmutable::createFromInterface($value);
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable((string)$value);
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public function getId(): ?string
|
||||
@@ -121,6 +154,17 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEventId(): ?string
|
||||
{
|
||||
return $this->eventId;
|
||||
}
|
||||
|
||||
public function setEventId(?string $eventId): self
|
||||
{
|
||||
$this->eventId = $eventId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantId;
|
||||
@@ -220,6 +264,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRuleScope(): ?string
|
||||
{
|
||||
return $this->ruleScope;
|
||||
}
|
||||
|
||||
public function setRuleScope(?string $ruleScope): self
|
||||
{
|
||||
if (
|
||||
$ruleScope !== null
|
||||
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
|
||||
) {
|
||||
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
|
||||
}
|
||||
|
||||
$this->ruleScope = $ruleScope;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIdentityId(): ?string
|
||||
{
|
||||
return $this->identityId;
|
||||
|
||||
@@ -11,6 +11,9 @@ use KTXF\Json\JsonDeserializable;
|
||||
*/
|
||||
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
public const SCOPE_TENANT = 'tenant';
|
||||
public const SCOPE_SYSTEM = 'system';
|
||||
|
||||
public const TYPE_IP = 'ip';
|
||||
public const TYPE_IP_RANGE = 'ip_range';
|
||||
public const TYPE_DEVICE = 'device';
|
||||
@@ -19,6 +22,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
public const ACTION_BLOCK = 'block';
|
||||
|
||||
private ?string $id = null;
|
||||
private string $scope = self::SCOPE_TENANT;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $type = null; // ip, ip_range, device
|
||||
private ?string $action = null; // allow, block
|
||||
@@ -42,6 +46,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
}
|
||||
|
||||
if (!array_key_exists('scope', $data)) {
|
||||
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
|
||||
}
|
||||
$this->setScope((string)$data['scope']);
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
@@ -61,14 +69,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
|
||||
}
|
||||
if (array_key_exists('createdAt', $data)) {
|
||||
$this->createdAt = $data['createdAt'] !== null
|
||||
? new \DateTimeImmutable($data['createdAt'])
|
||||
: null;
|
||||
$this->createdAt = self::deserializeDate($data['createdAt']);
|
||||
}
|
||||
if (array_key_exists('expiresAt', $data)) {
|
||||
$this->expiresAt = $data['expiresAt'] !== null
|
||||
? new \DateTimeImmutable($data['expiresAt'])
|
||||
: null;
|
||||
$this->expiresAt = self::deserializeDate($data['expiresAt']);
|
||||
}
|
||||
if (array_key_exists('enabled', $data)) {
|
||||
$this->enabled = (bool)$data['enabled'];
|
||||
@@ -84,6 +88,7 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'scope' => $this->scope,
|
||||
'tenantId' => $this->tenantId,
|
||||
'type' => $this->type,
|
||||
'action' => $this->action,
|
||||
@@ -97,6 +102,24 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
];
|
||||
}
|
||||
|
||||
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
return \DateTimeImmutable::createFromMutable($value->toDateTime());
|
||||
}
|
||||
if ($value instanceof \DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return \DateTimeImmutable::createFromInterface($value);
|
||||
}
|
||||
|
||||
return new \DateTimeImmutable((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this rule has expired
|
||||
*/
|
||||
@@ -134,6 +157,42 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
public function getScope(): string
|
||||
{
|
||||
return $this->scope;
|
||||
}
|
||||
|
||||
public function setScope(string $scope): self
|
||||
{
|
||||
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
|
||||
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
|
||||
}
|
||||
|
||||
$this->scope = $scope;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isTenantScoped(): bool
|
||||
{
|
||||
return $this->scope === self::SCOPE_TENANT;
|
||||
}
|
||||
|
||||
public function isSystemScoped(): bool
|
||||
{
|
||||
return $this->scope === self::SCOPE_SYSTEM;
|
||||
}
|
||||
|
||||
public function assertValidScopeOwnership(): void
|
||||
{
|
||||
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
|
||||
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
|
||||
}
|
||||
|
||||
if ($this->isSystemScoped() && $this->tenantId !== null) {
|
||||
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
|
||||
}
|
||||
}
|
||||
|
||||
public function setTenantId(?string $tenantId): self
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
|
||||
@@ -11,11 +11,13 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
{
|
||||
protected TenantAuthentication $authentication;
|
||||
protected TenantSecurity $security;
|
||||
protected TenantFirewall $firewall;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->authentication = new TenantAuthentication();
|
||||
$this->security = new TenantSecurity();
|
||||
$this->firewall = new TenantFirewall();
|
||||
}
|
||||
|
||||
public function authentication(): TenantAuthentication {
|
||||
@@ -26,4 +28,8 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
return $this->security;
|
||||
}
|
||||
|
||||
public function firewall(): TenantFirewall {
|
||||
return $this->firewall;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
class TenantFirewall extends JsonSerializableObject
|
||||
{
|
||||
protected bool $enabled = true;
|
||||
protected int $maxAuthFailures = 5;
|
||||
protected int $authFailureWindow = 300;
|
||||
protected int $autoBlockDuration = 3600;
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function maxAuthFailures(): int
|
||||
{
|
||||
return $this->maxAuthFailures;
|
||||
}
|
||||
|
||||
public function authFailureWindow(): int
|
||||
{
|
||||
return $this->authFailureWindow;
|
||||
}
|
||||
|
||||
public function autoBlockDuration(): int
|
||||
{
|
||||
return $this->autoBlockDuration;
|
||||
}
|
||||
}
|
||||
+122
-2
@@ -2,6 +2,29 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
use KTXC\Service\SystemFirewallStatusService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallStatusService;
|
||||
use KTXC\Security\Event\AccessDeniedEvent;
|
||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||
use KTXC\Security\Event\BruteForceDetectedEvent;
|
||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||
use KTXC\Security\Event\RateLimitExceededEvent;
|
||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
@@ -13,7 +36,51 @@ use KTXF\Module\ModuleInstanceAbstract;
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||
{
|
||||
public function __construct() {}
|
||||
public function __construct(
|
||||
private readonly EventListenerRegistrarInterface $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->events->listen(
|
||||
'core',
|
||||
AuthenticationFailedEvent::class,
|
||||
FirewallService::class,
|
||||
'handleAuthFailure',
|
||||
DeliveryMode::Immediate,
|
||||
priority: 100,
|
||||
);
|
||||
|
||||
$this->events->listen(
|
||||
'core',
|
||||
AuthenticationSucceededEvent::class,
|
||||
FirewallService::class,
|
||||
'logAuthenticationSuccess',
|
||||
DeliveryMode::Deferred,
|
||||
);
|
||||
|
||||
foreach ([
|
||||
AccessDeniedEvent::class,
|
||||
BruteForceDetectedEvent::class,
|
||||
RateLimitExceededEvent::class,
|
||||
SuspiciousActivityEvent::class,
|
||||
FirewallRuleCreatedEvent::class,
|
||||
FirewallRuleExtendedEvent::class,
|
||||
FirewallRuleEnabledEvent::class,
|
||||
FirewallRuleDisabledEvent::class,
|
||||
FirewallRuleRemovedEvent::class,
|
||||
FirewallSettingsUpdatedEvent::class,
|
||||
] as $event) {
|
||||
$this->events->listen(
|
||||
'core',
|
||||
$event,
|
||||
FirewallService::class,
|
||||
'logSecurityEvent',
|
||||
DeliveryMode::Deferred,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
@@ -82,7 +149,57 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
'group' => 'Module Management'
|
||||
],
|
||||
|
||||
// System Administration
|
||||
// Firewall Management
|
||||
TenantFirewallRuleService::PERMISSION_READ => [
|
||||
'label' => 'View Tenant Firewall Rules',
|
||||
'description' => 'View firewall rules owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
TenantFirewallRuleService::PERMISSION_MANAGE => [
|
||||
'label' => 'Manage Tenant Firewall Rules',
|
||||
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
TenantFirewallLogService::PERMISSION_READ => [
|
||||
'label' => 'View Tenant Firewall Logs',
|
||||
'description' => 'View firewall security and audit logs owned by the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
TenantFirewallStatusService::PERMISSION_SETTINGS_READ => [
|
||||
'label' => 'View Tenant Firewall Settings',
|
||||
'description' => 'View effective firewall settings for the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
|
||||
'label' => 'Manage Tenant Firewall Settings',
|
||||
'description' => 'Update firewall settings for the current tenant',
|
||||
'group' => 'Firewall Management'
|
||||
],
|
||||
SystemFirewallRuleService::PERMISSION_READ => [
|
||||
'label' => 'View System Firewall Rules',
|
||||
'description' => 'View firewall rules that apply to every tenant',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
SystemFirewallRuleService::PERMISSION_MANAGE => [
|
||||
'label' => 'Manage System Firewall Rules',
|
||||
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
SystemFirewallLogService::PERMISSION_READ => [
|
||||
'label' => 'View System Firewall Logs',
|
||||
'description' => 'View firewall security and audit logs across tenants',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ => [
|
||||
'label' => 'View Firewall Maintenance Status',
|
||||
'description' => 'View the last firewall cleanup result and operational status',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
|
||||
'label' => 'Manage Tenant Firewall Settings System-Wide',
|
||||
'description' => 'Update firewall settings for any tenant',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
'system.admin' => [
|
||||
'label' => 'System Administrator',
|
||||
'description' => 'Full system access (superuser)',
|
||||
@@ -99,6 +216,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
public function registerCI(): array
|
||||
{
|
||||
return [
|
||||
FirewallSetupCommand::class,
|
||||
FirewallMaintenanceCommand::class,
|
||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||
\KTXC\Console\Module\ModuleListCommand::class,
|
||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
||||
\KTXC\Console\Module\ModuleDisableCommand::class,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Server;
|
||||
use Composer\Autoload\ClassLoader;
|
||||
|
||||
/**
|
||||
* Custom autoloader for modules that allows PascalCase namespaces
|
||||
@@ -20,7 +20,10 @@ class ModuleAutoloader
|
||||
private array $namespaceMap = [];
|
||||
private bool $scanned = false;
|
||||
|
||||
public function __construct(string $modulesRoot)
|
||||
public function __construct(
|
||||
string $modulesRoot,
|
||||
private readonly ?ClassLoader $composerLoader = null,
|
||||
)
|
||||
{
|
||||
$this->modulesRoot = rtrim($modulesRoot, '/');
|
||||
}
|
||||
@@ -73,10 +76,9 @@ class ModuleAutoloader
|
||||
}
|
||||
|
||||
// Register module namespaces with Composer ClassLoader
|
||||
$composerLoader = Server::getComposerLoader();
|
||||
if ($composerLoader !== null) {
|
||||
if ($this->composerLoader !== null) {
|
||||
foreach ($this->namespaceMap as $namespace => $folderName) {
|
||||
$composerLoader->addPsr4(
|
||||
$this->composerLoader->addPsr4(
|
||||
'KTXM\\' . $namespace . '\\',
|
||||
$this->modulesRoot . '/' . $folderName . '/lib/'
|
||||
);
|
||||
|
||||
@@ -276,12 +276,13 @@ class ModuleManager
|
||||
try {
|
||||
$module->boot();
|
||||
$this->logger->debug('Module booted', ['handle' => $handle]);
|
||||
} catch (Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Module boot failed: ' . $handle, [
|
||||
'exception' => $e,
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Runtime\Console;
|
||||
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Kernel;
|
||||
use KTXC\KernelInterface;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class ConsoleRuntime
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
|
||||
{
|
||||
return $this->kernel->executionRunner()->execute(
|
||||
ExecutionDescriptor::cli(),
|
||||
function () use ($input, $output): int {
|
||||
$container = $this->kernel->container();
|
||||
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
|
||||
$console->setAutoExit(false);
|
||||
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$instance = $module->instance();
|
||||
if (!$instance instanceof ModuleConsoleInterface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($instance->registerCI() as $commandClass) {
|
||||
$this->registerCommand($console, $container, $commandClass);
|
||||
}
|
||||
}
|
||||
|
||||
return $console->run($input, $output);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string $commandClass
|
||||
*/
|
||||
private function registerCommand(ConsoleApplication $console, ContainerInterface $container, string $commandClass): void {
|
||||
if (!class_exists($commandClass)) {
|
||||
throw new \RuntimeException("Command class not found: {$commandClass}");
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($commandClass);
|
||||
$attributes = $reflection->getAttributes(AsCommand::class);
|
||||
if ($attributes === []) {
|
||||
throw new \RuntimeException("Command {$commandClass} is missing #[AsCommand].");
|
||||
}
|
||||
|
||||
$attribute = $attributes[0]->newInstance();
|
||||
$console->add(new LazyCommand(
|
||||
$attribute->name,
|
||||
[],
|
||||
$attribute->description ?? '',
|
||||
$attribute->hidden ?? false,
|
||||
static fn() => $container->get($commandClass),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Runtime\Http;
|
||||
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Http\Middleware\AuthenticationMiddleware;
|
||||
use KTXC\Http\Middleware\FirewallMiddleware;
|
||||
use KTXC\Http\Middleware\MiddlewarePipeline;
|
||||
use KTXC\Http\Middleware\RouterMiddleware;
|
||||
use KTXC\Http\Middleware\TenantMiddleware;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Request\RequestContext;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\KernelInterface;
|
||||
|
||||
final class HttpRuntime
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly bool $debug,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(?Request $request = null, bool $send = true): Response
|
||||
{
|
||||
$request ??= Request::createFromGlobals();
|
||||
$requestContext = null;
|
||||
|
||||
try {
|
||||
return $this->kernel->executionRunner()->execute(
|
||||
ExecutionDescriptor::http(),
|
||||
function () use ($request, $send, &$requestContext): Response {
|
||||
$requestContext = $this->kernel->container()->get(RequestContext::class);
|
||||
$requestContext->initialize($request);
|
||||
|
||||
$response = $this->pipeline()->handle($request);
|
||||
if ($send) {
|
||||
$response->send();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$pipeline = new MiddlewarePipeline($this->kernel->container());
|
||||
$pipeline->pipe(TenantMiddleware::class);
|
||||
$pipeline->pipe(FirewallMiddleware::class);
|
||||
$pipeline->pipe(AuthenticationMiddleware::class);
|
||||
$pipeline->pipe(RouterMiddleware::class);
|
||||
|
||||
return $pipeline;
|
||||
}
|
||||
|
||||
private function errorResponse(\Throwable $error): Response
|
||||
{
|
||||
error_log(sprintf(
|
||||
'Application error: %s in %s:%d',
|
||||
$error->getMessage(),
|
||||
$error->getFile(),
|
||||
$error->getLine(),
|
||||
));
|
||||
|
||||
$content = $this->debug
|
||||
? '<pre>' . htmlspecialchars((string) $error) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
|
||||
return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,14 @@ use KTXC\Models\Identity\User;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||
use KTXC\Service\TokenService;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||
use KTXF\Security\Authentication\AuthenticationSession;
|
||||
use KTXF\Security\Authentication\ProviderContext;
|
||||
@@ -26,13 +29,14 @@ class AuthenticationManager
|
||||
private string $securityCode;
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EphemeralCacheInterface $cache,
|
||||
private readonly ProviderManager $providerManager,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
) {
|
||||
$this->securityCode = $this->tenant->configuration()->security()->code();
|
||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -75,7 +79,7 @@ class AuthenticationManager
|
||||
$methods = $this->methodsConfigured();
|
||||
|
||||
$session = AuthenticationSession::create(
|
||||
$this->tenant->identifier(),
|
||||
$this->tenantContext->identifier(),
|
||||
AuthenticationSession::STATE_FRESH
|
||||
);
|
||||
|
||||
@@ -103,7 +107,7 @@ class AuthenticationManager
|
||||
// Filter to non-redirect methods since redirects don't need identity first
|
||||
$methods = $this->methodsConfigured();
|
||||
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect'));
|
||||
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
|
||||
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
|
||||
|
||||
// Store identity in session without validating to prevent enumeration
|
||||
$session->setMethods(array_column($methods, 'id'), $require);
|
||||
@@ -187,6 +191,10 @@ class AuthenticationManager
|
||||
|
||||
if (!$result->isSuccess()) {
|
||||
$this->saveSession($session);
|
||||
$this->publishAuthenticationFailure(
|
||||
$session,
|
||||
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
'Authentication failed. If you haven\'t set up this method, try another option.',
|
||||
@@ -389,6 +397,10 @@ class AuthenticationManager
|
||||
$result = $provider->completeRedirect($context, $request->params);
|
||||
|
||||
if ($result->isFailed()) {
|
||||
$this->publishAuthenticationFailure(
|
||||
$session,
|
||||
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
);
|
||||
$this->deleteSession($session->id);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
@@ -429,7 +441,7 @@ class AuthenticationManager
|
||||
$session->methodCompleted($method);
|
||||
|
||||
// Check if MFA is required
|
||||
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
|
||||
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
|
||||
if ($require > 1) {
|
||||
$remainingMethods = $this->methodsConfigured([$method]);
|
||||
// Filter out redirect methods - they can't be used as secondary factors
|
||||
@@ -523,7 +535,7 @@ class AuthenticationManager
|
||||
|
||||
$accessToken = $this->tokenService->createToken(
|
||||
[
|
||||
'tenant' => $this->tenant->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
@@ -566,6 +578,17 @@ class AuthenticationManager
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
private function publishAuthenticationFailure(
|
||||
AuthenticationSession $session,
|
||||
string $reason,
|
||||
): void {
|
||||
$this->events->dispatch(new AuthenticationFailedEvent(
|
||||
userId: $session->userIdentifier,
|
||||
reason: $reason,
|
||||
tenantId: $session->tenantIdentifier,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider context from session
|
||||
*/
|
||||
@@ -585,7 +608,7 @@ class AuthenticationManager
|
||||
*/
|
||||
private function getProviderConfig(string $method): array
|
||||
{
|
||||
$providers = $this->tenant->configuration()->authentication()->providers();
|
||||
$providers = $this->tenantContext->configuration()->authentication()->providers();
|
||||
return $providers[$method]['config'] ?? [];
|
||||
}
|
||||
|
||||
@@ -594,7 +617,16 @@ class AuthenticationManager
|
||||
*/
|
||||
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
||||
{
|
||||
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
|
||||
$userId = $session->userIdentifier;
|
||||
if ($userId === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_SESSION,
|
||||
'Authenticated user is missing',
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
$userData = $this->userService->fetchByIdentifier($userId);
|
||||
|
||||
if ($userData === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
@@ -611,6 +643,11 @@ class AuthenticationManager
|
||||
|
||||
$this->deleteSession($session->id);
|
||||
|
||||
$this->events->dispatch(new AuthenticationSucceededEvent(
|
||||
$userId,
|
||||
$session->tenantIdentifier,
|
||||
));
|
||||
|
||||
return AuthenticationResponse::success(
|
||||
$this->buildUserData($user),
|
||||
$tokens
|
||||
@@ -635,7 +672,7 @@ class AuthenticationManager
|
||||
*/
|
||||
private function methodsConfigured(array $methodsCompleted = []): array
|
||||
{
|
||||
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
|
||||
$tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
|
||||
$methods = [];
|
||||
|
||||
foreach ($tenantProviders as $providerId => $providerConfiguration) {
|
||||
@@ -669,7 +706,7 @@ class AuthenticationManager
|
||||
private function createTokens(User $user, bool $mfaVerified = false): array
|
||||
{
|
||||
$payload = [
|
||||
'tenant' => $this->tenant->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Security\Authorization;
|
||||
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
|
||||
/**
|
||||
* Permission Checker
|
||||
@@ -11,7 +11,7 @@ use KTXC\SessionIdentity;
|
||||
class PermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
private readonly IdentityContextInterface $identityContext
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ class PermissionChecker
|
||||
*/
|
||||
public function can(string $permission, mixed $resource = null): bool
|
||||
{
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
$identity = $this->identityContext->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return false;
|
||||
@@ -113,7 +113,7 @@ class PermissionChecker
|
||||
*/
|
||||
public function getUserPermissions(): array
|
||||
{
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
$identity = $this->identityContext->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $ipAddress,
|
||||
private readonly string $ruleId,
|
||||
private readonly string $ruleScope,
|
||||
private readonly ?string $deviceFingerprint = null,
|
||||
private readonly ?string $reason = null,
|
||||
?string $tenantId = null,
|
||||
?string $identityId = null,
|
||||
) {
|
||||
if ($ipAddress === '') {
|
||||
throw new \InvalidArgumentException('Access denial requires an IP address.');
|
||||
}
|
||||
if ($ruleId === '') {
|
||||
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
|
||||
}
|
||||
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
|
||||
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
|
||||
$tenantId,
|
||||
$identityId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIpAddress(): string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function getRuleId(): string
|
||||
{
|
||||
return $this->ruleId;
|
||||
}
|
||||
|
||||
public function getRuleScope(): string
|
||||
{
|
||||
return $this->ruleScope;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return $this->deviceFingerprint;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::WARNING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?string $userId = null,
|
||||
private readonly ?string $reason = null,
|
||||
?string $tenantId = null,
|
||||
?string $identityId = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
self::class,
|
||||
['userId' => $userId, 'reason' => $reason],
|
||||
$tenantId,
|
||||
$identityId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::WARNING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $userId,
|
||||
?string $tenantId = null,
|
||||
) {
|
||||
if ($userId === '') {
|
||||
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
['userId' => $userId],
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getUserId(): string
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::INFO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
|
||||
{
|
||||
private readonly string $reason;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $ipAddress,
|
||||
private readonly int $failureCount,
|
||||
private readonly int $windowSeconds,
|
||||
?string $tenantId = null,
|
||||
) {
|
||||
if ($ipAddress === '') {
|
||||
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
|
||||
}
|
||||
if ($failureCount < 1) {
|
||||
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
|
||||
}
|
||||
if ($windowSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
|
||||
}
|
||||
|
||||
$this->reason = sprintf(
|
||||
'%d failed attempts in %d seconds',
|
||||
$failureCount,
|
||||
$windowSeconds,
|
||||
);
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIpAddress(): string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function getFailureCount(): int
|
||||
{
|
||||
return $this->failureCount;
|
||||
}
|
||||
|
||||
public function getWindowSeconds(): int
|
||||
{
|
||||
return $this->windowSeconds;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::CRITICAL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $deviceFingerprint,
|
||||
private readonly ?string $reason = null,
|
||||
?string $tenantId = null,
|
||||
) {
|
||||
if ($deviceFingerprint === '') {
|
||||
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
['device' => $deviceFingerprint, 'reason' => $reason],
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): string
|
||||
{
|
||||
return $this->deviceFingerprint;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::CRITICAL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
|
||||
{
|
||||
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
|
||||
|
||||
final public function __construct(
|
||||
private readonly string $ipAddress,
|
||||
private readonly ?string $reason = null,
|
||||
?string $tenantId = null,
|
||||
) {
|
||||
if ($ipAddress === '') {
|
||||
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
static::class,
|
||||
['ip' => $ipAddress, 'reason' => $reason],
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIpAddress(): string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return static::SEVERITY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXF\Event\Event;
|
||||
|
||||
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
|
||||
{
|
||||
final protected function __construct(
|
||||
private readonly string $ruleId,
|
||||
private readonly string $ruleScope,
|
||||
private readonly string $ruleType,
|
||||
private readonly string $ruleAction,
|
||||
private readonly string $ruleValue,
|
||||
private readonly ?string $reason,
|
||||
private readonly string $origin,
|
||||
private readonly ?string $expiresAt,
|
||||
private readonly array $details,
|
||||
?string $tenantId,
|
||||
?string $identityId,
|
||||
) {
|
||||
if ($ruleId === '') {
|
||||
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
|
||||
}
|
||||
foreach ([
|
||||
'scope' => $ruleScope,
|
||||
'type' => $ruleType,
|
||||
'action' => $ruleAction,
|
||||
'value' => $ruleValue,
|
||||
'origin' => $origin,
|
||||
] as $field => $value) {
|
||||
if ($value === '') {
|
||||
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
||||
}
|
||||
}
|
||||
foreach ([
|
||||
'scope' => $ruleScope,
|
||||
'type' => $ruleType,
|
||||
'action' => $ruleAction,
|
||||
'value' => $ruleValue,
|
||||
'origin' => $origin,
|
||||
] as $field => $value) {
|
||||
if ($value === '') {
|
||||
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
||||
}
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
static::class,
|
||||
[
|
||||
'ruleId' => $ruleId,
|
||||
'ruleScope' => $ruleScope,
|
||||
'ruleType' => $ruleType,
|
||||
'ruleAction' => $ruleAction,
|
||||
'ruleValue' => $ruleValue,
|
||||
'reason' => $reason,
|
||||
'origin' => $origin,
|
||||
'expiresAt' => $expiresAt,
|
||||
...$details,
|
||||
],
|
||||
$tenantId,
|
||||
$identityId,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromRule(
|
||||
FirewallRuleObject $rule,
|
||||
?string $actorId = null,
|
||||
array $change = [],
|
||||
): static {
|
||||
$metadata = $rule->getMetadata() ?? [];
|
||||
$details = [...$metadata, ...$change];
|
||||
foreach ([
|
||||
'ruleId',
|
||||
'ruleScope',
|
||||
'ruleType',
|
||||
'ruleAction',
|
||||
'ruleValue',
|
||||
'reason',
|
||||
'origin',
|
||||
'expiresAt',
|
||||
] as $reservedKey) {
|
||||
unset($details[$reservedKey]);
|
||||
}
|
||||
|
||||
return new static(
|
||||
ruleId: (string) $rule->getId(),
|
||||
ruleScope: (string) $rule->getScope(),
|
||||
ruleType: (string) $rule->getType(),
|
||||
ruleAction: (string) $rule->getAction(),
|
||||
ruleValue: (string) $rule->getValue(),
|
||||
reason: $rule->getReason(),
|
||||
origin: (string) ($metadata['origin'] ?? 'manual'),
|
||||
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
||||
details: $details,
|
||||
tenantId: $rule->getTenantId(),
|
||||
identityId: $actorId ?? $rule->getCreatedBy(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getRuleId(): string
|
||||
{
|
||||
return $this->ruleId;
|
||||
}
|
||||
|
||||
public function getRuleScope(): string
|
||||
{
|
||||
return $this->ruleScope;
|
||||
}
|
||||
|
||||
public function getRuleType(): string
|
||||
{
|
||||
return $this->ruleType;
|
||||
}
|
||||
|
||||
public function getRuleAction(): string
|
||||
{
|
||||
return $this->ruleAction;
|
||||
}
|
||||
|
||||
public function getRuleValue(): string
|
||||
{
|
||||
return $this->ruleValue;
|
||||
}
|
||||
|
||||
public function getOrigin(): string
|
||||
{
|
||||
return $this->origin;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): ?string
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
public function getDetails(): array
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::INFO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $changeReason,
|
||||
private readonly array $previous,
|
||||
private readonly array $current,
|
||||
string $tenantId,
|
||||
?string $actorId = null,
|
||||
private readonly string $changeOrigin = 'manual',
|
||||
) {
|
||||
if ($changeReason === '') {
|
||||
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
|
||||
}
|
||||
if ($tenantId === '') {
|
||||
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
|
||||
}
|
||||
if ($changeOrigin === '') {
|
||||
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
[
|
||||
'changeReason' => $changeReason,
|
||||
'changeOrigin' => $changeOrigin,
|
||||
'previous' => $previous,
|
||||
'current' => $current,
|
||||
],
|
||||
$tenantId,
|
||||
$actorId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getChangeReason(): string
|
||||
{
|
||||
return $this->changeReason;
|
||||
}
|
||||
|
||||
public function getPrevious(): array
|
||||
{
|
||||
return $this->previous;
|
||||
}
|
||||
|
||||
public function getCurrent(): array
|
||||
{
|
||||
return $this->current;
|
||||
}
|
||||
|
||||
public function getChangeOrigin(): string
|
||||
{
|
||||
return $this->changeOrigin;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->changeReason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::INFO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class IpAllowedEvent extends FirewallIpEvent
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
final class IpBlockedEvent extends FirewallIpEvent
|
||||
{
|
||||
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
|
||||
{
|
||||
private readonly string $reason;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $ipAddress,
|
||||
private readonly int $requestCount,
|
||||
private readonly int $windowSeconds,
|
||||
private readonly ?string $endpoint = null,
|
||||
?string $tenantId = null,
|
||||
) {
|
||||
if ($ipAddress === '') {
|
||||
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
|
||||
}
|
||||
if ($requestCount < 1) {
|
||||
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
|
||||
}
|
||||
if ($windowSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
|
||||
}
|
||||
if ($endpoint === '') {
|
||||
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
|
||||
}
|
||||
|
||||
$this->reason = sprintf(
|
||||
'%d requests in %d seconds',
|
||||
$requestCount,
|
||||
$windowSeconds,
|
||||
);
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
[
|
||||
'requestCount' => $requestCount,
|
||||
'windowSeconds' => $windowSeconds,
|
||||
'endpoint' => $endpoint,
|
||||
],
|
||||
$tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIpAddress(): string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function getRequestCount(): int
|
||||
{
|
||||
return $this->requestCount;
|
||||
}
|
||||
|
||||
public function getWindowSeconds(): int
|
||||
{
|
||||
return $this->windowSeconds;
|
||||
}
|
||||
|
||||
public function getEndpoint(): ?string
|
||||
{
|
||||
return $this->endpoint;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return $this->endpoint;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::ERROR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
interface SecurityEventInterface
|
||||
{
|
||||
public function 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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
enum SecurityEventSeverity: int
|
||||
{
|
||||
case DEBUG = 0;
|
||||
case INFO = 1;
|
||||
case WARNING = 2;
|
||||
case ERROR = 3;
|
||||
case CRITICAL = 4;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
interface SecurityRequestEventInterface extends SecurityEventInterface
|
||||
{
|
||||
public function getIpAddress(): ?string;
|
||||
|
||||
public function getDeviceFingerprint(): ?string;
|
||||
|
||||
public function getUserAgent(): ?string;
|
||||
|
||||
public function getRequestPath(): ?string;
|
||||
|
||||
public function getRequestMethod(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Event;
|
||||
|
||||
use KTXF\Event\Event;
|
||||
|
||||
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $ipAddress,
|
||||
private readonly string $detector,
|
||||
private readonly array $detectionData = [],
|
||||
?string $tenantId = null,
|
||||
?string $identityId = null,
|
||||
private readonly ?string $deviceFingerprint = null,
|
||||
private readonly ?string $userAgent = null,
|
||||
private readonly ?string $requestPath = null,
|
||||
private readonly ?string $requestMethod = null,
|
||||
private readonly ?string $userId = null,
|
||||
private readonly ?string $reason = null,
|
||||
) {
|
||||
if ($ipAddress === '') {
|
||||
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
|
||||
}
|
||||
if ($detector === '') {
|
||||
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
|
||||
}
|
||||
if (array_key_exists('detector', $detectionData)) {
|
||||
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
||||
}
|
||||
if (array_key_exists('detector', $detectionData)) {
|
||||
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
||||
}
|
||||
if ($requestPath === '') {
|
||||
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
|
||||
}
|
||||
if ($requestMethod === '') {
|
||||
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
self::class,
|
||||
['detector' => $detector] + $detectionData,
|
||||
$tenantId,
|
||||
$identityId,
|
||||
);
|
||||
}
|
||||
|
||||
public function getIpAddress(): string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function getDetector(): string
|
||||
{
|
||||
return $this->detector;
|
||||
}
|
||||
|
||||
public function getDetectionData(): array
|
||||
{
|
||||
return $this->detectionData;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return $this->deviceFingerprint;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return $this->requestPath;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return $this->requestMethod;
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function getSeverity(): SecurityEventSeverity
|
||||
{
|
||||
return SecurityEventSeverity::ERROR;
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
|
||||
/**
|
||||
* Server class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
private static $composerLoader = null;
|
||||
private static ?self $instance = null;
|
||||
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
self::$instance = $this;
|
||||
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
// Determine environment and debug mode
|
||||
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||
|
||||
// Create kernel with configuration
|
||||
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||
|
||||
// Register module autoloader for both HTTP and CLI contexts
|
||||
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
|
||||
$moduleAutoloader->register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function runHttp(): void
|
||||
{
|
||||
try {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handle($request);
|
||||
$response->send();
|
||||
$this->terminate();
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort error handling for kernel initialization failures
|
||||
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$content = $this->kernel->debug()
|
||||
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
$response->send();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run as a console application (CLI runtime).
|
||||
*/
|
||||
public function runConsole(): int
|
||||
{
|
||||
$this->kernel()->boot();
|
||||
$container = $this->container();
|
||||
|
||||
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
|
||||
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$instance = $module->instance();
|
||||
if (!$instance instanceof ModuleConsoleInterface) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
foreach ($instance->registerCI() as $commandClass) {
|
||||
if (!class_exists($commandClass)) {
|
||||
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||
continue;
|
||||
}
|
||||
$this->registerLazyCommand($console, $container, $commandClass);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
|
||||
return $console->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->rootDir . '/modules';
|
||||
}
|
||||
|
||||
public function varDir(): string
|
||||
{
|
||||
return $this->rootDir . '/var';
|
||||
}
|
||||
|
||||
public function logDir(): string
|
||||
{
|
||||
return $this->varDir() . '/logs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Support dot notation: 'database.uri'
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config directory
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
$configFile = $this->rootDir . '/config/system.php';
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
error_log('Configuration file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException('Configuration file must return an array');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project root directory.
|
||||
*
|
||||
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||
* We walk up the directory tree until we find composer.json.
|
||||
*/
|
||||
private function resolveProjectRoot(string $startDir): string
|
||||
{
|
||||
$dir = rtrim($startDir, '/');
|
||||
if ($dir === '') {
|
||||
return $startDir;
|
||||
}
|
||||
|
||||
// If startDir is a file path, use its directory.
|
||||
if (is_file($dir)) {
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
|
||||
$current = $dir;
|
||||
while (true) {
|
||||
if (is_file($current . '/composer.json')) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$parent = dirname($current);
|
||||
if ($parent === $current) {
|
||||
// Reached filesystem root
|
||||
return $dir;
|
||||
}
|
||||
$current = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composer ClassLoader instance
|
||||
*/
|
||||
public static function setComposerLoader($loader): void
|
||||
{
|
||||
self::$composerLoader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composer ClassLoader instance
|
||||
*/
|
||||
public static function getComposerLoader()
|
||||
{
|
||||
return self::$composerLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Application instance
|
||||
*/
|
||||
public static function getInstance(): ?self
|
||||
{
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single command via lazy loading using its #[AsCommand] attribute.
|
||||
*/
|
||||
private function registerLazyCommand(
|
||||
ConsoleApplication $console,
|
||||
ContainerInterface $container,
|
||||
string $commandClass
|
||||
): void {
|
||||
try {
|
||||
$ref = new \ReflectionClass($commandClass);
|
||||
$attrs = $ref->getAttributes(AsCommand::class);
|
||||
|
||||
if (empty($attrs)) {
|
||||
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||
return;
|
||||
}
|
||||
|
||||
$attr = $attrs[0]->newInstance();
|
||||
$console->add(new LazyCommand(
|
||||
$attr->name,
|
||||
[],
|
||||
$attr->description ?? '',
|
||||
$attr->hidden ?? false,
|
||||
fn() => $container->get($commandClass)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace KTXC\Service;
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\Collection;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
|
||||
class ConfigurationService
|
||||
{
|
||||
@@ -24,7 +24,7 @@ class ConfigurationService
|
||||
|
||||
public function __construct(
|
||||
DataStore $store,
|
||||
private readonly SessionTenant $tenant
|
||||
private readonly TenantContextInterface $tenantContext
|
||||
) {
|
||||
// DataStore provides selectCollection method
|
||||
$this->collection = $store->selectCollection(self::TABLE_NAME);
|
||||
@@ -36,10 +36,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
}
|
||||
|
||||
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||
@@ -54,10 +54,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
}
|
||||
|
||||
$type = $this->determineType($value);
|
||||
@@ -84,10 +84,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
}
|
||||
|
||||
$filter = ['did' => $tenant];
|
||||
@@ -116,10 +116,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function delete(string $path, string $key, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
}
|
||||
|
||||
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||
@@ -131,10 +131,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
}
|
||||
|
||||
$filter = ['did' => $tenant];
|
||||
@@ -155,10 +155,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function exists(string $path, string $key, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
}
|
||||
|
||||
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
|
||||
final class FirewallLogService
|
||||
{
|
||||
public const MAX_LIMIT = 100;
|
||||
|
||||
private const EVENT_TYPES = [
|
||||
FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
FirewallLogObject::EVENT_RATE_LIMIT,
|
||||
FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||
FirewallLogObject::EVENT_SUSPICIOUS,
|
||||
FirewallLogObject::EVENT_RULE_MATCH,
|
||||
FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
FirewallLogObject::EVENT_RULE_CREATED,
|
||||
FirewallLogObject::EVENT_RULE_EXTENDED,
|
||||
FirewallLogObject::EVENT_RULE_ENABLED,
|
||||
FirewallLogObject::EVENT_RULE_DISABLED,
|
||||
FirewallLogObject::EVENT_RULE_REMOVED,
|
||||
FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
||||
];
|
||||
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
public function tenant(string $tenantId, array $filters, int $limit, int $offset): array
|
||||
{
|
||||
return $this->store->queryTenantLogs(
|
||||
$tenantId,
|
||||
$this->validate($filters, $limit, $offset),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
public function system(?string $tenantId, array $filters, int $limit, int $offset): array
|
||||
{
|
||||
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
|
||||
throw new \InvalidArgumentException('Invalid tenant filter.');
|
||||
}
|
||||
return $this->store->querySystemLogs(
|
||||
$tenantId,
|
||||
$this->validate($filters, $limit, $offset),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
private function validate(array $filters, int $limit, int $offset): array
|
||||
{
|
||||
if ($limit < 1 || $limit > self::MAX_LIMIT || $offset < 0) {
|
||||
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
|
||||
}
|
||||
$ipAddress = self::nullableString($filters, 'ipAddress');
|
||||
if ($ipAddress !== null && filter_var($ipAddress, FILTER_VALIDATE_IP) === false) {
|
||||
throw new \InvalidArgumentException('Invalid IP address filter.');
|
||||
}
|
||||
$eventType = self::nullableString($filters, 'eventType');
|
||||
if ($eventType !== null && !in_array($eventType, self::EVENT_TYPES, true)) {
|
||||
throw new \InvalidArgumentException('Invalid firewall event type filter.');
|
||||
}
|
||||
$result = self::nullableString($filters, 'result');
|
||||
if ($result !== null && !in_array($result, [
|
||||
FirewallLogObject::RESULT_ALLOWED,
|
||||
FirewallLogObject::RESULT_BLOCKED,
|
||||
FirewallLogObject::RESULT_RECORDED,
|
||||
], true)) {
|
||||
throw new \InvalidArgumentException('Invalid firewall result filter.');
|
||||
}
|
||||
$ruleScope = self::nullableString($filters, 'ruleScope');
|
||||
if ($ruleScope !== null && !in_array($ruleScope, [
|
||||
FirewallRuleObject::SCOPE_TENANT,
|
||||
FirewallRuleObject::SCOPE_SYSTEM,
|
||||
], true)) {
|
||||
throw new \InvalidArgumentException('Invalid rule scope filter.');
|
||||
}
|
||||
$from = self::date($filters, 'from');
|
||||
$to = self::date($filters, 'to');
|
||||
if ($from !== null && $to !== null && $from > $to) {
|
||||
throw new \InvalidArgumentException('The from date must not be later than the to date.');
|
||||
}
|
||||
|
||||
return [
|
||||
'ipAddress' => $ipAddress,
|
||||
'eventType' => $eventType,
|
||||
'result' => $result,
|
||||
'ruleId' => self::nullableString($filters, 'ruleId'),
|
||||
'ruleScope' => $ruleScope,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
}
|
||||
|
||||
private static function nullableString(array $filters, string $key): ?string
|
||||
{
|
||||
$value = $filters[$key] ?? null;
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_string($value) || $value === '' || strlen($value) > 255) {
|
||||
throw new \InvalidArgumentException("Invalid {$key} filter.");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function date(array $filters, string $key): ?\DateTimeImmutable
|
||||
{
|
||||
$value = self::nullableString($filters, $key);
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new \DateTimeImmutable($value);
|
||||
} catch (\Exception) {
|
||||
throw new \InvalidArgumentException("Invalid {$key} date filter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
|
||||
final class FirewallRuleCache
|
||||
{
|
||||
/** @var array<string, FirewallRuleObject[]> */
|
||||
private array $tenantRules = [];
|
||||
|
||||
/** @var FirewallRuleObject[]|null */
|
||||
private ?array $systemRules = null;
|
||||
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return FirewallRuleObject[] */
|
||||
public function tenant(string $tenantId): array
|
||||
{
|
||||
return $this->tenantRules[$tenantId] ??= $this->store->listRules($tenantId);
|
||||
}
|
||||
|
||||
/** @return FirewallRuleObject[] */
|
||||
public function system(): array
|
||||
{
|
||||
return $this->systemRules ??= $this->store->listSystemRules();
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$this->tenantRules = [];
|
||||
$this->systemRules = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
final class FirewallRuleConflictException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $conflictCode,
|
||||
string $message
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
||||
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
||||
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
||||
use KTXC\Security\Event\FirewallRuleEvent;
|
||||
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
||||
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
||||
use KTXC\Security\Event\DeviceBlockedEvent;
|
||||
use KTXC\Security\Event\IpAllowedEvent;
|
||||
use KTXC\Security\Event\IpBlockedEvent;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\IpUtils;
|
||||
|
||||
final class FirewallRuleManager
|
||||
{
|
||||
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
|
||||
public const MAX_QUERY_LIMIT = 100;
|
||||
public const ORIGIN_MANUAL = 'manual';
|
||||
public const ORIGIN_AUTOMATIC = 'automatic';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly FirewallRuleCache $cache,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function list(FirewallRuleScope $scope, bool $activeOnly = true): array
|
||||
{
|
||||
return $scope->scope === FirewallRuleObject::SCOPE_SYSTEM
|
||||
? $this->store->listSystemRules($activeOnly)
|
||||
: $this->store->listRules($scope->tenantId, $activeOnly);
|
||||
}
|
||||
|
||||
public function query(
|
||||
FirewallRuleScope $scope,
|
||||
string $status = 'active',
|
||||
?string $type = null,
|
||||
?string $action = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0
|
||||
): array {
|
||||
if (!in_array($status, self::QUERY_STATUSES, true)) {
|
||||
throw new \InvalidArgumentException('Invalid rule status filter.');
|
||||
}
|
||||
if ($type !== null && !in_array($type, [
|
||||
FirewallRuleObject::TYPE_IP,
|
||||
FirewallRuleObject::TYPE_IP_RANGE,
|
||||
FirewallRuleObject::TYPE_DEVICE,
|
||||
], true)) {
|
||||
throw new \InvalidArgumentException('Invalid rule type filter.');
|
||||
}
|
||||
if ($action !== null && !in_array($action, [
|
||||
FirewallRuleObject::ACTION_ALLOW,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
], true)) {
|
||||
throw new \InvalidArgumentException('Invalid rule action filter.');
|
||||
}
|
||||
if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) {
|
||||
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
|
||||
}
|
||||
|
||||
return $this->store->queryRules(
|
||||
$scope->scope,
|
||||
$scope->tenantId,
|
||||
$status,
|
||||
$type,
|
||||
$action,
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
||||
{
|
||||
return $this->ownedRule($scope, $ruleId);
|
||||
}
|
||||
|
||||
/** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */
|
||||
public function effectivePolicy(string $tenantId): array
|
||||
{
|
||||
return [
|
||||
'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'],
|
||||
'system' => $this->store->listSystemRules(),
|
||||
'tenant' => $this->store->listRules($tenantId),
|
||||
];
|
||||
}
|
||||
|
||||
public function createManualRule(
|
||||
FirewallRuleScope $scope,
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null,
|
||||
?string $currentIp = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): FirewallRuleObject {
|
||||
$reason = trim($reason);
|
||||
if ($reason === '' || strlen($reason) > 1000) {
|
||||
throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.');
|
||||
}
|
||||
if ($currentIp !== null) {
|
||||
$currentIp = FirewallRuleValidator::ipAddress($currentIp);
|
||||
}
|
||||
if (
|
||||
!$confirmCurrentIp
|
||||
&& $currentIp !== null
|
||||
&& $action === FirewallRuleObject::ACTION_BLOCK
|
||||
&& $this->matchesIp($type, $value, $currentIp)
|
||||
) {
|
||||
throw new FirewallRuleConflictException(
|
||||
'current_ip_confirmation_required',
|
||||
'This rule would block your current IP address. Explicit confirmation is required.'
|
||||
);
|
||||
}
|
||||
|
||||
return match ([$type, $action]) {
|
||||
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] =>
|
||||
$this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds),
|
||||
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] =>
|
||||
$durationSeconds === null
|
||||
? $this->allowIp($scope, $value, $reason, $createdBy)
|
||||
: throw new \InvalidArgumentException('Temporary allow rules are not supported.'),
|
||||
[FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] =>
|
||||
$durationSeconds === null
|
||||
? $this->blockIpRange($scope, $value, $reason, $createdBy)
|
||||
: throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'),
|
||||
[FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] =>
|
||||
$this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds),
|
||||
default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'),
|
||||
};
|
||||
}
|
||||
|
||||
private function matchesIp(string $type, string $value, string $currentIp): bool
|
||||
{
|
||||
if ($type === FirewallRuleObject::TYPE_IP) {
|
||||
$value = FirewallRuleValidator::ipAddress($value);
|
||||
return inet_pton($value) === inet_pton($currentIp);
|
||||
}
|
||||
if ($type === FirewallRuleObject::TYPE_IP_RANGE) {
|
||||
return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function blockIp(
|
||||
FirewallRuleScope $scope,
|
||||
string $ipAddress,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null,
|
||||
string $origin = self::ORIGIN_MANUAL,
|
||||
array $metadata = []
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
||||
FirewallRuleValidator::duration($durationSeconds);
|
||||
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$scope->tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
$scope->scope
|
||||
);
|
||||
if ($existing) {
|
||||
if (
|
||||
$origin === self::ORIGIN_AUTOMATIC
|
||||
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
|
||||
&& $durationSeconds !== null
|
||||
) {
|
||||
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_IP,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
$ipAddress,
|
||||
$reason ?? 'Blocked by administrator',
|
||||
$createdBy,
|
||||
$durationSeconds,
|
||||
$origin,
|
||||
$metadata
|
||||
);
|
||||
$this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function allowIp(
|
||||
FirewallRuleScope $scope,
|
||||
string $ipAddress,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
string $origin = self::ORIGIN_MANUAL
|
||||
): FirewallRuleObject {
|
||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
||||
$rule = $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_IP,
|
||||
FirewallRuleObject::ACTION_ALLOW,
|
||||
$ipAddress,
|
||||
$reason ?? 'Allowed by administrator',
|
||||
$createdBy,
|
||||
null,
|
||||
$origin
|
||||
);
|
||||
$this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function blockIpRange(
|
||||
FirewallRuleScope $scope,
|
||||
string $cidr,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
string $origin = self::ORIGIN_MANUAL
|
||||
): FirewallRuleObject {
|
||||
return $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_IP_RANGE,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
FirewallRuleValidator::cidr($cidr),
|
||||
$reason ?? 'Range blocked by administrator',
|
||||
$createdBy,
|
||||
null,
|
||||
$origin
|
||||
);
|
||||
}
|
||||
|
||||
public function blockDevice(
|
||||
FirewallRuleScope $scope,
|
||||
string $fingerprint,
|
||||
?string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null,
|
||||
string $origin = self::ORIGIN_MANUAL
|
||||
): FirewallRuleObject {
|
||||
FirewallRuleValidator::duration($durationSeconds);
|
||||
$fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint);
|
||||
$rule = $this->create(
|
||||
$scope,
|
||||
FirewallRuleObject::TYPE_DEVICE,
|
||||
FirewallRuleObject::ACTION_BLOCK,
|
||||
$fingerprint,
|
||||
$reason ?? 'Device blocked by administrator',
|
||||
$createdBy,
|
||||
$durationSeconds,
|
||||
$origin
|
||||
);
|
||||
|
||||
$event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function disableManual(
|
||||
FirewallRuleScope $scope,
|
||||
string $ruleId,
|
||||
string $reason,
|
||||
?string $actorId
|
||||
): ?FirewallRuleObject {
|
||||
$reason = self::manualReason($reason);
|
||||
$rule = $this->ownedRule($scope, $ruleId);
|
||||
if (!$rule) {
|
||||
return null;
|
||||
}
|
||||
if ($rule->isEnabled()) {
|
||||
$rule->setEnabled(false);
|
||||
$this->store->depositRule($rule);
|
||||
$this->cache->invalidate();
|
||||
$this->publishLifecycleEvent(
|
||||
FirewallRuleDisabledEvent::class,
|
||||
$rule,
|
||||
$actorId,
|
||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||
);
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function enableManual(
|
||||
FirewallRuleScope $scope,
|
||||
string $ruleId,
|
||||
string $reason,
|
||||
?string $actorId,
|
||||
?string $currentIp = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): ?FirewallRuleObject {
|
||||
$reason = self::manualReason($reason);
|
||||
$rule = $this->ownedRule($scope, $ruleId);
|
||||
if (!$rule) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!$confirmCurrentIp
|
||||
&& $currentIp !== null
|
||||
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
|
||||
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
|
||||
) {
|
||||
throw new FirewallRuleConflictException(
|
||||
'current_ip_confirmation_required',
|
||||
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
|
||||
);
|
||||
}
|
||||
if (!$rule->isEnabled()) {
|
||||
$rule->setEnabled(true);
|
||||
$this->store->depositRule($rule);
|
||||
$this->cache->invalidate();
|
||||
$this->publishLifecycleEvent(
|
||||
FirewallRuleEnabledEvent::class,
|
||||
$rule,
|
||||
$actorId,
|
||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||
);
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function extendManual(
|
||||
FirewallRuleScope $scope,
|
||||
string $ruleId,
|
||||
int $durationSeconds,
|
||||
string $reason,
|
||||
?string $actorId
|
||||
): ?FirewallRuleObject {
|
||||
$reason = self::manualReason($reason);
|
||||
FirewallRuleValidator::duration($durationSeconds);
|
||||
$rule = $this->ownedRule($scope, $ruleId);
|
||||
if (!$rule) {
|
||||
return null;
|
||||
}
|
||||
$previousExpiry = $rule->getExpiresAt();
|
||||
if ($previousExpiry === null) {
|
||||
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
|
||||
}
|
||||
$now = new \DateTimeImmutable();
|
||||
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
|
||||
->modify("+{$durationSeconds} seconds");
|
||||
$metadata = $rule->getMetadata() ?? [];
|
||||
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
|
||||
$extensions[] = [
|
||||
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
|
||||
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
|
||||
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
|
||||
'origin' => self::ORIGIN_MANUAL,
|
||||
'actorId' => $actorId,
|
||||
'reason' => $reason,
|
||||
];
|
||||
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
|
||||
$this->store->depositRule($rule);
|
||||
$this->cache->invalidate();
|
||||
$this->publishLifecycleEvent(
|
||||
FirewallRuleExtendedEvent::class,
|
||||
$rule,
|
||||
$actorId,
|
||||
[
|
||||
'changeReason' => $reason,
|
||||
'changeOrigin' => self::ORIGIN_MANUAL,
|
||||
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
|
||||
]
|
||||
);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
public function removeManual(
|
||||
FirewallRuleScope $scope,
|
||||
string $ruleId,
|
||||
string $reason,
|
||||
?string $actorId
|
||||
): ?FirewallRuleObject {
|
||||
$reason = self::manualReason($reason);
|
||||
$rule = $this->ownedRule($scope, $ruleId);
|
||||
if (!$rule) {
|
||||
return null;
|
||||
}
|
||||
$this->store->destroyRule($rule);
|
||||
$this->cache->invalidate();
|
||||
$this->publishLifecycleEvent(
|
||||
FirewallRuleRemovedEvent::class,
|
||||
$rule,
|
||||
$actorId,
|
||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||
);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
private static function manualReason(string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($reason === '' || strlen($reason) > 1000) {
|
||||
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
|
||||
}
|
||||
|
||||
return $reason;
|
||||
}
|
||||
|
||||
private function create(
|
||||
FirewallRuleScope $scope,
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?string $createdBy,
|
||||
?int $durationSeconds = null,
|
||||
string $origin = self::ORIGIN_MANUAL,
|
||||
array $metadata = []
|
||||
): FirewallRuleObject {
|
||||
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
|
||||
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
|
||||
}
|
||||
|
||||
$rule = (new FirewallRuleObject())
|
||||
->setScope($scope->scope)
|
||||
->setTenantId($scope->tenantId)
|
||||
->setType($type)
|
||||
->setAction($action)
|
||||
->setValue($value)
|
||||
->setReason($reason)
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
|
||||
}
|
||||
$metadata = [...$metadata, 'origin' => $origin];
|
||||
if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) {
|
||||
$metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM);
|
||||
$metadata['extensions'] = [];
|
||||
}
|
||||
$rule->setMetadata($metadata);
|
||||
|
||||
$rule = $this->store->depositRule($rule)
|
||||
?? throw new \RuntimeException('Failed to persist firewall rule.');
|
||||
$this->cache->invalidate();
|
||||
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
private function extendAutomaticBlock(
|
||||
FirewallRuleObject $rule,
|
||||
int $durationSeconds,
|
||||
array $policy
|
||||
): FirewallRuleObject {
|
||||
$now = new \DateTimeImmutable();
|
||||
$previousExpiry = $rule->getExpiresAt();
|
||||
$newExpiry = $now->modify("+{$durationSeconds} seconds");
|
||||
if ($previousExpiry !== null && $newExpiry <= $previousExpiry) {
|
||||
return $rule;
|
||||
}
|
||||
|
||||
$metadata = $rule->getMetadata() ?? [];
|
||||
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
|
||||
$extensions[] = [
|
||||
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
|
||||
'previousExpiresAt' => $previousExpiry?->format(\DateTimeInterface::ATOM),
|
||||
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
|
||||
'failureCount' => $policy['lastFailureCount'] ?? null,
|
||||
];
|
||||
$rule->setExpiresAt($newExpiry)->setMetadata([
|
||||
...$metadata,
|
||||
...$policy,
|
||||
'origin' => self::ORIGIN_AUTOMATIC,
|
||||
'originalExpiresAt' => $metadata['originalExpiresAt']
|
||||
?? $previousExpiry?->format(\DateTimeInterface::ATOM),
|
||||
'extensions' => $extensions,
|
||||
'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->cache->invalidate();
|
||||
$this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
|
||||
return $rule && $scope->owns($rule) ? $rule : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<FirewallRuleEvent> $eventClass
|
||||
*/
|
||||
private function publishLifecycleEvent(
|
||||
string $eventClass,
|
||||
FirewallRuleObject $rule,
|
||||
?string $actorId = null,
|
||||
array $change = []
|
||||
): void
|
||||
{
|
||||
$event = $eventClass::fromRule($rule, $actorId, $change);
|
||||
$this->events->dispatch($event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
|
||||
final class FirewallRuleScope
|
||||
{
|
||||
private function __construct(
|
||||
public readonly string $scope,
|
||||
public readonly ?string $tenantId,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function tenant(string $tenantId): self
|
||||
{
|
||||
if ($tenantId === '') {
|
||||
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
|
||||
}
|
||||
|
||||
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
|
||||
}
|
||||
|
||||
public static function system(): self
|
||||
{
|
||||
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
|
||||
}
|
||||
|
||||
public function owns(FirewallRuleObject $rule): bool
|
||||
{
|
||||
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
final class FirewallRuleValidator
|
||||
{
|
||||
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function ipAddress(string $ipAddress): string
|
||||
{
|
||||
$ipAddress = trim($ipAddress);
|
||||
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
|
||||
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
|
||||
}
|
||||
|
||||
return $ipAddress;
|
||||
}
|
||||
|
||||
public static function cidr(string $cidr): string
|
||||
{
|
||||
$cidr = trim($cidr);
|
||||
if (substr_count($cidr, '/') !== 1) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
[$address, $prefix] = explode('/', $cidr, 2);
|
||||
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
|
||||
if ((int)$prefix > $maximumPrefix) {
|
||||
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
|
||||
}
|
||||
|
||||
return $cidr;
|
||||
}
|
||||
|
||||
public static function deviceFingerprint(string $fingerprint): string
|
||||
{
|
||||
$fingerprint = trim($fingerprint);
|
||||
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
return $fingerprint;
|
||||
}
|
||||
|
||||
public static function duration(?int $durationSeconds): void
|
||||
{
|
||||
if ($durationSeconds !== null && $durationSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,26 @@ declare(strict_types=1);
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Request\RequestContext;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
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\IpUtils;
|
||||
|
||||
/**
|
||||
@@ -29,6 +43,9 @@ class FirewallService
|
||||
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
||||
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
||||
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
|
||||
private const MAX_AUTH_FAILURES = 1000;
|
||||
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
|
||||
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
|
||||
|
||||
// Configuration keys
|
||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||
@@ -36,38 +53,14 @@ class FirewallService
|
||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||
|
||||
/** @var FirewallRuleObject[]|null */
|
||||
private ?array $rulesCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly EventBus $eventBus
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly FirewallRuleCache $ruleCache,
|
||||
private readonly RequestContext $requestContext,
|
||||
) {
|
||||
// Listen for auth failures to detect brute force
|
||||
$this->eventBus->subscribe(
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
[$this, 'handleAuthFailure'],
|
||||
100 // High priority
|
||||
);
|
||||
|
||||
// Log all security events asynchronously
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::ACCESS_DENIED,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,36 +88,33 @@ class FirewallService
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null
|
||||
): FirewallAnalyzeResult {
|
||||
// Check if firewall is enabled for this tenant
|
||||
if (!$this->isEnabled()) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$ruleGroups = [
|
||||
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK],
|
||||
];
|
||||
|
||||
if ($tenantId !== null && $this->isEnabled()) {
|
||||
$tenantRules = $this->ruleCache->tenant($tenantId);
|
||||
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
|
||||
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
|
||||
}
|
||||
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW];
|
||||
|
||||
$rules = $this->getActiveRules();
|
||||
foreach ($ruleGroups as [$rules, $action]) {
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== $action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First check for explicit allow rules (whitelist takes precedence)
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||
}
|
||||
}
|
||||
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($action === FirewallRuleObject::ACTION_ALLOW) {
|
||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||
}
|
||||
|
||||
// Then check for block rules
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
|
||||
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
||||
}
|
||||
@@ -155,23 +145,31 @@ class FirewallService
|
||||
/**
|
||||
* Handle authentication failure event
|
||||
*/
|
||||
public function handleAuthFailure(SecurityEvent $event): void
|
||||
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
||||
{
|
||||
$ipAddress = $event->getIpAddress();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
$request = $this->requestContext->current();
|
||||
$ipAddress = $request?->getClientIp();
|
||||
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||
|
||||
if (!$ipAddress || !$tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log = $this->securityLog($event, $request);
|
||||
if ($log === null || !$this->store->createLogOnce($log)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for brute force
|
||||
$windowSeconds = $this->getConfig(
|
||||
$windowSeconds = $this->getBoundedIntegerConfig(
|
||||
self::CONFIG_FAILURE_WINDOW,
|
||||
self::DEFAULT_AUTH_FAILURE_WINDOW
|
||||
self::DEFAULT_AUTH_FAILURE_WINDOW,
|
||||
self::MAX_AUTH_FAILURE_WINDOW
|
||||
);
|
||||
$maxFailures = $this->getConfig(
|
||||
$maxFailures = $this->getBoundedIntegerConfig(
|
||||
self::CONFIG_MAX_FAILURES,
|
||||
self::DEFAULT_MAX_AUTH_FAILURES
|
||||
self::DEFAULT_MAX_AUTH_FAILURES,
|
||||
self::MAX_AUTH_FAILURES
|
||||
);
|
||||
|
||||
$failureCount = $this->store->countRecentFailures(
|
||||
@@ -180,11 +178,24 @@ class FirewallService
|
||||
$windowSeconds
|
||||
);
|
||||
|
||||
// Include current failure in count
|
||||
$failureCount++;
|
||||
|
||||
if ($failureCount >= $maxFailures) {
|
||||
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
|
||||
$blockDuration = $this->getBoundedIntegerConfig(
|
||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||
self::DEFAULT_AUTO_BLOCK_DURATION,
|
||||
self::MAX_AUTO_BLOCK_DURATION
|
||||
);
|
||||
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
|
||||
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handleBruteForce(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
$failureCount,
|
||||
$windowSeconds,
|
||||
$blockDuration
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,53 +203,91 @@ class FirewallService
|
||||
* Handle detected brute force attack
|
||||
*/
|
||||
private function handleBruteForce(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
int $failureCount,
|
||||
int $windowSeconds
|
||||
int $windowSeconds,
|
||||
int $blockDuration
|
||||
): void {
|
||||
// Publish brute force event
|
||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
// Auto-block the IP
|
||||
$blockDuration = $this->getConfig(
|
||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||
self::DEFAULT_AUTO_BLOCK_DURATION
|
||||
$event = new BruteForceDetectedEvent(
|
||||
$ipAddress,
|
||||
$failureCount,
|
||||
$windowSeconds,
|
||||
$tenantId,
|
||||
);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
$this->blockIp(
|
||||
$this->rules->blockIp(
|
||||
FirewallRuleScope::tenant($tenantId),
|
||||
$ipAddress,
|
||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||
null, // System-created
|
||||
$blockDuration
|
||||
$blockDuration,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => $this->getBoundedIntegerConfig(
|
||||
self::CONFIG_MAX_FAILURES,
|
||||
self::DEFAULT_MAX_AUTH_FAILURES,
|
||||
self::MAX_AUTH_FAILURES
|
||||
),
|
||||
'failureWindowSeconds' => $windowSeconds,
|
||||
'lastFailureCount' => $failureCount,
|
||||
'blockDurationSeconds' => $blockDuration,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event to firewall logs
|
||||
*/
|
||||
public function logSecurityEvent(SecurityEvent $event): void
|
||||
public function logSecurityEvent(SecurityEventInterface $event): void
|
||||
{
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return;
|
||||
$log = $this->securityLog($event);
|
||||
if ($log !== null) {
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
}
|
||||
|
||||
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
|
||||
{
|
||||
$log = $this->securityLog($event, $this->requestContext->current());
|
||||
if ($log !== null) {
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
}
|
||||
|
||||
private function securityLog(
|
||||
SecurityEventInterface $event,
|
||||
?Request $request = null,
|
||||
): ?FirewallLogObject
|
||||
{
|
||||
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||
$ruleScope = $event->get('ruleScope');
|
||||
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$log = new FirewallLogObject();
|
||||
$log->setTenantId($tenantId)
|
||||
->setIpAddress($event->getIpAddress())
|
||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
||||
->setUserAgent($event->getUserAgent())
|
||||
->setRequestPath($event->getRequestPath())
|
||||
->setRequestMethod($event->getRequestMethod())
|
||||
->setEventType($this->mapEventToLogType($event->getName()))
|
||||
->setResult($this->mapEventToResult($event->getName()))
|
||||
->setIdentityId($event->getUserId())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->getData());
|
||||
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
|
||||
|
||||
$this->store->createLog($log);
|
||||
$log = new FirewallLogObject();
|
||||
return $log->setEventId($event->identifier())
|
||||
->setTenantId($tenantId)
|
||||
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
|
||||
->setDeviceFingerprint(
|
||||
$request?->headers->get('X-Device-Fingerprint')
|
||||
?? $requestEvent?->getDeviceFingerprint()
|
||||
)
|
||||
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
|
||||
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
|
||||
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
|
||||
->setEventType($this->mapEventToLogType($event->label()))
|
||||
->setResult($this->mapEventToResult($event))
|
||||
->setRuleId($event->get('ruleId'))
|
||||
->setRuleScope($ruleScope)
|
||||
->setIdentityId($event->getUserId() ?? $event->actorIdentity())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->context());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,12 +296,18 @@ class FirewallService
|
||||
private function mapEventToLogType(string $eventName): string
|
||||
{
|
||||
return match ($eventName) {
|
||||
SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
|
||||
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
|
||||
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
|
||||
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
|
||||
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
};
|
||||
}
|
||||
@@ -260,11 +315,16 @@ class FirewallService
|
||||
/**
|
||||
* Map security event to result
|
||||
*/
|
||||
private function mapEventToResult(string $eventName): string
|
||||
private function mapEventToResult(SecurityEventInterface $event): string
|
||||
{
|
||||
return match ($eventName) {
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||
return match ($event->label()) {
|
||||
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
|
||||
FirewallRuleCreatedEvent::class,
|
||||
FirewallRuleExtendedEvent::class,
|
||||
FirewallRuleEnabledEvent::class,
|
||||
FirewallRuleDisabledEvent::class,
|
||||
FirewallRuleRemovedEvent::class,
|
||||
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
|
||||
default => FirewallLogObject::RESULT_BLOCKED,
|
||||
};
|
||||
}
|
||||
@@ -277,272 +337,17 @@ class FirewallService
|
||||
?string $deviceFingerprint,
|
||||
FirewallRuleObject $rule
|
||||
): void {
|
||||
$event = SecurityEvent::accessDenied(
|
||||
$ipAddress,
|
||||
$deviceFingerprint,
|
||||
$rule->getId(),
|
||||
$rule->getReason()
|
||||
$event = new AccessDeniedEvent(
|
||||
ipAddress: $ipAddress,
|
||||
ruleId: $rule->getId(),
|
||||
ruleScope: $rule->getScope(),
|
||||
deviceFingerprint: $deviceFingerprint,
|
||||
reason: $rule->getReason(),
|
||||
tenantId: $this->tenantContext->identifier(),
|
||||
);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($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->tenant->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->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow an IP address (whitelist)
|
||||
*/
|
||||
public function allowIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->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->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP range (CIDR notation)
|
||||
*/
|
||||
public function blockIpRange(
|
||||
string $cidr,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->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->tenant->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->eventBus->publish($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->tenant->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->tenant->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->tenant->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->tenant->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->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->store->countBlockedRequests($tenantId, $since);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Check if firewall is enabled for current tenant
|
||||
*/
|
||||
@@ -556,7 +361,10 @@ class FirewallService
|
||||
*/
|
||||
private function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$config = $this->tenant->configuration();
|
||||
$config = $this->tenantContext->configuration();
|
||||
if ($config instanceof \JsonSerializable) {
|
||||
$config = $config->jsonSerialize();
|
||||
}
|
||||
$parts = explode('.', $key);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
@@ -569,27 +377,14 @@ class FirewallService
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active rules (cached)
|
||||
* @return FirewallRuleObject[]
|
||||
*/
|
||||
private function getActiveRules(): array
|
||||
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int
|
||||
{
|
||||
if ($this->rulesCache === null) {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$this->rulesCache = $tenantId
|
||||
? $this->store->listRules($tenantId, true)
|
||||
: [];
|
||||
$value = $this->getConfig($key, $default);
|
||||
if (!is_int($value) || $value < 1 || $value > $maximum) {
|
||||
return $default;
|
||||
}
|
||||
return $this->rulesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rules cache
|
||||
*/
|
||||
private function clearRulesCache(): void
|
||||
{
|
||||
$this->rulesCache = null;
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -597,13 +392,31 @@ class FirewallService
|
||||
*/
|
||||
public function cleanup(): array
|
||||
{
|
||||
$expiredRules = $this->store->cleanupExpiredRules();
|
||||
$oldLogs = $this->store->cleanupOldLogs(30);
|
||||
$startedAt = new \DateTimeImmutable();
|
||||
|
||||
return [
|
||||
'expiredRules' => $expiredRules,
|
||||
'oldLogs' => $oldLogs,
|
||||
];
|
||||
try {
|
||||
$result = [
|
||||
'expiredRules' => $this->store->cleanupExpiredRules(),
|
||||
'oldLogs' => $this->store->cleanupOldLogs(30),
|
||||
'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(),
|
||||
];
|
||||
$this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $error) {
|
||||
try {
|
||||
$this->store->recordMaintenanceStatus(
|
||||
$startedAt,
|
||||
new \DateTimeImmutable(),
|
||||
'failed',
|
||||
[],
|
||||
$error->getMessage()
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Preserve the cleanup failure when the status store is also unavailable.
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||
|
||||
final class FirewallSettingsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenants,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function update(
|
||||
string $tenantId,
|
||||
bool $enabled,
|
||||
int $maxAuthFailures,
|
||||
int $authFailureWindow,
|
||||
int $autoBlockDuration,
|
||||
string $reason,
|
||||
?string $actorId
|
||||
): ?array {
|
||||
$reason = trim($reason);
|
||||
if ($reason === '' || strlen($reason) > 1000) {
|
||||
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
|
||||
}
|
||||
self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures');
|
||||
self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window');
|
||||
self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration');
|
||||
|
||||
$tenant = $this->tenants->fetchById($tenantId);
|
||||
if ($tenant === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previous = $tenant->getConfiguration()->firewall()->jsonSerialize();
|
||||
$current = [
|
||||
'enabled' => $enabled,
|
||||
'maxAuthFailures' => $maxAuthFailures,
|
||||
'authFailureWindow' => $authFailureWindow,
|
||||
'autoBlockDuration' => $autoBlockDuration,
|
||||
];
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
...$tenant->getConfiguration()->jsonSerialize(),
|
||||
'firewall' => $current,
|
||||
]);
|
||||
$tenant->setConfiguration($configuration);
|
||||
$this->tenants->deposit($tenant);
|
||||
|
||||
$event = new FirewallSettingsUpdatedEvent(
|
||||
changeReason: $reason,
|
||||
previous: $previous,
|
||||
current: $current,
|
||||
tenantId: $tenantId,
|
||||
actorId: $actorId,
|
||||
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
|
||||
);
|
||||
$this->events->dispatch($event);
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
private static function bounded(int $value, int $minimum, int $maximum, string $label): void
|
||||
{
|
||||
if ($value < $minimum || $value > $maximum) {
|
||||
throw new \InvalidArgumentException(
|
||||
"{$label} must be between {$minimum} and {$maximum}."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Stores\FirewallStore;
|
||||
|
||||
final class FirewallStatusService
|
||||
{
|
||||
public function __construct(private readonly FirewallStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
public function tenantMetrics(string $tenantId, ?string $since = null): array
|
||||
{
|
||||
$sinceDate = self::date($since);
|
||||
return [
|
||||
'tenantId' => $tenantId,
|
||||
'blockedRequests' => $this->store->countBlockedRequests($tenantId, $sinceDate),
|
||||
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
public function systemMetrics(?string $tenantId = null, ?string $since = null): array
|
||||
{
|
||||
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
|
||||
throw new \InvalidArgumentException('Invalid tenant filter.');
|
||||
}
|
||||
$sinceDate = self::date($since);
|
||||
return [
|
||||
'tenantId' => $tenantId,
|
||||
'blockedRequests' => $this->store->countSystemBlockedRequests($tenantId, $sinceDate),
|
||||
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
public function maintenanceStatus(): array
|
||||
{
|
||||
return $this->store->maintenanceStatus() ?? [
|
||||
'status' => 'never_run',
|
||||
'startedAt' => null,
|
||||
'completedAt' => null,
|
||||
'result' => null,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function date(?string $value): ?\DateTimeImmutable
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
if ($value === '' || strlen($value) > 255) {
|
||||
throw new \InvalidArgumentException('Invalid since date filter.');
|
||||
}
|
||||
try {
|
||||
return new \DateTimeImmutable($value);
|
||||
} catch (\Exception) {
|
||||
throw new \InvalidArgumentException('Invalid since date filter.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace KTXC\Service;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||
|
||||
/**
|
||||
@@ -23,12 +23,12 @@ class SecurityService
|
||||
private string $securityCode;
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly ProviderManager $providerManager,
|
||||
) {
|
||||
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
|
||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +118,7 @@ class SecurityService
|
||||
continue;
|
||||
}
|
||||
$context = new \KTXF\Security\Authentication\ProviderContext(
|
||||
tenantId: $this->sessionTenant->identifier(),
|
||||
tenantId: $this->tenantContext->identifier(),
|
||||
userIdentity: $identity,
|
||||
);
|
||||
$result = $provider->verify($context, $credentials);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
|
||||
final class SystemFirewallLogService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.system.logs.read';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallLogService $logs,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function query(?string $tenantId, array $filters, int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
|
||||
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
|
||||
}
|
||||
return $this->logs->system($tenantId, $filters, $limit, $offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
|
||||
final class SystemFirewallRuleService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.system.rules.read';
|
||||
public const PERMISSION_MANAGE = 'firewall.system.rules.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->list(FirewallRuleScope::system(), $activeOnly);
|
||||
}
|
||||
|
||||
public function queryRules(
|
||||
string $status = 'active',
|
||||
?string $type = null,
|
||||
?string $action = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0
|
||||
): array {
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->query(FirewallRuleScope::system(), $status, $type, $action, $limit, $offset);
|
||||
}
|
||||
|
||||
public function fetchRule(string $ruleId): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->fetch(FirewallRuleScope::system(), $ruleId);
|
||||
}
|
||||
|
||||
public function createRule(
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?int $durationSeconds = null,
|
||||
?string $currentIp = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->createManualRule(
|
||||
FirewallRuleScope::system(),
|
||||
$type,
|
||||
$action,
|
||||
$value,
|
||||
$reason,
|
||||
$this->identity->identifier(),
|
||||
$durationSeconds,
|
||||
$currentIp,
|
||||
$confirmCurrentIp
|
||||
);
|
||||
}
|
||||
|
||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIp(
|
||||
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->allowIp(
|
||||
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIpRange(
|
||||
FirewallRuleScope::system(), $cidr, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockDevice(
|
||||
FirewallRuleScope::system(),
|
||||
$fingerprint,
|
||||
$reason,
|
||||
$this->identity->identifier(),
|
||||
$durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->disableManual(
|
||||
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function enableRule(
|
||||
string $ruleId,
|
||||
string $reason,
|
||||
?string $currentIp = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): ?FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->enableManual(
|
||||
FirewallRuleScope::system(),
|
||||
$ruleId,
|
||||
$reason,
|
||||
$this->identity->identifier(),
|
||||
$currentIp,
|
||||
$confirmCurrentIp
|
||||
);
|
||||
}
|
||||
|
||||
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->extendManual(
|
||||
FirewallRuleScope::system(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->removeManual(
|
||||
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
|
||||
final class SystemFirewallStatusService
|
||||
{
|
||||
public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'firewall.system.settings.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStatusService $status,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
private readonly FirewallSettingsService $settings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function metrics(?string $tenantId = null, ?string $since = null): array
|
||||
{
|
||||
$this->requirePermission(SystemFirewallLogService::PERMISSION_READ);
|
||||
return $this->status->systemMetrics($tenantId, $since);
|
||||
}
|
||||
|
||||
public function maintenanceStatus(): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MAINTENANCE_READ);
|
||||
return $this->status->maintenanceStatus();
|
||||
}
|
||||
|
||||
public function updateTenantConfiguration(
|
||||
string $tenantId,
|
||||
bool $enabled,
|
||||
int $maxAuthFailures,
|
||||
int $authFailureWindow,
|
||||
int $autoBlockDuration,
|
||||
string $reason
|
||||
): ?array {
|
||||
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
|
||||
return $this->settings->update(
|
||||
$tenantId,
|
||||
$enabled,
|
||||
$maxAuthFailures,
|
||||
$authFailureWindow,
|
||||
$autoBlockDuration,
|
||||
$reason,
|
||||
$this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
|
||||
final class TenantFirewallLogService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.tenant.logs.read';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallLogService $logs,
|
||||
private readonly TenantContextInterface $tenant,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function query(array $filters, int $limit = 50, int $offset = 0): array
|
||||
{
|
||||
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
|
||||
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
|
||||
}
|
||||
return $this->logs->tenant($this->tenant->requireIdentifier(), $filters, $limit, $offset);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
|
||||
final class TenantFirewallRuleService
|
||||
{
|
||||
public const PERMISSION_READ = 'firewall.tenant.rules.read';
|
||||
public const PERMISSION_MANAGE = 'firewall.tenant.rules.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallRuleManager $rules,
|
||||
private readonly TenantContextInterface $tenant,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->list($this->scope(), $activeOnly);
|
||||
}
|
||||
|
||||
public function queryRules(
|
||||
string $status = 'active',
|
||||
?string $type = null,
|
||||
?string $action = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0
|
||||
): array {
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->query($this->scope(), $status, $type, $action, $limit, $offset);
|
||||
}
|
||||
|
||||
public function fetchRule(string $ruleId): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->fetch($this->scope(), $ruleId);
|
||||
}
|
||||
|
||||
public function createRule(
|
||||
string $type,
|
||||
string $action,
|
||||
string $value,
|
||||
string $reason,
|
||||
?int $durationSeconds = null,
|
||||
?string $currentIp = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->createManualRule(
|
||||
$this->scope(),
|
||||
$type,
|
||||
$action,
|
||||
$value,
|
||||
$reason,
|
||||
$this->identity->identifier(),
|
||||
$durationSeconds,
|
||||
$currentIp,
|
||||
$confirmCurrentIp
|
||||
);
|
||||
}
|
||||
|
||||
public function effectivePolicy(): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_READ);
|
||||
return $this->rules->effectivePolicy($this->tenant->requireIdentifier());
|
||||
}
|
||||
|
||||
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIp(
|
||||
$this->scope(), $ip, $reason, $this->identity->identifier(), $durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->allowIp($this->scope(), $ip, $reason, $this->identity->identifier());
|
||||
}
|
||||
|
||||
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockIpRange($this->scope(), $cidr, $reason, $this->identity->identifier());
|
||||
}
|
||||
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->blockDevice(
|
||||
$this->scope(), $fingerprint, $reason, $this->identity->identifier(), $durationSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
|
||||
}
|
||||
|
||||
public function enableRule(
|
||||
string $ruleId,
|
||||
string $reason,
|
||||
?string $currentIp = null,
|
||||
bool $confirmCurrentIp = false
|
||||
): ?FirewallRuleObject {
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->enableManual(
|
||||
$this->scope(),
|
||||
$ruleId,
|
||||
$reason,
|
||||
$this->identity->identifier(),
|
||||
$currentIp,
|
||||
$confirmCurrentIp
|
||||
);
|
||||
}
|
||||
|
||||
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->extendManual(
|
||||
$this->scope(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_MANAGE);
|
||||
return $this->rules->removeManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
|
||||
}
|
||||
|
||||
private function scope(): FirewallRuleScope
|
||||
{
|
||||
return FirewallRuleScope::tenant($this->tenant->requireIdentifier());
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
|
||||
final class TenantFirewallStatusService
|
||||
{
|
||||
public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'firewall.tenant.settings.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStatusService $status,
|
||||
private readonly TenantContextInterface $tenant,
|
||||
private readonly IdentityContextInterface $identity,
|
||||
private readonly FirewallSettingsService $settings,
|
||||
) {
|
||||
}
|
||||
|
||||
public function metrics(?string $since = null): array
|
||||
{
|
||||
$this->requirePermission(TenantFirewallLogService::PERMISSION_READ);
|
||||
return $this->status->tenantMetrics($this->tenant->requireIdentifier(), $since);
|
||||
}
|
||||
|
||||
public function configuration(): array
|
||||
{
|
||||
$this->requirePermission(self::PERMISSION_SETTINGS_READ);
|
||||
return $this->tenant->configuration()?->firewall()->jsonSerialize()
|
||||
?? [
|
||||
'enabled' => true,
|
||||
'maxAuthFailures' => 5,
|
||||
'authFailureWindow' => 300,
|
||||
'autoBlockDuration' => 3600,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateConfiguration(
|
||||
bool $enabled,
|
||||
int $maxAuthFailures,
|
||||
int $authFailureWindow,
|
||||
int $autoBlockDuration,
|
||||
string $reason
|
||||
): ?array {
|
||||
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
|
||||
return $this->settings->update(
|
||||
$this->tenant->requireIdentifier(),
|
||||
$enabled,
|
||||
$maxAuthFailures,
|
||||
$authFailureWindow,
|
||||
$autoBlockDuration,
|
||||
$reason,
|
||||
$this->identity->identifier()
|
||||
);
|
||||
}
|
||||
|
||||
private function requirePermission(string $permission): void
|
||||
{
|
||||
if (!$this->identity->hasPermission($permission)) {
|
||||
throw new \RuntimeException("Missing required permission: {$permission}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
|
||||
@@ -26,7 +26,7 @@ class TokenService
|
||||
private string $algorithm = 'HS256';
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EphemeralCacheInterface $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
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
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsStore $userStore
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -26,7 +31,7 @@ class UserAccountsService
|
||||
*/
|
||||
public function listUsers(array $filters = []): array
|
||||
{
|
||||
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters);
|
||||
$users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters);
|
||||
|
||||
// Remove sensitive data
|
||||
foreach ($users as &$user) {
|
||||
@@ -38,7 +43,7 @@ class UserAccountsService
|
||||
|
||||
public function fetchByIdentity(string $identifier): User | null
|
||||
{
|
||||
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
$data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
@@ -50,32 +55,68 @@ class UserAccountsService
|
||||
|
||||
public function fetchByIdentifier(string $identifier): array | null
|
||||
{
|
||||
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
|
||||
return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier);
|
||||
}
|
||||
|
||||
public function fetchByIdentityRaw(string $identifier): array | null
|
||||
{
|
||||
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
|
||||
}
|
||||
|
||||
public function fetchByProviderSubject(string $provider, string $subject): ?array
|
||||
{
|
||||
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
|
||||
return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject);
|
||||
}
|
||||
|
||||
public function createUser(array $userData): array
|
||||
{
|
||||
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
|
||||
$tenantId = $this->tenantContext->requireIdentifier();
|
||||
$user = $this->userStore->createUser($tenantId, $userData);
|
||||
$this->events->dispatch(UserCreatedEvent::fromUser(
|
||||
$user,
|
||||
$tenantId,
|
||||
$this->identityContext->identifier(),
|
||||
));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function updateUser(string $uid, array $updates): bool
|
||||
public function updateUser(string $userId, array $updates): bool
|
||||
{
|
||||
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
|
||||
$tenantId = $this->tenantContext->requireIdentifier();
|
||||
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 $uid): bool
|
||||
public function deleteUser(string $userId): bool
|
||||
{
|
||||
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
|
||||
$tenantId = $this->tenantContext->requireIdentifier();
|
||||
$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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -84,7 +125,7 @@ class UserAccountsService
|
||||
|
||||
public function fetchProfile(string $uid): ?array
|
||||
{
|
||||
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
|
||||
return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid);
|
||||
}
|
||||
|
||||
public function storeProfile(string $uid, array $profileFields): bool
|
||||
@@ -109,7 +150,7 @@ class UserAccountsService
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
|
||||
return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -118,18 +159,14 @@ class UserAccountsService
|
||||
|
||||
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
|
||||
{
|
||||
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten);
|
||||
return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten);
|
||||
}
|
||||
|
||||
public function storeSettings(array $settings): bool
|
||||
{
|
||||
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->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
|
||||
*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
|
||||
class UserRolesService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly UserRolesStore $roleStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
@@ -26,7 +26,7 @@ class UserRolesService
|
||||
*/
|
||||
public function listRoles(): array
|
||||
{
|
||||
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
|
||||
return $this->roleStore->listRoles($this->tenantContext->identifier());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ class UserRolesService
|
||||
*/
|
||||
public function getRole(string $rid): ?array
|
||||
{
|
||||
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
|
||||
return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,11 +45,11 @@ class UserRolesService
|
||||
$this->validateRoleData($roleData);
|
||||
|
||||
$this->logger->info('Creating role', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'label' => $roleData['label'] ?? 'Unnamed'
|
||||
]);
|
||||
|
||||
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
|
||||
return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,11 +70,11 @@ class UserRolesService
|
||||
$this->validateRoleData($updates, false);
|
||||
|
||||
$this->logger->info('Updating role', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'rid' => $rid
|
||||
]);
|
||||
|
||||
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
|
||||
return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,17 +93,17 @@ class UserRolesService
|
||||
}
|
||||
|
||||
// Check if role is assigned to users
|
||||
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||
$userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
|
||||
if ($userCount > 0) {
|
||||
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
|
||||
}
|
||||
|
||||
$this->logger->info('Deleting role', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'rid' => $rid
|
||||
]);
|
||||
|
||||
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
|
||||
return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +111,7 @@ class UserRolesService
|
||||
*/
|
||||
public function getRoleUserCount(string $rid): int
|
||||
{
|
||||
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||
return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
|
||||
class SessionIdentity
|
||||
{
|
||||
private bool $identityLock = false;
|
||||
private ?User $identityData = null;
|
||||
|
||||
public function initialize(User $identity, bool $lock = true): void
|
||||
{
|
||||
if ($this->identityLock) {
|
||||
throw new \RuntimeException('Identity is already locked and cannot be changed.');
|
||||
}
|
||||
|
||||
$this->identityData = $identity;
|
||||
$this->identityLock = $lock;
|
||||
}
|
||||
|
||||
public function identity(): ?User
|
||||
{
|
||||
return $this->identityData;
|
||||
}
|
||||
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->identityData?->getId();
|
||||
}
|
||||
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->identityData?->getLabel();
|
||||
}
|
||||
|
||||
public function mailAddress(): ?string
|
||||
{
|
||||
return $this->identityData?->getIdentity();
|
||||
}
|
||||
|
||||
public function nameFirst(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function nameLast(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return $this->identityData?->getPermissions() ?? [];
|
||||
}
|
||||
|
||||
public function roles(): array
|
||||
{
|
||||
return $this->identityData?->getRoles() ?? [];
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
$permissions = $this->permissions();
|
||||
|
||||
// Exact match
|
||||
if (in_array($permission, $permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match
|
||||
foreach ($permissions as $userPerm) {
|
||||
if (str_ends_with($userPerm, '.*')) {
|
||||
$prefix = substr($userPerm, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full wildcard
|
||||
if (in_array('*', $permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roles());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
|
||||
class SessionTenant
|
||||
{
|
||||
private ?TenantObject $tenant = null;
|
||||
private ?string $domain = null;
|
||||
private bool $configured = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Configure the tenant information
|
||||
* This method is called by the SecurityMiddleware after validation
|
||||
*/
|
||||
public function configure(string $domain): void
|
||||
{
|
||||
if ($this->configured) {
|
||||
return;
|
||||
}
|
||||
$tenant = $this->tenantService->fetchByDomain($domain);
|
||||
if ($tenant) {
|
||||
$this->domain = $domain;
|
||||
$this->tenant = $tenant;
|
||||
$this->configured = true;
|
||||
} else {
|
||||
$this->domain = null;
|
||||
$this->tenant = null;
|
||||
$this->configured = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the tenant by its identifier (for console / CLI usage).
|
||||
*/
|
||||
public function configureById(string $identifier): void
|
||||
{
|
||||
if ($this->configured) {
|
||||
return;
|
||||
}
|
||||
$tenant = $this->tenantService->fetchById($identifier);
|
||||
if ($tenant) {
|
||||
$this->domain = $identifier;
|
||||
$this->tenant = $tenant;
|
||||
$this->configured = true;
|
||||
} else {
|
||||
$this->domain = null;
|
||||
$this->tenant = null;
|
||||
$this->configured = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tenant configured
|
||||
*/
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tenant enabled
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->tenant?->getEnabled() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant domain
|
||||
*/
|
||||
public function domain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant identifier
|
||||
*/
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->tenant?->getIdentifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant label
|
||||
*/
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->tenant?->getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant configuration
|
||||
*/
|
||||
public function configuration(): TenantConfiguration
|
||||
{
|
||||
return $this->tenant?->getConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant settings
|
||||
*/
|
||||
public function settings(): array
|
||||
{
|
||||
return $this->tenant?->getSettings() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all identity providers configuration for this tenant
|
||||
* @return array<string, array> Map of provider ID to provider config
|
||||
*/
|
||||
public function identityProviders(): array
|
||||
{
|
||||
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for a specific identity provider
|
||||
*
|
||||
* @param string $providerId Provider identifier (e.g., 'default', 'oidc')
|
||||
* @return array|null Provider configuration or null if not found
|
||||
*/
|
||||
public function identityProviderConfig(string $providerId): ?array
|
||||
{
|
||||
$providers = $this->identityProviders();
|
||||
return $providers[$providerId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identity provider is enabled for this tenant
|
||||
*/
|
||||
public function isIdentityProviderEnabled(string $providerId): bool
|
||||
{
|
||||
$config = $this->identityProviderConfig($providerId);
|
||||
return $config !== null && ($config['enabled'] ?? false);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\ObjectId;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
|
||||
@@ -15,28 +17,147 @@ class FirewallStore
|
||||
{
|
||||
protected const RULES_COLLECTION = 'firewall_rules';
|
||||
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
||||
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Install the indexes used by firewall enforcement, audit queries, and expiry.
|
||||
*
|
||||
* MongoDB createIndex is idempotent when the name and specification match.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function ensureIndexes(): array
|
||||
{
|
||||
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
|
||||
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
|
||||
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
||||
|
||||
return [
|
||||
$rules->createIndex(
|
||||
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
|
||||
['name' => 'rules_by_scope_tenant_active']
|
||||
),
|
||||
$rules->createIndex(
|
||||
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
|
||||
['name' => 'rules_exact_lookup']
|
||||
),
|
||||
$rules->createIndex(
|
||||
['scope' => 1, 'tenantId' => 1, 'createdAt' => -1],
|
||||
['name' => 'rules_browse']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_auth_failures']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_tenant_timeline']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_blocked_counts']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_event_type']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1],
|
||||
['name' => 'logs_rule']
|
||||
),
|
||||
$logs->createIndex(
|
||||
['timestamp' => -1],
|
||||
['name' => 'logs_global_timeline']
|
||||
),
|
||||
$claims->createIndex(
|
||||
['expiresAt' => 1],
|
||||
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Rule Operations
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Query rules within one ownership scope.
|
||||
*
|
||||
* @return array{items: FirewallRuleObject[], total: int, limit: int, offset: int}
|
||||
*/
|
||||
public function queryRules(
|
||||
string $scope,
|
||||
?string $tenantId,
|
||||
string $status,
|
||||
?string $type,
|
||||
?string $action,
|
||||
int $limit,
|
||||
int $offset
|
||||
): array {
|
||||
$filter = [
|
||||
'scope' => $scope,
|
||||
'tenantId' => $scope === FirewallRuleObject::SCOPE_SYSTEM ? null : $tenantId,
|
||||
];
|
||||
$now = self::bsonDate(new \DateTimeImmutable());
|
||||
if ($status === 'active') {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$or'] = [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => $now]],
|
||||
];
|
||||
} elseif ($status === 'disabled') {
|
||||
$filter['enabled'] = false;
|
||||
} elseif ($status === 'expired') {
|
||||
$filter['expiresAt'] = ['$ne' => null, '$lte' => $now];
|
||||
}
|
||||
if ($type !== null) {
|
||||
$filter['type'] = $type;
|
||||
}
|
||||
if ($action !== null) {
|
||||
$filter['action'] = $action;
|
||||
}
|
||||
|
||||
$collection = $this->dataStore->selectCollection(self::RULES_COLLECTION);
|
||||
$items = [];
|
||||
foreach ($collection->find($filter, [
|
||||
'sort' => ['createdAt' => -1, '_id' => -1],
|
||||
'limit' => $limit,
|
||||
'skip' => $offset,
|
||||
]) as $entry) {
|
||||
$items[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $collection->countDocuments($filter),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all rules for a tenant
|
||||
*/
|
||||
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||
{
|
||||
$filter = ['tenantId' => $tenantId];
|
||||
$filter = [
|
||||
'tenantId' => $tenantId,
|
||||
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
||||
];
|
||||
|
||||
if ($activeOnly) {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$or'] = [
|
||||
$filter['$and'] = [[
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
];
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
||||
],
|
||||
]];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
@@ -50,6 +171,26 @@ class FirewallStore
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function listSystemRules(bool $activeOnly = true): array
|
||||
{
|
||||
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
|
||||
if ($activeOnly) {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$or'] = [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
||||
];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
$list = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rules by IP address
|
||||
*/
|
||||
@@ -61,7 +202,7 @@ class FirewallStore
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
||||
]
|
||||
];
|
||||
|
||||
@@ -88,7 +229,7 @@ class FirewallStore
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
||||
]
|
||||
];
|
||||
|
||||
@@ -108,7 +249,7 @@ class FirewallStore
|
||||
*/
|
||||
public function fetchRule(string $id): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
@@ -118,15 +259,33 @@ class FirewallStore
|
||||
/**
|
||||
* Check if exact IP rule exists
|
||||
*/
|
||||
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
|
||||
public function findExactIpRule(
|
||||
?string $tenantId,
|
||||
string $ipAddress,
|
||||
string $action,
|
||||
string $scope = FirewallRuleObject::SCOPE_TENANT
|
||||
): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
||||
'tenantId' => $tenantId,
|
||||
$filter = [
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'value' => $ipAddress,
|
||||
'action' => $action,
|
||||
'enabled' => true,
|
||||
]);
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
||||
],
|
||||
];
|
||||
|
||||
if ($scope === FirewallRuleObject::SCOPE_SYSTEM) {
|
||||
$filter['scope'] = FirewallRuleObject::SCOPE_SYSTEM;
|
||||
$filter['tenantId'] = null;
|
||||
} else {
|
||||
$filter['tenantId'] = $tenantId;
|
||||
$filter['scope'] = FirewallRuleObject::SCOPE_TENANT;
|
||||
}
|
||||
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne($filter);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
@@ -139,6 +298,8 @@ class FirewallStore
|
||||
*/
|
||||
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$rule->assertValidScopeOwnership();
|
||||
|
||||
if ($rule->getId()) {
|
||||
return $this->updateRule($rule);
|
||||
} else {
|
||||
@@ -148,7 +309,7 @@ class FirewallStore
|
||||
|
||||
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$data = $rule->jsonSerialize();
|
||||
$data = self::ruleDocument($rule);
|
||||
unset($data['id']); // Remove id for insert
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
||||
@@ -163,11 +324,11 @@ class FirewallStore
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $rule->jsonSerialize();
|
||||
$data = self::ruleDocument($rule);
|
||||
unset($data['id']);
|
||||
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||
['_id' => $id],
|
||||
self::ruleIdFilter($id),
|
||||
['$set' => $data]
|
||||
);
|
||||
return $rule;
|
||||
@@ -182,7 +343,7 @@ class FirewallStore
|
||||
if (!$id) {
|
||||
return;
|
||||
}
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,8 +352,10 @@ class FirewallStore
|
||||
public function cleanupExpiredRules(): int
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
||||
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
|
||||
'expiresAt' => ['$ne' => null]
|
||||
'expiresAt' => [
|
||||
'$lt' => self::bsonDate(new \DateTimeImmutable()),
|
||||
'$ne' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
@@ -202,12 +365,68 @@ class FirewallStore
|
||||
// Log Operations
|
||||
// ========================================
|
||||
|
||||
public function queryTenantLogs(
|
||||
string $tenantId,
|
||||
array $filters,
|
||||
int $limit,
|
||||
int $offset
|
||||
): array {
|
||||
return $this->queryLogs(['tenantId' => $tenantId], $filters, $limit, $offset);
|
||||
}
|
||||
|
||||
public function querySystemLogs(
|
||||
?string $tenantId,
|
||||
array $filters,
|
||||
int $limit,
|
||||
int $offset
|
||||
): array {
|
||||
return $this->queryLogs($tenantId === null ? [] : ['tenantId' => $tenantId], $filters, $limit, $offset);
|
||||
}
|
||||
|
||||
/** @return array{items: FirewallLogObject[], total: int, limit: int, offset: int} */
|
||||
private function queryLogs(array $scopeFilter, array $filters, int $limit, int $offset): array
|
||||
{
|
||||
$filter = $scopeFilter;
|
||||
foreach (['ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope'] as $field) {
|
||||
if (($filters[$field] ?? null) !== null) {
|
||||
$filter[$field] = $filters[$field];
|
||||
}
|
||||
}
|
||||
$timestamp = [];
|
||||
if (($filters['from'] ?? null) instanceof \DateTimeInterface) {
|
||||
$timestamp['$gte'] = self::bsonDate($filters['from']);
|
||||
}
|
||||
if (($filters['to'] ?? null) instanceof \DateTimeInterface) {
|
||||
$timestamp['$lte'] = self::bsonDate($filters['to']);
|
||||
}
|
||||
if ($timestamp !== []) {
|
||||
$filter['timestamp'] = $timestamp;
|
||||
}
|
||||
|
||||
$collection = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
|
||||
$items = [];
|
||||
foreach ($collection->find($filter, [
|
||||
'sort' => ['timestamp' => -1, '_id' => -1],
|
||||
'limit' => $limit,
|
||||
'skip' => $offset,
|
||||
]) as $entry) {
|
||||
$items[] = (new FirewallLogObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $collection->countDocuments($filter),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a firewall event
|
||||
*/
|
||||
public function createLog(FirewallLogObject $log): FirewallLogObject
|
||||
{
|
||||
$data = $log->jsonSerialize();
|
||||
$data = self::logDocument($log);
|
||||
unset($data['id']);
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
||||
@@ -215,6 +434,30 @@ class FirewallStore
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
|
||||
*/
|
||||
public function createLogOnce(FirewallLogObject $log): bool
|
||||
{
|
||||
$eventId = $log->getEventId();
|
||||
if ($eventId === null || $eventId === '') {
|
||||
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
|
||||
}
|
||||
|
||||
$data = self::logDocument($log);
|
||||
unset($data['id']);
|
||||
$data['_id'] = $eventId;
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
|
||||
['_id' => $eventId],
|
||||
['$setOnInsert' => $data],
|
||||
['upsert' => true]
|
||||
);
|
||||
$log->setId($eventId);
|
||||
|
||||
return $result->getUpsertedCount() === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a tenant with optional filters
|
||||
*/
|
||||
@@ -270,10 +513,50 @@ class FirewallStore
|
||||
'tenantId' => $tenantId,
|
||||
'ipAddress' => $ipAddress,
|
||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
|
||||
'timestamp' => ['$gte' => self::bsonDate($since)]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
|
||||
*/
|
||||
public function claimBruteForce(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
int $claimDurationSeconds
|
||||
): bool {
|
||||
if ($claimDurationSeconds < 1) {
|
||||
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
|
||||
}
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
|
||||
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
||||
|
||||
$collection->deleteOne([
|
||||
'_id' => $claimId,
|
||||
'expiresAt' => ['$lte' => self::bsonDate($now)],
|
||||
]);
|
||||
|
||||
try {
|
||||
$collection->insertOne([
|
||||
'_id' => $claimId,
|
||||
'tenantId' => $tenantId,
|
||||
'ipAddress' => $ipAddress,
|
||||
'createdAt' => self::bsonDate($now),
|
||||
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
|
||||
]);
|
||||
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
|
||||
if ($error->getCode() === 11000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count for dashboard
|
||||
*/
|
||||
@@ -287,12 +570,27 @@ class FirewallStore
|
||||
];
|
||||
|
||||
if ($since !== null) {
|
||||
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
|
||||
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
|
||||
}
|
||||
|
||||
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
||||
}
|
||||
|
||||
public function countSystemBlockedRequests(
|
||||
?string $tenantId = null,
|
||||
?\DateTimeImmutable $since = null
|
||||
): int {
|
||||
$filter = ['result' => FirewallLogObject::RESULT_BLOCKED];
|
||||
if ($tenantId !== null) {
|
||||
$filter['tenantId'] = $tenantId;
|
||||
}
|
||||
if ($since !== null) {
|
||||
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
|
||||
}
|
||||
|
||||
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old logs
|
||||
*/
|
||||
@@ -301,9 +599,79 @@ class FirewallStore
|
||||
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
||||
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
|
||||
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
public function cleanupExpiredBruteForceClaims(): int
|
||||
{
|
||||
$result = $this->dataStore
|
||||
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
|
||||
->deleteMany([
|
||||
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
public function recordMaintenanceStatus(
|
||||
\DateTimeImmutable $startedAt,
|
||||
\DateTimeImmutable $completedAt,
|
||||
string $status,
|
||||
array $result,
|
||||
?string $error = null
|
||||
): void {
|
||||
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
|
||||
['_id' => 'cleanup'],
|
||||
['$set' => [
|
||||
'startedAt' => self::bsonDate($startedAt),
|
||||
'completedAt' => self::bsonDate($completedAt),
|
||||
'status' => $status,
|
||||
'result' => $result,
|
||||
'error' => $error,
|
||||
]],
|
||||
['upsert' => true]
|
||||
);
|
||||
}
|
||||
|
||||
public function maintenanceStatus(): ?array
|
||||
{
|
||||
return $this->dataStore
|
||||
->selectCollection(self::MAINTENANCE_COLLECTION)
|
||||
->findOne(['_id' => 'cleanup']);
|
||||
}
|
||||
|
||||
private static function ruleDocument(FirewallRuleObject $rule): array
|
||||
{
|
||||
$data = $rule->jsonSerialize();
|
||||
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
|
||||
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function logDocument(FirewallLogObject $log): array
|
||||
{
|
||||
$data = $log->jsonSerialize();
|
||||
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
|
||||
{
|
||||
return $date === null ? null : self::bsonDate($date);
|
||||
}
|
||||
|
||||
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
|
||||
{
|
||||
return UTCDateTime::fromDateTime($date);
|
||||
}
|
||||
|
||||
private static function ruleIdFilter(string $id): array
|
||||
{
|
||||
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user