diff --git a/bin/console b/bin/console index a4b912e..6e058a3 100755 --- a/bin/console +++ b/bin/console @@ -7,21 +7,21 @@ declare(strict_types=1); -use KTXC\Server; +use KTXC\Application; if (!is_dir(dirname(__DIR__).'/vendor')) { fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n"); exit(1); } -require_once dirname(__DIR__).'/vendor/autoload.php'; +$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php'; try { - $server = new Server(dirname(__DIR__)); - exit($server->runConsole()); + $application = Application::create(dirname(__DIR__), $composerLoader); + exit($application->runConsole()); } catch (\Throwable $e) { fwrite(STDERR, "Fatal error: {$e->getMessage()}\n"); - if (isset($server) && $server->debug()) { + if (($application ?? null)?->debug()) { fwrite(STDERR, $e->getTraceAsString()."\n"); } exit(1); diff --git a/core/lib/Application.php b/core/lib/Application.php new file mode 100644 index 0000000..d6b6d8a --- /dev/null +++ b/core/lib/Application.php @@ -0,0 +1,154 @@ +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 handleHttp(Request $request): Response + { + return $this->http->handle($request); + } + + 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; + } + +} diff --git a/core/lib/Application/Execution/ExecutionContext.php b/core/lib/Application/Execution/ExecutionContext.php new file mode 100644 index 0000000..fe037d2 --- /dev/null +++ b/core/lib/Application/Execution/ExecutionContext.php @@ -0,0 +1,29 @@ +runtime, + $descriptor->executionId, + $descriptor->correlationId, + operationName: $descriptor->operationName, + ); + } +} diff --git a/core/lib/Application/Execution/ExecutionDescriptor.php b/core/lib/Application/Execution/ExecutionDescriptor.php new file mode 100644 index 0000000..b3ac857 --- /dev/null +++ b/core/lib/Application/Execution/ExecutionDescriptor.php @@ -0,0 +1,35 @@ +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(); + } +} diff --git a/core/lib/Application/Execution/RuntimeType.php b/core/lib/Application/Execution/RuntimeType.php new file mode 100644 index 0000000..5687d10 --- /dev/null +++ b/core/lib/Application/Execution/RuntimeType.php @@ -0,0 +1,11 @@ + $failures + */ + public function __construct( + public int $deferredProcessed = 0, + public int $deferredRemaining = 0, + public array $failures = [], + public bool $deadlineExceeded = false, + public bool $limitExceeded = false, + ) { + } +} diff --git a/core/lib/Application/KernelOptions.php b/core/lib/Application/KernelOptions.php new file mode 100644 index 0000000..023eb28 --- /dev/null +++ b/core/lib/Application/KernelOptions.php @@ -0,0 +1,18 @@ +environment === '') { + throw new \InvalidArgumentException('Kernel environment cannot be empty.'); + } + } +} diff --git a/core/lib/Application/ProjectPaths.php b/core/lib/Application/ProjectPaths.php new file mode 100644 index 0000000..fe18d1a --- /dev/null +++ b/core/lib/Application/ProjectPaths.php @@ -0,0 +1,53 @@ +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'; + } +} diff --git a/core/lib/Console/Event/EventsDebugCommand.php b/core/lib/Console/Event/EventsDebugCommand.php new file mode 100644 index 0000000..1bda498 --- /dev/null +++ b/core/lib/Console/Event/EventsDebugCommand.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/core/lib/Context/IdentityContext.php b/core/lib/Context/IdentityContext.php new file mode 100644 index 0000000..97f6442 --- /dev/null +++ b/core/lib/Context/IdentityContext.php @@ -0,0 +1,101 @@ +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); + } +} diff --git a/core/lib/Context/IdentityContextInterface.php b/core/lib/Context/IdentityContextInterface.php new file mode 100644 index 0000000..602b2e6 --- /dev/null +++ b/core/lib/Context/IdentityContextInterface.php @@ -0,0 +1,23 @@ +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); + } +} diff --git a/core/lib/Context/TenantContextInterface.php b/core/lib/Context/TenantContextInterface.php new file mode 100644 index 0000000..29a914a --- /dev/null +++ b/core/lib/Context/TenantContextInterface.php @@ -0,0 +1,23 @@ +identity->identifier()) { + if ($this->identityContext->identifier()) { return new FileResponse( $this->rootDir . '/public/private.html', Response::HTTP_OK, @@ -116,7 +116,7 @@ class DefaultController extends ControllerAbstract public function catchAll(Request $request, string $path = ''): Response { // If an authenticated identity is available, serve the private app - if ($this->identity->identifier()) { + if ($this->identityContext->identifier()) { return new FileResponse( $this->rootDir . '/public/private.html', Response::HTTP_OK, diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index 6de2b1f..bff27b0 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -8,16 +8,16 @@ use KTXC\L10N\LocaleResolver; use KTXC\Module\ModuleManager; use KTXC\Security\Authorization\PermissionChecker; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContextInterface; use KTXF\Controller\ControllerAbstract; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; class InitController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenant, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly ModuleManager $moduleManager, private readonly UserAccountsService $userService, private readonly PermissionChecker $permissionChecker, @@ -54,21 +54,21 @@ class InitController extends ControllerAbstract // tenant $configuration['tenant'] = [ - 'id' => $this->tenant->identifier(), - 'domain' => $this->tenant->domain(), - 'label' => $this->tenant->label(), + 'id' => $this->tenantContext->identifier(), + 'domain' => $this->tenantContext->domain(), + 'label' => $this->tenantContext->label(), ]; // user $configuration['user'] = [ 'auth' => [ - 'identifier' => $this->userIdentity->identifier(), - 'identity' => $this->userIdentity->identity()->getIdentity(), - 'label' => $this->userIdentity->label(), - 'roles' => $this->userIdentity->identity()->getRoles(), - 'permissions' => $this->userIdentity->identity()->getPermissions(), + 'identifier' => $this->identityContext->identifier(), + 'identity' => $this->identityContext->identity()->getIdentity(), + 'label' => $this->identityContext->label(), + 'roles' => $this->identityContext->identity()->getRoles(), + 'permissions' => $this->identityContext->identity()->getPermissions(), ], - 'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), + 'profile' => $this->userService->getEditableFields($this->identityContext->identifier()), 'settings' => $this->userService->fetchSettings([], true), ]; diff --git a/core/lib/Controllers/TenantSettingsController.php b/core/lib/Controllers/TenantSettingsController.php index dd8101b..3eecb95 100644 --- a/core/lib/Controllers/TenantSettingsController.php +++ b/core/lib/Controllers/TenantSettingsController.php @@ -4,7 +4,7 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\TenantService; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -19,7 +19,7 @@ use KTXF\Routing\Attributes\AuthenticatedRoute; class TenantSettingsController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, + private readonly TenantContextInterface $tenantContext, private readonly TenantService $tenantService, ) {} @@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract )] public function read(): JsonResponse { - $settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier()); + $settings = $this->tenantService->fetchSettings($this->tenantContext->identifier()); return new JsonResponse($settings, JsonResponse::HTTP_OK); } @@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract )] public function update(array $data): JsonResponse { - $this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data); + $this->tenantService->storeSettings($this->tenantContext->identifier(), $data); - $updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data)); + $updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data)); return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK); } diff --git a/core/lib/Controllers/UserAccountsController.php b/core/lib/Controllers/UserAccountsController.php index 57bac4e..585d402 100644 --- a/core/lib/Controllers/UserAccountsController.php +++ b/core/lib/Controllers/UserAccountsController.php @@ -4,8 +4,8 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; use Psr\Log\LoggerInterface; @@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface; class UserAccountsController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsService $userService, private readonly LoggerInterface $logger ) {} @@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract { try { // Check admin permission - if (!$this->userIdentity->hasPermission('user.admin')) { + if (!$this->identityContext->hasPermission('user.admin')) { return new JsonResponse([ 'status' => 'error', 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] @@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract */ private function userCreate(array $data): array { - if (!$this->userIdentity->hasPermission('user.create')) { + if (!$this->identityContext->hasPermission('user.create')) { throw new \InvalidArgumentException('Insufficient permissions to create users'); } @@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract ]; $this->logger->info('Creating user', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'identity' => $userData['identity'], - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->createUser($userData); @@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract */ private function userUpdate(array $data): bool { - if (!$this->userIdentity->hasPermission('user.update')) { + if (!$this->identityContext->hasPermission('user.update')) { throw new \InvalidArgumentException('Insufficient permissions to update users'); } @@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract } $this->logger->info('Updating user', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'uid' => $uid, - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->updateUser($uid, $updates); @@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract */ private function userDelete(array $data): bool { - if (!$this->userIdentity->hasPermission('user.delete')) { + if (!$this->identityContext->hasPermission('user.delete')) { throw new \InvalidArgumentException('Insufficient permissions to delete users'); } $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); // Prevent self-deletion - if ($uid === $this->userIdentity->identifier()) { + if ($uid === $this->identityContext->identifier()) { throw new \InvalidArgumentException('Cannot delete your own account'); } $this->logger->info('Deleting user', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'uid' => $uid, - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->deleteUser($uid); @@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract */ private function userProviderUnlink(array $data): bool { - if (!$this->userIdentity->hasPermission('user.admin')) { + if (!$this->identityContext->hasPermission('user.admin')) { throw new \InvalidArgumentException('Insufficient permissions'); } @@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract ]; $this->logger->info('Unlinking provider', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'uid' => $uid, - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->updateUser($uid, $updates); diff --git a/core/lib/Controllers/UserProfileController.php b/core/lib/Controllers/UserProfileController.php index fbeedad..88af7f3 100644 --- a/core/lib/Controllers/UserProfileController.php +++ b/core/lib/Controllers/UserProfileController.php @@ -4,16 +4,16 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; class UserProfileController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsService $userService ) {} @@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract )] public function read(): JsonResponse { - $userId = $this->userIdentity->identifier(); + $userId = $this->identityContext->identifier(); // Get profile with editability metadata $profile = $this->userService->getEditableFields($userId); @@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract )] public function update(array $data): JsonResponse { - $userId = $this->userIdentity->identifier(); + $userId = $this->identityContext->identifier(); // storeProfile automatically filters out provider-managed fields $this->userService->storeProfile($userId, $data); diff --git a/core/lib/Controllers/UserRolesController.php b/core/lib/Controllers/UserRolesController.php index 630c58c..3349373 100644 --- a/core/lib/Controllers/UserRolesController.php +++ b/core/lib/Controllers/UserRolesController.php @@ -3,8 +3,8 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXC\Service\UserRolesService; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface; class UserRolesController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserRolesService $roleService, private readonly LoggerInterface $logger ) {} @@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract { try { // Check role admin permission - if (!$this->userIdentity->hasPermission('role.admin')) { + if (!$this->identityContext->hasPermission('role.admin')) { return new JsonResponse([ 'status' => 'error', 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] @@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract */ private function roleCreate(array $data): array { - if (!$this->userIdentity->hasPermission('role.manage')) { + if (!$this->identityContext->hasPermission('role.manage')) { throw new \InvalidArgumentException('Insufficient permissions to create roles'); } @@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract */ private function roleUpdate(array $data): bool { - if (!$this->userIdentity->hasPermission('role.manage')) { + if (!$this->identityContext->hasPermission('role.manage')) { throw new \InvalidArgumentException('Insufficient permissions to update roles'); } @@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract */ private function roleDelete(array $data): bool { - if (!$this->userIdentity->hasPermission('role.manage')) { + if (!$this->identityContext->hasPermission('role.manage')) { throw new \InvalidArgumentException('Insufficient permissions to delete roles'); } diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php index b71aa83..4d27ecc 100644 --- a/core/lib/Controllers/UserSettingsController.php +++ b/core/lib/Controllers/UserSettingsController.php @@ -5,16 +5,16 @@ namespace KTXC\Controllers; use KTXC\Http\Request\Request; use KTXC\Http\Response\JsonResponse; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; class UserSettingsController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsService $userService ) {} diff --git a/core/lib/Http/Middleware/AuthenticationMiddleware.php b/core/lib/Http/Middleware/AuthenticationMiddleware.php index 73a72ab..e106e99 100644 --- a/core/lib/Http/Middleware/AuthenticationMiddleware.php +++ b/core/lib/Http/Middleware/AuthenticationMiddleware.php @@ -5,7 +5,10 @@ namespace KTXC\Http\Middleware; use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; use KTXC\Service\SecurityService; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContext; +use KTXF\Cache\BlobCacheInterface; +use KTXF\Cache\EphemeralCacheInterface; +use KTXF\Cache\PersistentCacheInterface; /** * Authentication middleware @@ -19,7 +22,10 @@ class AuthenticationMiddleware implements MiddlewareInterface { public function __construct( private readonly SecurityService $securityService, - private readonly SessionIdentity $sessionIdentity + private readonly IdentityContext $identityContext, + private readonly EphemeralCacheInterface $ephemeralCache, + private readonly PersistentCacheInterface $persistentCache, + private readonly BlobCacheInterface $blobCache, ) {} public function process(Request $request, RequestHandlerInterface $handler): Response @@ -29,7 +35,11 @@ class AuthenticationMiddleware implements MiddlewareInterface // Initialize session identity if authentication succeeded if ($identity) { - $this->sessionIdentity->initialize($identity, true); + $this->identityContext->initialize($identity); + $identityId = $this->identityContext->identifier(); + $this->ephemeralCache->setUserContext($identityId); + $this->persistentCache->setUserContext($identityId); + $this->blobCache->setUserContext($identityId); } // Continue to next middleware (authentication is optional at this stage) diff --git a/core/lib/Http/Middleware/RouterMiddleware.php b/core/lib/Http/Middleware/RouterMiddleware.php index 410cb34..8bedfa7 100644 --- a/core/lib/Http/Middleware/RouterMiddleware.php +++ b/core/lib/Http/Middleware/RouterMiddleware.php @@ -6,7 +6,7 @@ use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; use KTXC\Routing\Router; use KTXC\Routing\Route; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContextInterface; use KTXC\Security\Authorization\PermissionChecker; /** @@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface { public function __construct( private readonly Router $router, - private readonly SessionIdentity $sessionIdentity, + private readonly IdentityContextInterface $identityContext, private readonly PermissionChecker $permissionChecker ) {} @@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface } // Check if route requires authentication - if ($match->authenticated && $this->sessionIdentity->identity() === null) { + if ($match->authenticated && $this->identityContext->identity() === null) { return new Response( Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED diff --git a/core/lib/Http/Middleware/TenantMiddleware.php b/core/lib/Http/Middleware/TenantMiddleware.php index 8f9be5d..728acd5 100644 --- a/core/lib/Http/Middleware/TenantMiddleware.php +++ b/core/lib/Http/Middleware/TenantMiddleware.php @@ -4,7 +4,10 @@ namespace KTXC\Http\Middleware; use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; -use KTXC\SessionTenant; +use KTXC\Context\TenantContext; +use KTXF\Cache\BlobCacheInterface; +use KTXF\Cache\EphemeralCacheInterface; +use KTXF\Cache\PersistentCacheInterface; /** * Tenant resolution middleware @@ -13,16 +16,23 @@ use KTXC\SessionTenant; class TenantMiddleware implements MiddlewareInterface { public function __construct( - private readonly SessionTenant $sessionTenant + private readonly TenantContext $tenantContext, + private readonly EphemeralCacheInterface $ephemeralCache, + private readonly PersistentCacheInterface $persistentCache, + private readonly BlobCacheInterface $blobCache, ) {} public function process(Request $request, RequestHandlerInterface $handler): Response { // Configure tenant from request host - $this->sessionTenant->configure($request->getHost()); + $this->tenantContext->resolveDomain($request->getHost()); + $tenantId = $this->tenantContext->identifier(); + $this->ephemeralCache->setTenantContext($tenantId); + $this->persistentCache->setTenantContext($tenantId); + $this->blobCache->setTenantContext($tenantId); // Check if tenant is configured and enabled - if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) { + if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) { return new Response( Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED diff --git a/core/lib/Kernel.php b/core/lib/Kernel.php index c50f8f7..11b265f 100644 --- a/core/lib/Kernel.php +++ b/core/lib/Kernel.php @@ -9,13 +9,15 @@ namespace KTXC; -use KTXC\Http\Request\Request; -use KTXC\Http\Response\Response; -use KTXC\Http\Middleware\MiddlewarePipeline; -use KTXC\Http\Middleware\TenantMiddleware; -use KTXC\Http\Middleware\FirewallMiddleware; -use KTXC\Http\Middleware\AuthenticationMiddleware; -use KTXC\Http\Middleware\RouterMiddleware; +use KTXC\Application\KernelOptions; +use KTXC\Application\Execution\ExecutionDescriptor; +use KTXC\Application\Execution\ExecutionOutcome; +use KTXC\Application\Execution\ExecutionScope; +use KTXC\Application\Execution\TerminationReport; +use KTXC\Context\IdentityContext; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContext; +use KTXC\Context\TenantContextInterface; use KTXC\Injection\Builder; use KTXC\Injection\Container; use Psr\Container\ContainerInterface; @@ -23,7 +25,10 @@ use KTXC\Module\ModuleManager; use Psr\Log\LoggerInterface; use KTXC\Logger\LoggerFactory; use KTXC\Logger\TenantAwareLogger; -use KTXF\Event\EventBus; +use KTXF\Event\DeferredEventProcessorInterface; +use KTXF\Event\EventDispatcher; +use KTXF\Event\EventDispatcherInterface; +use KTXF\Event\EventListenerRegistry; use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\PersistentCacheInterface; use KTXF\Cache\BlobCacheInterface; @@ -31,7 +36,7 @@ use KTXF\Cache\Store\FileEphemeralCache; use KTXF\Cache\Store\FilePersistentCache; use KTXF\Cache\Store\FileBlobCache; -class Kernel +class Kernel implements KernelInterface { public const VERSION = '1.0.0'; public const VERSION_ID = 10000; @@ -45,26 +50,16 @@ class Kernel protected ?float $startTime = null; protected ?ContainerInterface $container = null; protected ?LoggerInterface $logger = null; - protected ?MiddlewarePipeline $pipeline = null; + private bool $errorHandlerInstalled = false; + private ?ExecutionScope $activeScope = null; - private string $projectDir; private array $config; public function __construct( - protected string $environment = 'prod', - protected bool $debug = false, + private readonly KernelOptions $options, array $config = [], - ?string $projectDir = null, ) { - if (!$environment) { - throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this))); - } - $this->config = $config; - - if ($projectDir !== null) { - $this->projectDir = $projectDir; - } } public function __clone() @@ -77,10 +72,10 @@ class Kernel private function initialize(): void { - if ($this->debug) { + if ($this->debug()) { $this->startTime = microtime(true); } - if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { + if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { if (\function_exists('putenv')) { putenv('SHELL_VERBOSITY=3'); } @@ -129,23 +124,7 @@ class Kernel return true; }); - // Handle uncaught exceptions - set_exception_handler(function (\Throwable $exception) { - $this->logger->error('Exception caught: ' . $exception->getMessage(), [ - 'exception' => $exception, - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTraceAsString(), - ]); - - if ($this->debug) { - echo '
Uncaught Exception: ' . $exception . ''; - } else { - echo 'An unexpected error occurred. Please try again later.'; - } - - exit(1); - }); + $this->errorHandlerInstalled = true; // Handle fatal errors register_shutdown_function(function () { @@ -161,11 +140,6 @@ class Kernel $this->logger->error($message, $error); - if ($this->debug) { - echo '
' . $message . ''; - } else { - echo 'A fatal error occurred. Please try again later.'; - } } }); } @@ -180,9 +154,9 @@ class Kernel /** @var ModuleManager $moduleManager */ $moduleManager = $this->container->get(ModuleManager::class); $moduleManager->modulesBoot(); - - // Build middleware pipeline - $this->pipeline = $this->buildMiddlewarePipeline(); + $this->container + ->get(EventListenerRegistry::class) + ->freeze($this->container); $this->booted = true; } @@ -199,52 +173,108 @@ class Kernel if (false === $this->initialized) { return; } + if ($this->activeScope !== null && !$this->activeScope->terminated()) { + throw new \LogicException('Cannot shut down the kernel while an execution scope is active.'); + } $this->initialized = false; $this->booted = false; $this->container = null; + if ($this->errorHandlerInstalled) { + restore_error_handler(); + $this->errorHandlerInstalled = false; + } } - public function handle(Request $request): Response + public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope { if (!$this->booted) { $this->boot(); } + if ($this->activeScope !== null) { + throw new \LogicException('The kernel already has an active execution scope.'); + } - // Use middleware pipeline to handle the request - return $this->pipeline->handle($request); + $scope = new ExecutionScope( + $descriptor, + \KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor), + $this->container->get(TenantContext::class), + $this->container->get(IdentityContext::class), + ); + $this->container + ->get(DeferredEventProcessorInterface::class) + ->beginExecution($descriptor->executionId); + $this->activeScope = $scope; + + return $scope; } - /** - * Build the middleware pipeline - */ - protected function buildMiddlewarePipeline(): MiddlewarePipeline + public function terminateExecution( + ExecutionScope $scope, + ExecutionOutcome $outcome, + ): TerminationReport { - $pipeline = new MiddlewarePipeline($this->container); - - // Register middleware in execution order - $pipeline->pipe(TenantMiddleware::class); - $pipeline->pipe(FirewallMiddleware::class); - $pipeline->pipe(AuthenticationMiddleware::class); - $pipeline->pipe(RouterMiddleware::class); - - return $pipeline; - } + if ($scope->terminated()) { + return new TerminationReport(); + } + if ($this->activeScope !== $scope) { + throw new \LogicException('Cannot terminate a scope that is not active.'); + } + + $processed = 0; + $remaining = 0; + $deadlineExceeded = false; + $limitExceeded = false; + $failures = []; - /** - * Process deferred events at the end of the request - */ - public function processEvents(): void - { try { - if ($this->container && $this->container->has(EventBus::class)) { - /** @var EventBus $eventBus */ - $eventBus = $this->container->get(EventBus::class); - $eventBus->processDeferred(); + if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) { + $result = $this->container + ->get(DeferredEventProcessorInterface::class) + ->processDeferred($scope->descriptor->executionId); + $processed = $result->processed; + $remaining = $result->remaining; + $deadlineExceeded = $result->deadlineExceeded; + $limitExceeded = $result->limitExceeded; } } catch (\Throwable $e) { - error_log('Event processing error: ' . $e->getMessage()); + $failures[] = $e; + try { + $this->container + ?->get(DeferredEventProcessorInterface::class) + ->discardDeferred($scope->descriptor->executionId); + } catch (\Throwable $discardError) { + $failures[] = $discardError; + } + $this->logger?->error('Deferred event processing failed.', [ + 'exception' => $e, + 'execution_id' => $scope->descriptor->executionId, + 'runtime' => $scope->descriptor->runtime->value, + ]); + } finally { + if ($this->container !== null) { + foreach ([ + EphemeralCacheInterface::class, + PersistentCacheInterface::class, + BlobCacheInterface::class, + ] as $cacheType) { + $cache = $this->container->get($cacheType); + $cache->setUserContext(null); + $cache->setTenantContext(null); + } + } + $scope->dispose(); + $scope->markTerminated(); + $this->activeScope = null; } + + return new TerminationReport( + deferredProcessed: $processed, + deferredRemaining: $remaining, + failures: $failures, + deadlineExceeded: $deadlineExceeded, + limitExceeded: $limitExceeded, + ); } /** @@ -256,13 +286,13 @@ class Kernel { return [ '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_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', 'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%', 'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', 'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%', - 'kernel.debug' => $this->debug, + 'kernel.debug' => $this->debug(), 'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(), 'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(), 'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(), @@ -272,12 +302,12 @@ class Kernel public function environment(): string { - return $this->environment; + return $this->options->environment; } public function debug(): bool { - return $this->debug; + return $this->options->debug; } public function container(): ContainerInterface @@ -291,7 +321,7 @@ class Kernel public function getStartTime(): float { - return $this->debug && null !== $this->startTime ? $this->startTime : -\INF; + return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF; } /** @@ -299,24 +329,7 @@ class Kernel */ 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; + return $this->options->paths->project; } @@ -325,12 +338,12 @@ class Kernel */ private function getConfigDir(): string { - return $this->folderRoot().'/config'; + return $this->options->paths->configuration(); } public function getCacheDir(): string { - return $this->folderRoot().'/var/cache/'.$this->environment; + return $this->options->paths->cache($this->environment()); } public function getBuildDir(): string @@ -340,7 +353,7 @@ class Kernel public function getLogDir(): string { - return $this->folderRoot().'/var/log'; + return $this->options->paths->logs(); } public function getCharset(): string @@ -382,7 +395,7 @@ class Kernel // Service definitions $projectDir = $this->folderRoot(); $moduleDir = $projectDir . '/modules'; - $environment = $this->environment; + $environment = $this->environment(); $builder->addDefinitions([ @@ -395,6 +408,9 @@ class Kernel // Without this alias, PHP-DI will happily autowire a new empty Container when asked Container::class => \DI\get(ContainerInterface::class), + TenantContextInterface::class => \DI\get(TenantContext::class), + IdentityContextInterface::class => \DI\get(IdentityContext::class), + LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) { $logConfig = $this->config['log'] ?? []; @@ -405,7 +421,7 @@ class Kernel return new TenantAwareLogger( $this->logger, - $c->get(SessionTenant::class), + $c->get(TenantContextInterface::class), $logDir, $channel, $level, @@ -413,8 +429,8 @@ class Kernel ); }, - // EventBus as singleton for consistent event handling - EventBus::class => \DI\create(EventBus::class), + EventDispatcherInterface::class => \DI\get(EventDispatcher::class), + DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class), // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; @@ -433,13 +449,13 @@ class Kernel $cache = new $storeClass($projectDir); // Set tenant/user context if available - if ($c->has(SessionTenant::class)) { - $tenant = $c->get(SessionTenant::class); - $cache->setTenantContext($tenant->identifier()); + if ($c->has(TenantContextInterface::class)) { + $tenantContext = $c->get(TenantContextInterface::class); + $cache->setTenantContext($tenantContext->identifier()); } - if ($c->has(SessionIdentity::class)) { - $identity = $c->get(SessionIdentity::class); - $cache->setUserContext($identity->identifier()); + if ($c->has(IdentityContextInterface::class)) { + $identityContext = $c->get(IdentityContextInterface::class); + $cache->setUserContext($identityContext->identifier()); } return $cache; @@ -462,13 +478,13 @@ class Kernel $cache = new $storeClass($projectDir); // Set tenant/user context if available - if ($c->has(SessionTenant::class)) { - $tenant = $c->get(SessionTenant::class); - $cache->setTenantContext($tenant->identifier()); + if ($c->has(TenantContextInterface::class)) { + $tenantContext = $c->get(TenantContextInterface::class); + $cache->setTenantContext($tenantContext->identifier()); } - if ($c->has(SessionIdentity::class)) { - $identity = $c->get(SessionIdentity::class); - $cache->setUserContext($identity->identifier()); + if ($c->has(IdentityContextInterface::class)) { + $identityContext = $c->get(IdentityContextInterface::class); + $cache->setUserContext($identityContext->identifier()); } return $cache; @@ -491,13 +507,13 @@ class Kernel $cache = new $storeClass($projectDir); // Set tenant/user context if available - if ($c->has(SessionTenant::class)) { - $tenant = $c->get(SessionTenant::class); - $cache->setTenantContext($tenant->identifier()); + if ($c->has(TenantContextInterface::class)) { + $tenantContext = $c->get(TenantContextInterface::class); + $cache->setTenantContext($tenantContext->identifier()); } - if ($c->has(SessionIdentity::class)) { - $identity = $c->get(SessionIdentity::class); - $cache->setUserContext($identity->identifier()); + if ($c->has(IdentityContextInterface::class)) { + $identityContext = $c->get(IdentityContextInterface::class); + $cache->setUserContext($identityContext->identifier()); } return $cache; diff --git a/core/lib/KernelInterface.php b/core/lib/KernelInterface.php new file mode 100644 index 0000000..f5b4f60 --- /dev/null +++ b/core/lib/KernelInterface.php @@ -0,0 +1,31 @@ +sessionTenant->configured()) { - $tenantId = $this->sessionTenant->identifier() ?? 'system'; + if ($this->tenantContext->configured()) { + $tenantId = $this->tenantContext->identifier() ?? 'system'; } // Inject tenant id as a reserved context key that concrete loggers extract. diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 8556c5a..0733768 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -2,6 +2,10 @@ 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\ModuleConsoleInterface; use KTXF\Module\ModuleInstanceAbstract; @@ -13,7 +17,36 @@ use KTXF\Module\ModuleInstanceAbstract; */ 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 { @@ -99,6 +132,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M public function registerCI(): array { return [ + \KTXC\Console\Event\EventsDebugCommand::class, \KTXC\Console\Module\ModuleListCommand::class, \KTXC\Console\Module\ModuleEnableCommand::class, \KTXC\Console\Module\ModuleDisableCommand::class, diff --git a/core/lib/Module/ModuleAutoloader.php b/core/lib/Module/ModuleAutoloader.php index ea840fc..fd78f68 100644 --- a/core/lib/Module/ModuleAutoloader.php +++ b/core/lib/Module/ModuleAutoloader.php @@ -2,7 +2,7 @@ namespace KTXC\Module; -use KTXC\Server; +use Composer\Autoload\ClassLoader; /** * Custom autoloader for modules that allows PascalCase namespaces @@ -20,7 +20,10 @@ class ModuleAutoloader private array $namespaceMap = []; private bool $scanned = false; - public function __construct(string $modulesRoot) + public function __construct( + string $modulesRoot, + private readonly ?ClassLoader $composerLoader = null, + ) { $this->modulesRoot = rtrim($modulesRoot, '/'); } @@ -73,10 +76,9 @@ class ModuleAutoloader } // Register module namespaces with Composer ClassLoader - $composerLoader = Server::getComposerLoader(); - if ($composerLoader !== null) { + if ($this->composerLoader !== null) { foreach ($this->namespaceMap as $namespace => $folderName) { - $composerLoader->addPsr4( + $this->composerLoader->addPsr4( 'KTXM\\' . $namespace . '\\', $this->modulesRoot . '/' . $folderName . '/lib/' ); diff --git a/core/lib/Module/ModuleManager.php b/core/lib/Module/ModuleManager.php index c550451..dc8bea1 100644 --- a/core/lib/Module/ModuleManager.php +++ b/core/lib/Module/ModuleManager.php @@ -276,12 +276,13 @@ class ModuleManager try { $module->boot(); $this->logger->debug('Module booted', ['handle' => $handle]); - } catch (Exception $e) { + } catch (\Throwable $e) { $this->logger->error('Module boot failed: ' . $handle, [ 'exception' => $e, 'message' => $e->getMessage(), 'code' => $e->getCode(), ]); + throw $e; } } } diff --git a/core/lib/Runtime/Console/ConsoleRuntime.php b/core/lib/Runtime/Console/ConsoleRuntime.php new file mode 100644 index 0000000..6312a82 --- /dev/null +++ b/core/lib/Runtime/Console/ConsoleRuntime.php @@ -0,0 +1,97 @@ +kernel->beginExecution(ExecutionDescriptor::cli()); + $outcome = ExecutionOutcome::incomplete(); + + try { + $exitCode = $this->application()->run($input, $output); + $outcome = ExecutionOutcome::success($exitCode); + + return $exitCode; + } catch (\Throwable $error) { + $outcome = ExecutionOutcome::failure($error); + throw $error; + } finally { + $this->kernel->terminateExecution($scope, $outcome); + } + } + + private function application(): ConsoleApplication + { + $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->registerLazyCommand($console, $container, $commandClass); + } + } + + return $console; + } + + /** + * @param class-string $commandClass + */ + private function registerLazyCommand( + 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), + )); + } +} diff --git a/core/lib/Runtime/Http/HttpRuntime.php b/core/lib/Runtime/Http/HttpRuntime.php new file mode 100644 index 0000000..be214a8 --- /dev/null +++ b/core/lib/Runtime/Http/HttpRuntime.php @@ -0,0 +1,96 @@ +execute( + $request ?? Request::createFromGlobals(), + send: true, + ); + } + + public function handle(Request $request): Response + { + return $this->execute($request, send: false); + } + + private function execute(Request $request, bool $send): Response + { + $scope = null; + $outcome = ExecutionOutcome::incomplete(); + + try { + $scope = $this->kernel->beginExecution(ExecutionDescriptor::http()); + $response = $this->pipeline()->handle($request); + $outcome = ExecutionOutcome::success($response); + if ($send) { + $response->send(); + } + + return $response; + } catch (\Throwable $error) { + $response = $this->errorResponse($error); + $outcome = ExecutionOutcome::failure($error, $response); + if ($send) { + $response->send(); + } + + return $response; + } finally { + if ($scope !== null) { + $this->kernel->terminateExecution($scope, $outcome); + } + } + } + + 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 + ? '
' . htmlspecialchars((string) $error) . '' + : 'An error occurred. Please try again later.'; + + return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [ + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + } +} diff --git a/core/lib/Security/AuthenticationManager.php b/core/lib/Security/AuthenticationManager.php index af2e037..11641e1 100644 --- a/core/lib/Security/AuthenticationManager.php +++ b/core/lib/Security/AuthenticationManager.php @@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Service\TokenService; use KTXC\Service\UserAccountsService; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Cache\CacheScope; use KTXF\Cache\EphemeralCacheInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface; @@ -26,13 +26,13 @@ class AuthenticationManager private string $securityCode; public function __construct( - private readonly SessionTenant $tenant, + private readonly TenantContextInterface $tenantContext, private readonly EphemeralCacheInterface $cache, private readonly ProviderManager $providerManager, private readonly TokenService $tokenService, private readonly UserAccountsService $userService, ) { - $this->securityCode = $this->tenant->configuration()->security()->code(); + $this->securityCode = $this->tenantContext->configuration()->security()->code(); } // ========================================================================= @@ -75,7 +75,7 @@ class AuthenticationManager $methods = $this->methodsConfigured(); $session = AuthenticationSession::create( - $this->tenant->identifier(), + $this->tenantContext->identifier(), AuthenticationSession::STATE_FRESH ); @@ -103,7 +103,7 @@ class AuthenticationManager // Filter to non-redirect methods since redirects don't need identity first $methods = $this->methodsConfigured(); $methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); - $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + $require = $this->tenantContext->configuration()->authentication()->methodsMinimal(); // Store identity in session without validating to prevent enumeration $session->setMethods(array_column($methods, 'id'), $require); @@ -429,7 +429,7 @@ class AuthenticationManager $session->methodCompleted($method); // Check if MFA is required - $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + $require = $this->tenantContext->configuration()->authentication()->methodsMinimal(); if ($require > 1) { $remainingMethods = $this->methodsConfigured([$method]); // Filter out redirect methods - they can't be used as secondary factors @@ -523,7 +523,7 @@ class AuthenticationManager $accessToken = $this->tokenService->createToken( [ - 'tenant' => $this->tenant->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), @@ -585,7 +585,7 @@ class AuthenticationManager */ private function getProviderConfig(string $method): array { - $providers = $this->tenant->configuration()->authentication()->providers(); + $providers = $this->tenantContext->configuration()->authentication()->providers(); return $providers[$method]['config'] ?? []; } @@ -635,7 +635,7 @@ class AuthenticationManager */ private function methodsConfigured(array $methodsCompleted = []): array { - $tenantProviders = $this->tenant->configuration()->authentication()->providers(); + $tenantProviders = $this->tenantContext->configuration()->authentication()->providers(); $methods = []; foreach ($tenantProviders as $providerId => $providerConfiguration) { @@ -669,7 +669,7 @@ class AuthenticationManager private function createTokens(User $user, bool $mfaVerified = false): array { $payload = [ - 'tenant' => $this->tenant->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), diff --git a/core/lib/Security/Authorization/PermissionChecker.php b/core/lib/Security/Authorization/PermissionChecker.php index 690b0f0..0d03b32 100644 --- a/core/lib/Security/Authorization/PermissionChecker.php +++ b/core/lib/Security/Authorization/PermissionChecker.php @@ -2,7 +2,7 @@ namespace KTXC\Security\Authorization; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContextInterface; /** * Permission Checker @@ -11,7 +11,7 @@ use KTXC\SessionIdentity; class PermissionChecker { public function __construct( - private readonly SessionIdentity $sessionIdentity + private readonly IdentityContextInterface $identityContext ) {} /** @@ -24,7 +24,7 @@ class PermissionChecker */ public function can(string $permission, mixed $resource = null): bool { - $identity = $this->sessionIdentity->identity(); + $identity = $this->identityContext->identity(); if (!$identity) { return false; @@ -113,7 +113,7 @@ class PermissionChecker */ public function getUserPermissions(): array { - $identity = $this->sessionIdentity->identity(); + $identity = $this->identityContext->identity(); if (!$identity) { return []; diff --git a/core/lib/Server.php b/core/lib/Server.php deleted file mode 100644 index 6a6d93b..0000000 --- a/core/lib/Server.php +++ /dev/null @@ -1,311 +0,0 @@ -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() - ? '
' . htmlspecialchars((string) $e) . '' - : '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"); - } - } -} diff --git a/core/lib/Service/ConfigurationService.php b/core/lib/Service/ConfigurationService.php index a5ea6b8..597d0bc 100644 --- a/core/lib/Service/ConfigurationService.php +++ b/core/lib/Service/ConfigurationService.php @@ -5,7 +5,7 @@ namespace KTXC\Service; use KTXC\Db\DataStore; use KTXC\Db\Collection; use KTXC\Db\UTCDateTime; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; class ConfigurationService { @@ -24,7 +24,7 @@ class ConfigurationService public function __construct( DataStore $store, - private readonly SessionTenant $tenant + private readonly TenantContextInterface $tenantContext ) { // DataStore provides selectCollection method $this->collection = $store->selectCollection(self::TABLE_NAME); @@ -36,10 +36,10 @@ class ConfigurationService */ public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]); @@ -54,10 +54,10 @@ class ConfigurationService */ public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $type = $this->determineType($value); @@ -84,10 +84,10 @@ class ConfigurationService */ public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $filter = ['did' => $tenant]; @@ -116,10 +116,10 @@ class ConfigurationService */ public function delete(string $path, string $key, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]); @@ -131,10 +131,10 @@ class ConfigurationService */ public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $filter = ['did' => $tenant]; @@ -155,10 +155,10 @@ class ConfigurationService */ public function exists(string $path, string $key, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0; diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index da872f5..0cae13b 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -8,8 +8,8 @@ use KTXC\Http\Request\Request; use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Stores\FirewallStore; -use KTXC\SessionTenant; -use KTXF\Event\EventBus; +use KTXC\Context\TenantContextInterface; +use KTXF\Event\EventDispatcherInterface; use KTXF\Event\SecurityEvent; use KTXF\IpUtils; @@ -41,33 +41,9 @@ class FirewallService public function __construct( private readonly FirewallStore $store, - private readonly SessionTenant $tenant, - private readonly EventBus $eventBus + private readonly TenantContextInterface $tenantContext, + private readonly EventDispatcherInterface $events, ) { - // 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'] - ); } /** @@ -100,7 +76,7 @@ class FirewallService return new FirewallAnalyzeResult(true); } - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return new FirewallAnalyzeResult(true); } @@ -158,7 +134,7 @@ class FirewallService public function handleAuthFailure(SecurityEvent $event): void { $ipAddress = $event->getIpAddress(); - $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); if (!$ipAddress || !$tenantId) { return; @@ -198,8 +174,8 @@ class FirewallService ): void { // Publish brute force event $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); - $event->setTenantId($this->tenant->identifier()); - $this->eventBus->publish($event); + $event->setTenantId($this->tenantContext->identifier()); + $this->events->dispatch($event); // Auto-block the IP $blockDuration = $this->getConfig( @@ -220,7 +196,7 @@ class FirewallService */ public function logSecurityEvent(SecurityEvent $event): void { - $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); if (!$tenantId) { return; } @@ -283,8 +259,8 @@ class FirewallService $rule->getId(), $rule->getReason() ); - $event->setTenantId($this->tenant->identifier()); - $this->eventBus->publish($event); + $event->setTenantId($this->tenantContext->identifier()); + $this->events->dispatch($event); } // ======================================== @@ -300,7 +276,7 @@ class FirewallService ?string $createdBy = null, ?int $durationSeconds = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -340,7 +316,7 @@ class FirewallService $event->setIpAddress($ipAddress) ->setReason($reason) ->setTenantId($tenantId); - $this->eventBus->publish($event); + $this->events->dispatch($event); return $rule; } @@ -353,7 +329,7 @@ class FirewallService ?string $reason = null, ?string $createdBy = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -376,7 +352,7 @@ class FirewallService $event->setIpAddress($ipAddress) ->setReason($reason) ->setTenantId($tenantId); - $this->eventBus->publish($event); + $this->events->dispatch($event); return $rule; } @@ -389,7 +365,7 @@ class FirewallService ?string $reason = null, ?string $createdBy = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -419,7 +395,7 @@ class FirewallService ?string $createdBy = null, ?int $durationSeconds = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -448,7 +424,7 @@ class FirewallService $event->setDeviceFingerprint($fingerprint) ->setReason($reason) ->setTenantId($tenantId); - $this->eventBus->publish($event); + $this->events->dispatch($event); return $rule; } @@ -464,7 +440,7 @@ class FirewallService } // Verify tenant ownership - if ($rule->getTenantId() !== $this->tenant->identifier()) { + if ($rule->getTenantId() !== $this->tenantContext->identifier()) { return false; } @@ -485,7 +461,7 @@ class FirewallService } // Verify tenant ownership - if ($rule->getTenantId() !== $this->tenant->identifier()) { + if ($rule->getTenantId() !== $this->tenantContext->identifier()) { return false; } @@ -501,7 +477,7 @@ class FirewallService */ public function listRules(bool $activeOnly = true): array { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return []; } @@ -518,7 +494,7 @@ class FirewallService ?string $result = null, int $limit = 100 ): array { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return []; } @@ -531,7 +507,7 @@ class FirewallService */ public function getBlockedCount(?\DateTimeImmutable $since = null): int { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return 0; } @@ -556,7 +532,7 @@ class FirewallService */ private function getConfig(string $key, mixed $default = null): mixed { - $config = $this->tenant->configuration(); + $config = $this->tenantContext->configuration(); $parts = explode('.', $key); foreach ($parts as $part) { @@ -576,7 +552,7 @@ class FirewallService private function getActiveRules(): array { if ($this->rulesCache === null) { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); $this->rulesCache = $tenantId ? $this->store->listRules($tenantId, true) : []; diff --git a/core/lib/Service/SecurityService.php b/core/lib/Service/SecurityService.php index f29b252..a2ab71d 100644 --- a/core/lib/Service/SecurityService.php +++ b/core/lib/Service/SecurityService.php @@ -7,7 +7,7 @@ namespace KTXC\Service; use KTXC\Http\Request\Request; use KTXC\Models\Identity\User; use KTXC\Resource\ProviderManager; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface; /** @@ -23,12 +23,12 @@ class SecurityService private string $securityCode; public function __construct( - private readonly SessionTenant $sessionTenant, + private readonly TenantContextInterface $tenantContext, private readonly TokenService $tokenService, private readonly UserAccountsService $userService, private readonly ProviderManager $providerManager, ) { - $this->securityCode = $this->sessionTenant->configuration()->security()->code(); + $this->securityCode = $this->tenantContext->configuration()->security()->code(); } /** @@ -118,7 +118,7 @@ class SecurityService continue; } $context = new \KTXF\Security\Authentication\ProviderContext( - tenantId: $this->sessionTenant->identifier(), + tenantId: $this->tenantContext->identifier(), userIdentity: $identity, ); $result = $provider->verify($context, $credentials); diff --git a/core/lib/Service/TokenService.php b/core/lib/Service/TokenService.php index ef22868..d9ca6a3 100644 --- a/core/lib/Service/TokenService.php +++ b/core/lib/Service/TokenService.php @@ -2,7 +2,7 @@ namespace KTXC\Service; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Cache\CacheScope; use KTXF\Cache\EphemeralCacheInterface; @@ -26,7 +26,7 @@ class TokenService private string $algorithm = 'HS256'; public function __construct( - private readonly SessionTenant $sessionTenant, + private readonly TenantContextInterface $tenantContext, private readonly EphemeralCacheInterface $cache, ) { } diff --git a/core/lib/Service/UserAccountsService.php b/core/lib/Service/UserAccountsService.php index 7936b5c..c29c9d0 100644 --- a/core/lib/Service/UserAccountsService.php +++ b/core/lib/Service/UserAccountsService.php @@ -3,16 +3,16 @@ namespace KTXC\Service; use KTXC\Models\Identity\User; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXC\Stores\UserAccountsStore; class UserAccountsService { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsStore $userStore ) { } @@ -26,7 +26,7 @@ class UserAccountsService */ public function listUsers(array $filters = []): array { - $users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters); + $users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters); // Remove sensitive data foreach ($users as &$user) { @@ -38,7 +38,7 @@ class UserAccountsService public function fetchByIdentity(string $identifier): User | null { - $data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + $data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier); if (!$data) { return null; } @@ -50,32 +50,32 @@ class UserAccountsService public function fetchByIdentifier(string $identifier): array | null { - return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier); + return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier); } public function fetchByIdentityRaw(string $identifier): array | null { - return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier); } public function fetchByProviderSubject(string $provider, string $subject): ?array { - return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject); + return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject); } public function createUser(array $userData): array { - return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData); + return $this->userStore->createUser($this->tenantContext->identifier(), $userData); } public function updateUser(string $uid, array $updates): bool { - return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates); + return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates); } public function deleteUser(string $uid): bool { - return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid); + return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid); } // ========================================================================= @@ -84,7 +84,7 @@ class UserAccountsService public function fetchProfile(string $uid): ?array { - return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid); + return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid); } public function storeProfile(string $uid, array $profileFields): bool @@ -109,7 +109,7 @@ class UserAccountsService return false; } - return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields); + return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields); } // ========================================================================= @@ -118,12 +118,12 @@ class UserAccountsService public function fetchSettings(array $settings = [], bool $flatten = false): array | null { - return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten); + return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten); } public function storeSettings(array $settings): bool { - return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); + return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings); } // ========================================================================= diff --git a/core/lib/Service/UserRolesService.php b/core/lib/Service/UserRolesService.php index 7383f93..e1ccfae 100644 --- a/core/lib/Service/UserRolesService.php +++ b/core/lib/Service/UserRolesService.php @@ -2,7 +2,7 @@ namespace KTXC\Service; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXC\Stores\UserRolesStore; use Psr\Log\LoggerInterface; @@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface; class UserRolesService { public function __construct( - private readonly SessionTenant $tenantIdentity, + private readonly TenantContextInterface $tenantContext, private readonly UserRolesStore $roleStore, private readonly LoggerInterface $logger ) {} @@ -26,7 +26,7 @@ class UserRolesService */ public function listRoles(): array { - return $this->roleStore->listRoles($this->tenantIdentity->identifier()); + return $this->roleStore->listRoles($this->tenantContext->identifier()); } /** @@ -34,7 +34,7 @@ class UserRolesService */ public function getRole(string $rid): ?array { - return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid); + return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid); } /** @@ -45,11 +45,11 @@ class UserRolesService $this->validateRoleData($roleData); $this->logger->info('Creating role', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'label' => $roleData['label'] ?? 'Unnamed' ]); - return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData); + return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData); } /** @@ -70,11 +70,11 @@ class UserRolesService $this->validateRoleData($updates, false); $this->logger->info('Updating role', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'rid' => $rid ]); - return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates); + return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates); } /** @@ -93,17 +93,17 @@ class UserRolesService } // Check if role is assigned to users - $userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + $userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid); if ($userCount > 0) { throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)"); } $this->logger->info('Deleting role', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'rid' => $rid ]); - return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid); + return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid); } /** @@ -111,7 +111,7 @@ class UserRolesService */ public function getRoleUserCount(string $rid): int { - return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid); } /** diff --git a/core/lib/SessionIdentity.php b/core/lib/SessionIdentity.php deleted file mode 100644 index cbd7979..0000000 --- a/core/lib/SessionIdentity.php +++ /dev/null @@ -1,94 +0,0 @@ -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()); - } - -} diff --git a/core/lib/SessionTenant.php b/core/lib/SessionTenant.php deleted file mode 100644 index 9a3244f..0000000 --- a/core/lib/SessionTenant.php +++ /dev/null @@ -1,145 +0,0 @@ -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