1 Commits

Author SHA1 Message Date
Sebastian 49c6beab20 fix(deps): update dependency vue-router to v5.0.7
renovate/artifacts Artifact file update failure
Build Test / build (pull_request) Failing after 12s
JS Unit Tests / test (pull_request) Failing after 12s
PHP Unit Tests / test (pull_request) Successful in 1m4s
2026-05-15 12:55:38 +00:00
425 changed files with 5150 additions and 18308 deletions
@@ -1,50 +0,0 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- name: Set up PHP
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
with:
php-version: '8.5'
tools: composer:v2
extensions: ctype, iconv, mongodb
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Configure server settings
env:
DATABASE_URI: 'mongodb://mongo:27017/?tls=false'
DATABASE_NAME: 'ktrix_ci'
APP_ENVIRONMENT: 'test'
run: php action-server-install/scripts/configure-server.php config/system.php
- name: Run tests
run: composer test:integration
+2 -8
View File
@@ -25,17 +25,11 @@ jobs:
tools: composer:v2
- name: Install Renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
run: npm install -g renovate
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
run: renovate ${{ gitea.repository }}
+4 -3
View File
@@ -14,10 +14,11 @@ node_modules/
# Backend development
/vendor/
/config/system.php
coverage/
*.cache
.phpunit.cache
.phpunit.coverage
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
@@ -33,4 +34,4 @@ logs
# Runtime
/modules/
/storage/
/var/
/var/
+5 -5
View File
@@ -7,21 +7,21 @@
declare(strict_types=1);
use KTXC\Application;
use KTXC\Server;
if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1);
}
$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
require_once dirname(__DIR__).'/vendor/autoload.php';
try {
$application = Application::create(dirname(__DIR__), $composerLoader);
exit($application->runConsole());
$server = new Server(dirname(__DIR__));
exit($server->runConsole());
} catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (($application ?? null)?->debug()) {
if (isset($server) && $server->debug()) {
fwrite(STDERR, $e->getTraceAsString()."\n");
}
exit(1);
+4 -5
View File
@@ -5,7 +5,7 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3",
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"mongodb/mongodb": "^2.1",
@@ -14,7 +14,7 @@
"symfony/console": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^12.5.31"
"phpunit/phpunit": "^11.0"
},
"config": {
"allow-plugins": {
@@ -39,8 +39,7 @@
],
"post-update-cmd": [
],
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
}
}
Generated
+367 -267
View File
File diff suppressed because it is too large Load Diff
@@ -2,14 +2,14 @@
return [
// Application Configuration
'name' => 'Application Name',
'name' => 'Ktrix',
'environment' => 'dev',
'debug' => true,
// Database Configuration
'database' => [
// MongoDB connection URI (include credentials if needed)
'uri' => 'mongodb://username:password@database-host:27017/?authSource=database&tls=true',
'database' => 'database',
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
'database' => 'ktrix',
// optional driver options
'options' => [],
'driverOptions' => [],
@@ -65,14 +65,13 @@ return [
'channel' => 'app',
// ── syslog driver options ──────────────────────────────────────────────
// Identity tag passed to openlog(); visible in syslog and journalctl.
'ident' => 'application',
// Identity tag passed to openlog(); visible in /var/logs/syslog and journalctl -t ktrix
'ident' => 'ktrix',
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
'facility' => LOG_USER,
],
// Security Configuration
// Generate a unique value for each deployment, for example: openssl rand -hex 32
'security.salt' => 'replace-with-a-random-secret',
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
];
-24
View File
@@ -1,24 +0,0 @@
{
"header": {
"searchPlaceholder": "Hier suchen.."
},
"systemMenu": {
"administration": "Administration",
"applications": "Anwendungen",
"personalSettings": "Persönliche Einstellungen",
"toggleAdmin": "Admin",
"toggleApps": "Apps",
"toggleSettings": "Einstellungen"
},
"notifications": {
"markAllRead": "Alle als gelesen markieren",
"title": "Benachrichtigungen",
"viewAll": "Alle anzeigen"
},
"userMenu": {
"darkMode": "Dunkles Design",
"lightMode": "Helles Design",
"logout": "Abmelden",
"settings": "Einstellungen"
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"systemMenu": {
"adminSettings": "System",
"apps": "Applications",
"personalSettings": "Settings",
"userSettings": "Settings"
},
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"logout": "Logout",
"settings": "Settings",
"systemMode": "System Mode"
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"userMenu": {
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"systemMode": "System Mode"
}
}
-154
View File
@@ -1,154 +0,0 @@
<?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;
}
}
@@ -1,29 +0,0 @@
<?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,
);
}
}
@@ -1,35 +0,0 @@
<?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));
}
}
@@ -1,30 +0,0 @@
<?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);
}
}
@@ -1,50 +0,0 @@
<?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);
}
}
}
@@ -1,22 +0,0 @@
<?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;
}
@@ -1,43 +0,0 @@
<?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();
}
}
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
enum RuntimeType: string
{
case HTTP = 'http';
case CLI = 'cli';
}
@@ -1,20 +0,0 @@
<?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,
) {
}
}
-18
View File
@@ -1,18 +0,0 @@
<?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.');
}
}
}
-53
View File
@@ -1,53 +0,0 @@
<?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';
}
}
@@ -1,48 +0,0 @@
<?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;
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Service\FirewallService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
final class FirewallMaintenanceCommand extends Command
{
public function __construct(private readonly FirewallService $firewall)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->firewall->cleanup();
} catch (\Throwable $error) {
$io->error('Firewall maintenance failed: '.$error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf(
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
$result['expiredRules'],
$result['oldLogs'],
$result['expiredBruteForceClaims']
));
return Command::SUCCESS;
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Stores\FirewallStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
final class FirewallSetupCommand extends Command
{
public function __construct(private readonly FirewallStore $store)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$indexes = $this->store->ensureIndexes();
} catch (\Throwable $error) {
$io->error('Firewall database setup failed: '.$error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
return Command::SUCCESS;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace KTXC\Console\Module;
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
-102
View File
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Assign Command
*
* Assigns a role to a user account.
*/
#[AsCommand(
name: 'role:assign',
description: 'Assign a role to a user',
)]
class RoleAssignCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to assign')
->setHelp('This command assigns a role to a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Assign Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (in_array($rid, $roles, true)) {
$io->text("User '{$identity}' already has role '{$rid}'.");
return Command::SUCCESS;
}
$roles[] = $rid;
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => array_values($roles)])) {
$io->error("Failed to assign role '{$rid}' to '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role assigned via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' assigned to '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to assign role: ' . $e->getMessage());
$this->logger->error('Role assign failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-109
View File
@@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Create Command
*
* Creates a new role within a tenant.
*/
#[AsCommand(
name: 'role:create',
description: 'Create a new role in a tenant',
)]
class RoleCreateCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('label', InputArgument::REQUIRED, 'Display label for the role')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Role description', '')
->addOption('permission', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) to grant (repeatable, e.g. --permission "*")', [])
->addOption('rid', null, InputOption::VALUE_REQUIRED, 'Explicit role id (defaults to a generated UUID)')
->addOption('system', null, InputOption::VALUE_NONE, 'Mark the role as a protected system role (cannot be updated or deleted)')
->setHelp('This command creates a new role in a tenant. Permissions are free-form strings matched by the runtime permission checker, including wildcard suffixes like "user_manager.role.*" or the full wildcard "*".')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$label = $input->getArgument('label');
$rid = $input->getOption('rid');
$io->title('Create Role');
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
if ($rid !== null && $this->rolesStore->fetchByRid($tenant, $rid)) {
$io->error("Role '{$rid}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roleData = [
'label' => $label,
'description' => $input->getOption('description') ?? '',
'permissions' => $input->getOption('permission'),
'system' => $input->getOption('system'),
];
if ($rid !== null) {
$roleData['rid'] = $rid;
}
$role = $this->rolesStore->createRole($tenant, $roleData);
$this->logger->info('Role created via console', [
'tenant' => $tenant,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->success("Role '{$label}' created successfully!");
$io->definitionList(
['Rid' => $role['rid'] ?? ''],
['Label' => $role['label'] ?? ''],
['System' => !empty($role['system']) ? 'yes' : 'no'],
['Permissions' => implode(', ', (array)($role['permissions'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create role: ' . $e->getMessage());
$this->logger->error('Role create failed', [
'tenant' => $tenant,
'label' => $label,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-103
View File
@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Delete Command
*
* Deletes a role from a tenant.
*/
#[AsCommand(
name: 'role:delete',
description: 'Delete a role from a tenant',
)]
class RoleDeleteCommand extends Command
{
public function __construct(
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('rid', InputArgument::REQUIRED, 'Role id')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a role from a tenant. System roles cannot be deleted, and roles still assigned to users cannot be deleted.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$rid = $input->getArgument('rid');
$force = $input->getOption('force');
$io->title('Delete Role');
try {
$role = $this->rolesStore->fetchByRid($tenant, $rid);
if (!$role) {
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if ($role['system'] ?? false) {
$io->error("Role '{$rid}' is a system role and cannot be deleted.");
return Command::FAILURE;
}
$userCount = $this->rolesStore->countUsersInRole($tenant, $rid);
if ($userCount > 0) {
$io->error("Role '{$rid}' is assigned to {$userCount} user(s) and cannot be deleted.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete role '{$rid}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->rolesStore->deleteRole($tenant, $rid)) {
$io->error("Failed to delete role '{$rid}'.");
return Command::FAILURE;
}
$this->logger->info('Role deleted via console', [
'tenant' => $tenant,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete role: ' . $e->getMessage());
$this->logger->error('Role delete failed', [
'tenant' => $tenant,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Service\TenantService;
use KTXC\Stores\UserRolesStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role List Command
*
* Lists all roles in a tenant.
*/
#[AsCommand(
name: 'role:list',
description: 'List roles in a tenant',
)]
class RoleListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all roles in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Roles in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$roles = $this->rolesStore->listRoles($tenant);
if (empty($roles)) {
$io->text('No roles found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($roles as $role) {
$rows[] = [
$role['rid'] ?? '',
$role['label'] ?? '',
!empty($role['system']) ? 'yes' : 'no',
implode(', ', (array)($role['permissions'] ?? [])),
];
}
$io->table(['Rid', 'Label', 'System', 'Permissions'], $rows);
$io->text(sprintf('Total: %d role(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list roles: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Role;
use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Role Revoke Command
*
* Revokes a role from a user account.
*/
#[AsCommand(
name: 'role:revoke',
description: 'Revoke a role from a user',
)]
class RoleRevokeCommand extends Command
{
public function __construct(
private readonly UserAccountsStore $userStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addArgument('rid', InputArgument::REQUIRED, 'Role id to revoke')
->setHelp('This command revokes a role from a user account.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$rid = $input->getArgument('rid');
$io->title('Revoke Role');
try {
$user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
$roles = (array)($user['roles'] ?? []);
if (!in_array($rid, $roles, true)) {
$io->text("User '{$identity}' does not have role '{$rid}'.");
return Command::SUCCESS;
}
$roles = array_values(array_filter($roles, fn($r) => $r !== $rid));
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => $roles])) {
$io->error("Failed to revoke role '{$rid}' from '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('Role revoked via console', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'command' => $this->getName(),
]);
$io->success("Role '{$rid}' revoked from '{$identity}' successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to revoke role: ' . $e->getMessage());
$this->logger->error('Role revoke failed', [
'tenant' => $tenant,
'identity' => $identity,
'rid' => $rid,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Auth Enable Command
*
* Enables an authentication provider for a tenant's login flow.
*/
#[AsCommand(
name: 'tenant:auth:enable',
description: 'Enable an authentication provider for a tenant',
)]
class TenantAuthEnableCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly ProviderManager $providerManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('provider', InputArgument::REQUIRED, 'Authentication provider id (e.g. password, oidc, totp)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label shown for this method during login')
->setHelp('This command enables an authentication provider for a tenant. The provider\'s module must already be installed and enabled.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenantIdentifier = $input->getArgument('tenant');
$providerId = $input->getArgument('provider');
$io->title('Enable Tenant Authentication Provider');
try {
$tenant = $this->tenantService->fetchById($tenantIdentifier);
if (!$tenant) {
$io->error("Tenant '{$tenantIdentifier}' not found.");
return Command::FAILURE;
}
if (!$this->providerManager->resolve('authentication', $providerId)) {
$io->error("Authentication provider '{$providerId}' is not registered. Is its module installed and enabled?");
return Command::FAILURE;
}
$authentication = $tenant->getConfiguration()->authentication();
$providers = $authentication->providers();
$providers[$providerId] = array_filter([
'enabled' => true,
'label' => $input->getOption('label'),
], fn($value) => $value !== null);
$authentication->jsonDeserialize(['providers' => $providers]);
$this->tenantService->deposit($tenant);
$this->logger->info('Tenant authentication provider enabled via console', [
'tenant' => $tenantIdentifier,
'provider' => $providerId,
'command' => $this->getName(),
]);
$io->success("Authentication provider '{$providerId}' enabled for tenant '{$tenantIdentifier}'.");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to enable authentication provider: ' . $e->getMessage());
$this->logger->error('Tenant auth provider enable failed', [
'tenant' => $tenantIdentifier,
'provider' => $providerId,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -1,179 +0,0 @@
<?php
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;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Create Command
*
* Creates a new tenant.
*/
#[AsCommand(
name: 'tenant:create',
description: 'Create a new tenant',
)]
class TenantCreateCommand extends Command
{
public function __construct(
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();
}
protected function configure(): void
{
$this
->addArgument('domain', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Domain(s) served by this tenant')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the tenant')
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Tenant description')
->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Explicit tenant identifier (defaults to a generated UUID)')
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the tenant in a disabled state')
->addOption('admin-identity', null, InputOption::VALUE_REQUIRED, 'Identity for the bootstrap admin user', 'admin')
->addOption('no-admin-user', null, InputOption::VALUE_NONE, 'Do not create a bootstrap admin user (the admin role is still seeded)')
->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided. An "admin" role with full permissions is seeded automatically, along with a bootstrap admin user unless --no-admin-user is passed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $input->getOption('identifier') ?? UUID::v4();
$domains = array_values(array_unique(array_filter(array_map('trim', $input->getArgument('domain')))));
$io->title('Create Tenant');
try {
if (empty($domains)) {
$io->error('At least one non-empty domain is required.');
return Command::FAILURE;
}
// Ensure identifier is unique
if ($this->tenantService->fetchById($identifier)) {
$io->error("Tenant '{$identifier}' already exists.");
return Command::FAILURE;
}
// Ensure domains are not claimed by another tenant
foreach ($domains as $domain) {
$existing = $this->tenantService->fetchByDomain($domain);
if ($existing) {
$io->error("Domain '{$domain}' is already assigned to tenant '{$existing->getIdentifier()}'.");
return Command::FAILURE;
}
}
$tenant = new TenantObject();
$tenant->setIdentifier($identifier);
$tenant->setEnabled(!$input->getOption('disabled'));
$tenant->setLabel($input->getOption('label') ?? $domains[0]);
$tenant->setDescription($input->getOption('description') ?? '');
$tenant->setDomains(new DomainCollection($domains));
$tenant->setConfiguration(new TenantConfiguration());
$tenant = $this->tenantService->deposit($tenant);
if (!$tenant) {
$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,
'command' => $this->getName(),
]);
$io->success("Tenant '{$identifier}' created successfully!");
$io->definitionList(
['Id' => $tenant->getId()],
['Identifier' => $tenant->getIdentifier()],
['Label' => $tenant->getLabel()],
['Enabled' => $tenant->getEnabled() ? 'yes' : 'no'],
['Domains' => implode(', ', $domains)],
);
$role = $this->rolesStore->createRole($identifier, [
'rid' => 'admin',
'label' => 'Administrator',
'description' => 'Full access to all tenant features',
'permissions' => ['*'],
'system' => true,
]);
$this->logger->info('Default admin role seeded via console', [
'tenant' => $identifier,
'rid' => $role['rid'] ?? null,
'command' => $this->getName(),
]);
$io->text("Default role 'admin' seeded with full permissions.");
if (!$input->getOption('no-admin-user')) {
$adminIdentity = $input->getOption('admin-identity');
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
} else {
$this->userService->createUser([
'identity' => $adminIdentity,
'label' => 'Administrator',
'enabled' => true,
'roles' => ['admin'],
'profile' => [],
'settings' => [],
'provider' => null,
'provider_subject' => null,
'provider_managed_fields' => [],
]);
$this->logger->info('Bootstrap admin user created via console', [
'tenant' => $identifier,
'identity' => $adminIdentity,
'command' => $this->getName(),
]);
$io->text("Admin user '{$adminIdentity}' created with the 'admin' role.");
$io->note("Set a credential for this account using the auth provider module you have installed, e.g.: php bin/console user:password {$identifier} {$adminIdentity}");
}
}
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create tenant: ' . $e->getMessage());
$this->logger->error('Tenant create failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Tenant Delete Command
*
* Deletes an existing tenant.
*/
#[AsCommand(
name: 'tenant:delete',
description: 'Delete a tenant',
)]
class TenantDeleteCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('identifier', InputArgument::REQUIRED, 'Tenant identifier to delete')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a tenant. Deletion must be confirmed by typing the tenant\'s primary domain. User accounts and module data belonging to the tenant are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $input->getArgument('identifier');
$force = $input->getOption('force');
$io->title('Delete Tenant');
try {
$tenant = $this->tenantService->fetchById($identifier);
if (!$tenant) {
$io->error("Tenant '{$identifier}' not found.");
return Command::FAILURE;
}
if (!$force) {
// Confirm by typing the tenant's primary domain (identifier for
// legacy tenants without domains)
$domains = $tenant->getDomains()?->getArrayCopy() ?? [];
$confirmation = $domains[0] ?? $identifier;
$io->definitionList(
['Identifier' => $tenant->getIdentifier()],
['Label' => $tenant->getLabel()],
['Domains' => implode(', ', $domains)],
);
$answer = $io->ask("Type '{$confirmation}' to confirm deletion");
if ($answer !== $confirmation) {
$io->error('Confirmation did not match. Delete cancelled.');
return Command::FAILURE;
}
}
$this->tenantService->destroy($tenant);
$this->logger->info('Tenant deleted via console', [
'identifier' => $identifier,
'command' => $this->getName(),
]);
$io->success("Tenant '{$identifier}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete tenant: ' . $e->getMessage());
$this->logger->error('Tenant delete failed', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\Service\TenantService;
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;
/**
* Tenant List Command
*
* Lists all tenants.
*/
#[AsCommand(
name: 'tenant:list',
description: 'List all tenants',
)]
class TenantListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('This command lists all tenants.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Tenants');
try {
$tenants = $this->tenantService->list();
if (empty($tenants)) {
$io->text('No tenants found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($tenants as $tenant) {
$domains = $tenant->getDomains();
$rows[] = [
$tenant->getIdentifier(),
$tenant->getLabel(),
$tenant->getEnabled() ? 'yes' : 'no',
$domains ? implode(', ', $domains->getArrayCopy()) : '',
];
}
$io->table(['Identifier', 'Label', 'Enabled', 'Domains'], $rows);
$io->text(sprintf('Total: %d tenant(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list tenants: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
-130
View File
@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Context\TenantContext;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Create Command
*
* Creates a new user account within a tenant.
*/
#[AsCommand(
name: 'user:create',
description: 'Create a new user account in a tenant',
)]
class UserCreateCommand extends Command
{
public function __construct(
private readonly TenantContext $tenantContext,
private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the user')
->addOption('role', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Role id(s) to assign', [])
->addOption('uid', null, InputOption::VALUE_REQUIRED, 'Explicit user id (defaults to a generated UUID)')
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the account in a disabled state')
->setHelp('This command creates a new user account in a tenant. Authentication credentials are managed separately by authentication provider modules.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$roles = $input->getOption('role');
$io->title('Create User');
try {
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)) {
$io->error("User '{$identity}' already exists in tenant '{$tenant}'.");
return Command::FAILURE;
}
// Ensure assigned roles exist
foreach ($roles as $role) {
if (!$this->rolesStore->fetchByRid($tenant, $role)) {
$io->error("Role '{$role}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
}
$userData = [
'identity' => $identity,
'label' => $input->getOption('label') ?? $identity,
'enabled' => !$input->getOption('disabled'),
'roles' => $roles,
'profile' => [],
'settings' => [],
'provider' => null,
'provider_subject' => null,
'provider_managed_fields' => [],
];
if ($input->getOption('uid')) {
$userData['uid'] = $input->getOption('uid');
}
$user = $this->userService->createUser($userData);
$this->logger->info('User created via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'] ?? null,
'command' => $this->getName(),
]);
$io->success("User '{$identity}' created successfully!");
$io->definitionList(
['Uid' => $user['uid'] ?? ''],
['Identity' => $user['identity'] ?? ''],
['Label' => $user['label'] ?? ''],
['Enabled' => !empty($user['enabled']) ? 'yes' : 'no'],
['Roles' => implode(', ', (array)($user['roles'] ?? []))],
);
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to create user: ' . $e->getMessage());
$this->logger->error('User create failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-103
View File
@@ -1,103 +0,0 @@
<?php
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;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User Delete Command
*
* Deletes a user account from a tenant.
*/
#[AsCommand(
name: 'user:delete',
description: 'Delete a user account from a tenant',
)]
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();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command deletes a user account from a tenant. Credentials stored by authentication provider modules are not removed.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$identity = $input->getArgument('identity');
$force = $input->getOption('force');
$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) {
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
return Command::FAILURE;
}
if (!$force && !$io->confirm("Are you sure you want to delete user '{$identity}' from tenant '{$tenant}'?", false)) {
$io->text('Delete cancelled.');
return Command::SUCCESS;
}
if (!$this->userService->deleteUser($user['uid'])) {
$io->error("Failed to delete user '{$identity}'.");
return Command::FAILURE;
}
$this->logger->info('User deleted via console', [
'tenant' => $tenant,
'identity' => $identity,
'uid' => $user['uid'],
'command' => $this->getName(),
]);
$io->success("User '{$identity}' deleted successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to delete user: ' . $e->getMessage());
$this->logger->error('User delete failed', [
'tenant' => $tenant,
'identity' => $identity,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\User;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* User List Command
*
* Lists all user accounts in a tenant.
*/
#[AsCommand(
name: 'user:list',
description: 'List user accounts in a tenant',
)]
class UserListCommand extends Command
{
public function __construct(
private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->setHelp('This command lists all user accounts in a tenant.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = $input->getArgument('tenant');
$io->title("Users in tenant '{$tenant}'");
try {
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$users = $this->userStore->listUsers($tenant);
if (empty($users)) {
$io->text('No users found.');
return Command::SUCCESS;
}
$rows = [];
foreach ($users as $user) {
$rows[] = [
$user['uid'] ?? '',
$user['identity'] ?? '',
$user['label'] ?? '',
!empty($user['enabled']) ? 'yes' : 'no',
implode(', ', (array)($user['roles'] ?? [])),
];
}
$io->table(['Uid', 'Identity', 'Label', 'Enabled', 'Roles'], $rows);
$io->text(sprintf('Total: %d user(s)', count($rows)));
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to list users: ' . $e->getMessage());
return Command::FAILURE;
}
}
}
-101
View File
@@ -1,101 +0,0 @@
<?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);
}
}
@@ -1,23 +0,0 @@
<?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;
}
-117
View File
@@ -1,117 +0,0 @@
<?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);
}
}
@@ -1,23 +0,0 @@
<?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);
$response = $this->authManager->handle($request);
$authResponse = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
return $this->buildJsonResponse($authResponse);
}
/**
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
$host = $request->getHost();
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($request);
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($authRequest);
return $this->buildJsonResponse($response);
}
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
return $this->redirectWithError('Missing state parameter');
}
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($request);
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($authRequest);
if ($response->isSuccess()) {
$returnUrl = $response->returnUrl ?? '/';
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
);
}
$request = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($request);
$authRequest = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($authRequest);
return $this->buildJsonResponse($response);
}
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
{
$sessionId = $request->query->get('session', '');
$request = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($request);
$authRequest = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($authRequest);
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
}
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
);
}
$request = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($request);
$authRequest = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($authRequest);
if ($response->isFailed()) {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
{
$token = $request->cookies->get('accessToken');
$request = AuthenticationRequest::logout($token, false);
$this->authManager->handle($request);
$authRequest = AuthenticationRequest::logout($token, false);
$this->authManager->handle($authRequest);
$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');
$request = AuthenticationRequest::logout($token, true);
$this->authManager->handle($request);
$authRequest = AuthenticationRequest::logout($token, true);
$this->authManager->handle($authRequest);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
return $this->clearTokenCookies($response);
+9 -9
View File
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService;
use KTXC\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract
{
public function __construct(
private readonly SecurityService $securityService,
private readonly IdentityContextInterface $identityContext,
private readonly SessionIdentity $identity,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
@@ -25,11 +25,11 @@ class DefaultController extends ControllerAbstract
public function home(Request $request): Response
{
// If an authenticated identity is available, serve the private app
if ($this->identityContext->identifier()) {
if ($this->identity->identifier()) {
return new FileResponse(
$this->rootDir . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
}
@@ -38,7 +38,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
// Clear any stale auth cookies since the user is not authenticated
@@ -58,7 +58,7 @@ class DefaultController extends ControllerAbstract
return new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
}
@@ -116,11 +116,11 @@ class DefaultController extends ControllerAbstract
public function catchAll(Request $request, string $path = ''): Response
{
// If an authenticated identity is available, serve the private app
if ($this->identityContext->identifier()) {
if ($this->identity->identifier()) {
return new FileResponse(
$this->rootDir . '/public/private.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
}
@@ -128,7 +128,7 @@ class DefaultController extends ControllerAbstract
$response = new FileResponse(
$this->rootDir . '/public/public.html',
Response::HTTP_OK,
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
['Content-Type' => 'text/html']
);
// Clear any stale auth cookies since the user is not authenticated
-465
View File
@@ -1,465 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\FirewallRuleConflictException;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
final class FirewallController extends ControllerAbstract
{
public function __construct(
private readonly TenantFirewallRuleService $tenantRules,
private readonly SystemFirewallRuleService $systemRules,
private readonly TenantFirewallLogService $tenantLogs,
private readonly SystemFirewallLogService $systemLogs,
private readonly TenantFirewallStatusService $tenantStatus,
private readonly SystemFirewallStatusService $systemStatus,
) {
}
#[AuthenticatedRoute(
'/firewall/rules',
name: 'firewall.tenant.rules.list',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function tenantRules(
string $status = 'active',
?string $type = null,
?string $action = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantRules->queryRules(
$status,
$type,
$action,
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.fetch',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function tenantRule(string $ruleId): JsonResponse
{
return $this->ruleResponse($this->tenantRules->fetchRule($ruleId));
}
#[AuthenticatedRoute(
'/firewall/effective-policy',
name: 'firewall.tenant.policy.effective',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function effectivePolicy(): JsonResponse
{
return new JsonResponse($this->tenantRules->effectivePolicy());
}
#[AuthenticatedRoute(
'/firewall/system/rules',
name: 'firewall.system.rules.list',
permissions: [SystemFirewallRuleService::PERMISSION_READ],
)]
public function systemRules(
string $status = 'active',
?string $type = null,
?string $action = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->systemRules->queryRules(
$status,
$type,
$action,
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.fetch',
permissions: [SystemFirewallRuleService::PERMISSION_READ],
)]
public function systemRule(string $ruleId): JsonResponse
{
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
}
#[AuthenticatedRoute(
'/firewall/rules',
name: 'firewall.tenant.rules.create',
methods: ['POST'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function createTenantRule(
Request $request,
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->mutationResponse(fn() => $this->tenantRules->createRule(
$type,
$action,
$value,
$reason,
$durationSeconds,
$request->getClientIp(),
$confirmCurrentIp
));
}
#[AuthenticatedRoute(
'/firewall/system/rules',
name: 'firewall.system.rules.create',
methods: ['POST'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function createSystemRule(
Request $request,
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->mutationResponse(fn() => $this->systemRules->createRule(
$type,
$action,
$value,
$reason,
$durationSeconds,
$request->getClientIp(),
$confirmCurrentIp
));
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.update',
methods: ['PATCH'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateTenantRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->tenantRules->disableRule($ruleId, $reason),
'enable' => $this->tenantRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->tenantRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.update',
methods: ['PATCH'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateSystemRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->systemRules->disableRule($ruleId, $reason),
'enable' => $this->systemRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->systemRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.delete',
methods: ['DELETE'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteTenantRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->tenantRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.delete',
methods: ['DELETE'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteSystemRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->systemRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/logs',
name: 'firewall.tenant.logs.list',
permissions: [TenantFirewallLogService::PERMISSION_READ],
)]
public function tenantLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $from = null,
?string $to = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query(
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/system/logs',
name: 'firewall.system.logs.list',
permissions: [SystemFirewallLogService::PERMISSION_READ],
)]
public function systemLogs(
?string $tenantId = null,
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $from = null,
?string $to = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query(
$tenantId,
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/metrics',
name: 'firewall.tenant.metrics.read',
permissions: [TenantFirewallLogService::PERMISSION_READ],
)]
public function tenantMetrics(?string $since = null): JsonResponse
{
return $this->readResponse(fn(): array => $this->tenantStatus->metrics($since));
}
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.read',
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_READ],
)]
public function tenantConfiguration(): JsonResponse
{
return new JsonResponse($this->tenantStatus->configuration());
}
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.update',
methods: ['PUT'],
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateTenantConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration(
$enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/tenants/{tenantId}/configuration',
name: 'firewall.system.tenant.configuration.update',
methods: ['PUT'],
permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateSystemTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration(
$tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/metrics',
name: 'firewall.system.metrics.read',
permissions: [SystemFirewallLogService::PERMISSION_READ],
)]
public function systemMetrics(?string $tenantId = null, ?string $since = null): JsonResponse
{
return $this->readResponse(fn(): array => $this->systemStatus->metrics($tenantId, $since));
}
#[AuthenticatedRoute(
'/firewall/system/maintenance',
name: 'firewall.system.maintenance.read',
permissions: [SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ],
)]
public function maintenanceStatus(): JsonResponse
{
return new JsonResponse($this->systemStatus->maintenanceStatus());
}
private function queryResponse(callable $query, string $limit, string $offset): JsonResponse
{
try {
if (!ctype_digit($limit) || !ctype_digit($offset)) {
throw new \InvalidArgumentException('Pagination values must be non-negative integers.');
}
return new JsonResponse($query((int)$limit, (int)$offset));
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function ruleResponse(?\JsonSerializable $rule): JsonResponse
{
if ($rule === null) {
return new JsonResponse(['error' => 'Firewall rule not found.'], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse($rule);
}
private function readResponse(callable $read): JsonResponse
{
try {
return new JsonResponse($read());
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function mutationResponse(callable $mutation): JsonResponse
{
try {
return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED);
} catch (FirewallRuleConflictException $error) {
return new JsonResponse(['error' => [
'code' => $error->conflictCode,
'message' => $error->getMessage(),
]], JsonResponse::HTTP_CONFLICT);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_rule',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function lifecycleResponse(callable $mutation): JsonResponse
{
try {
$rule = $mutation();
if ($rule === null) {
return new JsonResponse(['error' => [
'code' => 'firewall_rule_not_found',
'message' => 'Firewall rule not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['rule' => $rule]);
} catch (FirewallRuleConflictException $error) {
return new JsonResponse(['error' => [
'code' => $error->conflictCode,
'message' => $error->getMessage(),
]], JsonResponse::HTTP_CONFLICT);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_rule',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function settingsResponse(callable $mutation): JsonResponse
{
try {
$configuration = $mutation();
if ($configuration === null) {
return new JsonResponse(['error' => [
'code' => 'tenant_not_found',
'message' => 'Tenant not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['configuration' => $configuration]);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_configuration',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
}
+15 -24
View File
@@ -2,30 +2,28 @@
namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\L10N\LocaleResolver;
use KTXC\Module\ModuleManager;
use KTXC\Security\Authorization\PermissionChecker;
use KTXC\Service\UserAccountsService;
use KTXC\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
use KTXF\Controller\ControllerAbstract;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenant,
private readonly SessionIdentity $userIdentity,
private readonly ModuleManager $moduleManager,
private readonly UserAccountsService $userService,
private readonly PermissionChecker $permissionChecker,
private readonly LocaleResolver $localeResolver,
) {}
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
public function index(Request $request): JsonResponse {
public function index(): JsonResponse {
$configuration = [];
@@ -45,30 +43,23 @@ class InitController extends ControllerAbstract
}
}
// localization
$configuration['l10n'] = [
'locale' => $this->localeResolver->resolve($request),
'fallback' => LocaleResolver::FALLBACK,
'available' => $this->localeResolver->available(),
];
// tenant
$configuration['tenant'] = [
'id' => $this->tenantContext->identifier(),
'domain' => $this->tenantContext->domain(),
'label' => $this->tenantContext->label(),
'id' => $this->tenant->identifier(),
'domain' => $this->tenant->domain(),
'label' => $this->tenant->label(),
];
// user
$configuration['user'] = [
'auth' => [
'identifier' => $this->identityContext->identifier(),
'identity' => $this->identityContext->identity()->getIdentity(),
'label' => $this->identityContext->label(),
'roles' => $this->identityContext->identity()->getRoles(),
'permissions' => $this->identityContext->identity()->getPermissions(),
'identifier' => $this->userIdentity->identifier(),
'identity' => $this->userIdentity->identity()->getIdentity(),
'label' => $this->userIdentity->label(),
'roles' => $this->userIdentity->identity()->getRoles(),
'permissions' => $this->userIdentity->identity()->getPermissions(),
],
'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
'settings' => $this->userService->fetchSettings([], true),
];
@@ -4,7 +4,7 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\TenantService;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly SessionTenant $tenantIdentity,
private readonly TenantService $tenantService,
) {}
@@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract
)]
public function read(): JsonResponse
{
$settings = $this->tenantService->fetchSettings($this->tenantContext->identifier());
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
@@ -49,9 +49,9 @@ class TenantSettingsController extends ControllerAbstract
* @example request body:
* {
* "data": {
* "theme_default_mode": "dark",
* "theme_palette": {"light": {"colors": {"primary": "#0284C7"}}},
* "theme_lock": true
* "default_mode": "dark",
* "primary_color": "#6366F1",
* "lock_user_colors": true
* }
* }
*
@@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract
)]
public function update(array $data): JsonResponse
{
$this->tenantService->storeSettings($this->tenantContext->identifier(), $data);
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
$updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data));
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
}
+18 -18
View File
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger
) {}
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
{
try {
// Check admin permission
if (!$this->identityContext->hasPermission('user.admin')) {
if (!$this->userIdentity->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->identityContext->hasPermission('user.create')) {
if (!$this->userIdentity->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->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'identity' => $userData['identity'],
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->identifier()
]);
return $this->userService->createUser($userData);
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userUpdate(array $data): bool
{
if (!$this->identityContext->hasPermission('user.update')) {
if (!$this->userIdentity->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->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'uid' => $uid,
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->identifier()
]);
return $this->userService->updateUser($uid, $updates);
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
*/
private function userDelete(array $data): bool
{
if (!$this->identityContext->hasPermission('user.delete')) {
if (!$this->userIdentity->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->identityContext->identifier()) {
if ($uid === $this->userIdentity->identifier()) {
throw new \InvalidArgumentException('Cannot delete your own account');
}
$this->logger->info('Deleting user', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'uid' => $uid,
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->identifier()
]);
return $this->userService->deleteUser($uid);
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
*/
private function userProviderUnlink(array $data): bool
{
if (!$this->identityContext->hasPermission('user.admin')) {
if (!$this->userIdentity->hasPermission('user.admin')) {
throw new \InvalidArgumentException('Insufficient permissions');
}
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
];
$this->logger->info('Unlinking provider', [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenantIdentity->identifier(),
'uid' => $uid,
'actor' => $this->identityContext->identifier()
'actor' => $this->userIdentity->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\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserProfileController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsService $userService
) {}
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
)]
public function read(): JsonResponse
{
$userId = $this->identityContext->identifier();
$userId = $this->userIdentity->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->identityContext->identifier();
$userId = $this->userIdentity->identifier();
// storeProfile automatically filters out provider-managed fields
$this->userService->storeProfile($userId, $data);
+8 -8
View File
@@ -3,8 +3,8 @@
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
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 TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserRolesService $roleService,
private readonly LoggerInterface $logger
) {}
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
{
try {
// Check role admin permission
if (!$this->identityContext->hasPermission('role.admin')) {
if (!$this->userIdentity->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->identityContext->hasPermission('role.manage')) {
if (!$this->userIdentity->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->identityContext->hasPermission('role.manage')) {
if (!$this->userIdentity->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->identityContext->hasPermission('role.manage')) {
if (!$this->userIdentity->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\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserSettingsController extends ControllerAbstract
{
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly UserAccountsService $userService
) {}
+1 -1
View File
@@ -66,6 +66,6 @@ class ObjectId
*/
public static function isValid(string $id): bool
{
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
return MongoObjectId::isValid($id);
}
}
@@ -1,14 +0,0 @@
<?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;
}
@@ -1,16 +0,0 @@
<?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,
) {
}
}
-128
View File
@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\FailurePolicy;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
{
/** @var array<string, list<Event>> */
private array $deferred = [];
private ?string $activeExecution = null;
private int $dispatchDepth = 0;
public function __construct(
private readonly EventListenerRegistry $registry,
private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
) {
}
public function dispatch(Event $event): void
{
if (++$this->dispatchDepth > 32) {
--$this->dispatchDepth;
throw new \RuntimeException('Event dispatch recursion limit exceeded.');
}
try {
$this->invoke($event, DeliveryMode::Immediate);
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
if ($this->activeExecution === null) {
throw new \LogicException('Deferred events require an active execution scope.');
}
$this->deferred[$this->activeExecution][] = $event;
}
} finally {
--$this->dispatchDepth;
}
}
public function beginExecution(string $executionId): void
{
if ($this->activeExecution !== null) {
throw new \LogicException('An event execution scope is already active.');
}
$this->activeExecution = $executionId;
$this->deferred[$executionId] = [];
}
public function processDeferred(string $executionId): DeferredProcessingResult
{
if ($this->activeExecution !== $executionId) {
throw new \LogicException('Cannot process deferred events for an inactive execution.');
}
try {
$processed = 0;
$deadline = microtime(true) + 1.0;
$deadlineExceeded = false;
$limitExceeded = false;
while (($event = array_shift($this->deferred[$executionId])) !== null) {
if ($processed >= 1000) {
$limitExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
if (microtime(true) >= $deadline) {
$deadlineExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
$processed += $this->invoke($event, DeliveryMode::Deferred);
}
return new DeferredProcessingResult(
$processed,
count($this->deferred[$executionId]),
$deadlineExceeded,
$limitExceeded,
);
} 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->getName(), $delivery) as $listener) {
if ($event->isPropagationStopped()) {
break;
}
try {
$service = $this->container->get($listener->service);
$service->{$listener->method}($event);
$processed++;
} catch (\Throwable $error) {
$this->logger->error('Event listener failed.', [
'event' => $event->getName(),
'module' => $listener->module,
'listener' => $listener->service . '::' . $listener->method,
'exception' => $error,
]);
if ($listener->failurePolicy === FailurePolicy::Propagate) {
throw $error;
}
}
}
return $processed;
}
}
@@ -1,25 +0,0 @@
<?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,
) {
}
}
-107
View File
@@ -1,107 +0,0 @@
<?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,10 +5,7 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Service\SecurityService;
use KTXC\Context\IdentityContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXC\SessionIdentity;
/**
* Authentication middleware
@@ -22,10 +19,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly SecurityService $securityService,
private readonly IdentityContext $identityContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
private readonly SessionIdentity $sessionIdentity
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -35,11 +29,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
// Initialize session identity if authentication succeeded
if ($identity) {
$this->identityContext->initialize($identity);
$identityId = $this->identityContext->identifier();
$this->ephemeralCache->setUserContext($identityId);
$this->persistentCache->setUserContext($identityId);
$this->blobCache->setUserContext($identityId);
$this->sessionIdentity->initialize($identity, true);
}
// 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\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
use KTXC\Security\Authorization\PermissionChecker;
/**
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Router $router,
private readonly IdentityContextInterface $identityContext,
private readonly SessionIdentity $sessionIdentity,
private readonly PermissionChecker $permissionChecker
) {}
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
}
// Check if route requires authentication
if ($match->authenticated && $this->identityContext->identity() === null) {
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED
+4 -14
View File
@@ -4,10 +4,7 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Context\TenantContext;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXC\SessionTenant;
/**
* Tenant resolution middleware
@@ -16,23 +13,16 @@ use KTXF\Cache\PersistentCacheInterface;
class TenantMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly TenantContext $tenantContext,
private readonly EphemeralCacheInterface $ephemeralCache,
private readonly PersistentCacheInterface $persistentCache,
private readonly BlobCacheInterface $blobCache,
private readonly SessionTenant $sessionTenant
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Configure tenant from request host
$this->tenantContext->resolveDomain($request->getHost());
$tenantId = $this->tenantContext->identifier();
$this->ephemeralCache->setTenantContext($tenantId);
$this->persistentCache->setTenantContext($tenantId);
$this->blobCache->setTenantContext($tenantId);
$this->sessionTenant->configure($request->getHost());
// Check if tenant is configured and enabled
if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED
-32
View File
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Request;
/**
* Holds the HTTP request for the duration of the current runtime execution.
*/
final class RequestContext
{
private ?Request $request = null;
public function initialize(Request $request): void
{
if ($this->request !== null) {
throw new \LogicException('The request context has already been initialized.');
}
$this->request = $request;
}
public function current(): ?Request
{
return $this->request;
}
public function clear(): void
{
$this->request = null;
}
}
+164 -161
View File
@@ -9,17 +9,13 @@
namespace KTXC;
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\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\Injection\Builder;
use KTXC\Injection\Container;
use Psr\Container\ContainerInterface;
@@ -27,11 +23,7 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger;
use KTXC\Event\DeferredEventProcessorInterface;
use KTXC\Event\EventDispatcher;
use KTXC\Event\EventListenerRegistry;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Event\EventBus;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface;
@@ -39,7 +31,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
use KTXF\Cache\Store\FilePersistentCache;
use KTXF\Cache\Store\FileBlobCache;
class Kernel implements KernelInterface
class Kernel
{
public const VERSION = '1.0.0';
public const VERSION_ID = 10000;
@@ -53,17 +45,26 @@ class Kernel implements KernelInterface
protected ?float $startTime = null;
protected ?ContainerInterface $container = null;
protected ?LoggerInterface $logger = null;
private bool $errorHandlerInstalled = false;
private ?ExecutionScope $activeScope = null;
private ?ExecutionRunnerInterface $runner = null;
protected ?MiddlewarePipeline $pipeline = null;
private string $projectDir;
private array $config;
public function __construct(
private readonly KernelOptions $options,
protected string $environment = 'prod',
protected bool $debug = false,
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()
@@ -71,16 +72,15 @@ class Kernel implements KernelInterface
$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,10 +89,7 @@ class Kernel implements KernelInterface
}
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
$this->logger = LoggerFactory::create(
$this->config,
$this->options->paths->project,
);
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
$this->initializeErrorHandlers();
@@ -132,7 +129,23 @@ class Kernel implements KernelInterface
return true;
});
$this->errorHandlerInstalled = 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);
});
// Handle fatal errors
register_shutdown_function(function () {
@@ -148,6 +161,11 @@ class Kernel implements KernelInterface
$this->logger->error($message, $error);
if ($this->debug) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
}
});
}
@@ -162,19 +180,14 @@ class Kernel implements KernelInterface
/** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot();
$this->container
->get(EventListenerRegistry::class)
->freeze($this->container);
// Build middleware pipeline
$this->pipeline = $this->buildMiddlewarePipeline();
$this->booted = true;
}
}
public function executionRunner(): ExecutionRunnerInterface
{
return $this->runner ??= new ExecutionRunner($this);
}
public function reboot(): void
{
$this->shutdown();
@@ -186,108 +199,52 @@ class Kernel implements KernelInterface
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 beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
public function handle(Request $request): Response
{
if (!$this->booted) {
$this->boot();
}
if ($this->activeScope !== null) {
throw new \LogicException('The kernel already has an active execution scope.');
}
$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;
// Use middleware pipeline to handle the request
return $this->pipeline->handle($request);
}
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport
/**
* Build the middleware pipeline
*/
protected function buildMiddlewarePipeline(): MiddlewarePipeline
{
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;
$failures = [];
$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;
}
/**
* Process deferred events at the end of the request
*/
public function processEvents(): void
{
try {
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;
if ($this->container && $this->container->has(EventBus::class)) {
/** @var EventBus $eventBus */
$eventBus = $this->container->get(EventBus::class);
$eventBus->processDeferred();
}
} catch (\Throwable $e) {
$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;
error_log('Event processing error: ' . $e->getMessage());
}
return new TerminationReport(
deferredProcessed: $processed,
deferredRemaining: $remaining,
failures: $failures,
deadlineExceeded: $deadlineExceeded,
limitExceeded: $limitExceeded,
);
}
/**
@@ -297,34 +254,30 @@ class Kernel implements KernelInterface
*/
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($projectDir) ?: $projectDir,
'kernel.environment' => $this->environment(),
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
'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($cacheDir) ?: $cacheDir,
'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
'kernel.charset' => 'UTF-8',
'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(),
];
}
public function environment(): string
{
return $this->options->environment;
return $this->environment;
}
public function debug(): bool
{
return $this->options->debug;
return $this->debug;
}
public function container(): ContainerInterface
@@ -338,7 +291,61 @@ class Kernel implements KernelInterface
public function getStartTime(): float
{
return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
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';
}
/**
@@ -373,9 +380,9 @@ class Kernel implements KernelInterface
protected function configureContainer(Builder $builder): void
{
// Service definitions
$projectDir = $this->options->paths->project;
$projectDir = $this->folderRoot();
$moduleDir = $projectDir . '/modules';
$environment = $this->environment();
$environment = $this->environment;
$builder->addDefinitions([
@@ -388,9 +395,6 @@ class Kernel implements KernelInterface
// 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'] ?? [];
@@ -401,7 +405,7 @@ class Kernel implements KernelInterface
return new TenantAwareLogger(
$this->logger,
$c->get(TenantContextInterface::class),
$c->get(SessionTenant::class),
$logDir,
$channel,
$level,
@@ -409,9 +413,8 @@ class Kernel implements KernelInterface
);
},
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
EventListenerRegistrarInterface::class => \DI\get(EventListenerRegistry::class),
// EventBus as singleton for consistent event handling
EventBus::class => \DI\create(EventBus::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';
@@ -430,13 +433,13 @@ class Kernel implements KernelInterface
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
}
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
}
return $cache;
@@ -459,13 +462,13 @@ class Kernel implements KernelInterface
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
}
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
}
return $cache;
@@ -488,13 +491,13 @@ class Kernel implements KernelInterface
$cache = new $storeClass($projectDir);
// Set tenant/user context if available
if ($c->has(TenantContextInterface::class)) {
$tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenantContext->identifier());
if ($c->has(SessionTenant::class)) {
$tenant = $c->get(SessionTenant::class);
$cache->setTenantContext($tenant->identifier());
}
if ($c->has(IdentityContextInterface::class)) {
$identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identityContext->identifier());
if ($c->has(SessionIdentity::class)) {
$identity = $c->get(SessionIdentity::class);
$cache->setUserContext($identity->identifier());
}
return $cache;
-34
View File
@@ -1,34 +0,0 @@
<?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;
}
-71
View File
@@ -1,71 +0,0 @@
<?php
namespace KTXC\L10N;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Service\UserAccountsService;
/**
* Resolves the effective UI locale for the current user.
*
* Resolution chain: user setting (core.locale) -> Accept-Language
* negotiation -> fallback. Tenant default slots in between once tenant
* settings expose one (Phase 3).
*/
class LocaleResolver
{
public const FALLBACK = 'en';
public const SETTING_KEY = 'core.locale';
public function __construct(
private readonly UserAccountsService $userService,
#[Inject('rootDir')] private readonly string $rootDir,
) {}
/**
* Locales with a core catalog on disk (public/l10n/*.json), always
* including the fallback. This backs the language picker, so a locale
* is only offered when it has at least core-shell coverage.
*
* @return string[]
*/
public function available(): array
{
$locales = [self::FALLBACK];
foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) {
$locales[] = basename($file, '.json');
}
$locales = array_values(array_unique($locales));
sort($locales);
return $locales;
}
public function resolve(Request $request): string
{
$available = $this->available();
// 1. explicit user preference
$settings = $this->userService->fetchSettings([self::SETTING_KEY]);
$preference = $settings[self::SETTING_KEY] ?? null;
if (is_string($preference) && in_array($preference, $available, true)) {
return $preference;
}
// 2. Accept-Language negotiation. getPreferredLanguage() returns the
// first candidate when nothing matches, so the fallback leads the
// list; it also normalizes to underscore form (pt_BR) while catalogs
// use BCP-47 hyphens.
$candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK]));
$negotiated = $request->getPreferredLanguage($candidates);
if ($negotiated !== null) {
$negotiated = str_replace('_', '-', $negotiated);
if (in_array($negotiated, $available, true)) {
return $negotiated;
}
}
// 3. fallback
return self::FALLBACK;
}
}
+1 -1
View File
@@ -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 TenantContextInterface singleton which lives in the container.
* requires access to the SessionTenant singleton which lives in the container.
*
* Supported config keys inside $config['log']:
*
+6 -6
View File
@@ -2,7 +2,7 @@
namespace KTXC\Logger;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
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 TenantContextInterface reference that is populated lazily
* Both behaviours rely on a live SessionTenant 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 TenantContextInterface $tenantContext Live reference configured by TenantMiddleware.
* @param SessionTenant $sessionTenant 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 TenantContextInterface $tenantContext,
private readonly SessionTenant $sessionTenant,
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->tenantContext->configured()) {
$tenantId = $this->tenantContext->identifier() ?? 'system';
if ($this->sessionTenant->configured()) {
$tenantId = $this->sessionTenant->identifier() ?? 'system';
}
// Inject tenant id as a reserved context key that concrete loggers extract.
+3 -65
View File
@@ -13,7 +13,6 @@ 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';
@@ -21,15 +20,8 @@ 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;
@@ -39,7 +31,6 @@ 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
@@ -59,9 +50,6 @@ 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;
}
@@ -86,14 +74,13 @@ 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 = self::deserializeDate($data['timestamp']);
$this->timestamp = $data['timestamp'] !== null
? new \DateTimeImmutable($data['timestamp'])
: null;
}
if (array_key_exists('metadata', $data)) {
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
@@ -106,7 +93,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'eventId' => $this->eventId,
'tenantId' => $this->tenantId,
'ipAddress' => $this->ipAddress,
'deviceFingerprint' => $this->deviceFingerprint,
@@ -116,31 +102,12 @@ 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
@@ -154,17 +121,6 @@ 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;
@@ -264,24 +220,6 @@ 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,9 +11,6 @@ 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';
@@ -22,7 +19,6 @@ 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
@@ -46,10 +42,6 @@ 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;
}
@@ -69,10 +61,14 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
}
if (array_key_exists('createdAt', $data)) {
$this->createdAt = self::deserializeDate($data['createdAt']);
$this->createdAt = $data['createdAt'] !== null
? new \DateTimeImmutable($data['createdAt'])
: null;
}
if (array_key_exists('expiresAt', $data)) {
$this->expiresAt = self::deserializeDate($data['expiresAt']);
$this->expiresAt = $data['expiresAt'] !== null
? new \DateTimeImmutable($data['expiresAt'])
: null;
}
if (array_key_exists('enabled', $data)) {
$this->enabled = (bool)$data['enabled'];
@@ -88,7 +84,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{
return [
'id' => $this->id,
'scope' => $this->scope,
'tenantId' => $this->tenantId,
'type' => $this->type,
'action' => $this->action,
@@ -102,24 +97,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
/**
* Check if this rule has expired
*/
@@ -157,42 +134,6 @@ 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,13 +11,11 @@ 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 {
@@ -28,8 +26,4 @@ class TenantConfiguration extends JsonSerializableObject
return $this->security;
}
public function firewall(): TenantFirewall {
return $this->firewall;
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use KTXF\Json\JsonSerializableObject;
class TenantFirewall extends JsonSerializableObject
{
protected bool $enabled = true;
protected int $maxAuthFailures = 5;
protected int $authFailureWindow = 300;
protected int $autoBlockDuration = 3600;
public function enabled(): bool
{
return $this->enabled;
}
public function maxAuthFailures(): int
{
return $this->maxAuthFailures;
}
public function authFailureWindow(): int
{
return $this->authFailureWindow;
}
public function autoBlockDuration(): int
{
return $this->autoBlockDuration;
}
}
+2 -2
View File
@@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject
'enabled' => $this->enabled,
'label' => $this->label,
'description' => $this->description,
'domains' => $this->domains?->getArrayCopy(),
'configuration' => $this->configuration?->jsonSerialize(),
'domains' => $this->domains,
'configuration' => $this->configuration,
];
}
+9 -141
View File
@@ -2,29 +2,6 @@
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;
@@ -36,51 +13,7 @@ use KTXF\Module\ModuleInstanceAbstract;
*/
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{
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 __construct() {}
public function handle(): string
{
@@ -94,7 +27,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function author(): string
{
return 'Vallarx';
return 'Ktrix';
}
public function description(): string
@@ -149,57 +82,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'group' => 'Module Management'
],
// 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 Administration
'system.admin' => [
'label' => 'System Administrator',
'description' => 'Full system access (superuser)',
@@ -216,27 +99,12 @@ 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,
\KTXC\Console\Module\ModuleInstallCommand::class,
\KTXC\Console\Module\ModuleUninstallCommand::class,
\KTXC\Console\Module\ModuleUpgradeCommand::class,
\KTXC\Console\Tenant\TenantCreateCommand::class,
\KTXC\Console\Tenant\TenantListCommand::class,
\KTXC\Console\Tenant\TenantDeleteCommand::class,
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
\KTXC\Console\User\UserCreateCommand::class,
\KTXC\Console\User\UserListCommand::class,
\KTXC\Console\User\UserDeleteCommand::class,
\KTXC\Console\Role\RoleCreateCommand::class,
\KTXC\Console\Role\RoleListCommand::class,
\KTXC\Console\Role\RoleDeleteCommand::class,
\KTXC\Console\Role\RoleAssignCommand::class,
\KTXC\Console\Role\RoleRevokeCommand::class,
\KTXC\Console\ModuleListCommand::class,
\KTXC\Console\ModuleEnableCommand::class,
\KTXC\Console\ModuleDisableCommand::class,
\KTXC\Console\ModuleInstallCommand::class,
\KTXC\Console\ModuleUninstallCommand::class,
\KTXC\Console\ModuleUpgradeCommand::class,
];
}
+5 -7
View File
@@ -2,7 +2,7 @@
namespace KTXC\Module;
use Composer\Autoload\ClassLoader;
use KTXC\Server;
/**
* Custom autoloader for modules that allows PascalCase namespaces
@@ -20,10 +20,7 @@ class ModuleAutoloader
private array $namespaceMap = [];
private bool $scanned = false;
public function __construct(
string $modulesRoot,
private readonly ?ClassLoader $composerLoader = null,
)
public function __construct(string $modulesRoot)
{
$this->modulesRoot = rtrim($modulesRoot, '/');
}
@@ -76,9 +73,10 @@ class ModuleAutoloader
}
// Register module namespaces with Composer ClassLoader
if ($this->composerLoader !== null) {
$composerLoader = Server::getComposerLoader();
if ($composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) {
$this->composerLoader->addPsr4(
$composerLoader->addPsr4(
'KTXM\\' . $namespace . '\\',
$this->modulesRoot . '/' . $folderName . '/lib/'
);
+1 -2
View File
@@ -276,13 +276,12 @@ class ModuleManager
try {
$module->boot();
$this->logger->debug('Module booted', ['handle' => $handle]);
} catch (\Throwable $e) {
} catch (Exception $e) {
$this->logger->error('Module boot failed: ' . $handle, [
'exception' => $e,
'message' => $e->getMessage(),
'code' => $e->getCode(),
]);
throw $e;
}
}
}
+2 -10
View File
@@ -4,7 +4,6 @@ namespace KTXC\Routing;
use DI\Attribute\Inject;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestInputParameters;
use KTXC\Http\Response\Response;
use KTXC\Injection\Container;
use KTXC\Module\ModuleManager;
@@ -208,8 +207,7 @@ class Router
/**
* Dispatch a matched route meta and return a Response (or null if controller does not return one).
* Performs light argument resolution: Request object, route params, body fields, query params, full body for array params.
* Precedence on name collision: route params > body fields > query params > parameter default.
* Performs light argument resolution: Request object, route params, body fields, full body for array params.
*/
public function dispatch(Route $route, Request $request): ?Response
{
@@ -226,8 +224,7 @@ class Router
try {
$requestParameters = $request->getPayload();
} catch (\Throwable) {
// ignore payload errors, fall back to an empty parameter bag
$requestParameters = new RequestInputParameters([]);
// ignore payload errors
}
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
$routeParams = $route->params ?? [];
@@ -261,11 +258,6 @@ class Router
$callArgs[] = $requestParameters->get($reflectionParameterName);
continue;
}
// if method parameter matches a query string param, use that
if ($request->query->has($reflectionParameterName)) {
$callArgs[] = $request->query->get($reflectionParameterName);
continue;
}
// if method parameter did not match, but has a default value, use that
if ($reflectionParameter->isDefaultValueAvailable()) {
$callArgs[] = $reflectionParameter->getDefaultValue();
@@ -1,76 +0,0 @@
<?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),
));
}
}
-87
View File
@@ -1,87 +0,0 @@
<?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',
]);
}
}
+11 -55
View File
@@ -8,14 +8,11 @@ use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXC\Context\TenantContextInterface;
use KTXC\SessionTenant;
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;
@@ -29,14 +26,13 @@ class AuthenticationManager
private string $securityCode;
public function __construct(
private readonly TenantContextInterface $tenantContext,
private readonly SessionTenant $tenant,
private readonly EphemeralCacheInterface $cache,
private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
private readonly EventDispatcherInterface $events,
) {
$this->securityCode = $this->tenantContext->configuration()->security()->code();
$this->securityCode = $this->tenant->configuration()->security()->code();
}
// =========================================================================
@@ -79,7 +75,7 @@ class AuthenticationManager
$methods = $this->methodsConfigured();
$session = AuthenticationSession::create(
$this->tenantContext->identifier(),
$this->tenant->identifier(),
AuthenticationSession::STATE_FRESH
);
@@ -107,7 +103,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->tenantContext->configuration()->authentication()->methodsMinimal();
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
// Store identity in session without validating to prevent enumeration
$session->setMethods(array_column($methods, 'id'), $require);
@@ -159,13 +155,6 @@ class AuthenticationManager
);
}
if ($session->userIdentifier === null && $session->userIdentity) {
$user = $this->userService->fetchByIdentity($session->userIdentity);
if ($user) {
$session->userIdentifier = $user->getId();
}
}
// Build provider context
$context = $this->buildProviderContext($session, $method);
@@ -191,10 +180,6 @@ 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.',
@@ -397,10 +382,6 @@ 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,
@@ -441,7 +422,7 @@ class AuthenticationManager
$session->methodCompleted($method);
// Check if MFA is required
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
if ($require > 1) {
$remainingMethods = $this->methodsConfigured([$method]);
// Filter out redirect methods - they can't be used as secondary factors
@@ -535,7 +516,7 @@ class AuthenticationManager
$accessToken = $this->tokenService->createToken(
[
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenant->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
@@ -578,17 +559,6 @@ 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
*/
@@ -608,7 +578,7 @@ class AuthenticationManager
*/
private function getProviderConfig(string $method): array
{
$providers = $this->tenantContext->configuration()->authentication()->providers();
$providers = $this->tenant->configuration()->authentication()->providers();
return $providers[$method]['config'] ?? [];
}
@@ -617,16 +587,7 @@ class AuthenticationManager
*/
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
{
$userId = $session->userIdentifier;
if ($userId === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Authenticated user is missing',
401,
);
}
$userData = $this->userService->fetchByIdentifier($userId);
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
if ($userData === null) {
return AuthenticationResponse::failed(
@@ -643,11 +604,6 @@ class AuthenticationManager
$this->deleteSession($session->id);
$this->events->dispatch(new AuthenticationSucceededEvent(
$userId,
$session->tenantIdentifier,
));
return AuthenticationResponse::success(
$this->buildUserData($user),
$tokens
@@ -672,7 +628,7 @@ class AuthenticationManager
*/
private function methodsConfigured(array $methodsCompleted = []): array
{
$tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
$methods = [];
foreach ($tenantProviders as $providerId => $providerConfiguration) {
@@ -706,7 +662,7 @@ class AuthenticationManager
private function createTokens(User $user, bool $mfaVerified = false): array
{
$payload = [
'tenant' => $this->tenantContext->identifier(),
'tenant' => $this->tenant->identifier(),
'identifier' => $user->getId(),
'identity' => $user->getIdentity(),
'label' => $user->getLabel(),
@@ -2,7 +2,7 @@
namespace KTXC\Security\Authorization;
use KTXC\Context\IdentityContextInterface;
use KTXC\SessionIdentity;
/**
* Permission Checker
@@ -11,7 +11,7 @@ use KTXC\Context\IdentityContextInterface;
class PermissionChecker
{
public function __construct(
private readonly IdentityContextInterface $identityContext
private readonly SessionIdentity $sessionIdentity
) {}
/**
@@ -24,7 +24,7 @@ class PermissionChecker
*/
public function can(string $permission, mixed $resource = null): bool
{
$identity = $this->identityContext->identity();
$identity = $this->sessionIdentity->identity();
if (!$identity) {
return false;
@@ -113,7 +113,7 @@ class PermissionChecker
*/
public function getUserPermissions(): array
{
$identity = $this->identityContext->identity();
$identity = $this->sessionIdentity->identity();
if (!$identity) {
return [];
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Access denial requires an IP address.');
}
if ($ruleId === '') {
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
}
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
}
parent::__construct(
self::class,
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly ?string $userId = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
parent::__construct(
self::class,
['userId' => $userId, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $userId,
?string $tenantId = null,
) {
if ($userId === '') {
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
}
parent::__construct(
self::class,
['userId' => $userId],
$tenantId,
);
}
public function getUserId(): string
{
return $this->userId;
}
public function getReason(): ?string
{
return null;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $failureCount,
private readonly int $windowSeconds,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
}
if ($failureCount < 1) {
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
}
$this->reason = sprintf(
'%d failed attempts in %d seconds',
$failureCount,
$windowSeconds,
);
parent::__construct(
self::class,
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getFailureCount(): int
{
return $this->failureCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $deviceFingerprint,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($deviceFingerprint === '') {
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
}
parent::__construct(
self::class,
['device' => $deviceFingerprint, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): ?string
{
return null;
}
public function getDeviceFingerprint(): string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
final public function __construct(
private readonly string $ipAddress,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
}
parent::__construct(
static::class,
['ip' => $ipAddress, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return static::SEVERITY;
}
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
{
}
@@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
{
final protected function __construct(
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly string $ruleType,
private readonly string $ruleAction,
private readonly string $ruleValue,
private readonly ?string $reason,
private readonly string $origin,
private readonly ?string $expiresAt,
private readonly array $details,
?string $tenantId,
?string $identityId,
) {
if ($ruleId === '') {
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
parent::__construct(
static::class,
[
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'ruleType' => $ruleType,
'ruleAction' => $ruleAction,
'ruleValue' => $ruleValue,
'reason' => $reason,
'origin' => $origin,
'expiresAt' => $expiresAt,
...$details,
],
$tenantId,
$identityId,
);
}
public static function fromRule(
FirewallRuleObject $rule,
?string $actorId = null,
array $change = [],
): static {
$metadata = $rule->getMetadata() ?? [];
$details = [...$metadata, ...$change];
foreach ([
'ruleId',
'ruleScope',
'ruleType',
'ruleAction',
'ruleValue',
'reason',
'origin',
'expiresAt',
] as $reservedKey) {
unset($details[$reservedKey]);
}
return new static(
ruleId: (string) $rule->getId(),
ruleScope: (string) $rule->getScope(),
ruleType: (string) $rule->getType(),
ruleAction: (string) $rule->getAction(),
ruleValue: (string) $rule->getValue(),
reason: $rule->getReason(),
origin: (string) ($metadata['origin'] ?? 'manual'),
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
details: $details,
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getRuleType(): string
{
return $this->ruleType;
}
public function getRuleAction(): string
{
return $this->ruleAction;
}
public function getRuleValue(): string
{
return $this->ruleValue;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getExpiresAt(): ?string
{
return $this->expiresAt;
}
public function getDetails(): array
{
return $this->details;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
{
}
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $changeReason,
private readonly array $previous,
private readonly array $current,
string $tenantId,
?string $actorId = null,
private readonly string $changeOrigin = 'manual',
) {
if ($changeReason === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
}
if ($tenantId === '') {
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
}
if ($changeOrigin === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
}
parent::__construct(
self::class,
[
'changeReason' => $changeReason,
'changeOrigin' => $changeOrigin,
'previous' => $previous,
'current' => $current,
],
$tenantId,
$actorId,
);
}
public function getChangeReason(): string
{
return $this->changeReason;
}
public function getPrevious(): array
{
return $this->previous;
}
public function getCurrent(): array
{
return $this->current;
}
public function getChangeOrigin(): string
{
return $this->changeOrigin;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->changeReason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpAllowedEvent extends FirewallIpEvent
{
}
@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpBlockedEvent extends FirewallIpEvent
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
}
@@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $requestCount,
private readonly int $windowSeconds,
private readonly ?string $endpoint = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
}
if ($requestCount < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
}
if ($endpoint === '') {
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
}
$this->reason = sprintf(
'%d requests in %d seconds',
$requestCount,
$windowSeconds,
);
parent::__construct(
self::class,
[
'requestCount' => $requestCount,
'windowSeconds' => $windowSeconds,
'endpoint' => $endpoint,
],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRequestCount(): int
{
return $this->requestCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getEndpoint(): ?string
{
return $this->endpoint;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return $this->endpoint;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
interface SecurityEventInterface
{
public function getName(): string;
public function get(string $key, mixed $default = null): mixed;
public function getData(): array;
public function getEventId(): string;
public function getTenantId(): ?string;
public function getIdentityId(): ?string;
public function getUserId(): ?string;
public function getReason(): ?string;
public function getSeverity(): SecurityEventSeverity;
}

Some files were not shown because too many files have changed in this diff Show More