Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d05f5af9d |
@@ -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
|
|
||||||
+3
-2
@@ -14,10 +14,11 @@ node_modules/
|
|||||||
|
|
||||||
# Backend development
|
# Backend development
|
||||||
/vendor/
|
/vendor/
|
||||||
/config/system.php
|
|
||||||
coverage/
|
coverage/
|
||||||
*.cache
|
.phpunit.cache
|
||||||
.phpunit.coverage
|
.phpunit.coverage
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
.phpactor/
|
.phpactor/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
|
|||||||
+5
-5
@@ -7,21 +7,21 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use KTXC\Application;
|
use KTXC\Server;
|
||||||
|
|
||||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
|
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
|
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$application = Application::create(dirname(__DIR__), $composerLoader);
|
$server = new Server(dirname(__DIR__));
|
||||||
exit($application->runConsole());
|
exit($server->runConsole());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
|
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
|
||||||
if (($application ?? null)?->debug()) {
|
if (isset($server) && $server->debug()) {
|
||||||
fwrite(STDERR, $e->getTraceAsString()."\n");
|
fwrite(STDERR, $e->getTraceAsString()."\n");
|
||||||
}
|
}
|
||||||
exit(1);
|
exit(1);
|
||||||
|
|||||||
+4
-5
@@ -5,7 +5,7 @@
|
|||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3",
|
"php": ">=8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"mongodb/mongodb": "^2.1",
|
"mongodb/mongodb": "^2.1",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"symfony/console": "^7.0"
|
"symfony/console": "^7.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^12.5.31"
|
"phpunit/phpunit": "^11.0"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -39,8 +39,7 @@
|
|||||||
],
|
],
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
],
|
],
|
||||||
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
|
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --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.unit.xml --coverage-html .phpunit.coverage --coverage-text"
|
||||||
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+336
-252
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
// Application Configuration
|
// Application Configuration
|
||||||
'name' => 'Application Name',
|
'name' => 'Vallarx',
|
||||||
'environment' => 'dev',
|
'environment' => 'dev',
|
||||||
'debug' => true,
|
'debug' => true,
|
||||||
// Database Configuration
|
// Database Configuration
|
||||||
'database' => [
|
'database' => [
|
||||||
// MongoDB connection URI (include credentials if needed)
|
// MongoDB connection URI (include credentials if needed)
|
||||||
'uri' => 'mongodb://username:password@database-host:27017/?authSource=database&tls=true',
|
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
|
||||||
'database' => 'database',
|
'database' => 'ktrix',
|
||||||
// optional driver options
|
// optional driver options
|
||||||
'options' => [],
|
'options' => [],
|
||||||
'driverOptions' => [],
|
'driverOptions' => [],
|
||||||
@@ -65,14 +65,13 @@ return [
|
|||||||
'channel' => 'app',
|
'channel' => 'app',
|
||||||
|
|
||||||
// ── syslog driver options ──────────────────────────────────────────────
|
// ── syslog driver options ──────────────────────────────────────────────
|
||||||
// Identity tag passed to openlog(); visible in syslog and journalctl.
|
// Identity tag passed to openlog(); visible in /var/logs/syslog and journalctl -t ktrix
|
||||||
'ident' => 'application',
|
'ident' => 'ktrix',
|
||||||
|
|
||||||
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
|
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
|
||||||
'facility' => LOG_USER,
|
'facility' => LOG_USER,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Security Configuration
|
// Security Configuration
|
||||||
// Generate a unique value for each deployment, for example: openssl rand -hex 32
|
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
|
||||||
'security.salt' => 'replace-with-a-random-secret',
|
|
||||||
];
|
];
|
||||||
@@ -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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 KTXF\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
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Console\Module;
|
namespace KTXC\Console;
|
||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Console\Module;
|
namespace KTXC\Console;
|
||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Console\Module;
|
namespace KTXC\Console;
|
||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Console\Module;
|
namespace KTXC\Console;
|
||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Console\Module;
|
namespace KTXC\Console;
|
||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace KTXC\Console\Module;
|
namespace KTXC\Console;
|
||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,171 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Console\Tenant;
|
|
||||||
|
|
||||||
use KTXC\Models\Tenant\DomainCollection;
|
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
|
||||||
use KTXC\Service\TenantService;
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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->userStore->createUser($identifier, [
|
|
||||||
'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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Console\User;
|
|
||||||
|
|
||||||
use KTXC\Service\TenantService;
|
|
||||||
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 TenantService $tenantService,
|
|
||||||
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)')
|
|
||||||
->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 {
|
|
||||||
// Ensure the tenant exists
|
|
||||||
if (!$this->tenantService->fetchById($tenant)) {
|
|
||||||
$io->error("Tenant '{$tenant}' not found.");
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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->userStore->createUser($tenant, $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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Console\User;
|
|
||||||
|
|
||||||
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 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)')
|
|
||||||
->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 {
|
|
||||||
$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->userStore->deleteUser($tenant, $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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
|
|||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||||
use KTXC\Service\SecurityService;
|
use KTXC\Service\SecurityService;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
|
|
||||||
class DefaultController extends ControllerAbstract
|
class DefaultController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SecurityService $securityService,
|
private readonly SecurityService $securityService,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $identity,
|
||||||
#[Inject('rootDir')] private readonly string $rootDir,
|
#[Inject('rootDir')] private readonly string $rootDir,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class DefaultController extends ControllerAbstract
|
|||||||
public function home(Request $request): Response
|
public function home(Request $request): Response
|
||||||
{
|
{
|
||||||
// If an authenticated identity is available, serve the private app
|
// If an authenticated identity is available, serve the private app
|
||||||
if ($this->identityContext->identifier()) {
|
if ($this->identity->identifier()) {
|
||||||
return new FileResponse(
|
return new FileResponse(
|
||||||
$this->rootDir . '/public/private.html',
|
$this->rootDir . '/public/private.html',
|
||||||
Response::HTTP_OK,
|
Response::HTTP_OK,
|
||||||
@@ -116,7 +116,7 @@ class DefaultController extends ControllerAbstract
|
|||||||
public function catchAll(Request $request, string $path = ''): Response
|
public function catchAll(Request $request, string $path = ''): Response
|
||||||
{
|
{
|
||||||
// If an authenticated identity is available, serve the private app
|
// If an authenticated identity is available, serve the private app
|
||||||
if ($this->identityContext->identifier()) {
|
if ($this->identity->identifier()) {
|
||||||
return new FileResponse(
|
return new FileResponse(
|
||||||
$this->rootDir . '/public/private.html',
|
$this->rootDir . '/public/private.html',
|
||||||
Response::HTTP_OK,
|
Response::HTTP_OK,
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ use KTXC\L10N\LocaleResolver;
|
|||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use KTXC\Security\Authorization\PermissionChecker;
|
use KTXC\Security\Authorization\PermissionChecker;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
class InitController extends ControllerAbstract
|
class InitController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenant,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly ModuleManager $moduleManager,
|
private readonly ModuleManager $moduleManager,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
private readonly PermissionChecker $permissionChecker,
|
private readonly PermissionChecker $permissionChecker,
|
||||||
@@ -54,21 +54,21 @@ class InitController extends ControllerAbstract
|
|||||||
|
|
||||||
// tenant
|
// tenant
|
||||||
$configuration['tenant'] = [
|
$configuration['tenant'] = [
|
||||||
'id' => $this->tenantContext->identifier(),
|
'id' => $this->tenant->identifier(),
|
||||||
'domain' => $this->tenantContext->domain(),
|
'domain' => $this->tenant->domain(),
|
||||||
'label' => $this->tenantContext->label(),
|
'label' => $this->tenant->label(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// user
|
// user
|
||||||
$configuration['user'] = [
|
$configuration['user'] = [
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'identifier' => $this->identityContext->identifier(),
|
'identifier' => $this->userIdentity->identifier(),
|
||||||
'identity' => $this->identityContext->identity()->getIdentity(),
|
'identity' => $this->userIdentity->identity()->getIdentity(),
|
||||||
'label' => $this->identityContext->label(),
|
'label' => $this->userIdentity->label(),
|
||||||
'roles' => $this->identityContext->identity()->getRoles(),
|
'roles' => $this->userIdentity->identity()->getRoles(),
|
||||||
'permissions' => $this->identityContext->identity()->getPermissions(),
|
'permissions' => $this->userIdentity->identity()->getPermissions(),
|
||||||
],
|
],
|
||||||
'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
|
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
|
||||||
'settings' => $this->userService->fetchSettings([], true),
|
'settings' => $this->userService->fetchSettings([], true),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace KTXC\Controllers;
|
|||||||
|
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Service\TenantService;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ use KTXF\Routing\Attributes\AuthenticatedRoute;
|
|||||||
class TenantSettingsController extends ControllerAbstract
|
class TenantSettingsController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly TenantService $tenantService,
|
private readonly TenantService $tenantService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract
|
|||||||
)]
|
)]
|
||||||
public function read(): JsonResponse
|
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);
|
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||||
}
|
}
|
||||||
@@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract
|
|||||||
)]
|
)]
|
||||||
public function update(array $data): JsonResponse
|
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);
|
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
|
|||||||
|
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
|
|||||||
class UserAccountsController extends ControllerAbstract
|
class UserAccountsController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {}
|
) {}
|
||||||
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// Check admin permission
|
// Check admin permission
|
||||||
if (!$this->identityContext->hasPermission('user.admin')) {
|
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||||
@@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function userCreate(array $data): array
|
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');
|
throw new \InvalidArgumentException('Insufficient permissions to create users');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
];
|
];
|
||||||
|
|
||||||
$this->logger->info('Creating user', [
|
$this->logger->info('Creating user', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'identity' => $userData['identity'],
|
'identity' => $userData['identity'],
|
||||||
'actor' => $this->identityContext->identifier()
|
'actor' => $this->userIdentity->identifier()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->userService->createUser($userData);
|
return $this->userService->createUser($userData);
|
||||||
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function userUpdate(array $data): bool
|
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');
|
throw new \InvalidArgumentException('Insufficient permissions to update users');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->info('Updating user', [
|
$this->logger->info('Updating user', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'actor' => $this->identityContext->identifier()
|
'actor' => $this->userIdentity->identifier()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->userService->updateUser($uid, $updates);
|
return $this->userService->updateUser($uid, $updates);
|
||||||
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function userDelete(array $data): bool
|
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');
|
throw new \InvalidArgumentException('Insufficient permissions to delete users');
|
||||||
}
|
}
|
||||||
|
|
||||||
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
||||||
|
|
||||||
// Prevent self-deletion
|
// Prevent self-deletion
|
||||||
if ($uid === $this->identityContext->identifier()) {
|
if ($uid === $this->userIdentity->identifier()) {
|
||||||
throw new \InvalidArgumentException('Cannot delete your own account');
|
throw new \InvalidArgumentException('Cannot delete your own account');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->info('Deleting user', [
|
$this->logger->info('Deleting user', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'actor' => $this->identityContext->identifier()
|
'actor' => $this->userIdentity->identifier()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->userService->deleteUser($uid);
|
return $this->userService->deleteUser($uid);
|
||||||
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function userProviderUnlink(array $data): bool
|
private function userProviderUnlink(array $data): bool
|
||||||
{
|
{
|
||||||
if (!$this->identityContext->hasPermission('user.admin')) {
|
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||||
throw new \InvalidArgumentException('Insufficient permissions');
|
throw new \InvalidArgumentException('Insufficient permissions');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
|
|||||||
];
|
];
|
||||||
|
|
||||||
$this->logger->info('Unlinking provider', [
|
$this->logger->info('Unlinking provider', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'actor' => $this->identityContext->identifier()
|
'actor' => $this->userIdentity->identifier()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->userService->updateUser($uid, $updates);
|
return $this->userService->updateUser($uid, $updates);
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ namespace KTXC\Controllers;
|
|||||||
|
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
class UserProfileController extends ControllerAbstract
|
class UserProfileController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly UserAccountsService $userService
|
private readonly UserAccountsService $userService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
|
|||||||
)]
|
)]
|
||||||
public function read(): JsonResponse
|
public function read(): JsonResponse
|
||||||
{
|
{
|
||||||
$userId = $this->identityContext->identifier();
|
$userId = $this->userIdentity->identifier();
|
||||||
|
|
||||||
// Get profile with editability metadata
|
// Get profile with editability metadata
|
||||||
$profile = $this->userService->getEditableFields($userId);
|
$profile = $this->userService->getEditableFields($userId);
|
||||||
@@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract
|
|||||||
)]
|
)]
|
||||||
public function update(array $data): JsonResponse
|
public function update(array $data): JsonResponse
|
||||||
{
|
{
|
||||||
$userId = $this->identityContext->identifier();
|
$userId = $this->userIdentity->identifier();
|
||||||
|
|
||||||
// storeProfile automatically filters out provider-managed fields
|
// storeProfile automatically filters out provider-managed fields
|
||||||
$this->userService->storeProfile($userId, $data);
|
$this->userService->storeProfile($userId, $data);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace KTXC\Controllers;
|
namespace KTXC\Controllers;
|
||||||
|
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXC\Service\UserRolesService;
|
use KTXC\Service\UserRolesService;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
|
|||||||
class UserRolesController extends ControllerAbstract
|
class UserRolesController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly UserRolesService $roleService,
|
private readonly UserRolesService $roleService,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {}
|
) {}
|
||||||
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// Check role admin permission
|
// Check role admin permission
|
||||||
if (!$this->identityContext->hasPermission('role.admin')) {
|
if (!$this->userIdentity->hasPermission('role.admin')) {
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||||
@@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function roleCreate(array $data): array
|
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');
|
throw new \InvalidArgumentException('Insufficient permissions to create roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function roleUpdate(array $data): bool
|
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');
|
throw new \InvalidArgumentException('Insufficient permissions to update roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract
|
|||||||
*/
|
*/
|
||||||
private function roleDelete(array $data): bool
|
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');
|
throw new \InvalidArgumentException('Insufficient permissions to delete roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ namespace KTXC\Controllers;
|
|||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
class UserSettingsController extends ControllerAbstract
|
class UserSettingsController extends ControllerAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly UserAccountsService $userService
|
private readonly UserAccountsService $userService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ namespace KTXC\Http\Middleware;
|
|||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Service\SecurityService;
|
use KTXC\Service\SecurityService;
|
||||||
use KTXC\Context\IdentityContext;
|
use KTXC\SessionIdentity;
|
||||||
use KTXF\Cache\BlobCacheInterface;
|
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
|
||||||
use KTXF\Cache\PersistentCacheInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication middleware
|
* Authentication middleware
|
||||||
@@ -22,10 +19,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SecurityService $securityService,
|
private readonly SecurityService $securityService,
|
||||||
private readonly IdentityContext $identityContext,
|
private readonly SessionIdentity $sessionIdentity
|
||||||
private readonly EphemeralCacheInterface $ephemeralCache,
|
|
||||||
private readonly PersistentCacheInterface $persistentCache,
|
|
||||||
private readonly BlobCacheInterface $blobCache,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
@@ -35,11 +29,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
|
|||||||
|
|
||||||
// Initialize session identity if authentication succeeded
|
// Initialize session identity if authentication succeeded
|
||||||
if ($identity) {
|
if ($identity) {
|
||||||
$this->identityContext->initialize($identity);
|
$this->sessionIdentity->initialize($identity, true);
|
||||||
$identityId = $this->identityContext->identifier();
|
|
||||||
$this->ephemeralCache->setUserContext($identityId);
|
|
||||||
$this->persistentCache->setUserContext($identityId);
|
|
||||||
$this->blobCache->setUserContext($identityId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue to next middleware (authentication is optional at this stage)
|
// 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\Http\Response\Response;
|
||||||
use KTXC\Routing\Router;
|
use KTXC\Routing\Router;
|
||||||
use KTXC\Routing\Route;
|
use KTXC\Routing\Route;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Security\Authorization\PermissionChecker;
|
use KTXC\Security\Authorization\PermissionChecker;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Router $router,
|
private readonly Router $router,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $sessionIdentity,
|
||||||
private readonly PermissionChecker $permissionChecker
|
private readonly PermissionChecker $permissionChecker
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if route requires authentication
|
// Check if route requires authentication
|
||||||
if ($match->authenticated && $this->identityContext->identity() === null) {
|
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
|
||||||
return new Response(
|
return new Response(
|
||||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||||
Response::HTTP_UNAUTHORIZED
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ namespace KTXC\Http\Middleware;
|
|||||||
|
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Context\TenantContext;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Cache\BlobCacheInterface;
|
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
|
||||||
use KTXF\Cache\PersistentCacheInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenant resolution middleware
|
* Tenant resolution middleware
|
||||||
@@ -16,23 +13,16 @@ use KTXF\Cache\PersistentCacheInterface;
|
|||||||
class TenantMiddleware implements MiddlewareInterface
|
class TenantMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContext $tenantContext,
|
private readonly SessionTenant $sessionTenant
|
||||||
private readonly EphemeralCacheInterface $ephemeralCache,
|
|
||||||
private readonly PersistentCacheInterface $persistentCache,
|
|
||||||
private readonly BlobCacheInterface $blobCache,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
{
|
{
|
||||||
// Configure tenant from request host
|
// Configure tenant from request host
|
||||||
$this->tenantContext->resolveDomain($request->getHost());
|
$this->sessionTenant->configure($request->getHost());
|
||||||
$tenantId = $this->tenantContext->identifier();
|
|
||||||
$this->ephemeralCache->setTenantContext($tenantId);
|
|
||||||
$this->persistentCache->setTenantContext($tenantId);
|
|
||||||
$this->blobCache->setTenantContext($tenantId);
|
|
||||||
|
|
||||||
// Check if tenant is configured and enabled
|
// Check if tenant is configured and enabled
|
||||||
if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
|
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
|
||||||
return new Response(
|
return new Response(
|
||||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||||
Response::HTTP_UNAUTHORIZED
|
Response::HTTP_UNAUTHORIZED
|
||||||
|
|||||||
+164
-159
@@ -9,17 +9,13 @@
|
|||||||
|
|
||||||
namespace KTXC;
|
namespace KTXC;
|
||||||
|
|
||||||
use KTXC\Application\KernelOptions;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Application\Execution\ExecutionOutcome;
|
use KTXC\Http\Middleware\MiddlewarePipeline;
|
||||||
use KTXC\Application\Execution\ExecutionRunner;
|
use KTXC\Http\Middleware\TenantMiddleware;
|
||||||
use KTXC\Application\Execution\ExecutionRunnerInterface;
|
use KTXC\Http\Middleware\FirewallMiddleware;
|
||||||
use KTXC\Application\Execution\ExecutionScope;
|
use KTXC\Http\Middleware\AuthenticationMiddleware;
|
||||||
use KTXC\Application\Execution\TerminationReport;
|
use KTXC\Http\Middleware\RouterMiddleware;
|
||||||
use KTXC\Context\IdentityContext;
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
|
||||||
use KTXC\Context\TenantContext;
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
|
||||||
use KTXC\Injection\Builder;
|
use KTXC\Injection\Builder;
|
||||||
use KTXC\Injection\Container;
|
use KTXC\Injection\Container;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
@@ -27,10 +23,7 @@ use KTXC\Module\ModuleManager;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use KTXC\Logger\LoggerFactory;
|
use KTXC\Logger\LoggerFactory;
|
||||||
use KTXC\Logger\TenantAwareLogger;
|
use KTXC\Logger\TenantAwareLogger;
|
||||||
use KTXF\Event\DeferredEventProcessorInterface;
|
use KTXF\Event\EventBus;
|
||||||
use KTXF\Event\EventDispatcher;
|
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
|
||||||
use KTXF\Event\EventListenerRegistry;
|
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
use KTXF\Cache\PersistentCacheInterface;
|
use KTXF\Cache\PersistentCacheInterface;
|
||||||
use KTXF\Cache\BlobCacheInterface;
|
use KTXF\Cache\BlobCacheInterface;
|
||||||
@@ -38,7 +31,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
|
|||||||
use KTXF\Cache\Store\FilePersistentCache;
|
use KTXF\Cache\Store\FilePersistentCache;
|
||||||
use KTXF\Cache\Store\FileBlobCache;
|
use KTXF\Cache\Store\FileBlobCache;
|
||||||
|
|
||||||
class Kernel implements KernelInterface
|
class Kernel
|
||||||
{
|
{
|
||||||
public const VERSION = '1.0.0';
|
public const VERSION = '1.0.0';
|
||||||
public const VERSION_ID = 10000;
|
public const VERSION_ID = 10000;
|
||||||
@@ -52,17 +45,26 @@ class Kernel implements KernelInterface
|
|||||||
protected ?float $startTime = null;
|
protected ?float $startTime = null;
|
||||||
protected ?ContainerInterface $container = null;
|
protected ?ContainerInterface $container = null;
|
||||||
protected ?LoggerInterface $logger = null;
|
protected ?LoggerInterface $logger = null;
|
||||||
private bool $errorHandlerInstalled = false;
|
protected ?MiddlewarePipeline $pipeline = null;
|
||||||
private ?ExecutionScope $activeScope = null;
|
|
||||||
private ?ExecutionRunnerInterface $runner = null;
|
|
||||||
|
|
||||||
|
private string $projectDir;
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly KernelOptions $options,
|
protected string $environment = 'prod',
|
||||||
|
protected bool $debug = false,
|
||||||
array $config = [],
|
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;
|
$this->config = $config;
|
||||||
|
|
||||||
|
if ($projectDir !== null) {
|
||||||
|
$this->projectDir = $projectDir;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __clone()
|
public function __clone()
|
||||||
@@ -70,16 +72,15 @@ class Kernel implements KernelInterface
|
|||||||
$this->initialized = false;
|
$this->initialized = false;
|
||||||
$this->booted = false;
|
$this->booted = false;
|
||||||
$this->container = null;
|
$this->container = null;
|
||||||
$this->runner = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function initialize(): void
|
private function initialize(): void
|
||||||
{
|
{
|
||||||
|
|
||||||
if ($this->debug()) {
|
if ($this->debug) {
|
||||||
$this->startTime = microtime(true);
|
$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')) {
|
if (\function_exists('putenv')) {
|
||||||
putenv('SHELL_VERBOSITY=3');
|
putenv('SHELL_VERBOSITY=3');
|
||||||
}
|
}
|
||||||
@@ -88,10 +89,7 @@ class Kernel implements KernelInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
|
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
|
||||||
$this->logger = LoggerFactory::create(
|
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
|
||||||
$this->config,
|
|
||||||
$this->options->paths->project,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->initializeErrorHandlers();
|
$this->initializeErrorHandlers();
|
||||||
|
|
||||||
@@ -131,7 +129,23 @@ class Kernel implements KernelInterface
|
|||||||
return true;
|
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
|
// Handle fatal errors
|
||||||
register_shutdown_function(function () {
|
register_shutdown_function(function () {
|
||||||
@@ -147,6 +161,11 @@ class Kernel implements KernelInterface
|
|||||||
|
|
||||||
$this->logger->error($message, $error);
|
$this->logger->error($message, $error);
|
||||||
|
|
||||||
|
if ($this->debug) {
|
||||||
|
echo '<pre>' . $message . '</pre>';
|
||||||
|
} else {
|
||||||
|
echo 'A fatal error occurred. Please try again later.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -161,19 +180,14 @@ class Kernel implements KernelInterface
|
|||||||
/** @var ModuleManager $moduleManager */
|
/** @var ModuleManager $moduleManager */
|
||||||
$moduleManager = $this->container->get(ModuleManager::class);
|
$moduleManager = $this->container->get(ModuleManager::class);
|
||||||
$moduleManager->modulesBoot();
|
$moduleManager->modulesBoot();
|
||||||
$this->container
|
|
||||||
->get(EventListenerRegistry::class)
|
// Build middleware pipeline
|
||||||
->freeze($this->container);
|
$this->pipeline = $this->buildMiddlewarePipeline();
|
||||||
|
|
||||||
$this->booted = true;
|
$this->booted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function executionRunner(): ExecutionRunnerInterface
|
|
||||||
{
|
|
||||||
return $this->runner ??= new ExecutionRunner($this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reboot(): void
|
public function reboot(): void
|
||||||
{
|
{
|
||||||
$this->shutdown();
|
$this->shutdown();
|
||||||
@@ -185,108 +199,52 @@ class Kernel implements KernelInterface
|
|||||||
if (false === $this->initialized) {
|
if (false === $this->initialized) {
|
||||||
return;
|
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->initialized = false;
|
||||||
$this->booted = false;
|
$this->booted = false;
|
||||||
$this->container = null;
|
$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) {
|
if (!$this->booted) {
|
||||||
$this->boot();
|
$this->boot();
|
||||||
}
|
}
|
||||||
if ($this->activeScope !== null) {
|
|
||||||
throw new \LogicException('The kernel already has an active execution scope.');
|
// Use middleware pipeline to handle the request
|
||||||
|
return $this->pipeline->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope = new ExecutionScope(
|
/**
|
||||||
$descriptor,
|
* Build the middleware pipeline
|
||||||
\KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor),
|
*/
|
||||||
$this->container->get(TenantContext::class),
|
protected function buildMiddlewarePipeline(): MiddlewarePipeline
|
||||||
$this->container->get(IdentityContext::class),
|
|
||||||
);
|
|
||||||
$this->container
|
|
||||||
->get(DeferredEventProcessorInterface::class)
|
|
||||||
->beginExecution($descriptor->executionId);
|
|
||||||
$this->activeScope = $scope;
|
|
||||||
|
|
||||||
return $scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function terminateExecution(
|
|
||||||
ExecutionScope $scope,
|
|
||||||
ExecutionOutcome $outcome,
|
|
||||||
): TerminationReport
|
|
||||||
{
|
{
|
||||||
if ($scope->terminated()) {
|
$pipeline = new MiddlewarePipeline($this->container);
|
||||||
return new TerminationReport();
|
|
||||||
}
|
// Register middleware in execution order
|
||||||
if ($this->activeScope !== $scope) {
|
$pipeline->pipe(TenantMiddleware::class);
|
||||||
throw new \LogicException('Cannot terminate a scope that is not active.');
|
$pipeline->pipe(FirewallMiddleware::class);
|
||||||
|
$pipeline->pipe(AuthenticationMiddleware::class);
|
||||||
|
$pipeline->pipe(RouterMiddleware::class);
|
||||||
|
|
||||||
|
return $pipeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
$processed = 0;
|
/**
|
||||||
$remaining = 0;
|
* Process deferred events at the end of the request
|
||||||
$deadlineExceeded = false;
|
*/
|
||||||
$limitExceeded = false;
|
public function processEvents(): void
|
||||||
$failures = [];
|
{
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
|
if ($this->container && $this->container->has(EventBus::class)) {
|
||||||
$result = $this->container
|
/** @var EventBus $eventBus */
|
||||||
->get(DeferredEventProcessorInterface::class)
|
$eventBus = $this->container->get(EventBus::class);
|
||||||
->processDeferred($scope->descriptor->executionId);
|
$eventBus->processDeferred();
|
||||||
$processed = $result->processed;
|
|
||||||
$remaining = $result->remaining;
|
|
||||||
$deadlineExceeded = $result->deadlineExceeded;
|
|
||||||
$limitExceeded = $result->limitExceeded;
|
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$failures[] = $e;
|
error_log('Event processing error: ' . $e->getMessage());
|
||||||
try {
|
|
||||||
$this->container
|
|
||||||
?->get(DeferredEventProcessorInterface::class)
|
|
||||||
->discardDeferred($scope->descriptor->executionId);
|
|
||||||
} catch (\Throwable $discardError) {
|
|
||||||
$failures[] = $discardError;
|
|
||||||
}
|
}
|
||||||
$this->logger?->error('Deferred event processing failed.', [
|
|
||||||
'exception' => $e,
|
|
||||||
'execution_id' => $scope->descriptor->executionId,
|
|
||||||
'runtime' => $scope->descriptor->runtime->value,
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if ($this->container !== null) {
|
|
||||||
foreach ([
|
|
||||||
EphemeralCacheInterface::class,
|
|
||||||
PersistentCacheInterface::class,
|
|
||||||
BlobCacheInterface::class,
|
|
||||||
] as $cacheType) {
|
|
||||||
$cache = $this->container->get($cacheType);
|
|
||||||
$cache->setUserContext(null);
|
|
||||||
$cache->setTenantContext(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$scope->dispose();
|
|
||||||
$scope->markTerminated();
|
|
||||||
$this->activeScope = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerminationReport(
|
|
||||||
deferredProcessed: $processed,
|
|
||||||
deferredRemaining: $remaining,
|
|
||||||
failures: $failures,
|
|
||||||
deadlineExceeded: $deadlineExceeded,
|
|
||||||
limitExceeded: $limitExceeded,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -296,34 +254,30 @@ class Kernel implements KernelInterface
|
|||||||
*/
|
*/
|
||||||
protected function parameters(): array
|
protected function parameters(): array
|
||||||
{
|
{
|
||||||
$projectDir = $this->options->paths->project;
|
|
||||||
$cacheDir = $this->options->paths->cache($this->environment());
|
|
||||||
$logsDir = $this->options->paths->logs();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'kernel.project_dir' => realpath($projectDir) ?: $projectDir,
|
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
|
||||||
'kernel.environment' => $this->environment(),
|
'kernel.environment' => $this->environment,
|
||||||
'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
|
'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' => '%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.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
|
||||||
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%',
|
'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.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%',
|
||||||
'kernel.debug' => $this->debug(),
|
'kernel.debug' => $this->debug,
|
||||||
'kernel.build_dir' => realpath($cacheDir) ?: $cacheDir,
|
'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(),
|
||||||
'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
|
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(),
|
||||||
'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
|
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(),
|
||||||
'kernel.charset' => 'UTF-8',
|
'kernel.charset' => $this->getCharset(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function environment(): string
|
public function environment(): string
|
||||||
{
|
{
|
||||||
return $this->options->environment;
|
return $this->environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function debug(): bool
|
public function debug(): bool
|
||||||
{
|
{
|
||||||
return $this->options->debug;
|
return $this->debug;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function container(): ContainerInterface
|
public function container(): ContainerInterface
|
||||||
@@ -337,7 +291,61 @@ class Kernel implements KernelInterface
|
|||||||
|
|
||||||
public function getStartTime(): float
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -372,9 +380,9 @@ class Kernel implements KernelInterface
|
|||||||
protected function configureContainer(Builder $builder): void
|
protected function configureContainer(Builder $builder): void
|
||||||
{
|
{
|
||||||
// Service definitions
|
// Service definitions
|
||||||
$projectDir = $this->options->paths->project;
|
$projectDir = $this->folderRoot();
|
||||||
$moduleDir = $projectDir . '/modules';
|
$moduleDir = $projectDir . '/modules';
|
||||||
$environment = $this->environment();
|
$environment = $this->environment;
|
||||||
|
|
||||||
$builder->addDefinitions([
|
$builder->addDefinitions([
|
||||||
|
|
||||||
@@ -387,9 +395,6 @@ class Kernel implements KernelInterface
|
|||||||
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
|
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
|
||||||
Container::class => \DI\get(ContainerInterface::class),
|
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) {
|
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
|
||||||
$logConfig = $this->config['log'] ?? [];
|
$logConfig = $this->config['log'] ?? [];
|
||||||
|
|
||||||
@@ -400,7 +405,7 @@ class Kernel implements KernelInterface
|
|||||||
|
|
||||||
return new TenantAwareLogger(
|
return new TenantAwareLogger(
|
||||||
$this->logger,
|
$this->logger,
|
||||||
$c->get(TenantContextInterface::class),
|
$c->get(SessionTenant::class),
|
||||||
$logDir,
|
$logDir,
|
||||||
$channel,
|
$channel,
|
||||||
$level,
|
$level,
|
||||||
@@ -408,8 +413,8 @@ class Kernel implements KernelInterface
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
|
// EventBus as singleton for consistent event handling
|
||||||
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
|
EventBus::class => \DI\create(EventBus::class),
|
||||||
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
|
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
|
||||||
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
||||||
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
|
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
|
||||||
@@ -428,13 +433,13 @@ class Kernel implements KernelInterface
|
|||||||
$cache = new $storeClass($projectDir);
|
$cache = new $storeClass($projectDir);
|
||||||
|
|
||||||
// Set tenant/user context if available
|
// Set tenant/user context if available
|
||||||
if ($c->has(TenantContextInterface::class)) {
|
if ($c->has(SessionTenant::class)) {
|
||||||
$tenantContext = $c->get(TenantContextInterface::class);
|
$tenant = $c->get(SessionTenant::class);
|
||||||
$cache->setTenantContext($tenantContext->identifier());
|
$cache->setTenantContext($tenant->identifier());
|
||||||
}
|
}
|
||||||
if ($c->has(IdentityContextInterface::class)) {
|
if ($c->has(SessionIdentity::class)) {
|
||||||
$identityContext = $c->get(IdentityContextInterface::class);
|
$identity = $c->get(SessionIdentity::class);
|
||||||
$cache->setUserContext($identityContext->identifier());
|
$cache->setUserContext($identity->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cache;
|
return $cache;
|
||||||
@@ -457,13 +462,13 @@ class Kernel implements KernelInterface
|
|||||||
$cache = new $storeClass($projectDir);
|
$cache = new $storeClass($projectDir);
|
||||||
|
|
||||||
// Set tenant/user context if available
|
// Set tenant/user context if available
|
||||||
if ($c->has(TenantContextInterface::class)) {
|
if ($c->has(SessionTenant::class)) {
|
||||||
$tenantContext = $c->get(TenantContextInterface::class);
|
$tenant = $c->get(SessionTenant::class);
|
||||||
$cache->setTenantContext($tenantContext->identifier());
|
$cache->setTenantContext($tenant->identifier());
|
||||||
}
|
}
|
||||||
if ($c->has(IdentityContextInterface::class)) {
|
if ($c->has(SessionIdentity::class)) {
|
||||||
$identityContext = $c->get(IdentityContextInterface::class);
|
$identity = $c->get(SessionIdentity::class);
|
||||||
$cache->setUserContext($identityContext->identifier());
|
$cache->setUserContext($identity->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cache;
|
return $cache;
|
||||||
@@ -486,13 +491,13 @@ class Kernel implements KernelInterface
|
|||||||
$cache = new $storeClass($projectDir);
|
$cache = new $storeClass($projectDir);
|
||||||
|
|
||||||
// Set tenant/user context if available
|
// Set tenant/user context if available
|
||||||
if ($c->has(TenantContextInterface::class)) {
|
if ($c->has(SessionTenant::class)) {
|
||||||
$tenantContext = $c->get(TenantContextInterface::class);
|
$tenant = $c->get(SessionTenant::class);
|
||||||
$cache->setTenantContext($tenantContext->identifier());
|
$cache->setTenantContext($tenant->identifier());
|
||||||
}
|
}
|
||||||
if ($c->has(IdentityContextInterface::class)) {
|
if ($c->has(SessionIdentity::class)) {
|
||||||
$identityContext = $c->get(IdentityContextInterface::class);
|
$identity = $c->get(SessionIdentity::class);
|
||||||
$cache->setUserContext($identityContext->identifier());
|
$cache->setUserContext($identity->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $cache;
|
return $cache;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ use Psr\Log\NullLogger;
|
|||||||
*
|
*
|
||||||
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
|
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
|
||||||
* DI container definition in Kernel::configureContainer(), because that
|
* 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']:
|
* Supported config keys inside $config['log']:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace KTXC\Logger;
|
namespace KTXC\Logger;
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +20,7 @@ use Psr\Log\LoggerInterface;
|
|||||||
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
|
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
|
||||||
* Messages that carry tenant "system" always go to the global logger.
|
* 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().
|
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
|
||||||
*/
|
*/
|
||||||
class TenantAwareLogger implements LoggerInterface
|
class TenantAwareLogger implements LoggerInterface
|
||||||
@@ -30,7 +30,7 @@ class TenantAwareLogger implements LoggerInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
|
* @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 $logDir Base log directory (e.g. /var/www/app/var/log).
|
||||||
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
|
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
|
||||||
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
|
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
|
||||||
@@ -38,7 +38,7 @@ class TenantAwareLogger implements LoggerInterface
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LoggerInterface $globalLogger,
|
private readonly LoggerInterface $globalLogger,
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
private readonly string $logDir,
|
private readonly string $logDir,
|
||||||
private readonly string $channel = 'app',
|
private readonly string $channel = 'app',
|
||||||
private readonly string $minLevel = 'debug',
|
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.
|
// Resolve current tenant id; fall back to 'system' for CLI / boot phase.
|
||||||
$tenantId = 'system';
|
$tenantId = 'system';
|
||||||
if ($this->tenantContext->configured()) {
|
if ($this->sessionTenant->configured()) {
|
||||||
$tenantId = $this->tenantContext->identifier() ?? 'system';
|
$tenantId = $this->sessionTenant->identifier() ?? 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject tenant id as a reserved context key that concrete loggers extract.
|
// Inject tenant id as a reserved context key that concrete loggers extract.
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject
|
|||||||
'enabled' => $this->enabled,
|
'enabled' => $this->enabled,
|
||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'domains' => $this->domains?->getArrayCopy(),
|
'domains' => $this->domains,
|
||||||
'configuration' => $this->configuration?->jsonSerialize(),
|
'configuration' => $this->configuration,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
namespace KTXC\Module;
|
namespace KTXC\Module;
|
||||||
|
|
||||||
use KTXC\Service\FirewallService;
|
|
||||||
use KTXF\Event\DeliveryMode;
|
|
||||||
use KTXF\Event\EventListenerRegistry;
|
|
||||||
use KTXF\Event\SecurityEvent;
|
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
use KTXF\Module\ModuleConsoleInterface;
|
use KTXF\Module\ModuleConsoleInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
@@ -17,36 +13,7 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
*/
|
*/
|
||||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct() {}
|
||||||
private readonly EventListenerRegistry $events,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
$this->events->listen(
|
|
||||||
'core',
|
|
||||||
SecurityEvent::AUTH_FAILURE,
|
|
||||||
FirewallService::class,
|
|
||||||
'handleAuthFailure',
|
|
||||||
priority: 100,
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
SecurityEvent::AUTH_FAILURE,
|
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
|
||||||
SecurityEvent::ACCESS_DENIED,
|
|
||||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
|
||||||
] as $event) {
|
|
||||||
$this->events->listen(
|
|
||||||
'core',
|
|
||||||
$event,
|
|
||||||
FirewallService::class,
|
|
||||||
'logSecurityEvent',
|
|
||||||
DeliveryMode::Deferred,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): string
|
public function handle(): string
|
||||||
{
|
{
|
||||||
@@ -132,25 +99,12 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
public function registerCI(): array
|
public function registerCI(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
\KTXC\Console\ModuleListCommand::class,
|
||||||
\KTXC\Console\Module\ModuleListCommand::class,
|
\KTXC\Console\ModuleEnableCommand::class,
|
||||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
\KTXC\Console\ModuleDisableCommand::class,
|
||||||
\KTXC\Console\Module\ModuleDisableCommand::class,
|
\KTXC\Console\ModuleInstallCommand::class,
|
||||||
\KTXC\Console\Module\ModuleInstallCommand::class,
|
\KTXC\Console\ModuleUninstallCommand::class,
|
||||||
\KTXC\Console\Module\ModuleUninstallCommand::class,
|
\KTXC\Console\ModuleUpgradeCommand::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,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace KTXC\Module;
|
namespace KTXC\Module;
|
||||||
|
|
||||||
use Composer\Autoload\ClassLoader;
|
use KTXC\Server;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom autoloader for modules that allows PascalCase namespaces
|
* Custom autoloader for modules that allows PascalCase namespaces
|
||||||
@@ -20,10 +20,7 @@ class ModuleAutoloader
|
|||||||
private array $namespaceMap = [];
|
private array $namespaceMap = [];
|
||||||
private bool $scanned = false;
|
private bool $scanned = false;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(string $modulesRoot)
|
||||||
string $modulesRoot,
|
|
||||||
private readonly ?ClassLoader $composerLoader = null,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
$this->modulesRoot = rtrim($modulesRoot, '/');
|
$this->modulesRoot = rtrim($modulesRoot, '/');
|
||||||
}
|
}
|
||||||
@@ -76,9 +73,10 @@ class ModuleAutoloader
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register module namespaces with Composer ClassLoader
|
// Register module namespaces with Composer ClassLoader
|
||||||
if ($this->composerLoader !== null) {
|
$composerLoader = Server::getComposerLoader();
|
||||||
|
if ($composerLoader !== null) {
|
||||||
foreach ($this->namespaceMap as $namespace => $folderName) {
|
foreach ($this->namespaceMap as $namespace => $folderName) {
|
||||||
$this->composerLoader->addPsr4(
|
$composerLoader->addPsr4(
|
||||||
'KTXM\\' . $namespace . '\\',
|
'KTXM\\' . $namespace . '\\',
|
||||||
$this->modulesRoot . '/' . $folderName . '/lib/'
|
$this->modulesRoot . '/' . $folderName . '/lib/'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -276,13 +276,12 @@ class ModuleManager
|
|||||||
try {
|
try {
|
||||||
$module->boot();
|
$module->boot();
|
||||||
$this->logger->debug('Module booted', ['handle' => $handle]);
|
$this->logger->debug('Module booted', ['handle' => $handle]);
|
||||||
} catch (\Throwable $e) {
|
} catch (Exception $e) {
|
||||||
$this->logger->error('Module boot failed: ' . $handle, [
|
$this->logger->error('Module boot failed: ' . $handle, [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
'code' => $e->getCode(),
|
'code' => $e->getCode(),
|
||||||
]);
|
]);
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace KTXC\Routing;
|
|||||||
|
|
||||||
use DI\Attribute\Inject;
|
use DI\Attribute\Inject;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Http\Request\RequestInputParameters;
|
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\Injection\Container;
|
use KTXC\Injection\Container;
|
||||||
use KTXC\Module\ModuleManager;
|
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).
|
* 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.
|
* Performs light argument resolution: Request object, route params, body fields, full body for array params.
|
||||||
* Precedence on name collision: route params > body fields > query params > parameter default.
|
|
||||||
*/
|
*/
|
||||||
public function dispatch(Route $route, Request $request): ?Response
|
public function dispatch(Route $route, Request $request): ?Response
|
||||||
{
|
{
|
||||||
@@ -226,8 +224,7 @@ class Router
|
|||||||
try {
|
try {
|
||||||
$requestParameters = $request->getPayload();
|
$requestParameters = $request->getPayload();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// ignore payload errors, fall back to an empty parameter bag
|
// ignore payload errors
|
||||||
$requestParameters = new RequestInputParameters([]);
|
|
||||||
}
|
}
|
||||||
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
|
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
|
||||||
$routeParams = $route->params ?? [];
|
$routeParams = $route->params ?? [];
|
||||||
@@ -261,11 +258,6 @@ class Router
|
|||||||
$callArgs[] = $requestParameters->get($reflectionParameterName);
|
$callArgs[] = $requestParameters->get($reflectionParameterName);
|
||||||
continue;
|
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 method parameter did not match, but has a default value, use that
|
||||||
if ($reflectionParameter->isDefaultValueAvailable()) {
|
if ($reflectionParameter->isDefaultValueAvailable()) {
|
||||||
$callArgs[] = $reflectionParameter->getDefaultValue();
|
$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),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +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\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();
|
|
||||||
|
|
||||||
return $this->kernel->executionRunner()->execute(
|
|
||||||
ExecutionDescriptor::http(),
|
|
||||||
function () use ($request, $send): Response {
|
|
||||||
$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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest;
|
|||||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||||
use KTXC\Service\TokenService;
|
use KTXC\Service\TokenService;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Cache\CacheScope;
|
use KTXF\Cache\CacheScope;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
@@ -26,13 +26,13 @@ class AuthenticationManager
|
|||||||
private string $securityCode;
|
private string $securityCode;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenant,
|
||||||
private readonly EphemeralCacheInterface $cache,
|
private readonly EphemeralCacheInterface $cache,
|
||||||
private readonly ProviderManager $providerManager,
|
private readonly ProviderManager $providerManager,
|
||||||
private readonly TokenService $tokenService,
|
private readonly TokenService $tokenService,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
) {
|
) {
|
||||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
$this->securityCode = $this->tenant->configuration()->security()->code();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -75,7 +75,7 @@ class AuthenticationManager
|
|||||||
$methods = $this->methodsConfigured();
|
$methods = $this->methodsConfigured();
|
||||||
|
|
||||||
$session = AuthenticationSession::create(
|
$session = AuthenticationSession::create(
|
||||||
$this->tenantContext->identifier(),
|
$this->tenant->identifier(),
|
||||||
AuthenticationSession::STATE_FRESH
|
AuthenticationSession::STATE_FRESH
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class AuthenticationManager
|
|||||||
// Filter to non-redirect methods since redirects don't need identity first
|
// Filter to non-redirect methods since redirects don't need identity first
|
||||||
$methods = $this->methodsConfigured();
|
$methods = $this->methodsConfigured();
|
||||||
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect'));
|
$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
|
// Store identity in session without validating to prevent enumeration
|
||||||
$session->setMethods(array_column($methods, 'id'), $require);
|
$session->setMethods(array_column($methods, 'id'), $require);
|
||||||
@@ -155,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
|
// Build provider context
|
||||||
$context = $this->buildProviderContext($session, $method);
|
$context = $this->buildProviderContext($session, $method);
|
||||||
|
|
||||||
@@ -429,7 +422,7 @@ class AuthenticationManager
|
|||||||
$session->methodCompleted($method);
|
$session->methodCompleted($method);
|
||||||
|
|
||||||
// Check if MFA is required
|
// Check if MFA is required
|
||||||
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
|
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
|
||||||
if ($require > 1) {
|
if ($require > 1) {
|
||||||
$remainingMethods = $this->methodsConfigured([$method]);
|
$remainingMethods = $this->methodsConfigured([$method]);
|
||||||
// Filter out redirect methods - they can't be used as secondary factors
|
// Filter out redirect methods - they can't be used as secondary factors
|
||||||
@@ -523,7 +516,7 @@ class AuthenticationManager
|
|||||||
|
|
||||||
$accessToken = $this->tokenService->createToken(
|
$accessToken = $this->tokenService->createToken(
|
||||||
[
|
[
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenant->identifier(),
|
||||||
'identifier' => $user->getId(),
|
'identifier' => $user->getId(),
|
||||||
'identity' => $user->getIdentity(),
|
'identity' => $user->getIdentity(),
|
||||||
'label' => $user->getLabel(),
|
'label' => $user->getLabel(),
|
||||||
@@ -585,7 +578,7 @@ class AuthenticationManager
|
|||||||
*/
|
*/
|
||||||
private function getProviderConfig(string $method): array
|
private function getProviderConfig(string $method): array
|
||||||
{
|
{
|
||||||
$providers = $this->tenantContext->configuration()->authentication()->providers();
|
$providers = $this->tenant->configuration()->authentication()->providers();
|
||||||
return $providers[$method]['config'] ?? [];
|
return $providers[$method]['config'] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +628,7 @@ class AuthenticationManager
|
|||||||
*/
|
*/
|
||||||
private function methodsConfigured(array $methodsCompleted = []): array
|
private function methodsConfigured(array $methodsCompleted = []): array
|
||||||
{
|
{
|
||||||
$tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
|
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
|
||||||
$methods = [];
|
$methods = [];
|
||||||
|
|
||||||
foreach ($tenantProviders as $providerId => $providerConfiguration) {
|
foreach ($tenantProviders as $providerId => $providerConfiguration) {
|
||||||
@@ -669,7 +662,7 @@ class AuthenticationManager
|
|||||||
private function createTokens(User $user, bool $mfaVerified = false): array
|
private function createTokens(User $user, bool $mfaVerified = false): array
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenant->identifier(),
|
||||||
'identifier' => $user->getId(),
|
'identifier' => $user->getId(),
|
||||||
'identity' => $user->getIdentity(),
|
'identity' => $user->getIdentity(),
|
||||||
'label' => $user->getLabel(),
|
'label' => $user->getLabel(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace KTXC\Security\Authorization;
|
namespace KTXC\Security\Authorization;
|
||||||
|
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission Checker
|
* Permission Checker
|
||||||
@@ -11,7 +11,7 @@ use KTXC\Context\IdentityContextInterface;
|
|||||||
class PermissionChecker
|
class PermissionChecker
|
||||||
{
|
{
|
||||||
public function __construct(
|
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
|
public function can(string $permission, mixed $resource = null): bool
|
||||||
{
|
{
|
||||||
$identity = $this->identityContext->identity();
|
$identity = $this->sessionIdentity->identity();
|
||||||
|
|
||||||
if (!$identity) {
|
if (!$identity) {
|
||||||
return false;
|
return false;
|
||||||
@@ -113,7 +113,7 @@ class PermissionChecker
|
|||||||
*/
|
*/
|
||||||
public function getUserPermissions(): array
|
public function getUserPermissions(): array
|
||||||
{
|
{
|
||||||
$identity = $this->identityContext->identity();
|
$identity = $this->sessionIdentity->identity();
|
||||||
|
|
||||||
if (!$identity) {
|
if (!$identity) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC;
|
||||||
|
|
||||||
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Response\Response;
|
||||||
|
use KTXC\Module\ModuleAutoloader;
|
||||||
|
use KTXC\Module\ModuleManager;
|
||||||
|
use KTXF\Module\ModuleConsoleInterface;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\LazyCommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server class - entry point for the framework
|
||||||
|
* Handles configuration loading and kernel lifecycle
|
||||||
|
*/
|
||||||
|
class Server
|
||||||
|
{
|
||||||
|
private static $composerLoader = null;
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
private Kernel $kernel;
|
||||||
|
private array $config;
|
||||||
|
private string $rootDir;
|
||||||
|
|
||||||
|
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||||
|
{
|
||||||
|
self::$instance = $this;
|
||||||
|
|
||||||
|
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
$this->config = $this->loadConfig();
|
||||||
|
|
||||||
|
// Determine environment and debug mode
|
||||||
|
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||||
|
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||||
|
|
||||||
|
// Create kernel with configuration
|
||||||
|
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||||
|
|
||||||
|
// Register module autoloader for both HTTP and CLI contexts
|
||||||
|
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
|
||||||
|
$moduleAutoloader->register();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the application - handle incoming request and send response
|
||||||
|
*/
|
||||||
|
public function runHttp(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request = Request::createFromGlobals();
|
||||||
|
$response = $this->handle($request);
|
||||||
|
$response->send();
|
||||||
|
$this->terminate();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Last resort error handling for kernel initialization failures
|
||||||
|
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
$content = $this->kernel->debug()
|
||||||
|
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||||
|
: 'An error occurred. Please try again later.';
|
||||||
|
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||||
|
'Content-Type' => 'text/html; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
$response->send();
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run as a console application (CLI runtime).
|
||||||
|
*/
|
||||||
|
public function runConsole(): int
|
||||||
|
{
|
||||||
|
$this->kernel()->boot();
|
||||||
|
$container = $this->container();
|
||||||
|
|
||||||
|
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
|
||||||
|
|
||||||
|
/** @var ModuleManager $moduleManager */
|
||||||
|
$moduleManager = $container->get(ModuleManager::class);
|
||||||
|
|
||||||
|
foreach ($moduleManager->list() as $module) {
|
||||||
|
$instance = $module->instance();
|
||||||
|
if (!$instance instanceof ModuleConsoleInterface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
foreach ($instance->registerCI() as $commandClass) {
|
||||||
|
if (!class_exists($commandClass)) {
|
||||||
|
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->registerLazyCommand($console, $container, $commandClass);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $console->run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a request
|
||||||
|
*/
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return $this->kernel->handle($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminate the application - process deferred events
|
||||||
|
*/
|
||||||
|
public function terminate(): void
|
||||||
|
{
|
||||||
|
$this->kernel->processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the kernel instance
|
||||||
|
*/
|
||||||
|
public function kernel(): Kernel
|
||||||
|
{
|
||||||
|
return $this->kernel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the container instance
|
||||||
|
*/
|
||||||
|
public function container(): ContainerInterface
|
||||||
|
{
|
||||||
|
return $this->kernel->container();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application root directory
|
||||||
|
*/
|
||||||
|
public function rootDir(): string
|
||||||
|
{
|
||||||
|
return $this->rootDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the modules directory
|
||||||
|
*/
|
||||||
|
public function moduleDir(): string
|
||||||
|
{
|
||||||
|
return $this->rootDir . '/modules';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function varDir(): string
|
||||||
|
{
|
||||||
|
return $this->rootDir . '/var';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logDir(): string
|
||||||
|
{
|
||||||
|
return $this->varDir() . '/logs';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration value
|
||||||
|
*/
|
||||||
|
public function config(?string $key = null, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
if ($key === null) {
|
||||||
|
return $this->config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support dot notation: 'database.uri'
|
||||||
|
$keys = explode('.', $key);
|
||||||
|
$value = $this->config;
|
||||||
|
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
$value = $value[$k];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get environment
|
||||||
|
*/
|
||||||
|
public function environment(): string
|
||||||
|
{
|
||||||
|
return $this->kernel->environment();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if debug mode is enabled
|
||||||
|
*/
|
||||||
|
public function debug(): bool
|
||||||
|
{
|
||||||
|
return $this->kernel->debug();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from config directory
|
||||||
|
*/
|
||||||
|
protected function loadConfig(): array
|
||||||
|
{
|
||||||
|
$configFile = $this->rootDir . '/config/system.php';
|
||||||
|
|
||||||
|
if (!file_exists($configFile)) {
|
||||||
|
error_log('Configuration file not found: ' . $configFile);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = include $configFile;
|
||||||
|
|
||||||
|
if (!is_array($config)) {
|
||||||
|
throw new \RuntimeException('Configuration file must return an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the project root directory.
|
||||||
|
*
|
||||||
|
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||||
|
* We walk up the directory tree until we find composer.json.
|
||||||
|
*/
|
||||||
|
private function resolveProjectRoot(string $startDir): string
|
||||||
|
{
|
||||||
|
$dir = rtrim($startDir, '/');
|
||||||
|
if ($dir === '') {
|
||||||
|
return $startDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If startDir is a file path, use its directory.
|
||||||
|
if (is_file($dir)) {
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $dir;
|
||||||
|
while (true) {
|
||||||
|
if (is_file($current . '/composer.json')) {
|
||||||
|
return $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = dirname($current);
|
||||||
|
if ($parent === $current) {
|
||||||
|
// Reached filesystem root
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
$current = $parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Composer ClassLoader instance
|
||||||
|
*/
|
||||||
|
public static function setComposerLoader($loader): void
|
||||||
|
{
|
||||||
|
self::$composerLoader = $loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Composer ClassLoader instance
|
||||||
|
*/
|
||||||
|
public static function getComposerLoader()
|
||||||
|
{
|
||||||
|
return self::$composerLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current Application instance
|
||||||
|
*/
|
||||||
|
public static function getInstance(): ?self
|
||||||
|
{
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a single command via lazy loading using its #[AsCommand] attribute.
|
||||||
|
*/
|
||||||
|
private function registerLazyCommand(
|
||||||
|
ConsoleApplication $console,
|
||||||
|
ContainerInterface $container,
|
||||||
|
string $commandClass
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$ref = new \ReflectionClass($commandClass);
|
||||||
|
$attrs = $ref->getAttributes(AsCommand::class);
|
||||||
|
|
||||||
|
if (empty($attrs)) {
|
||||||
|
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attr = $attrs[0]->newInstance();
|
||||||
|
$console->add(new LazyCommand(
|
||||||
|
$attr->name,
|
||||||
|
[],
|
||||||
|
$attr->description ?? '',
|
||||||
|
$attr->hidden ?? false,
|
||||||
|
fn() => $container->get($commandClass)
|
||||||
|
));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ namespace KTXC\Service;
|
|||||||
use KTXC\Db\DataStore;
|
use KTXC\Db\DataStore;
|
||||||
use KTXC\Db\Collection;
|
use KTXC\Db\Collection;
|
||||||
use KTXC\Db\UTCDateTime;
|
use KTXC\Db\UTCDateTime;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
|
|
||||||
class ConfigurationService
|
class ConfigurationService
|
||||||
{
|
{
|
||||||
@@ -24,7 +24,7 @@ class ConfigurationService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
DataStore $store,
|
DataStore $store,
|
||||||
private readonly TenantContextInterface $tenantContext
|
private readonly SessionTenant $tenant
|
||||||
) {
|
) {
|
||||||
// DataStore provides selectCollection method
|
// DataStore provides selectCollection method
|
||||||
$this->collection = $store->selectCollection(self::TABLE_NAME);
|
$this->collection = $store->selectCollection(self::TABLE_NAME);
|
||||||
@@ -36,10 +36,10 @@ class ConfigurationService
|
|||||||
*/
|
*/
|
||||||
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
|
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
|
||||||
{
|
{
|
||||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
} elseif ($tenant === null) {
|
} elseif ($tenant === null) {
|
||||||
$tenant = $this->tenantContext->identifier();
|
$tenant = $this->tenant->identifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||||
@@ -54,10 +54,10 @@ class ConfigurationService
|
|||||||
*/
|
*/
|
||||||
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
|
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
|
||||||
{
|
{
|
||||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
} elseif ($tenant === null) {
|
} elseif ($tenant === null) {
|
||||||
$tenant = $this->tenantContext->identifier();
|
$tenant = $this->tenant->identifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = $this->determineType($value);
|
$type = $this->determineType($value);
|
||||||
@@ -84,10 +84,10 @@ class ConfigurationService
|
|||||||
*/
|
*/
|
||||||
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
|
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
|
||||||
{
|
{
|
||||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
} elseif ($tenant === null) {
|
} elseif ($tenant === null) {
|
||||||
$tenant = $this->tenantContext->identifier();
|
$tenant = $this->tenant->identifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
$filter = ['did' => $tenant];
|
$filter = ['did' => $tenant];
|
||||||
@@ -116,10 +116,10 @@ class ConfigurationService
|
|||||||
*/
|
*/
|
||||||
public function delete(string $path, string $key, ?string $tenant = null): bool
|
public function delete(string $path, string $key, ?string $tenant = null): bool
|
||||||
{
|
{
|
||||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
} elseif ($tenant === null) {
|
} elseif ($tenant === null) {
|
||||||
$tenant = $this->tenantContext->identifier();
|
$tenant = $this->tenant->identifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||||
@@ -131,10 +131,10 @@ class ConfigurationService
|
|||||||
*/
|
*/
|
||||||
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
|
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
|
||||||
{
|
{
|
||||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
} elseif ($tenant === null) {
|
} elseif ($tenant === null) {
|
||||||
$tenant = $this->tenantContext->identifier();
|
$tenant = $this->tenant->identifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
$filter = ['did' => $tenant];
|
$filter = ['did' => $tenant];
|
||||||
@@ -155,10 +155,10 @@ class ConfigurationService
|
|||||||
*/
|
*/
|
||||||
public function exists(string $path, string $key, ?string $tenant = null): bool
|
public function exists(string $path, string $key, ?string $tenant = null): bool
|
||||||
{
|
{
|
||||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||||
} elseif ($tenant === null) {
|
} elseif ($tenant === null) {
|
||||||
$tenant = $this->tenantContext->identifier();
|
$tenant = $this->tenant->identifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
|
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use KTXC\Http\Request\Request;
|
|||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventBus;
|
||||||
use KTXF\Event\SecurityEvent;
|
use KTXF\Event\SecurityEvent;
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
@@ -41,9 +41,33 @@ class FirewallService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FirewallStore $store,
|
private readonly FirewallStore $store,
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenant,
|
||||||
private readonly EventDispatcherInterface $events,
|
private readonly EventBus $eventBus
|
||||||
) {
|
) {
|
||||||
|
// Listen for auth failures to detect brute force
|
||||||
|
$this->eventBus->subscribe(
|
||||||
|
SecurityEvent::AUTH_FAILURE,
|
||||||
|
[$this, 'handleAuthFailure'],
|
||||||
|
100 // High priority
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log all security events asynchronously
|
||||||
|
$this->eventBus->subscribeAsync(
|
||||||
|
SecurityEvent::AUTH_FAILURE,
|
||||||
|
[$this, 'logSecurityEvent']
|
||||||
|
);
|
||||||
|
$this->eventBus->subscribeAsync(
|
||||||
|
SecurityEvent::AUTH_SUCCESS,
|
||||||
|
[$this, 'logSecurityEvent']
|
||||||
|
);
|
||||||
|
$this->eventBus->subscribeAsync(
|
||||||
|
SecurityEvent::ACCESS_DENIED,
|
||||||
|
[$this, 'logSecurityEvent']
|
||||||
|
);
|
||||||
|
$this->eventBus->subscribeAsync(
|
||||||
|
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||||
|
[$this, 'logSecurityEvent']
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +100,7 @@ class FirewallService
|
|||||||
return new FirewallAnalyzeResult(true);
|
return new FirewallAnalyzeResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
return new FirewallAnalyzeResult(true);
|
return new FirewallAnalyzeResult(true);
|
||||||
}
|
}
|
||||||
@@ -134,7 +158,7 @@ class FirewallService
|
|||||||
public function handleAuthFailure(SecurityEvent $event): void
|
public function handleAuthFailure(SecurityEvent $event): void
|
||||||
{
|
{
|
||||||
$ipAddress = $event->getIpAddress();
|
$ipAddress = $event->getIpAddress();
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||||
|
|
||||||
if (!$ipAddress || !$tenantId) {
|
if (!$ipAddress || !$tenantId) {
|
||||||
return;
|
return;
|
||||||
@@ -174,8 +198,8 @@ class FirewallService
|
|||||||
): void {
|
): void {
|
||||||
// Publish brute force event
|
// Publish brute force event
|
||||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||||
$event->setTenantId($this->tenantContext->identifier());
|
$event->setTenantId($this->tenant->identifier());
|
||||||
$this->events->dispatch($event);
|
$this->eventBus->publish($event);
|
||||||
|
|
||||||
// Auto-block the IP
|
// Auto-block the IP
|
||||||
$blockDuration = $this->getConfig(
|
$blockDuration = $this->getConfig(
|
||||||
@@ -196,7 +220,7 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function logSecurityEvent(SecurityEvent $event): void
|
public function logSecurityEvent(SecurityEvent $event): void
|
||||||
{
|
{
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,8 +283,8 @@ class FirewallService
|
|||||||
$rule->getId(),
|
$rule->getId(),
|
||||||
$rule->getReason()
|
$rule->getReason()
|
||||||
);
|
);
|
||||||
$event->setTenantId($this->tenantContext->identifier());
|
$event->setTenantId($this->tenant->identifier());
|
||||||
$this->events->dispatch($event);
|
$this->eventBus->publish($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -276,7 +300,7 @@ class FirewallService
|
|||||||
?string $createdBy = null,
|
?string $createdBy = null,
|
||||||
?int $durationSeconds = null
|
?int $durationSeconds = null
|
||||||
): FirewallRuleObject {
|
): FirewallRuleObject {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
}
|
}
|
||||||
@@ -316,7 +340,7 @@ class FirewallService
|
|||||||
$event->setIpAddress($ipAddress)
|
$event->setIpAddress($ipAddress)
|
||||||
->setReason($reason)
|
->setReason($reason)
|
||||||
->setTenantId($tenantId);
|
->setTenantId($tenantId);
|
||||||
$this->events->dispatch($event);
|
$this->eventBus->publish($event);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -329,7 +353,7 @@ class FirewallService
|
|||||||
?string $reason = null,
|
?string $reason = null,
|
||||||
?string $createdBy = null
|
?string $createdBy = null
|
||||||
): FirewallRuleObject {
|
): FirewallRuleObject {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
}
|
}
|
||||||
@@ -352,7 +376,7 @@ class FirewallService
|
|||||||
$event->setIpAddress($ipAddress)
|
$event->setIpAddress($ipAddress)
|
||||||
->setReason($reason)
|
->setReason($reason)
|
||||||
->setTenantId($tenantId);
|
->setTenantId($tenantId);
|
||||||
$this->events->dispatch($event);
|
$this->eventBus->publish($event);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -365,7 +389,7 @@ class FirewallService
|
|||||||
?string $reason = null,
|
?string $reason = null,
|
||||||
?string $createdBy = null
|
?string $createdBy = null
|
||||||
): FirewallRuleObject {
|
): FirewallRuleObject {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
}
|
}
|
||||||
@@ -395,7 +419,7 @@ class FirewallService
|
|||||||
?string $createdBy = null,
|
?string $createdBy = null,
|
||||||
?int $durationSeconds = null
|
?int $durationSeconds = null
|
||||||
): FirewallRuleObject {
|
): FirewallRuleObject {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||||
}
|
}
|
||||||
@@ -424,7 +448,7 @@ class FirewallService
|
|||||||
$event->setDeviceFingerprint($fingerprint)
|
$event->setDeviceFingerprint($fingerprint)
|
||||||
->setReason($reason)
|
->setReason($reason)
|
||||||
->setTenantId($tenantId);
|
->setTenantId($tenantId);
|
||||||
$this->events->dispatch($event);
|
$this->eventBus->publish($event);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -440,7 +464,7 @@ class FirewallService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify tenant ownership
|
// Verify tenant ownership
|
||||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +485,7 @@ class FirewallService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify tenant ownership
|
// Verify tenant ownership
|
||||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +501,7 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function listRules(bool $activeOnly = true): array
|
public function listRules(bool $activeOnly = true): array
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -494,7 +518,7 @@ class FirewallService
|
|||||||
?string $result = null,
|
?string $result = null,
|
||||||
int $limit = 100
|
int $limit = 100
|
||||||
): array {
|
): array {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -507,7 +531,7 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
if (!$tenantId) {
|
if (!$tenantId) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -532,7 +556,7 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
private function getConfig(string $key, mixed $default = null): mixed
|
private function getConfig(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$config = $this->tenantContext->configuration();
|
$config = $this->tenant->configuration();
|
||||||
$parts = explode('.', $key);
|
$parts = explode('.', $key);
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
foreach ($parts as $part) {
|
||||||
@@ -552,7 +576,7 @@ class FirewallService
|
|||||||
private function getActiveRules(): array
|
private function getActiveRules(): array
|
||||||
{
|
{
|
||||||
if ($this->rulesCache === null) {
|
if ($this->rulesCache === null) {
|
||||||
$tenantId = $this->tenantContext->identifier();
|
$tenantId = $this->tenant->identifier();
|
||||||
$this->rulesCache = $tenantId
|
$this->rulesCache = $tenantId
|
||||||
? $this->store->listRules($tenantId, true)
|
? $this->store->listRules($tenantId, true)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace KTXC\Service;
|
|||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
use KTXC\Models\Identity\User;
|
use KTXC\Models\Identity\User;
|
||||||
use KTXC\Resource\ProviderManager;
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,12 +23,12 @@ class SecurityService
|
|||||||
private string $securityCode;
|
private string $securityCode;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
private readonly TokenService $tokenService,
|
private readonly TokenService $tokenService,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
private readonly ProviderManager $providerManager,
|
private readonly ProviderManager $providerManager,
|
||||||
) {
|
) {
|
||||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +118,7 @@ class SecurityService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$context = new \KTXF\Security\Authentication\ProviderContext(
|
$context = new \KTXF\Security\Authentication\ProviderContext(
|
||||||
tenantId: $this->tenantContext->identifier(),
|
tenantId: $this->sessionTenant->identifier(),
|
||||||
userIdentity: $identity,
|
userIdentity: $identity,
|
||||||
);
|
);
|
||||||
$result = $provider->verify($context, $credentials);
|
$result = $provider->verify($context, $credentials);
|
||||||
|
|||||||
@@ -21,31 +21,6 @@ class TenantService
|
|||||||
return $this->store->fetch($identifier);
|
return $this->store->fetch($identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List all tenants keyed by id
|
|
||||||
*
|
|
||||||
* @return array<string, TenantObject>
|
|
||||||
*/
|
|
||||||
public function list(): array
|
|
||||||
{
|
|
||||||
return $this->store->list();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deposit(TenantObject $tenant): ?TenantObject
|
|
||||||
{
|
|
||||||
$domains = $tenant->getDomains();
|
|
||||||
if ($domains === null || count($domains) === 0) {
|
|
||||||
throw new \InvalidArgumentException('A tenant must have at least one domain.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->store->deposit($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(TenantObject $tenant): void
|
|
||||||
{
|
|
||||||
$this->store->destroy($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Settings
|
// Settings
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Cache\CacheScope;
|
use KTXF\Cache\CacheScope;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class TokenService
|
|||||||
private string $algorithm = 'HS256';
|
private string $algorithm = 'HS256';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $sessionTenant,
|
||||||
private readonly EphemeralCacheInterface $cache,
|
private readonly EphemeralCacheInterface $cache,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Models\Identity\User;
|
use KTXC\Models\Identity\User;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
|
|
||||||
class UserAccountsService
|
class UserAccountsService
|
||||||
{
|
{
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly UserAccountsStore $userStore
|
private readonly UserAccountsStore $userStore
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ class UserAccountsService
|
|||||||
*/
|
*/
|
||||||
public function listUsers(array $filters = []): array
|
public function listUsers(array $filters = []): array
|
||||||
{
|
{
|
||||||
$users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters);
|
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters);
|
||||||
|
|
||||||
// Remove sensitive data
|
// Remove sensitive data
|
||||||
foreach ($users as &$user) {
|
foreach ($users as &$user) {
|
||||||
@@ -38,7 +38,7 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function fetchByIdentity(string $identifier): User | null
|
public function fetchByIdentity(string $identifier): User | null
|
||||||
{
|
{
|
||||||
$data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
|
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -50,32 +50,32 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function fetchByIdentifier(string $identifier): array | null
|
public function fetchByIdentifier(string $identifier): array | null
|
||||||
{
|
{
|
||||||
return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier);
|
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fetchByIdentityRaw(string $identifier): array | null
|
public function fetchByIdentityRaw(string $identifier): array | null
|
||||||
{
|
{
|
||||||
return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
|
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fetchByProviderSubject(string $provider, string $subject): ?array
|
public function fetchByProviderSubject(string $provider, string $subject): ?array
|
||||||
{
|
{
|
||||||
return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject);
|
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createUser(array $userData): array
|
public function createUser(array $userData): array
|
||||||
{
|
{
|
||||||
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
|
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateUser(string $uid, array $updates): bool
|
public function updateUser(string $uid, array $updates): bool
|
||||||
{
|
{
|
||||||
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
|
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteUser(string $uid): bool
|
public function deleteUser(string $uid): bool
|
||||||
{
|
{
|
||||||
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
|
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -84,7 +84,7 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function fetchProfile(string $uid): ?array
|
public function fetchProfile(string $uid): ?array
|
||||||
{
|
{
|
||||||
return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid);
|
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function storeProfile(string $uid, array $profileFields): bool
|
public function storeProfile(string $uid, array $profileFields): bool
|
||||||
@@ -109,7 +109,7 @@ class UserAccountsService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields);
|
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -118,12 +118,12 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
|
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
|
||||||
{
|
{
|
||||||
return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten);
|
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function storeSettings(array $settings): bool
|
public function storeSettings(array $settings): bool
|
||||||
{
|
{
|
||||||
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
|
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use KTXC\Stores\UserRolesStore;
|
use KTXC\Stores\UserRolesStore;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
|
|||||||
class UserRolesService
|
class UserRolesService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly UserRolesStore $roleStore,
|
private readonly UserRolesStore $roleStore,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {}
|
) {}
|
||||||
@@ -26,7 +26,7 @@ class UserRolesService
|
|||||||
*/
|
*/
|
||||||
public function listRoles(): array
|
public function listRoles(): array
|
||||||
{
|
{
|
||||||
return $this->roleStore->listRoles($this->tenantContext->identifier());
|
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,7 +34,7 @@ class UserRolesService
|
|||||||
*/
|
*/
|
||||||
public function getRole(string $rid): ?array
|
public function getRole(string $rid): ?array
|
||||||
{
|
{
|
||||||
return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid);
|
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,11 +45,11 @@ class UserRolesService
|
|||||||
$this->validateRoleData($roleData);
|
$this->validateRoleData($roleData);
|
||||||
|
|
||||||
$this->logger->info('Creating role', [
|
$this->logger->info('Creating role', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'label' => $roleData['label'] ?? 'Unnamed'
|
'label' => $roleData['label'] ?? 'Unnamed'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData);
|
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,11 +70,11 @@ class UserRolesService
|
|||||||
$this->validateRoleData($updates, false);
|
$this->validateRoleData($updates, false);
|
||||||
|
|
||||||
$this->logger->info('Updating role', [
|
$this->logger->info('Updating role', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'rid' => $rid
|
'rid' => $rid
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates);
|
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,17 +93,17 @@ class UserRolesService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if role is assigned to users
|
// Check if role is assigned to users
|
||||||
$userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
|
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||||
if ($userCount > 0) {
|
if ($userCount > 0) {
|
||||||
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
|
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->info('Deleting role', [
|
$this->logger->info('Deleting role', [
|
||||||
'tenant' => $this->tenantContext->identifier(),
|
'tenant' => $this->tenantIdentity->identifier(),
|
||||||
'rid' => $rid
|
'rid' => $rid
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid);
|
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +111,7 @@ class UserRolesService
|
|||||||
*/
|
*/
|
||||||
public function getRoleUserCount(string $rid): int
|
public function getRoleUserCount(string $rid): int
|
||||||
{
|
{
|
||||||
return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
|
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC;
|
||||||
|
|
||||||
|
use KTXC\Models\Identity\User;
|
||||||
|
|
||||||
|
class SessionIdentity
|
||||||
|
{
|
||||||
|
private bool $identityLock = false;
|
||||||
|
private ?User $identityData = null;
|
||||||
|
|
||||||
|
public function initialize(User $identity, bool $lock = true): void
|
||||||
|
{
|
||||||
|
if ($this->identityLock) {
|
||||||
|
throw new \RuntimeException('Identity is already locked and cannot be changed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->identityData = $identity;
|
||||||
|
$this->identityLock = $lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identity(): ?User
|
||||||
|
{
|
||||||
|
return $this->identityData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identifier(): ?string
|
||||||
|
{
|
||||||
|
return $this->identityData?->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): ?string
|
||||||
|
{
|
||||||
|
return $this->identityData?->getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mailAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->identityData?->getIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nameFirst(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nameLast(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return $this->identityData?->getPermissions() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function roles(): array
|
||||||
|
{
|
||||||
|
return $this->identityData?->getRoles() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPermission(string $permission): bool
|
||||||
|
{
|
||||||
|
$permissions = $this->permissions();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (in_array($permission, $permissions)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard match
|
||||||
|
foreach ($permissions as $userPerm) {
|
||||||
|
if (str_ends_with($userPerm, '.*')) {
|
||||||
|
$prefix = substr($userPerm, 0, -2);
|
||||||
|
if (str_starts_with($permission, $prefix . '.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full wildcard
|
||||||
|
if (in_array('*', $permissions)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRole(string $role): bool
|
||||||
|
{
|
||||||
|
return in_array($role, $this->roles());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC;
|
||||||
|
|
||||||
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
|
||||||
|
class SessionTenant
|
||||||
|
{
|
||||||
|
private ?TenantObject $tenant = null;
|
||||||
|
private ?string $domain = null;
|
||||||
|
private bool $configured = false;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TenantService $tenantService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the tenant information
|
||||||
|
* This method is called by the SecurityMiddleware after validation
|
||||||
|
*/
|
||||||
|
public function configure(string $domain): void
|
||||||
|
{
|
||||||
|
if ($this->configured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tenant = $this->tenantService->fetchByDomain($domain);
|
||||||
|
if ($tenant) {
|
||||||
|
$this->domain = $domain;
|
||||||
|
$this->tenant = $tenant;
|
||||||
|
$this->configured = true;
|
||||||
|
} else {
|
||||||
|
$this->domain = null;
|
||||||
|
$this->tenant = null;
|
||||||
|
$this->configured = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the tenant by its identifier (for console / CLI usage).
|
||||||
|
*/
|
||||||
|
public function configureById(string $identifier): void
|
||||||
|
{
|
||||||
|
if ($this->configured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tenant = $this->tenantService->fetchById($identifier);
|
||||||
|
if ($tenant) {
|
||||||
|
$this->domain = $identifier;
|
||||||
|
$this->tenant = $tenant;
|
||||||
|
$this->configured = true;
|
||||||
|
} else {
|
||||||
|
$this->domain = null;
|
||||||
|
$this->tenant = null;
|
||||||
|
$this->configured = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the tenant configured
|
||||||
|
*/
|
||||||
|
public function configured(): bool
|
||||||
|
{
|
||||||
|
return $this->configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the tenant enabled
|
||||||
|
*/
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return $this->tenant?->getEnabled() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current tenant domain
|
||||||
|
*/
|
||||||
|
public function domain(): ?string
|
||||||
|
{
|
||||||
|
return $this->domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current tenant identifier
|
||||||
|
*/
|
||||||
|
public function identifier(): ?string
|
||||||
|
{
|
||||||
|
return $this->tenant?->getIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current tenant label
|
||||||
|
*/
|
||||||
|
public function label(): ?string
|
||||||
|
{
|
||||||
|
return $this->tenant?->getLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current tenant configuration
|
||||||
|
*/
|
||||||
|
public function configuration(): TenantConfiguration
|
||||||
|
{
|
||||||
|
return $this->tenant?->getConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current tenant settings
|
||||||
|
*/
|
||||||
|
public function settings(): array
|
||||||
|
{
|
||||||
|
return $this->tenant?->getSettings() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all identity providers configuration for this tenant
|
||||||
|
* @return array<string, array> Map of provider ID to provider config
|
||||||
|
*/
|
||||||
|
public function identityProviders(): array
|
||||||
|
{
|
||||||
|
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration for a specific identity provider
|
||||||
|
*
|
||||||
|
* @param string $providerId Provider identifier (e.g., 'default', 'oidc')
|
||||||
|
* @return array|null Provider configuration or null if not found
|
||||||
|
*/
|
||||||
|
public function identityProviderConfig(string $providerId): ?array
|
||||||
|
{
|
||||||
|
$providers = $this->identityProviders();
|
||||||
|
return $providers[$providerId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an identity provider is enabled for this tenant
|
||||||
|
*/
|
||||||
|
public function isIdentityProviderEnabled(string $providerId): bool
|
||||||
|
{
|
||||||
|
$config = $this->identityProviderConfig($providerId);
|
||||||
|
return $config !== null && ($config['enabled'] ?? false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace KTXC\Stores;
|
namespace KTXC\Stores;
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
use KTXC\Db\DataStore;
|
||||||
use KTXC\Db\ObjectId;
|
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
|
|
||||||
class TenantStore
|
class TenantStore
|
||||||
@@ -53,9 +52,7 @@ class TenantStore
|
|||||||
|
|
||||||
private function create(TenantObject $entry): ?TenantObject
|
private function create(TenantObject $entry): ?TenantObject
|
||||||
{
|
{
|
||||||
$document = $entry->jsonSerialize();
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
|
||||||
unset($document['id']);
|
|
||||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
|
|
||||||
$entry->setId((string)$result->getInsertedId());
|
$entry->setId((string)$result->getInsertedId());
|
||||||
return $entry;
|
return $entry;
|
||||||
}
|
}
|
||||||
@@ -64,9 +61,7 @@ class TenantStore
|
|||||||
{
|
{
|
||||||
$id = $entry->getId();
|
$id = $entry->getId();
|
||||||
if (!$id) { return null; }
|
if (!$id) { return null; }
|
||||||
$document = $entry->jsonSerialize();
|
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||||
unset($document['id']);
|
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $document]);
|
|
||||||
return $entry;
|
return $entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +69,7 @@ class TenantStore
|
|||||||
{
|
{
|
||||||
$id = $entry->getId();
|
$id = $entry->getId();
|
||||||
if (!$id) { return; }
|
if (!$id) { return; }
|
||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ class UserAccountsStore
|
|||||||
$userData['uid'] = $userData['uid'] ?? UUID::v4();
|
$userData['uid'] = $userData['uid'] ?? UUID::v4();
|
||||||
$userData['enabled'] = $userData['enabled'] ?? true;
|
$userData['enabled'] = $userData['enabled'] ?? true;
|
||||||
$userData['roles'] = $userData['roles'] ?? [];
|
$userData['roles'] = $userData['roles'] ?? [];
|
||||||
$userData['profile'] = (object) ($userData['profile'] ?? []);
|
$userData['profile'] = $userData['profile'] ?? [];
|
||||||
$userData['settings'] = (object) ($userData['settings'] ?? []);
|
$userData['settings'] = $userData['settings'] ?? [];
|
||||||
|
|
||||||
$this->store->selectCollection('user_accounts')->insertOne($userData);
|
$this->store->selectCollection('user_accounts')->insertOne($userData);
|
||||||
|
|
||||||
@@ -232,21 +232,12 @@ class UserAccountsStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$collection = $this->store->selectCollection('user_accounts');
|
|
||||||
|
|
||||||
// Repair legacy documents where 'profile' was stored as an empty BSON array:
|
|
||||||
// dot-notation $set below cannot add a named field to an array.
|
|
||||||
$collection->updateOne(
|
|
||||||
['tid' => $tenant, 'uid' => $uid, 'profile' => []],
|
|
||||||
['$set' => ['profile' => (object) []]]
|
|
||||||
);
|
|
||||||
|
|
||||||
$updates = [];
|
$updates = [];
|
||||||
foreach ($profileFields as $key => $value) {
|
foreach ($profileFields as $key => $value) {
|
||||||
$updates["profile.{$key}"] = $value;
|
$updates["profile.{$key}"] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $collection->updateOne(
|
$result = $this->store->selectCollection('user_accounts')->updateOne(
|
||||||
['tid' => $tenant, 'uid' => $uid],
|
['tid' => $tenant, 'uid' => $uid],
|
||||||
['$set' => $updates]
|
['$set' => $updates]
|
||||||
);
|
);
|
||||||
@@ -316,21 +307,12 @@ class UserAccountsStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$collection = $this->store->selectCollection('user_accounts');
|
|
||||||
|
|
||||||
// Repair legacy documents where 'settings' was stored as an empty BSON array:
|
|
||||||
// dot-notation $set below cannot add a named field to an array.
|
|
||||||
$collection->updateOne(
|
|
||||||
['tid' => $tenant, 'uid' => $uid, 'settings' => []],
|
|
||||||
['$set' => ['settings' => (object) []]]
|
|
||||||
);
|
|
||||||
|
|
||||||
$updates = [];
|
$updates = [];
|
||||||
foreach ($settings as $key => $value) {
|
foreach ($settings as $key => $value) {
|
||||||
$updates["settings.{$key}"] = $value;
|
$updates["settings.{$key}"] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $collection->updateOne(
|
$result = $this->store->selectCollection('user_accounts')->updateOne(
|
||||||
['tid' => $tenant, 'uid' => $uid],
|
['tid' => $tenant, 'uid' => $uid],
|
||||||
['$set' => $updates]
|
['$set' => $updates]
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-3
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use KTXC\Application;
|
use KTXC\Server;
|
||||||
|
|
||||||
// Capture Composer ClassLoader instance for compatibility
|
// Capture Composer ClassLoader instance for compatibility
|
||||||
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
|
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
$application = Application::create(dirname(__DIR__), $composerLoader);
|
$server = new Server(dirname(__DIR__));
|
||||||
$application->runHttp();
|
Server::setComposerLoader($composerLoader);
|
||||||
|
|
||||||
|
$server->runHttp();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
|
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
import { useL10n } from '@KTXC/composables/useL10n';
|
import { useL10n } from '@KTXC/composables/useL10n';
|
||||||
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
|
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
|
||||||
@@ -25,12 +25,33 @@ const menuEntries = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static list of menu modes shown as icon buttons
|
// Menu mode display info
|
||||||
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => [
|
const menuModeInfo = computed(() => {
|
||||||
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
|
switch (layoutStore.menuMode) {
|
||||||
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
|
case 'user-settings':
|
||||||
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
|
return {
|
||||||
]);
|
icon: 'mdi-account-cog',
|
||||||
|
label: t('systemMenu.personalSettings', 'Settings'),
|
||||||
|
toggleLabel: t('systemMenu.adminSettings', 'System'),
|
||||||
|
toggleIcon: 'mdi-shield-crown',
|
||||||
|
};
|
||||||
|
case 'admin-settings':
|
||||||
|
return {
|
||||||
|
icon: 'mdi-shield-crown',
|
||||||
|
label: t('systemMenu.adminSettings', 'System'),
|
||||||
|
toggleLabel: t('systemMenu.apps', 'Applications'),
|
||||||
|
toggleIcon: 'mdi-view-dashboard',
|
||||||
|
};
|
||||||
|
case 'apps':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: 'mdi-view-dashboard',
|
||||||
|
label: t('systemMenu.apps', 'Applications'),
|
||||||
|
toggleLabel: t('systemMenu.userSettings', 'Settings'),
|
||||||
|
toggleIcon: 'mdi-account-cog',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -82,74 +103,20 @@ export default {
|
|||||||
</v-list>
|
</v-list>
|
||||||
</perfect-scrollbar>
|
</perfect-scrollbar>
|
||||||
|
|
||||||
<!-- Menu Mode Switcher -->
|
<!-- Menu Mode Toggle -->
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
|
<v-list density="compact" class="pa-2">
|
||||||
<v-tooltip
|
<v-list-item
|
||||||
v-for="mode in menuModes"
|
rounded
|
||||||
:key="mode.value"
|
color="primary"
|
||||||
location="right"
|
@click="layoutStore.toggleMenuMode()"
|
||||||
|
:prepend-icon="menuModeInfo.toggleIcon"
|
||||||
|
class="menu-mode-toggle"
|
||||||
>
|
>
|
||||||
<template v-slot:activator="{ props }">
|
<v-list-item-title class="text-body-2">{{ menuModeInfo.toggleLabel }}</v-list-item-title>
|
||||||
<v-btn
|
</v-list-item>
|
||||||
v-bind="props"
|
</v-list>
|
||||||
icon
|
|
||||||
variant="text"
|
|
||||||
density="comfortable"
|
|
||||||
:color="layoutStore.menuMode === mode.value ? 'primary' : undefined"
|
|
||||||
:class="['menu-mode-btn', { 'menu-mode-btn--active': layoutStore.menuMode === mode.value }]"
|
|
||||||
@click="layoutStore.setMenuMode(mode.value)"
|
|
||||||
>
|
|
||||||
<v-icon>{{ mode.icon }}</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<span>{{ mode.label }}</span>
|
|
||||||
</v-tooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.menu-mode-btn {
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-mode-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-mode-btn--active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsed rail: only the active mode icon fits without crowding */
|
|
||||||
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering) .menu-mode-switcher {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering) .menu-mode-btn:not(.menu-mode-btn--active) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sub-menu items: Vuetify's default group indent (prepend width + indent
|
|
||||||
size) stacks up to ~72px; bring it back down closer to top-level items */
|
|
||||||
.scrollnavbar :deep(.v-list-group__items .v-list-item) {
|
|
||||||
padding-inline-start: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapsed rail (not hovering): sub-items have no room to show icon or
|
|
||||||
text, so they render as an empty highlighted bar when active. Hide the
|
|
||||||
expanded group entirely and highlight the parent icon instead. */
|
|
||||||
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering) :deep(.v-list-group__items) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leftSidebar.v-navigation-drawer--rail:not(.v-navigation-drawer--is-hovering)
|
|
||||||
:deep(.v-list-group:has(.v-list-item--active) > .v-list-item.v-list-group__header) {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[program:ktrix-mail-daemon]
|
||||||
|
command=/usr/bin/php /var/www/ktrix/main/bin/console mail:queue:daemon
|
||||||
|
directory=/var/www/ktrix/main
|
||||||
|
user=www-data
|
||||||
|
numprocs=1
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=5
|
||||||
|
startretries=3
|
||||||
|
exitcodes=0
|
||||||
|
stopsignal=TERM
|
||||||
|
stopwaitsecs=30
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
redirect_stderr=true
|
||||||
|
stdout_logfile=/var/www/ktrix/main/var/log/mail-daemon.log
|
||||||
|
stdout_logfile_maxbytes=10MB
|
||||||
|
stdout_logfile_backups=5
|
||||||
|
environment=PHP_INI_SCAN_DIR="/etc/php/8.2/cli/conf.d"
|
||||||
|
|
||||||
|
; Process name for easier identification
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ktrix Mail Queue Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/ktrix/main
|
||||||
|
ExecStart=/usr/bin/php bin/console mail:queue:daemon
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:/var/www/ktrix/main/var/log/mail-daemon.log
|
||||||
|
StandardError=append:/var/www/ktrix/main/var/log/mail-daemon.log
|
||||||
|
|
||||||
|
# Process management
|
||||||
|
KillMode=process
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/www/ktrix/main/storage
|
||||||
|
ReadWritePaths=/var/www/ktrix/main/var
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Update the test deployment, including the independently cloned modules.
|
|
||||||
# Run this script as root when DEPLOY_OWNER requires chown privileges.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
PROJECT_ROOT=${PROJECT_ROOT:-$(dirname -- "$SCRIPT_DIR")}
|
|
||||||
DEPLOY_OWNER=${DEPLOY_OWNER:-www-data:www-data}
|
|
||||||
LOCK_FILE=${LOCK_FILE:-/tmp/ktrix-test-update.lock}
|
|
||||||
|
|
||||||
# Composer and npm can invoke Git themselves, bypassing git_in(). Pass the
|
|
||||||
# deployment exceptions to every child Git process without changing global
|
|
||||||
# Git configuration.
|
|
||||||
GIT_CONFIG_COUNT=2
|
|
||||||
GIT_CONFIG_KEY_0=safe.directory
|
|
||||||
GIT_CONFIG_VALUE_0=$PROJECT_ROOT
|
|
||||||
GIT_CONFIG_KEY_1=safe.directory
|
|
||||||
GIT_CONFIG_VALUE_1=$PROJECT_ROOT/modules/*
|
|
||||||
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
|
|
||||||
export GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
|
|
||||||
|
|
||||||
log()
|
|
||||||
{
|
|
||||||
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
require_command()
|
|
||||||
{
|
|
||||||
if ! command -v "$1" >/dev/null 2>&1; then
|
|
||||||
log "ERROR: Required command not found: $1"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
git_in()
|
|
||||||
{
|
|
||||||
repository=$1
|
|
||||||
shift
|
|
||||||
git -c "safe.directory=$repository" -C "$repository" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
update_repository()
|
|
||||||
{
|
|
||||||
repository=$1
|
|
||||||
name=$2
|
|
||||||
|
|
||||||
if [ -n "$(git_in "$repository" status --porcelain)" ]; then
|
|
||||||
log "ERROR: $name has local changes; refusing to overwrite them"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git_in "$repository" rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then
|
|
||||||
log "ERROR: $name's current branch has no upstream"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Updating $name"
|
|
||||||
git_in "$repository" pull --ff-only
|
|
||||||
}
|
|
||||||
|
|
||||||
install_dependencies()
|
|
||||||
{
|
|
||||||
directory=$1
|
|
||||||
name=$2
|
|
||||||
|
|
||||||
if [ -f "$directory/composer.json" ]; then
|
|
||||||
log "Installing PHP dependencies for $name"
|
|
||||||
composer install \
|
|
||||||
--working-dir="$directory" \
|
|
||||||
--no-interaction \
|
|
||||||
--prefer-dist \
|
|
||||||
--optimize-autoloader
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$directory/package-lock.json" ]; then
|
|
||||||
log "Installing Node dependencies for $name"
|
|
||||||
npm ci --prefix "$directory"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
restore_ownership()
|
|
||||||
{
|
|
||||||
status=$?
|
|
||||||
trap - EXIT HUP INT TERM
|
|
||||||
|
|
||||||
log "Setting ownership to $DEPLOY_OWNER"
|
|
||||||
if ! chown -R "$DEPLOY_OWNER" "$PROJECT_ROOT"; then
|
|
||||||
log "ERROR: Could not restore project ownership"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$status" -ne 0 ]; then
|
|
||||||
log "ERROR: Update failed with exit code $status"
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
for command in git composer npm flock chown; do
|
|
||||||
require_command "$command"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ! -d "$PROJECT_ROOT/.git" ]; then
|
|
||||||
log "ERROR: PROJECT_ROOT is not a Git clone: $PROJECT_ROOT"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec 9>"$LOCK_FILE"
|
|
||||||
if ! flock -n 9; then
|
|
||||||
log "Another update is already running; exiting"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
trap restore_ownership EXIT HUP INT TERM
|
|
||||||
|
|
||||||
log "Starting test-server update in $PROJECT_ROOT"
|
|
||||||
update_repository "$PROJECT_ROOT" "server"
|
|
||||||
|
|
||||||
for module in "$PROJECT_ROOT"/modules/*; do
|
|
||||||
[ -d "$module/.git" ] || continue
|
|
||||||
update_repository "$module" "module $(basename -- "$module")"
|
|
||||||
done
|
|
||||||
|
|
||||||
install_dependencies "$PROJECT_ROOT" "server"
|
|
||||||
for module in "$PROJECT_ROOT"/modules/*; do
|
|
||||||
[ -d "$module" ] || continue
|
|
||||||
install_dependencies "$module" "module $(basename -- "$module")"
|
|
||||||
done
|
|
||||||
|
|
||||||
log "Building server and modules"
|
|
||||||
npm run build:all --prefix "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
log "Test-server update completed successfully"
|
|
||||||
Generated
+344
-375
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
"@vue/compiler-sfc": "^3.5.34",
|
"@vue/compiler-sfc": "^3.5.34",
|
||||||
"dompurify": "^3.4.3",
|
"dompurify": "^3.4.3",
|
||||||
"pinia": "^4.0.0",
|
"pinia": "^3.0.4",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vite-plugin-vuetify": "^2.1.3",
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ namespace KTXF\Cache;
|
|||||||
*/
|
*/
|
||||||
interface BlobCacheInterface
|
interface BlobCacheInterface
|
||||||
{
|
{
|
||||||
public function setTenantContext(?string $tenantId): void;
|
|
||||||
|
|
||||||
public function setUserContext(?string $userId): void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default TTL for blob cache entries (7 days)
|
* Default TTL for blob cache entries (7 days)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ namespace KTXF\Cache;
|
|||||||
*/
|
*/
|
||||||
interface CacheInterface
|
interface CacheInterface
|
||||||
{
|
{
|
||||||
public function setTenantContext(?string $tenantId): void;
|
|
||||||
|
|
||||||
public function setUserContext(?string $userId): void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve an item from the cache
|
* Retrieve an item from the cache
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\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 KTXF\Event;
|
|
||||||
|
|
||||||
final readonly class DeferredProcessingResult
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public int $processed,
|
|
||||||
public int $remaining,
|
|
||||||
public bool $deadlineExceeded,
|
|
||||||
public bool $limitExceeded = false,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
enum DeliveryMode: string
|
|
||||||
{
|
|
||||||
case Immediate = 'immediate';
|
|
||||||
case Deferred = 'deferred';
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple event bus for decoupled pub/sub communication between services
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Priority-based listener ordering
|
||||||
|
* - Synchronous and asynchronous (deferred) event handling
|
||||||
|
* - Event propagation control
|
||||||
|
*/
|
||||||
|
class EventBus
|
||||||
|
{
|
||||||
|
/** @var array<string, array<array{callback: callable, priority: int}>> */
|
||||||
|
private array $listeners = [];
|
||||||
|
|
||||||
|
/** @var array<string, array<callable>> */
|
||||||
|
private array $asyncListeners = [];
|
||||||
|
|
||||||
|
/** @var Event[] */
|
||||||
|
private array $deferredEvents = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an event with optional priority
|
||||||
|
* Higher priority listeners are called first
|
||||||
|
*/
|
||||||
|
public function subscribe(string $eventName, callable $listener, int $priority = 0): self
|
||||||
|
{
|
||||||
|
$this->listeners[$eventName][] = [
|
||||||
|
'callback' => $listener,
|
||||||
|
'priority' => $priority,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by priority (higher first)
|
||||||
|
usort(
|
||||||
|
$this->listeners[$eventName],
|
||||||
|
fn($a, $b) => $b['priority'] <=> $a['priority']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an event for async/deferred processing
|
||||||
|
* These handlers run at the end of the request cycle
|
||||||
|
*/
|
||||||
|
public function subscribeAsync(string $eventName, callable $listener): self
|
||||||
|
{
|
||||||
|
$this->asyncListeners[$eventName][] = $listener;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe a listener from an event
|
||||||
|
*/
|
||||||
|
public function unsubscribe(string $eventName, callable $listener): self
|
||||||
|
{
|
||||||
|
if (isset($this->listeners[$eventName])) {
|
||||||
|
$this->listeners[$eventName] = array_filter(
|
||||||
|
$this->listeners[$eventName],
|
||||||
|
fn($item) => $item['callback'] !== $listener
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->asyncListeners[$eventName])) {
|
||||||
|
$this->asyncListeners[$eventName] = array_filter(
|
||||||
|
$this->asyncListeners[$eventName],
|
||||||
|
fn($item) => $item !== $listener
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event to all subscribers
|
||||||
|
*/
|
||||||
|
public function publish(Event $event): self
|
||||||
|
{
|
||||||
|
$eventName = $event->getName();
|
||||||
|
|
||||||
|
// Execute synchronous listeners
|
||||||
|
if (isset($this->listeners[$eventName])) {
|
||||||
|
foreach ($this->listeners[$eventName] as $listenerData) {
|
||||||
|
if ($event->isPropagationStopped()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
call_user_func($listenerData['callback'], $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log error but don't break the chain
|
||||||
|
error_log(sprintf(
|
||||||
|
'Event listener error for %s: %s',
|
||||||
|
$eventName,
|
||||||
|
$e->getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue for async processing if there are async listeners
|
||||||
|
if (isset($this->asyncListeners[$eventName]) && !empty($this->asyncListeners[$eventName])) {
|
||||||
|
$this->deferredEvents[] = $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process deferred/async events
|
||||||
|
* Call this at the end of the request cycle
|
||||||
|
*/
|
||||||
|
public function processDeferred(): int
|
||||||
|
{
|
||||||
|
$processed = 0;
|
||||||
|
|
||||||
|
foreach ($this->deferredEvents as $event) {
|
||||||
|
$eventName = $event->getName();
|
||||||
|
|
||||||
|
if (!isset($this->asyncListeners[$eventName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->asyncListeners[$eventName] as $listener) {
|
||||||
|
try {
|
||||||
|
call_user_func($listener, $event);
|
||||||
|
$processed++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Log but don't fail - these are non-critical
|
||||||
|
error_log(sprintf(
|
||||||
|
'Async event handler error for %s: %s',
|
||||||
|
$eventName,
|
||||||
|
$e->getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deferredEvents = [];
|
||||||
|
|
||||||
|
return $processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event has any listeners
|
||||||
|
*/
|
||||||
|
public function hasListeners(string $eventName): bool
|
||||||
|
{
|
||||||
|
return !empty($this->listeners[$eventName]) || !empty($this->asyncListeners[$eventName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of listeners for an event
|
||||||
|
*/
|
||||||
|
public function getListenerCount(string $eventName): int
|
||||||
|
{
|
||||||
|
$sync = isset($this->listeners[$eventName]) ? count($this->listeners[$eventName]) : 0;
|
||||||
|
$async = isset($this->asyncListeners[$eventName]) ? count($this->asyncListeners[$eventName]) : 0;
|
||||||
|
|
||||||
|
return $sync + $async;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of pending deferred events
|
||||||
|
*/
|
||||||
|
public function getDeferredCount(): int
|
||||||
|
{
|
||||||
|
return count($this->deferredEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all listeners (useful for testing)
|
||||||
|
*/
|
||||||
|
public function clear(): self
|
||||||
|
{
|
||||||
|
$this->listeners = [];
|
||||||
|
$this->asyncListeners = [];
|
||||||
|
$this->deferredEvents = [];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
use Psr\Container\ContainerInterface;
|
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
|
|
||||||
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
|
||||||
{
|
|
||||||
/** @var array<string, list<Event>> */
|
|
||||||
private array $deferred = [];
|
|
||||||
private ?string $activeExecution = null;
|
|
||||||
private int $dispatchDepth = 0;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly EventListenerRegistry $registry,
|
|
||||||
private readonly ContainerInterface $container,
|
|
||||||
private readonly LoggerInterface $logger,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dispatch(Event $event): void
|
|
||||||
{
|
|
||||||
if (++$this->dispatchDepth > 32) {
|
|
||||||
--$this->dispatchDepth;
|
|
||||||
throw new \RuntimeException('Event dispatch recursion limit exceeded.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->invoke($event, DeliveryMode::Immediate);
|
|
||||||
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
|
|
||||||
if ($this->activeExecution === null) {
|
|
||||||
throw new \LogicException('Deferred events require an active execution scope.');
|
|
||||||
}
|
|
||||||
$this->deferred[$this->activeExecution][] = $event;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
--$this->dispatchDepth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginExecution(string $executionId): void
|
|
||||||
{
|
|
||||||
if ($this->activeExecution !== null) {
|
|
||||||
throw new \LogicException('An event execution scope is already active.');
|
|
||||||
}
|
|
||||||
$this->activeExecution = $executionId;
|
|
||||||
$this->deferred[$executionId] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function processDeferred(string $executionId): DeferredProcessingResult
|
|
||||||
{
|
|
||||||
if ($this->activeExecution !== $executionId) {
|
|
||||||
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$processed = 0;
|
|
||||||
$deadline = microtime(true) + 1.0;
|
|
||||||
$deadlineExceeded = false;
|
|
||||||
$limitExceeded = false;
|
|
||||||
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
|
||||||
if ($processed >= 1000) {
|
|
||||||
$limitExceeded = true;
|
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (microtime(true) >= $deadline) {
|
|
||||||
$deadlineExceeded = true;
|
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$processed += $this->invoke($event, DeliveryMode::Deferred);
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining = count($this->deferred[$executionId]);
|
|
||||||
unset($this->deferred[$executionId]);
|
|
||||||
$this->activeExecution = null;
|
|
||||||
|
|
||||||
return new DeferredProcessingResult(
|
|
||||||
$processed,
|
|
||||||
$remaining,
|
|
||||||
$deadlineExceeded,
|
|
||||||
$limitExceeded,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function discardDeferred(string $executionId): void
|
|
||||||
{
|
|
||||||
unset($this->deferred[$executionId]);
|
|
||||||
if ($this->activeExecution === $executionId) {
|
|
||||||
$this->activeExecution = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invoke(Event $event, DeliveryMode $delivery): int
|
|
||||||
{
|
|
||||||
$processed = 0;
|
|
||||||
foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) {
|
|
||||||
if ($event->isPropagationStopped()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service = $this->container->get($listener->service);
|
|
||||||
$service->{$listener->method}($event);
|
|
||||||
$processed++;
|
|
||||||
} catch (\Throwable $error) {
|
|
||||||
$this->logger->error('Event listener failed.', [
|
|
||||||
'event' => $event->getName(),
|
|
||||||
'module' => $listener->module,
|
|
||||||
'listener' => $listener->service . '::' . $listener->method,
|
|
||||||
'exception' => $error,
|
|
||||||
]);
|
|
||||||
if ($listener->failurePolicy === FailurePolicy::Propagate) {
|
|
||||||
throw $error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $processed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
interface EventDispatcherInterface
|
|
||||||
{
|
|
||||||
public function dispatch(Event $event): void;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
use Psr\Container\ContainerInterface;
|
|
||||||
|
|
||||||
final class EventListenerRegistry
|
|
||||||
{
|
|
||||||
/** @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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Event;
|
|
||||||
|
|
||||||
enum FailurePolicy: string
|
|
||||||
{
|
|
||||||
case Continue = 'continue';
|
|
||||||
case Propagate = 'propagate';
|
|
||||||
}
|
|
||||||
@@ -23,17 +23,6 @@ interface ProviderBaseInterface extends ResourceProviderBaseInterface{
|
|||||||
|
|
||||||
public const JSON_TYPE = 'mail:provider';
|
public const JSON_TYPE = 'mail:provider';
|
||||||
|
|
||||||
/**
|
|
||||||
* Reserved user identifier for system-owned mail services
|
|
||||||
*
|
|
||||||
* Services stored under this user id belong to the tenant itself rather
|
|
||||||
* than any real user (e.g. accounts used to deliver system-generated
|
|
||||||
* messages such as verification codes and password resets).
|
|
||||||
*
|
|
||||||
* @since 2026.07.01
|
|
||||||
*/
|
|
||||||
public const USER_SYSTEM = 'system';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a service that handles a specific email address
|
* Finds a service that handles a specific email address
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace KTXF\Security;
|
namespace KTXF\Security;
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\SessionTenant;
|
||||||
use phpseclib3\Crypt\AES;
|
use phpseclib3\Crypt\AES;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +27,7 @@ class Crypto
|
|||||||
private const ENCODING_HEADER_VERSION = 1;
|
private const ENCODING_HEADER_VERSION = 1;
|
||||||
private const ENCODING_HEADER_LEN = 14;
|
private const ENCODING_HEADER_LEN = 14;
|
||||||
|
|
||||||
public function __construct(protected TenantContextInterface $tenantContext)
|
public function __construct(protected SessionTenant $sessionTenant)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +136,7 @@ class Crypto
|
|||||||
|
|
||||||
private function tenantSecret(): ?string
|
private function tenantSecret(): ?string
|
||||||
{
|
{
|
||||||
$config = $this->tenantContext->configuration();
|
$config = $this->sessionTenant->configuration();
|
||||||
return $config->security()->code();
|
return $config->security()->code();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useLayoutStore } from '@KTXC/stores/layoutStore'
|
||||||
|
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||||
|
import { useUserStore } from '@KTXC/stores/userStore'
|
||||||
|
|
||||||
|
const userServiceMock = vi.hoisted(() => ({
|
||||||
|
updateProfile: vi.fn(() => Promise.resolve()),
|
||||||
|
updateSettings: vi.fn(() => Promise.resolve()),
|
||||||
|
flushAll: vi.fn(() => Promise.resolve()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@KTXC/services/user/userService', () => ({
|
||||||
|
userService: userServiceMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('layoutStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const storage = new Map<string, string>()
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => storage.set(key, value)),
|
||||||
|
removeItem: vi.fn((key: string) => storage.delete(key)),
|
||||||
|
clear: vi.fn(() => storage.clear()),
|
||||||
|
})
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates initial layout preferences after settings are loaded', () => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const tenantStore = useTenantStore()
|
||||||
|
|
||||||
|
const layoutStore = useLayoutStore()
|
||||||
|
expect(layoutStore.sidebarDrawer).toBe(true)
|
||||||
|
expect(layoutStore.miniSidebar).toBe(false)
|
||||||
|
expect(layoutStore.theme).toBe('light')
|
||||||
|
|
||||||
|
tenantStore.init({
|
||||||
|
id: 'tenant',
|
||||||
|
domain: 'example.test',
|
||||||
|
label: 'Example',
|
||||||
|
settings: {
|
||||||
|
theme_default_mode: 'dark',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
userStore.init({
|
||||||
|
settings: {
|
||||||
|
sidebar_drawer: false,
|
||||||
|
mini_sidebar: true,
|
||||||
|
theme: 'system',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
layoutStore.hydrateFromSettings()
|
||||||
|
|
||||||
|
expect(layoutStore.sidebarDrawer).toBe(false)
|
||||||
|
expect(layoutStore.miniSidebar).toBe(true)
|
||||||
|
expect(layoutStore.theme).toBe('system')
|
||||||
|
expect(userServiceMock.updateSettings).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('continues to persist user-driven layout changes', () => {
|
||||||
|
const layoutStore = useLayoutStore()
|
||||||
|
|
||||||
|
layoutStore.toggleSidebarDrawer()
|
||||||
|
layoutStore.setMiniSidebar(true)
|
||||||
|
layoutStore.setTheme('dark')
|
||||||
|
|
||||||
|
expect(userServiceMock.updateSettings).toHaveBeenCalledWith({ sidebar_drawer: false })
|
||||||
|
expect(userServiceMock.updateSettings).toHaveBeenCalledWith({ mini_sidebar: true })
|
||||||
|
expect(userServiceMock.updateSettings).toHaveBeenCalledWith({ theme: 'dark' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace KTXT\Integration;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class BaseTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testBasicAssertion(): void
|
|
||||||
{
|
|
||||||
$this->assertTrue(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testArrayOperations(): void
|
|
||||||
{
|
|
||||||
$array = ['foo' => 'bar'];
|
|
||||||
|
|
||||||
$this->assertArrayHasKey('foo', $array);
|
|
||||||
$this->assertEquals('bar', $array['foo']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testStringOperations(): void
|
|
||||||
{
|
|
||||||
$string = 'Hello, World!';
|
|
||||||
|
|
||||||
$this->assertStringContainsString('World', $string);
|
|
||||||
$this->assertEquals(13, strlen($string));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Application;
|
|
||||||
|
|
||||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
|
||||||
use KTXC\Application\Execution\ExecutionOutcome;
|
|
||||||
use KTXC\Application\KernelOptions;
|
|
||||||
use KTXC\Application\ProjectPaths;
|
|
||||||
use KTXC\Context\IdentityContext;
|
|
||||||
use KTXC\Context\TenantContext;
|
|
||||||
use KTXC\Injection\Container;
|
|
||||||
use KTXC\Kernel;
|
|
||||||
use KTXC\Module\ModuleManager;
|
|
||||||
use KTXC\Service\TenantService;
|
|
||||||
use KTXF\Cache\BlobCacheInterface;
|
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
|
||||||
use KTXF\Cache\PersistentCacheInterface;
|
|
||||||
use KTXF\Event\DeferredEventProcessorInterface;
|
|
||||||
use KTXF\Event\DeferredProcessingResult;
|
|
||||||
use KTXF\Event\EventListenerRegistry;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class KernelTest extends TestCase
|
|
||||||
{
|
|
||||||
/** @var list<Kernel> */
|
|
||||||
private array $kernels = [];
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
foreach ($this->kernels as $kernel) {
|
|
||||||
try {
|
|
||||||
$kernel->shutdown();
|
|
||||||
} catch (\LogicException) {
|
|
||||||
// Tests that intentionally leave a scope active terminate it themselves.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Kernel boot is idempotent and freezes one registry')]
|
|
||||||
public function bootsOnce(): void
|
|
||||||
{
|
|
||||||
[$kernel, $modules, $registry] = $this->kernel();
|
|
||||||
|
|
||||||
$kernel->boot();
|
|
||||||
$kernel->boot();
|
|
||||||
|
|
||||||
self::assertTrue($registry->frozen());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Beginning execution boots the kernel automatically')]
|
|
||||||
public function bootsOnExecution(): void
|
|
||||||
{
|
|
||||||
[$kernel, $modules] = $this->kernel();
|
|
||||||
|
|
||||||
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
$kernel->terminateExecution($scope, ExecutionOutcome::success());
|
|
||||||
|
|
||||||
self::assertTrue($scope->terminated());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Runner returns execution results and terminates')]
|
|
||||||
public function runs(): void
|
|
||||||
{
|
|
||||||
$processor = new RecordingProcessor();
|
|
||||||
[$kernel] = $this->kernel($processor);
|
|
||||||
|
|
||||||
$result = $kernel->executionRunner()->execute(
|
|
||||||
ExecutionDescriptor::cli(),
|
|
||||||
static fn(): string => 'complete',
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame('complete', $result);
|
|
||||||
self::assertSame(1, $processor->processed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Runner terminates before rethrowing execution failures')]
|
|
||||||
public function rethrows(): void
|
|
||||||
{
|
|
||||||
[$kernel] = $this->kernel();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$kernel->executionRunner()->execute(
|
|
||||||
ExecutionDescriptor::cli(),
|
|
||||||
static fn() => throw new \RuntimeException('Execution failed.'),
|
|
||||||
);
|
|
||||||
self::fail('Expected execution to fail.');
|
|
||||||
} catch (\RuntimeException $error) {
|
|
||||||
self::assertSame('Execution failed.', $error->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
$kernel->terminateExecution($scope, ExecutionOutcome::success());
|
|
||||||
|
|
||||||
self::assertTrue($scope->terminated());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Only one execution scope can be active')]
|
|
||||||
public function rejectsConcurrentScopes(): void
|
|
||||||
{
|
|
||||||
[$kernel] = $this->kernel();
|
|
||||||
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
|
|
||||||
try {
|
|
||||||
$kernel->beginExecution(ExecutionDescriptor::http());
|
|
||||||
self::fail('Expected the second execution scope to be rejected.');
|
|
||||||
} catch (\LogicException) {
|
|
||||||
self::assertFalse($scope->terminated());
|
|
||||||
} finally {
|
|
||||||
$kernel->terminateExecution($scope, ExecutionOutcome::success());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Repeated termination does not repeat side effects')]
|
|
||||||
public function terminatesOnce(): void
|
|
||||||
{
|
|
||||||
$processor = new RecordingProcessor();
|
|
||||||
[$kernel] = $this->kernel($processor);
|
|
||||||
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
|
|
||||||
$first = $kernel->terminateExecution($scope, ExecutionOutcome::success());
|
|
||||||
$second = $kernel->terminateExecution($scope, ExecutionOutcome::success());
|
|
||||||
|
|
||||||
self::assertSame(1, $processor->processed);
|
|
||||||
self::assertSame(1, $first->deferredProcessed);
|
|
||||||
self::assertSame(0, $second->deferredProcessed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Shutdown rejects active execution scopes')]
|
|
||||||
public function rejectsActiveShutdown(): void
|
|
||||||
{
|
|
||||||
[$kernel] = $this->kernel();
|
|
||||||
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
|
|
||||||
try {
|
|
||||||
$kernel->shutdown();
|
|
||||||
self::fail('Expected active shutdown to be rejected.');
|
|
||||||
} catch (\LogicException) {
|
|
||||||
self::assertFalse($scope->terminated());
|
|
||||||
} finally {
|
|
||||||
$kernel->terminateExecution($scope, ExecutionOutcome::success());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Deferred failures are reported and the next execution can start')]
|
|
||||||
public function recoversFromDeferredFailure(): void
|
|
||||||
{
|
|
||||||
$processor = new RecordingProcessor();
|
|
||||||
$processor->failure = new \RuntimeException('Deferred processing failed.');
|
|
||||||
[$kernel] = $this->kernel($processor);
|
|
||||||
$first = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
|
|
||||||
$report = $kernel->terminateExecution($first, ExecutionOutcome::success());
|
|
||||||
$processor->failure = null;
|
|
||||||
$second = $kernel->beginExecution(ExecutionDescriptor::cli());
|
|
||||||
$kernel->terminateExecution($second, ExecutionOutcome::success());
|
|
||||||
|
|
||||||
self::assertCount(1, $report->failures);
|
|
||||||
self::assertTrue($first->terminated());
|
|
||||||
self::assertTrue($second->terminated());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{TestKernel, ModuleManager&MockObject, EventListenerRegistry}
|
|
||||||
*/
|
|
||||||
private function kernel(?RecordingProcessor $processor = null): array
|
|
||||||
{
|
|
||||||
$modules = $this->createMock(ModuleManager::class);
|
|
||||||
$modules->expects(self::once())->method('modulesBoot');
|
|
||||||
$registry = new EventListenerRegistry();
|
|
||||||
$processor ??= new RecordingProcessor();
|
|
||||||
$container = new Container();
|
|
||||||
$container->set(ModuleManager::class, $modules);
|
|
||||||
$container->set(EventListenerRegistry::class, $registry);
|
|
||||||
$container->set(DeferredEventProcessorInterface::class, $processor);
|
|
||||||
$container->set(
|
|
||||||
TenantContext::class,
|
|
||||||
new TenantContext($this->createStub(TenantService::class)),
|
|
||||||
);
|
|
||||||
$container->set(IdentityContext::class, new IdentityContext());
|
|
||||||
$container->set(EphemeralCacheInterface::class, $this->createStub(EphemeralCacheInterface::class));
|
|
||||||
$container->set(PersistentCacheInterface::class, $this->createStub(PersistentCacheInterface::class));
|
|
||||||
$container->set(BlobCacheInterface::class, $this->createStub(BlobCacheInterface::class));
|
|
||||||
|
|
||||||
$kernel = new TestKernel(
|
|
||||||
new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), 'test'),
|
|
||||||
$container,
|
|
||||||
);
|
|
||||||
$this->kernels[] = $kernel;
|
|
||||||
|
|
||||||
return [$kernel, $modules, $registry];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class TestKernel extends Kernel
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
KernelOptions $options,
|
|
||||||
private readonly Container $testContainer,
|
|
||||||
) {
|
|
||||||
parent::__construct($options, ['log' => ['driver' => 'null']]);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function initializeContainer(): Container
|
|
||||||
{
|
|
||||||
return $this->testContainer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class RecordingProcessor implements DeferredEventProcessorInterface
|
|
||||||
{
|
|
||||||
public int $processed = 0;
|
|
||||||
public ?\Throwable $failure = null;
|
|
||||||
private ?string $active = null;
|
|
||||||
|
|
||||||
public function beginExecution(string $executionId): void
|
|
||||||
{
|
|
||||||
if ($this->active !== null) {
|
|
||||||
throw new \LogicException('Execution already active.');
|
|
||||||
}
|
|
||||||
$this->active = $executionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function processDeferred(string $executionId): DeferredProcessingResult
|
|
||||||
{
|
|
||||||
if ($this->failure !== null) {
|
|
||||||
throw $this->failure;
|
|
||||||
}
|
|
||||||
$this->active = null;
|
|
||||||
$this->processed++;
|
|
||||||
|
|
||||||
return new DeferredProcessingResult(1, 0, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function discardDeferred(string $executionId): void
|
|
||||||
{
|
|
||||||
$this->active = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Application;
|
|
||||||
|
|
||||||
use KTXC\Application\KernelOptions;
|
|
||||||
use KTXC\Application\ProjectPaths;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class ProjectPathsTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Project roots resolve identically from nested entrypoints')]
|
|
||||||
public function resolves(): void
|
|
||||||
{
|
|
||||||
$root = dirname(__DIR__, 4);
|
|
||||||
$fromRoot = ProjectPaths::resolve($root);
|
|
||||||
$fromCore = ProjectPaths::resolve($root . '/core/lib/index.php');
|
|
||||||
$fromConsole = ProjectPaths::resolve($root . '/bin/console');
|
|
||||||
|
|
||||||
self::assertSame($fromRoot->project, $fromCore->project);
|
|
||||||
self::assertSame($fromRoot->project, $fromConsole->project);
|
|
||||||
self::assertSame($fromRoot->modules(), $fromCore->modules());
|
|
||||||
self::assertSame($fromRoot->logs(), $fromConsole->logs());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Missing project roots are rejected')]
|
|
||||||
public function rejectsMissingRoots(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
ProjectPaths::resolve(sys_get_temp_dir());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Empty kernel environments are rejected')]
|
|
||||||
public function rejectsEmptyEnvironments(): void
|
|
||||||
{
|
|
||||||
$this->expectException(\InvalidArgumentException::class);
|
|
||||||
new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Console\Tenant;
|
|
||||||
|
|
||||||
use KTXC\Console\Tenant\TenantCreateCommand;
|
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
|
||||||
use KTXC\Service\TenantService;
|
|
||||||
use KTXC\Stores\UserAccountsStore;
|
|
||||||
use KTXC\Stores\UserRolesStore;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
|
||||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use Psr\Log\NullLogger;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
|
||||||
|
|
||||||
#[AllowMockObjectsWithoutExpectations]
|
|
||||||
class TenantCreateCommandTest extends TestCase
|
|
||||||
{
|
|
||||||
private TenantService&MockObject $tenantService;
|
|
||||||
private UserRolesStore $rolesStore;
|
|
||||||
private UserAccountsStore $userStore;
|
|
||||||
private CommandTester $tester;
|
|
||||||
private ?TenantObject $deposited = null;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->tenantService = $this->createMock(TenantService::class);
|
|
||||||
$this->rolesStore = $this->createStub(UserRolesStore::class);
|
|
||||||
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
|
|
||||||
$this->userStore = $this->createStub(UserAccountsStore::class);
|
|
||||||
$this->userStore->method('createUser')->willReturn(['uid' => 'admin']);
|
|
||||||
$this->tester = new CommandTester(
|
|
||||||
new TenantCreateCommand(
|
|
||||||
$this->tenantService,
|
|
||||||
$this->rolesStore,
|
|
||||||
$this->userStore,
|
|
||||||
new NullLogger(),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the service mock to accept and capture the deposited tenant
|
|
||||||
*/
|
|
||||||
private function expectDeposit(): void
|
|
||||||
{
|
|
||||||
$this->tenantService
|
|
||||||
->method('deposit')
|
|
||||||
->willReturnCallback(function (TenantObject $tenant): TenantObject {
|
|
||||||
$tenant->setId('6a5d84498ac04b41fa0c43a2');
|
|
||||||
$this->deposited = $tenant;
|
|
||||||
return $tenant;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreatesTenantWithGeneratedIdentifier(): void
|
|
||||||
{
|
|
||||||
$this->expectDeposit();
|
|
||||||
|
|
||||||
$status = $this->tester->execute(['domain' => ['acme.test']]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::SUCCESS, $status);
|
|
||||||
$this->assertNotNull($this->deposited);
|
|
||||||
$this->assertMatchesRegularExpression(
|
|
||||||
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
|
|
||||||
$this->deposited->getIdentifier()
|
|
||||||
);
|
|
||||||
$this->assertTrue($this->deposited->getEnabled());
|
|
||||||
$this->assertSame(['acme.test'], $this->deposited->getDomains()->getArrayCopy());
|
|
||||||
$this->assertStringContainsString('created successfully', $this->tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreatesTenantWithExplicitIdentifier(): void
|
|
||||||
{
|
|
||||||
$this->expectDeposit();
|
|
||||||
|
|
||||||
$status = $this->tester->execute([
|
|
||||||
'domain' => ['acme.test'],
|
|
||||||
'--identifier' => 'acme',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::SUCCESS, $status);
|
|
||||||
$this->assertSame('acme', $this->deposited->getIdentifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLabelDefaultsToFirstDomain(): void
|
|
||||||
{
|
|
||||||
$this->expectDeposit();
|
|
||||||
|
|
||||||
$this->tester->execute(['domain' => ['acme.test', 'acme.example']]);
|
|
||||||
|
|
||||||
$this->assertSame('acme.test', $this->deposited->getLabel());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAppliesOptions(): void
|
|
||||||
{
|
|
||||||
$this->expectDeposit();
|
|
||||||
|
|
||||||
$status = $this->tester->execute([
|
|
||||||
'domain' => ['acme.test'],
|
|
||||||
'--label' => 'Acme Inc',
|
|
||||||
'--description' => 'Test tenant',
|
|
||||||
'--disabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::SUCCESS, $status);
|
|
||||||
$this->assertSame('Acme Inc', $this->deposited->getLabel());
|
|
||||||
$this->assertSame('Test tenant', $this->deposited->getDescription());
|
|
||||||
$this->assertFalse($this->deposited->getEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testTrimsAndDeduplicatesDomains(): void
|
|
||||||
{
|
|
||||||
$this->expectDeposit();
|
|
||||||
|
|
||||||
$this->tester->execute(['domain' => [' acme.test ', 'acme.test', 'acme.example']]);
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
['acme.test', 'acme.example'],
|
|
||||||
$this->deposited->getDomains()->getArrayCopy()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFailsWhenNoValidDomainProvided(): void
|
|
||||||
{
|
|
||||||
$this->tenantService->expects($this->never())->method('deposit');
|
|
||||||
|
|
||||||
$status = $this->tester->execute(['domain' => [' ']]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $status);
|
|
||||||
$this->assertStringContainsString('At least one non-empty domain is required', $this->tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFailsWhenIdentifierAlreadyExists(): void
|
|
||||||
{
|
|
||||||
$this->tenantService
|
|
||||||
->method('fetchById')
|
|
||||||
->with('acme')
|
|
||||||
->willReturn(new TenantObject());
|
|
||||||
$this->tenantService->expects($this->never())->method('deposit');
|
|
||||||
|
|
||||||
$status = $this->tester->execute([
|
|
||||||
'domain' => ['acme.test'],
|
|
||||||
'--identifier' => 'acme',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $status);
|
|
||||||
$this->assertStringContainsString('already exists', $this->tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFailsWhenDomainAssignedToAnotherTenant(): void
|
|
||||||
{
|
|
||||||
$owner = (new TenantObject())->setIdentifier('other');
|
|
||||||
$this->tenantService
|
|
||||||
->method('fetchByDomain')
|
|
||||||
->with('acme.test')
|
|
||||||
->willReturn($owner);
|
|
||||||
$this->tenantService->expects($this->never())->method('deposit');
|
|
||||||
|
|
||||||
$status = $this->tester->execute(['domain' => ['acme.test']]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $status);
|
|
||||||
$this->assertStringContainsString("already assigned to tenant 'other'", $this->tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFailsWhenDepositReturnsNull(): void
|
|
||||||
{
|
|
||||||
$this->tenantService->method('deposit')->willReturn(null);
|
|
||||||
|
|
||||||
$status = $this->tester->execute(['domain' => ['acme.test']]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $status);
|
|
||||||
$this->assertStringContainsString('Failed to create tenant', $this->tester->getDisplay());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testFailsWhenServiceThrows(): void
|
|
||||||
{
|
|
||||||
$this->tenantService
|
|
||||||
->method('deposit')
|
|
||||||
->willThrowException(new \RuntimeException('datastore unavailable'));
|
|
||||||
|
|
||||||
$status = $this->tester->execute(['domain' => ['acme.test']]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $status);
|
|
||||||
$this->assertStringContainsString('datastore unavailable', $this->tester->getDisplay());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user