1 Commits

Author SHA1 Message Date
Sebastian 2c3b1be934 chore(deps): update dependency vue-i18n to v11.4.8
Build Test / build (pull_request) Successful in 14s
JS Unit Tests / test (pull_request) Successful in 15s
PHP Unit Tests / test (pull_request) Failing after 46s
PHP Integration Tests / Integration Tests (pull_request) Failing after 57s
2026-07-29 03:02:03 +00:00
170 changed files with 1244 additions and 11922 deletions
Generated
+8 -8
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "25e4604f76b54a60904f1517dd3b210d", "content-hash": "68916c1b58d9ce06a59f6c58e4a2d226",
"packages": [ "packages": [
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
@@ -606,16 +606,16 @@
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v7.4.18", "version": "v7.4.14",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -680,7 +680,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v7.4.18" "source": "https://github.com/symfony/console/tree/v7.4.14"
}, },
"funding": [ "funding": [
{ {
@@ -700,7 +700,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-25T14:18:37+00:00" "time": "2026-06-16T11:50:14+00:00"
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
@@ -3057,5 +3057,5 @@
"ext-iconv": "*" "ext-iconv": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }
@@ -15,9 +15,6 @@ final readonly class TerminationReport
public array $failures = [], public array $failures = [],
public bool $deadlineExceeded = false, public bool $deadlineExceeded = false,
public bool $limitExceeded = false, public bool $limitExceeded = false,
public int $deferredListenerInvocations = 0,
public bool $deferredEventLimitExceeded = false,
public bool $deferredListenerInvocationLimitExceeded = false,
) { ) {
} }
} }
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace KTXC\Console\Event; namespace KTXC\Console\Event;
use KTXC\Event\EventListenerRegistry; use KTXF\Event\EventListenerRegistry;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Service\FirewallService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
final class FirewallMaintenanceCommand extends Command
{
public function __construct(private readonly FirewallService $firewall)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->firewall->cleanup();
} catch (\Throwable $error) {
$io->error('Firewall maintenance failed: '.$error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf(
'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.',
$result['expiredRules'],
$result['oldLogs'],
$result['expiredBruteForceClaims']
));
return Command::SUCCESS;
}
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Stores\FirewallStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
final class FirewallSetupCommand extends Command
{
public function __construct(private readonly FirewallStore $store)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$indexes = $this->store->ensureIndexes();
} catch (\Throwable $error) {
$io->error('Firewall database setup failed: '.$error->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes)));
return Command::SUCCESS;
}
}
@@ -4,12 +4,10 @@ declare(strict_types=1);
namespace KTXC\Console\Tenant; namespace KTXC\Console\Tenant;
use KTXC\Context\TenantContext;
use KTXC\Models\Tenant\DomainCollection; use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject; use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService; use KTXC\Service\TenantService;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use KTXF\Utile\UUID; use KTXF\Utile\UUID;
@@ -37,8 +35,6 @@ class TenantCreateCommand extends Command
private readonly TenantService $tenantService, private readonly TenantService $tenantService,
private readonly UserRolesStore $rolesStore, private readonly UserRolesStore $rolesStore,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly TenantContext $tenantContext,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
parent::__construct(); parent::__construct();
@@ -101,10 +97,6 @@ class TenantCreateCommand extends Command
$io->error('Failed to create tenant.'); $io->error('Failed to create tenant.');
return Command::FAILURE; return Command::FAILURE;
} }
if (!$this->tenantContext->resolveIdentifier($identifier)) {
throw new \RuntimeException("Failed to initialize tenant context for '{$identifier}'.");
}
$identifier = $this->tenantContext->requireIdentifier();
$this->logger->info('Tenant created via console', [ $this->logger->info('Tenant created via console', [
'identifier' => $identifier, 'identifier' => $identifier,
@@ -142,7 +134,7 @@ class TenantCreateCommand extends Command
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) { if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation."); $io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
} else { } else {
$this->userService->createUser([ $this->userStore->createUser($identifier, [
'identity' => $adminIdentity, 'identity' => $adminIdentity,
'label' => 'Administrator', 'label' => 'Administrator',
'enabled' => true, 'enabled' => true,
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\SystemStore\SystemStoreConfigurationService;
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;
#[AsCommand(name: 'tenant:store:list', description: 'List logical stores configured for a tenant')]
class TenantStoreListCommand extends Command
{
public function __construct(private readonly SystemStoreConfigurationService $stores)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = (string) $input->getArgument('tenant');
try {
$stores = $this->stores->list($tenant);
if ($stores === []) {
$io->text("No logical stores configured for tenant '{$tenant}'.");
return Command::SUCCESS;
}
$rows = [];
foreach ($stores as $name => $store) {
$rows[] = [$name, $store->provider, (string) $store->service, $store->namespace];
}
$io->table(['Name', 'Provider', 'Service', 'Namespace'], $rows);
return Command::SUCCESS;
} catch (\Throwable $error) {
$io->error('Failed to list tenant stores: ' . $error->getMessage());
return Command::FAILURE;
}
}
}
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\SystemStore\SystemStoreConfigurationService;
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;
#[AsCommand(name: 'tenant:store:remove', description: 'Remove a logical store from a tenant')]
class TenantStoreRemoveCommand extends Command
{
public function __construct(
private readonly SystemStoreConfigurationService $stores,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('name', InputArgument::REQUIRED, 'Logical store name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = (string) $input->getArgument('tenant');
$name = (string) $input->getArgument('name');
try {
if (!$this->stores->remove($tenant, $name)) {
$io->warning("Logical store '{$name}' was not configured for tenant '{$tenant}'.");
return Command::SUCCESS;
}
$this->logger->info('Tenant logical store removed via console', compact('tenant', 'name'));
$io->success("Logical store '{$name}' removed from tenant '{$tenant}'.");
return Command::SUCCESS;
} catch (\Throwable $error) {
$this->logger->error('Tenant logical store removal failed', [
'tenant' => $tenant,
'name' => $name,
'error' => $error->getMessage(),
]);
$io->error('Failed to remove tenant store: ' . $error->getMessage());
return Command::FAILURE;
}
}
}
@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Tenant;
use KTXC\SystemStore\SystemStoreConfigurationService;
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;
#[AsCommand(name: 'tenant:store:set', description: 'Configure a logical store for a tenant')]
class TenantStoreSetCommand extends Command
{
public function __construct(
private readonly SystemStoreConfigurationService $stores,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
->addArgument('name', InputArgument::REQUIRED, 'Logical store name, such as previews')
->addArgument('provider', InputArgument::REQUIRED, 'System-store provider identifier')
->addArgument('service', InputArgument::REQUIRED, 'System-store service identifier')
->addArgument('namespace', InputArgument::REQUIRED, 'Provider key namespace');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tenant = (string) $input->getArgument('tenant');
$name = (string) $input->getArgument('name');
$provider = (string) $input->getArgument('provider');
$service = (string) $input->getArgument('service');
$namespace = (string) $input->getArgument('namespace');
try {
$this->stores->set($tenant, $name, $provider, $service, $namespace);
$this->logger->info('Tenant logical store configured via console', compact(
'tenant', 'name', 'provider', 'service', 'namespace',
));
$io->success("Logical store '{$name}' configured for tenant '{$tenant}'.");
return Command::SUCCESS;
} catch (\Throwable $error) {
$this->logger->error('Tenant logical store configuration failed', [
'tenant' => $tenant,
'name' => $name,
'error' => $error->getMessage(),
]);
$io->error('Failed to configure tenant store: ' . $error->getMessage());
return Command::FAILURE;
}
}
}
+5 -7
View File
@@ -4,8 +4,7 @@ declare(strict_types=1);
namespace KTXC\Console\User; namespace KTXC\Console\User;
use KTXC\Context\TenantContext; use KTXC\Service\TenantService;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -29,9 +28,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class UserCreateCommand extends Command class UserCreateCommand extends Command
{ {
public function __construct( public function __construct(
private readonly TenantContext $tenantContext, private readonly TenantService $tenantService,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly UserRolesStore $rolesStore, private readonly UserRolesStore $rolesStore,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
@@ -61,11 +59,11 @@ class UserCreateCommand extends Command
$io->title('Create User'); $io->title('Create User');
try { try {
if (!$this->tenantContext->resolveIdentifier($tenant)) { // Ensure the tenant exists
if (!$this->tenantService->fetchById($tenant)) {
$io->error("Tenant '{$tenant}' not found."); $io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE; return Command::FAILURE;
} }
$tenant = $this->tenantContext->requireIdentifier();
// Ensure identity is unique within the tenant // Ensure identity is unique within the tenant
if ($this->userStore->fetchByIdentity($tenant, $identity)) { if ($this->userStore->fetchByIdentity($tenant, $identity)) {
@@ -97,7 +95,7 @@ class UserCreateCommand extends Command
$userData['uid'] = $input->getOption('uid'); $userData['uid'] = $input->getOption('uid');
} }
$user = $this->userService->createUser($userData); $user = $this->userStore->createUser($tenant, $userData);
$this->logger->info('User created via console', [ $this->logger->info('User created via console', [
'tenant' => $tenant, 'tenant' => $tenant,
+1 -11
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace KTXC\Console\User; namespace KTXC\Console\User;
use KTXC\Context\TenantContext;
use KTXC\Service\UserAccountsService;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -28,9 +26,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class UserDeleteCommand extends Command class UserDeleteCommand extends Command
{ {
public function __construct( public function __construct(
private readonly TenantContext $tenantContext,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore,
private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
parent::__construct(); parent::__construct();
@@ -56,12 +52,6 @@ class UserDeleteCommand extends Command
$io->title('Delete User'); $io->title('Delete User');
try { try {
if (!$this->tenantContext->resolveIdentifier($tenant)) {
$io->error("Tenant '{$tenant}' not found.");
return Command::FAILURE;
}
$tenant = $this->tenantContext->requireIdentifier();
$user = $this->userStore->fetchByIdentity($tenant, $identity); $user = $this->userStore->fetchByIdentity($tenant, $identity);
if (!$user) { if (!$user) {
@@ -74,7 +64,7 @@ class UserDeleteCommand extends Command
return Command::SUCCESS; return Command::SUCCESS;
} }
if (!$this->userService->deleteUser($user['uid'])) { if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
$io->error("Failed to delete user '{$identity}'."); $io->error("Failed to delete user '{$identity}'.");
return Command::FAILURE; return Command::FAILURE;
} }
@@ -92,9 +92,9 @@ class AuthenticationController extends ControllerAbstract
} }
$request = AuthenticationRequest::verify($session, $method, $response); $request = AuthenticationRequest::verify($session, $method, $response);
$response = $this->authManager->handle($request); $authResponse = $this->authManager->handle($request);
return $this->buildJsonResponse($response); return $this->buildJsonResponse($authResponse);
} }
/** /**
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
$host = $request->getHost(); $host = $request->getHost();
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}"; $callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl); $authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($request); $response = $this->authManager->handle($authRequest);
return $this->buildJsonResponse($response); return $this->buildJsonResponse($response);
} }
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
return $this->redirectWithError('Missing state parameter'); return $this->redirectWithError('Missing state parameter');
} }
$request = AuthenticationRequest::callback($sessionId, $provider, $params); $authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($request); $response = $this->authManager->handle($authRequest);
if ($response->isSuccess()) { if ($response->isSuccess()) {
$returnUrl = $response->returnUrl ?? '/'; $returnUrl = $response->returnUrl ?? '/';
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
); );
} }
$request = AuthenticationRequest::status($sessionId); $authRequest = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($request); $response = $this->authManager->handle($authRequest);
return $this->buildJsonResponse($response); return $this->buildJsonResponse($response);
} }
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
{ {
$sessionId = $request->query->get('session', ''); $sessionId = $request->query->get('session', '');
$request = AuthenticationRequest::cancel($sessionId); $authRequest = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($request); $this->authManager->handle($authRequest);
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']); return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
} }
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
); );
} }
$request = AuthenticationRequest::refresh($refreshToken); $authRequest = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($request); $response = $this->authManager->handle($authRequest);
if ($response->isFailed()) { if ($response->isFailed()) {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); $httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
{ {
$token = $request->cookies->get('accessToken'); $token = $request->cookies->get('accessToken');
$request = AuthenticationRequest::logout($token, false); $authRequest = AuthenticationRequest::logout($token, false);
$this->authManager->handle($request); $this->authManager->handle($authRequest);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']); $response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
return $this->clearTokenCookies($response); return $this->clearTokenCookies($response);
@@ -274,8 +274,8 @@ class AuthenticationController extends ControllerAbstract
{ {
$token = $request->cookies->get('accessToken'); $token = $request->cookies->get('accessToken');
$request = AuthenticationRequest::logout($token, true); $authRequest = AuthenticationRequest::logout($token, true);
$this->authManager->handle($request); $this->authManager->handle($authRequest);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']); $response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
return $this->clearTokenCookies($response); return $this->clearTokenCookies($response);
-465
View File
@@ -1,465 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\FirewallRuleConflictException;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
final class FirewallController extends ControllerAbstract
{
public function __construct(
private readonly TenantFirewallRuleService $tenantRules,
private readonly SystemFirewallRuleService $systemRules,
private readonly TenantFirewallLogService $tenantLogs,
private readonly SystemFirewallLogService $systemLogs,
private readonly TenantFirewallStatusService $tenantStatus,
private readonly SystemFirewallStatusService $systemStatus,
) {
}
#[AuthenticatedRoute(
'/firewall/rules',
name: 'firewall.tenant.rules.list',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function tenantRules(
string $status = 'active',
?string $type = null,
?string $action = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantRules->queryRules(
$status,
$type,
$action,
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.fetch',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function tenantRule(string $ruleId): JsonResponse
{
return $this->ruleResponse($this->tenantRules->fetchRule($ruleId));
}
#[AuthenticatedRoute(
'/firewall/effective-policy',
name: 'firewall.tenant.policy.effective',
permissions: [TenantFirewallRuleService::PERMISSION_READ],
)]
public function effectivePolicy(): JsonResponse
{
return new JsonResponse($this->tenantRules->effectivePolicy());
}
#[AuthenticatedRoute(
'/firewall/system/rules',
name: 'firewall.system.rules.list',
permissions: [SystemFirewallRuleService::PERMISSION_READ],
)]
public function systemRules(
string $status = 'active',
?string $type = null,
?string $action = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->systemRules->queryRules(
$status,
$type,
$action,
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.fetch',
permissions: [SystemFirewallRuleService::PERMISSION_READ],
)]
public function systemRule(string $ruleId): JsonResponse
{
return $this->ruleResponse($this->systemRules->fetchRule($ruleId));
}
#[AuthenticatedRoute(
'/firewall/rules',
name: 'firewall.tenant.rules.create',
methods: ['POST'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function createTenantRule(
Request $request,
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->mutationResponse(fn() => $this->tenantRules->createRule(
$type,
$action,
$value,
$reason,
$durationSeconds,
$request->getClientIp(),
$confirmCurrentIp
));
}
#[AuthenticatedRoute(
'/firewall/system/rules',
name: 'firewall.system.rules.create',
methods: ['POST'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function createSystemRule(
Request $request,
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->mutationResponse(fn() => $this->systemRules->createRule(
$type,
$action,
$value,
$reason,
$durationSeconds,
$request->getClientIp(),
$confirmCurrentIp
));
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.update',
methods: ['PATCH'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateTenantRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->tenantRules->disableRule($ruleId, $reason),
'enable' => $this->tenantRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->tenantRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.update',
methods: ['PATCH'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function updateSystemRule(
Request $request,
string $ruleId,
string $operation,
string $reason,
?int $durationSeconds = null,
bool $confirmCurrentIp = false
): JsonResponse {
return $this->lifecycleResponse(fn() => match ($operation) {
'disable' => $this->systemRules->disableRule($ruleId, $reason),
'enable' => $this->systemRules->enableRule(
$ruleId, $reason, $request->getClientIp(), $confirmCurrentIp
),
'extend' => $this->systemRules->extendRule(
$ruleId,
$durationSeconds ?? throw new \InvalidArgumentException('Rule extension duration is required.'),
$reason
),
default => throw new \InvalidArgumentException('Invalid firewall rule operation.'),
});
}
#[AuthenticatedRoute(
'/firewall/rules/{ruleId}',
name: 'firewall.tenant.rules.delete',
methods: ['DELETE'],
permissions: [TenantFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteTenantRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->tenantRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/system/rules/{ruleId}',
name: 'firewall.system.rules.delete',
methods: ['DELETE'],
permissions: [SystemFirewallRuleService::PERMISSION_MANAGE],
)]
public function deleteSystemRule(string $ruleId, string $reason): JsonResponse
{
return $this->lifecycleResponse(fn() => $this->systemRules->removeRule($ruleId, $reason));
}
#[AuthenticatedRoute(
'/firewall/logs',
name: 'firewall.tenant.logs.list',
permissions: [TenantFirewallLogService::PERMISSION_READ],
)]
public function tenantLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $from = null,
?string $to = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->tenantLogs->query(
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/system/logs',
name: 'firewall.system.logs.list',
permissions: [SystemFirewallLogService::PERMISSION_READ],
)]
public function systemLogs(
?string $tenantId = null,
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $from = null,
?string $to = null,
string $limit = '50',
string $offset = '0'
): JsonResponse {
return $this->queryResponse(
fn(int $parsedLimit, int $parsedOffset): array => $this->systemLogs->query(
$tenantId,
compact('ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope', 'from', 'to'),
$parsedLimit,
$parsedOffset
),
$limit,
$offset
);
}
#[AuthenticatedRoute(
'/firewall/metrics',
name: 'firewall.tenant.metrics.read',
permissions: [TenantFirewallLogService::PERMISSION_READ],
)]
public function tenantMetrics(?string $since = null): JsonResponse
{
return $this->readResponse(fn(): array => $this->tenantStatus->metrics($since));
}
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.read',
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_READ],
)]
public function tenantConfiguration(): JsonResponse
{
return new JsonResponse($this->tenantStatus->configuration());
}
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.update',
methods: ['PUT'],
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateTenantConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration(
$enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/tenants/{tenantId}/configuration',
name: 'firewall.system.tenant.configuration.update',
methods: ['PUT'],
permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateSystemTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration(
$tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/metrics',
name: 'firewall.system.metrics.read',
permissions: [SystemFirewallLogService::PERMISSION_READ],
)]
public function systemMetrics(?string $tenantId = null, ?string $since = null): JsonResponse
{
return $this->readResponse(fn(): array => $this->systemStatus->metrics($tenantId, $since));
}
#[AuthenticatedRoute(
'/firewall/system/maintenance',
name: 'firewall.system.maintenance.read',
permissions: [SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ],
)]
public function maintenanceStatus(): JsonResponse
{
return new JsonResponse($this->systemStatus->maintenanceStatus());
}
private function queryResponse(callable $query, string $limit, string $offset): JsonResponse
{
try {
if (!ctype_digit($limit) || !ctype_digit($offset)) {
throw new \InvalidArgumentException('Pagination values must be non-negative integers.');
}
return new JsonResponse($query((int)$limit, (int)$offset));
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function ruleResponse(?\JsonSerializable $rule): JsonResponse
{
if ($rule === null) {
return new JsonResponse(['error' => 'Firewall rule not found.'], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse($rule);
}
private function readResponse(callable $read): JsonResponse
{
try {
return new JsonResponse($read());
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => $error->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function mutationResponse(callable $mutation): JsonResponse
{
try {
return new JsonResponse(['rule' => $mutation()], JsonResponse::HTTP_CREATED);
} catch (FirewallRuleConflictException $error) {
return new JsonResponse(['error' => [
'code' => $error->conflictCode,
'message' => $error->getMessage(),
]], JsonResponse::HTTP_CONFLICT);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_rule',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function lifecycleResponse(callable $mutation): JsonResponse
{
try {
$rule = $mutation();
if ($rule === null) {
return new JsonResponse(['error' => [
'code' => 'firewall_rule_not_found',
'message' => 'Firewall rule not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['rule' => $rule]);
} catch (FirewallRuleConflictException $error) {
return new JsonResponse(['error' => [
'code' => $error->conflictCode,
'message' => $error->getMessage(),
]], JsonResponse::HTTP_CONFLICT);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_rule',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
private function settingsResponse(callable $mutation): JsonResponse
{
try {
$configuration = $mutation();
if ($configuration === null) {
return new JsonResponse(['error' => [
'code' => 'tenant_not_found',
'message' => 'Tenant not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['configuration' => $configuration]);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_configuration',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
}
+5 -10
View File
@@ -6,7 +6,6 @@ use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\L10N\LocaleResolver; use KTXC\L10N\LocaleResolver;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use KTXC\Module\Configuration\BrowserModuleContext;
use KTXC\Security\Authorization\PermissionChecker; use KTXC\Security\Authorization\PermissionChecker;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\Context\IdentityContextInterface; use KTXC\Context\IdentityContextInterface;
@@ -31,11 +30,7 @@ class InitController extends ControllerAbstract
$configuration = []; $configuration = [];
// modules - filter by permissions // modules - filter by permissions
$browserContext = new BrowserModuleContext( $configuration['modules'] = [];
$this->tenantContext->requireIdentifier(),
$this->identityContext->requireIdentifier(),
);
foreach ($this->moduleManager->list(true, true) as $module) { foreach ($this->moduleManager->list(true, true) as $module) {
// Check if user has permission to view this module // Check if user has permission to view this module
// Allow access if user has: {module_handle}, {module_handle}.*, or * permission // Allow access if user has: {module_handle}, {module_handle}.*, or * permission
@@ -44,12 +39,12 @@ class InitController extends ControllerAbstract
continue; continue;
} }
$module->configure($browserContext); $integrations = $module->registerBI();
if ($integrations !== null) {
$configuration['modules'][$handle] = $integrations;
}
} }
$configuration['modules'] = $browserContext->modules();
$configuration = array_merge($configuration, $browserContext->configuration());
// localization // localization
$configuration['l10n'] = [ $configuration['l10n'] = [
'locale' => $this->localeResolver->resolve($request), 'locale' => $this->localeResolver->resolve($request),
+1 -1
View File
@@ -66,6 +66,6 @@ class ObjectId
*/ */
public static function isValid(string $id): bool public static function isValid(string $id): bool
{ {
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1; return MongoObjectId::isValid($id);
} }
} }
-161
View File
@@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\FailurePolicy;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
{
private const DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS = 300.0;
private const DEFAULT_MAX_DEFERRED_EVENTS = 1000;
private const DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS = 50000;
/** @var array<string, list<Event>> */
private array $deferred = [];
private ?string $activeExecution = null;
private int $dispatchDepth = 0;
public function __construct(
private readonly EventListenerRegistry $registry,
private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
private readonly float $deferredProcessingTimeoutSeconds = self::DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS,
private readonly int $maxDeferredEvents = self::DEFAULT_MAX_DEFERRED_EVENTS,
private readonly int $maxDeferredListenerInvocations = self::DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS,
) {
if ($this->deferredProcessingTimeoutSeconds <= 0) {
throw new \InvalidArgumentException('The deferred processing timeout must be greater than zero.');
}
if ($this->maxDeferredEvents <= 0) {
throw new \InvalidArgumentException('The deferred event limit must be greater than zero.');
}
if ($this->maxDeferredListenerInvocations <= 0) {
throw new \InvalidArgumentException('The deferred listener invocation limit must be greater than zero.');
}
}
public function dispatch(Event $event): void
{
if (++$this->dispatchDepth > 32) {
--$this->dispatchDepth;
throw new \RuntimeException('Event dispatch recursion limit exceeded.');
}
try {
$this->invoke($event, DeliveryMode::Immediate);
if ($this->registry->listeners($event->label(), DeliveryMode::Deferred) !== []) {
if ($this->activeExecution === null) {
throw new \LogicException('Deferred events require an active execution scope.');
}
$this->deferred[$this->activeExecution][] = $event;
}
} finally {
--$this->dispatchDepth;
}
}
public function beginExecution(string $executionId): void
{
if ($this->activeExecution !== null) {
throw new \LogicException('An event execution scope is already active.');
}
$this->activeExecution = $executionId;
$this->deferred[$executionId] = [];
}
public function processDeferred(string $executionId): DeferredProcessingResult
{
if ($this->activeExecution !== $executionId) {
throw new \LogicException('Cannot process deferred events for an inactive execution.');
}
try {
$processedEvents = 0;
$listenerInvocations = 0;
$deadline = microtime(true) + $this->deferredProcessingTimeoutSeconds;
$deadlineExceeded = false;
$eventLimitExceeded = false;
$listenerInvocationLimitExceeded = false;
while (($event = array_shift($this->deferred[$executionId])) !== null) {
if ($processedEvents >= $this->maxDeferredEvents) {
$eventLimitExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
if (microtime(true) >= $deadline) {
$deadlineExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
$eventListenerCount = count($this->registry->listeners(
$event->label(),
DeliveryMode::Deferred,
));
if ($listenerInvocations + $eventListenerCount > $this->maxDeferredListenerInvocations) {
$listenerInvocationLimitExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
$listenerInvocations += $this->invoke($event, DeliveryMode::Deferred);
$processedEvents++;
}
return new DeferredProcessingResult(
processed: $processedEvents,
remaining: count($this->deferred[$executionId]),
deadlineExceeded: $deadlineExceeded,
limitExceeded: $eventLimitExceeded || $listenerInvocationLimitExceeded,
listenerInvocations: $listenerInvocations,
eventLimitExceeded: $eventLimitExceeded,
listenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
);
} finally {
$this->discardDeferred($executionId);
}
}
public function discardDeferred(string $executionId): void
{
unset($this->deferred[$executionId]);
if ($this->activeExecution === $executionId) {
$this->activeExecution = null;
}
}
private function invoke(Event $event, DeliveryMode $delivery): int
{
$processed = 0;
foreach ($this->registry->listeners($event->label(), $delivery) as $listener) {
if ($event->isPropagationStopped()) {
break;
}
$processed++;
try {
$service = $this->container->get($listener->service);
$service->{$listener->method}($event);
} catch (\Throwable $error) {
$this->logger->error('Event listener failed.', [
'event' => $event->label(),
'module' => $listener->module,
'listener' => $listener->service . '::' . $listener->method,
'exception' => $error,
]);
if ($listener->failurePolicy === FailurePolicy::Propagate) {
throw $error;
}
}
}
return $processed;
}
}
-32
View File
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Request;
/**
* Holds the HTTP request for the duration of the current runtime execution.
*/
final class RequestContext
{
private ?Request $request = null;
public function initialize(Request $request): void
{
if ($this->request !== null) {
throw new \LogicException('The request context has already been initialized.');
}
$this->request = $request;
}
public function current(): ?Request
{
return $this->request;
}
public function clear(): void
{
$this->request = null;
}
}
+3 -14
View File
@@ -27,11 +27,10 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory; use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger; use KTXC\Logger\TenantAwareLogger;
use KTXC\Event\DeferredEventProcessorInterface; use KTXF\Event\DeferredEventProcessorInterface;
use KTXC\Event\EventDispatcher; use KTXF\Event\EventDispatcher;
use KTXC\Event\EventListenerRegistry;
use KTXF\Event\EventDispatcherInterface; use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\EventListenerRegistrarInterface; use KTXF\Event\EventListenerRegistry;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface; use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface; use KTXF\Cache\BlobCacheInterface;
@@ -238,9 +237,6 @@ class Kernel implements KernelInterface
$remaining = 0; $remaining = 0;
$deadlineExceeded = false; $deadlineExceeded = false;
$limitExceeded = false; $limitExceeded = false;
$listenerInvocations = 0;
$eventLimitExceeded = false;
$listenerInvocationLimitExceeded = false;
$failures = []; $failures = [];
try { try {
@@ -252,9 +248,6 @@ class Kernel implements KernelInterface
$remaining = $result->remaining; $remaining = $result->remaining;
$deadlineExceeded = $result->deadlineExceeded; $deadlineExceeded = $result->deadlineExceeded;
$limitExceeded = $result->limitExceeded; $limitExceeded = $result->limitExceeded;
$listenerInvocations = $result->listenerInvocations;
$eventLimitExceeded = $result->eventLimitExceeded;
$listenerInvocationLimitExceeded = $result->listenerInvocationLimitExceeded;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$failures[] = $e; $failures[] = $e;
@@ -293,9 +286,6 @@ class Kernel implements KernelInterface
failures: $failures, failures: $failures,
deadlineExceeded: $deadlineExceeded, deadlineExceeded: $deadlineExceeded,
limitExceeded: $limitExceeded, limitExceeded: $limitExceeded,
deferredListenerInvocations: $listenerInvocations,
deferredEventLimitExceeded: $eventLimitExceeded,
deferredListenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
); );
} }
@@ -420,7 +410,6 @@ class Kernel implements KernelInterface
EventDispatcherInterface::class => \DI\get(EventDispatcher::class), EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class), DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
EventListenerRegistrarInterface::class => \DI\get(EventListenerRegistry::class),
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
+3 -65
View File
@@ -13,7 +13,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{ {
public const RESULT_ALLOWED = 'allowed'; public const RESULT_ALLOWED = 'allowed';
public const RESULT_BLOCKED = 'blocked'; public const RESULT_BLOCKED = 'blocked';
public const RESULT_RECORDED = 'recorded';
public const EVENT_AUTH_FAILURE = 'auth_failure'; public const EVENT_AUTH_FAILURE = 'auth_failure';
public const EVENT_RATE_LIMIT = 'rate_limit'; public const EVENT_RATE_LIMIT = 'rate_limit';
@@ -21,15 +20,8 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
public const EVENT_SUSPICIOUS = 'suspicious'; public const EVENT_SUSPICIOUS = 'suspicious';
public const EVENT_RULE_MATCH = 'rule_match'; public const EVENT_RULE_MATCH = 'rule_match';
public const EVENT_ACCESS_CHECK = 'access_check'; public const EVENT_ACCESS_CHECK = 'access_check';
public const EVENT_RULE_CREATED = 'rule_created';
public const EVENT_RULE_EXTENDED = 'rule_extended';
public const EVENT_RULE_ENABLED = 'rule_enabled';
public const EVENT_RULE_DISABLED = 'rule_disabled';
public const EVENT_RULE_REMOVED = 'rule_removed';
public const EVENT_SETTINGS_UPDATED = 'settings_updated';
private ?string $id = null; private ?string $id = null;
private ?string $eventId = null;
private ?string $tenantId = null; private ?string $tenantId = null;
private ?string $ipAddress = null; private ?string $ipAddress = null;
private ?string $deviceFingerprint = null; private ?string $deviceFingerprint = null;
@@ -39,7 +31,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
private ?string $eventType = null; private ?string $eventType = null;
private ?string $result = null; // allowed, blocked private ?string $result = null; // allowed, blocked
private ?string $ruleId = null; // Which rule triggered (if any) private ?string $ruleId = null; // Which rule triggered (if any)
private ?string $ruleScope = null; // tenant or system
private ?string $identityId = null; // User ID if authenticated private ?string $identityId = null; // User ID if authenticated
private ?\DateTimeImmutable $timestamp = null; private ?\DateTimeImmutable $timestamp = null;
private ?array $metadata = null; // Additional context private ?array $metadata = null; // Additional context
@@ -59,9 +50,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('tenantId', $data)) { if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null; $this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
} }
if (array_key_exists('eventId', $data)) {
$this->eventId = $data['eventId'] !== null ? (string)$data['eventId'] : null;
}
if (array_key_exists('ipAddress', $data)) { if (array_key_exists('ipAddress', $data)) {
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null; $this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
} }
@@ -86,14 +74,13 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
if (array_key_exists('ruleId', $data)) { if (array_key_exists('ruleId', $data)) {
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null; $this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
} }
if (array_key_exists('ruleScope', $data)) {
$this->ruleScope = $data['ruleScope'] !== null ? (string)$data['ruleScope'] : null;
}
if (array_key_exists('identityId', $data)) { if (array_key_exists('identityId', $data)) {
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null; $this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
} }
if (array_key_exists('timestamp', $data)) { if (array_key_exists('timestamp', $data)) {
$this->timestamp = self::deserializeDate($data['timestamp']); $this->timestamp = $data['timestamp'] !== null
? new \DateTimeImmutable($data['timestamp'])
: null;
} }
if (array_key_exists('metadata', $data)) { if (array_key_exists('metadata', $data)) {
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null; $this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
@@ -106,7 +93,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'eventId' => $this->eventId,
'tenantId' => $this->tenantId, 'tenantId' => $this->tenantId,
'ipAddress' => $this->ipAddress, 'ipAddress' => $this->ipAddress,
'deviceFingerprint' => $this->deviceFingerprint, 'deviceFingerprint' => $this->deviceFingerprint,
@@ -116,31 +102,12 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
'eventType' => $this->eventType, 'eventType' => $this->eventType,
'result' => $this->result, 'result' => $this->result,
'ruleId' => $this->ruleId, 'ruleId' => $this->ruleId,
'ruleScope' => $this->ruleScope,
'identityId' => $this->identityId, 'identityId' => $this->identityId,
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM), 'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
'metadata' => $this->metadata, 'metadata' => $this->metadata,
]; ];
} }
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
// Getters and setters // Getters and setters
public function getId(): ?string public function getId(): ?string
@@ -154,17 +121,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this; return $this;
} }
public function getEventId(): ?string
{
return $this->eventId;
}
public function setEventId(?string $eventId): self
{
$this->eventId = $eventId;
return $this;
}
public function getTenantId(): ?string public function getTenantId(): ?string
{ {
return $this->tenantId; return $this->tenantId;
@@ -264,24 +220,6 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
return $this; return $this;
} }
public function getRuleScope(): ?string
{
return $this->ruleScope;
}
public function setRuleScope(?string $ruleScope): self
{
if (
$ruleScope !== null
&& !in_array($ruleScope, [FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_SYSTEM], true)
) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$ruleScope}");
}
$this->ruleScope = $ruleScope;
return $this;
}
public function getIdentityId(): ?string public function getIdentityId(): ?string
{ {
return $this->identityId; return $this->identityId;
@@ -11,9 +11,6 @@ use KTXF\Json\JsonDeserializable;
*/ */
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{ {
public const SCOPE_TENANT = 'tenant';
public const SCOPE_SYSTEM = 'system';
public const TYPE_IP = 'ip'; public const TYPE_IP = 'ip';
public const TYPE_IP_RANGE = 'ip_range'; public const TYPE_IP_RANGE = 'ip_range';
public const TYPE_DEVICE = 'device'; public const TYPE_DEVICE = 'device';
@@ -22,7 +19,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
public const ACTION_BLOCK = 'block'; public const ACTION_BLOCK = 'block';
private ?string $id = null; private ?string $id = null;
private string $scope = self::SCOPE_TENANT;
private ?string $tenantId = null; private ?string $tenantId = null;
private ?string $type = null; // ip, ip_range, device private ?string $type = null; // ip, ip_range, device
private ?string $action = null; // allow, block private ?string $action = null; // allow, block
@@ -46,10 +42,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->id = $data['id'] !== null ? (string)$data['id'] : null; $this->id = $data['id'] !== null ? (string)$data['id'] : null;
} }
if (!array_key_exists('scope', $data)) {
throw new \InvalidArgumentException('Firewall rules require an explicit scope.');
}
$this->setScope((string)$data['scope']);
if (array_key_exists('tenantId', $data)) { if (array_key_exists('tenantId', $data)) {
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null; $this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
} }
@@ -69,10 +61,14 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null; $this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
} }
if (array_key_exists('createdAt', $data)) { if (array_key_exists('createdAt', $data)) {
$this->createdAt = self::deserializeDate($data['createdAt']); $this->createdAt = $data['createdAt'] !== null
? new \DateTimeImmutable($data['createdAt'])
: null;
} }
if (array_key_exists('expiresAt', $data)) { if (array_key_exists('expiresAt', $data)) {
$this->expiresAt = self::deserializeDate($data['expiresAt']); $this->expiresAt = $data['expiresAt'] !== null
? new \DateTimeImmutable($data['expiresAt'])
: null;
} }
if (array_key_exists('enabled', $data)) { if (array_key_exists('enabled', $data)) {
$this->enabled = (bool)$data['enabled']; $this->enabled = (bool)$data['enabled'];
@@ -88,7 +84,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'scope' => $this->scope,
'tenantId' => $this->tenantId, 'tenantId' => $this->tenantId,
'type' => $this->type, 'type' => $this->type,
'action' => $this->action, 'action' => $this->action,
@@ -102,24 +97,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
]; ];
} }
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
/** /**
* Check if this rule has expired * Check if this rule has expired
*/ */
@@ -157,42 +134,6 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
return $this->tenantId; return $this->tenantId;
} }
public function getScope(): string
{
return $this->scope;
}
public function setScope(string $scope): self
{
if (!in_array($scope, [self::SCOPE_TENANT, self::SCOPE_SYSTEM], true)) {
throw new \InvalidArgumentException("Invalid firewall rule scope: {$scope}");
}
$this->scope = $scope;
return $this;
}
public function isTenantScoped(): bool
{
return $this->scope === self::SCOPE_TENANT;
}
public function isSystemScoped(): bool
{
return $this->scope === self::SCOPE_SYSTEM;
}
public function assertValidScopeOwnership(): void
{
if ($this->isTenantScoped() && ($this->tenantId === null || $this->tenantId === '')) {
throw new \InvalidArgumentException('Tenant firewall rules require a tenant ID.');
}
if ($this->isSystemScoped() && $this->tenantId !== null) {
throw new \InvalidArgumentException('System firewall rules cannot have a tenant ID.');
}
}
public function setTenantId(?string $tenantId): self public function setTenantId(?string $tenantId): self
{ {
$this->tenantId = $tenantId; $this->tenantId = $tenantId;
@@ -11,18 +11,11 @@ class TenantConfiguration extends JsonSerializableObject
{ {
protected TenantAuthentication $authentication; protected TenantAuthentication $authentication;
protected TenantSecurity $security; protected TenantSecurity $security;
protected TenantFirewall $firewall;
protected TenantStores $stores;
protected TenantPreview $preview;
protected array $services = [];
public function __construct() public function __construct()
{ {
$this->authentication = new TenantAuthentication(); $this->authentication = new TenantAuthentication();
$this->security = new TenantSecurity(); $this->security = new TenantSecurity();
$this->firewall = new TenantFirewall();
$this->stores = new TenantStores();
$this->preview = new TenantPreview();
} }
public function authentication(): TenantAuthentication { public function authentication(): TenantAuthentication {
@@ -33,20 +26,4 @@ class TenantConfiguration extends JsonSerializableObject
return $this->security; return $this->security;
} }
public function firewall(): TenantFirewall {
return $this->firewall;
}
public function services(): array {
return $this->services;
}
public function stores(): TenantStores {
return $this->stores;
}
public function preview(): TenantPreview {
return $this->preview;
}
} }
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use KTXF\Json\JsonSerializableObject;
class TenantFirewall extends JsonSerializableObject
{
protected bool $enabled = true;
protected int $maxAuthFailures = 5;
protected int $authFailureWindow = 300;
protected int $autoBlockDuration = 3600;
public function enabled(): bool
{
return $this->enabled;
}
public function maxAuthFailures(): int
{
return $this->maxAuthFailures;
}
public function authFailureWindow(): int
{
return $this->authFailureWindow;
}
public function autoBlockDuration(): int
{
return $this->autoBlockDuration;
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Json\JsonSerializableObject;
final class TenantPreview extends JsonSerializableObject
{
protected bool $enabled = true;
protected string $store = 'previews';
protected int $maxSourceSize = 26214400;
protected TenantPreviewVariants $variants;
public function __construct()
{
$this->variants = new TenantPreviewVariants();
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
}
if (array_key_exists('enabled', $data)) {
if (!is_bool($data['enabled'])) {
throw new InvalidArgumentException('Preview enabled must be a boolean');
}
$this->enabled = $data['enabled'];
}
if (array_key_exists('store', $data)) {
if (!is_string($data['store']) || preg_match('/^[a-z][a-z0-9-]*$/', $data['store']) !== 1) {
throw new InvalidArgumentException('Preview store must be a logical system-store name');
}
$this->store = $data['store'];
}
if (array_key_exists('maxSourceSize', $data)) {
if (!is_int($data['maxSourceSize']) || $data['maxSourceSize'] < 1) {
throw new InvalidArgumentException('Preview maximum source size must be a positive integer');
}
$this->maxSourceSize = $data['maxSourceSize'];
}
if (array_key_exists('variants', $data)) {
if (!is_array($data['variants'])) {
throw new InvalidArgumentException('Preview variants configuration must be an object');
}
$this->variants->jsonDeserialize($data['variants']);
}
if ($this->enabled && $this->variants->all() === []) {
throw new InvalidArgumentException('At least one preview variant is required when previews are enabled');
}
return $this;
}
public function enabled(): bool
{
return $this->enabled;
}
public function store(): string
{
return $this->store;
}
public function maxSourceSize(): int
{
return $this->maxSourceSize;
}
public function variants(): TenantPreviewVariants
{
return $this->variants;
}
}
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Preview\MimeType;
final readonly class TenantPreviewVariant
{
public string $format;
public function __construct(
public int $width,
public int $height,
string $format,
public int $quality,
) {
if ($this->width < 1 || $this->height < 1) {
throw new InvalidArgumentException('Preview variant dimensions must be positive');
}
if ($this->quality < 1 || $this->quality > 100) {
throw new InvalidArgumentException('Preview variant quality must be between 1 and 100');
}
$this->format = MimeType::normalize($format);
}
public static function fromArray(array $data, ?self $defaults = null): self
{
$width = $data['width'] ?? $defaults?->width;
$height = $data['height'] ?? $defaults?->height;
$format = $data['format'] ?? $defaults?->format;
$quality = $data['quality'] ?? $defaults?->quality;
if (!is_int($width) || !is_int($height) || !is_string($format) || !is_int($quality)) {
throw new InvalidArgumentException('Preview variants require integer dimensions and quality plus a MIME format');
}
return new self($width, $height, $format, $quality);
}
/** @return array{width:int,height:int,format:string,quality:int} */
public function toArray(): array
{
return [
'width' => $this->width,
'height' => $this->height,
'format' => $this->format,
'quality' => $this->quality,
];
}
}
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Json\JsonSerializableObject;
final class TenantPreviewVariants extends JsonSerializableObject
{
/** @var array<string, TenantPreviewVariant> */
private array $entries;
public function __construct()
{
$this->entries = self::defaults();
}
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
}
$defaults = self::defaults();
$entries = [];
foreach ($data as $name => $variant) {
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
throw new InvalidArgumentException('Invalid preview variant name');
}
if (!is_array($variant)) {
throw new InvalidArgumentException('Preview variant configuration must be an object');
}
$entries[$name] = TenantPreviewVariant::fromArray($variant, $defaults[$name] ?? null);
}
$this->entries = $entries;
return $this;
}
public function variant(string $name): ?TenantPreviewVariant
{
return $this->entries[$name] ?? null;
}
/** @return array<string, TenantPreviewVariant> */
public function all(): array
{
return $this->entries;
}
public function jsonSerialize(): array
{
return array_map(
static fn(TenantPreviewVariant $variant): array => $variant->toArray(),
$this->entries,
);
}
/** @return array<string, TenantPreviewVariant> */
private static function defaults(): array
{
return [
'thumbnail' => new TenantPreviewVariant(128, 128, 'image/webp', 70),
'inline' => new TenantPreviewVariant(640, 640, 'image/webp', 80),
'fullscreen' => new TenantPreviewVariant(2560, 2560, 'image/webp', 90),
];
}
}
-57
View File
@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Models\Tenant;
use InvalidArgumentException;
use KTXF\Json\JsonSerializableObject;
use KTXF\SystemStore\StoreReference;
/**
* Logical system stores configured for a tenant.
*/
final class TenantStores extends JsonSerializableObject
{
/** @var array<string, StoreReference> */
private array $entries = [];
public function jsonDeserialize(array|string $data): static
{
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->entries = [];
foreach ($data as $name => $store) {
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
throw new InvalidArgumentException('Invalid logical system-store name');
}
if (!is_array($store)) {
throw new InvalidArgumentException('Invalid tenant store configuration entry');
}
$this->entries[$name] = StoreReference::fromArray($store);
}
return $this;
}
public function jsonSerialize(): array
{
return array_map(
static fn(StoreReference $store): array => $store->toArray(),
$this->entries,
);
}
public function store(string $name): ?StoreReference
{
return $this->entries[$name] ?? null;
}
/** @return array<string, StoreReference> */
public function all(): array
{
return $this->entries;
}
}
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Module\Configuration;
use InvalidArgumentException;
use LogicException;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextType;
use KTXF\Module\ModuleInstanceInterface;
final class BrowserModuleContext implements BrowserModuleContextInterface
{
private const RESERVED_KEYS = ['modules', 'tenant', 'user', 'l10n'];
/** @var array<string,array<string,mixed>> */
private array $modules = [];
/** @var array<string,mixed> */
private array $configuration = [];
public function __construct(
private readonly string $tenantIdentifier,
private readonly string $identityIdentifier,
) {
}
public function type(): ModuleContextType
{
return ModuleContextType::Browser;
}
public function registerModule(
ModuleInstanceInterface $module,
string $namespace,
?string $boot = null,
): void {
if (trim($namespace) === '' || ($boot !== null && trim($boot) === '')) {
throw new InvalidArgumentException('Browser namespace must not be empty and boot path must be null or non-empty');
}
$handle = $module->handle();
if (isset($this->modules[$handle])) {
throw new LogicException("Browser module '{$handle}' is already registered");
}
$entry = [
'handle' => $handle,
'namespace' => $namespace,
'version' => $module->version(),
'label' => $module->label(),
'author' => $module->author(),
'description' => $module->description(),
];
if ($boot !== null) {
$entry['boot'] = $boot;
}
$this->modules[$handle] = $entry;
}
public function set(string $key, mixed $value): void
{
if (trim($key) === '') {
throw new InvalidArgumentException('Browser configuration key must not be empty');
}
if (in_array($key, self::RESERVED_KEYS, true) || array_key_exists($key, $this->configuration)) {
throw new LogicException("Browser configuration '{$key}' is already reserved or registered");
}
$this->configuration[$key] = $value;
}
public function tenantIdentifier(): string
{
return $this->tenantIdentifier;
}
public function identityIdentifier(): string
{
return $this->identityIdentifier;
}
public function modules(): array
{
return $this->modules;
}
public function configuration(): array
{
return $this->configuration;
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Module\Configuration;
use InvalidArgumentException;
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextType;
final class ConsoleModuleContext implements ConsoleModuleContextInterface
{
/** @var array<class-string,true> */
private array $commands = [];
public function type(): ModuleContextType
{
return ModuleContextType::Console;
}
public function registerCommand(string $command): void
{
if (trim($command) === '') {
throw new InvalidArgumentException('Console command class must not be empty');
}
$this->commands[$command] = true;
}
public function commands(): array
{
return array_keys($this->commands);
}
}
+20 -120
View File
@@ -2,33 +2,12 @@
namespace KTXC\Module; namespace KTXC\Module;
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
use KTXC\Console\Firewall\FirewallSetupCommand;
use KTXC\Preview\PreviewManager;
use KTXC\Service\FirewallService; use KTXC\Service\FirewallService;
use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallRuleService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXF\Event\DeliveryMode; use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface; use KTXF\Event\EventListenerRegistry;
use KTXF\Module\Configuration\BrowserModuleContextInterface; use KTXF\Event\SecurityEvent;
use KTXF\Module\Configuration\ConsoleModuleContextInterface; use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\Configuration\ModuleContextInterface; use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract; use KTXF\Module\ModuleInstanceAbstract;
/** /**
@@ -36,11 +15,10 @@ use KTXF\Module\ModuleInstanceAbstract;
* *
* Provides core system functionality and permissions * Provides core system functionality and permissions
*/ */
class Module extends ModuleInstanceAbstract class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
{ {
public function __construct( public function __construct(
private readonly EventListenerRegistrarInterface $events, private readonly EventListenerRegistry $events,
private readonly PreviewManager $previews,
) { ) {
} }
@@ -48,32 +26,17 @@ class Module extends ModuleInstanceAbstract
{ {
$this->events->listen( $this->events->listen(
'core', 'core',
AuthenticationFailedEvent::class, SecurityEvent::AUTH_FAILURE,
FirewallService::class, FirewallService::class,
'handleAuthFailure', 'handleAuthFailure',
DeliveryMode::Immediate,
priority: 100, priority: 100,
); );
$this->events->listen(
'core',
AuthenticationSucceededEvent::class,
FirewallService::class,
'logAuthenticationSuccess',
DeliveryMode::Deferred,
);
foreach ([ foreach ([
AccessDeniedEvent::class, SecurityEvent::AUTH_FAILURE,
BruteForceDetectedEvent::class, SecurityEvent::AUTH_SUCCESS,
RateLimitExceededEvent::class, SecurityEvent::ACCESS_DENIED,
SuspiciousActivityEvent::class, SecurityEvent::BRUTE_FORCE_DETECTED,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class,
] as $event) { ] as $event) {
$this->events->listen( $this->events->listen(
'core', 'core',
@@ -152,57 +115,7 @@ class Module extends ModuleInstanceAbstract
'group' => 'Module Management' 'group' => 'Module Management'
], ],
// Firewall Management // System Administration
TenantFirewallRuleService::PERMISSION_READ => [
'label' => 'View Tenant Firewall Rules',
'description' => 'View firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallRuleService::PERMISSION_MANAGE => [
'label' => 'Manage Tenant Firewall Rules',
'description' => 'Create, disable, and remove firewall rules owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallLogService::PERMISSION_READ => [
'label' => 'View Tenant Firewall Logs',
'description' => 'View firewall security and audit logs owned by the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallStatusService::PERMISSION_SETTINGS_READ => [
'label' => 'View Tenant Firewall Settings',
'description' => 'View effective firewall settings for the current tenant',
'group' => 'Firewall Management'
],
TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
'label' => 'Manage Tenant Firewall Settings',
'description' => 'Update firewall settings for the current tenant',
'group' => 'Firewall Management'
],
SystemFirewallRuleService::PERMISSION_READ => [
'label' => 'View System Firewall Rules',
'description' => 'View firewall rules that apply to every tenant',
'group' => 'System Administration'
],
SystemFirewallRuleService::PERMISSION_MANAGE => [
'label' => 'Manage System Firewall Rules',
'description' => 'Create, disable, and remove firewall rules that apply to every tenant',
'group' => 'System Administration'
],
SystemFirewallLogService::PERMISSION_READ => [
'label' => 'View System Firewall Logs',
'description' => 'View firewall security and audit logs across tenants',
'group' => 'System Administration'
],
SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ => [
'label' => 'View Firewall Maintenance Status',
'description' => 'View the last firewall cleanup result and operational status',
'group' => 'System Administration'
],
SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
'label' => 'Manage Tenant Firewall Settings System-Wide',
'description' => 'Update firewall settings for any tenant',
'group' => 'System Administration'
],
'system.admin' => [ 'system.admin' => [
'label' => 'System Administrator', 'label' => 'System Administrator',
'description' => 'Full system access (superuser)', 'description' => 'Full system access (superuser)',
@@ -216,22 +129,9 @@ class Module extends ModuleInstanceAbstract
]; ];
} }
public function configure(ModuleContextInterface $context): void public function registerCI(): array
{ {
if ($context instanceof BrowserModuleContextInterface) { return [
$context->set(
'preview',
$this->previews->availability($context->tenantIdentifier()),
);
}
if (!$context instanceof ConsoleModuleContextInterface) {
return;
}
foreach ([
FirewallSetupCommand::class,
FirewallMaintenanceCommand::class,
\KTXC\Console\Event\EventsDebugCommand::class, \KTXC\Console\Event\EventsDebugCommand::class,
\KTXC\Console\Module\ModuleListCommand::class, \KTXC\Console\Module\ModuleListCommand::class,
\KTXC\Console\Module\ModuleEnableCommand::class, \KTXC\Console\Module\ModuleEnableCommand::class,
@@ -243,9 +143,6 @@ class Module extends ModuleInstanceAbstract
\KTXC\Console\Tenant\TenantListCommand::class, \KTXC\Console\Tenant\TenantListCommand::class,
\KTXC\Console\Tenant\TenantDeleteCommand::class, \KTXC\Console\Tenant\TenantDeleteCommand::class,
\KTXC\Console\Tenant\TenantAuthEnableCommand::class, \KTXC\Console\Tenant\TenantAuthEnableCommand::class,
\KTXC\Console\Tenant\TenantStoreListCommand::class,
\KTXC\Console\Tenant\TenantStoreSetCommand::class,
\KTXC\Console\Tenant\TenantStoreRemoveCommand::class,
\KTXC\Console\User\UserCreateCommand::class, \KTXC\Console\User\UserCreateCommand::class,
\KTXC\Console\User\UserListCommand::class, \KTXC\Console\User\UserListCommand::class,
\KTXC\Console\User\UserDeleteCommand::class, \KTXC\Console\User\UserDeleteCommand::class,
@@ -254,8 +151,11 @@ class Module extends ModuleInstanceAbstract
\KTXC\Console\Role\RoleDeleteCommand::class, \KTXC\Console\Role\RoleDeleteCommand::class,
\KTXC\Console\Role\RoleAssignCommand::class, \KTXC\Console\Role\RoleAssignCommand::class,
\KTXC\Console\Role\RoleRevokeCommand::class, \KTXC\Console\Role\RoleRevokeCommand::class,
] as $command) { ];
$context->registerCommand($command); }
}
public function registerBI(): array
{
return [];
} }
} }
+15 -3
View File
@@ -4,7 +4,8 @@ namespace KTXC\Module;
use JsonSerializable; use JsonSerializable;
use KTXC\Module\Store\ModuleEntry; use KTXC\Module\Store\ModuleEntry;
use KTXF\Module\Configuration\ModuleContextInterface; use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceInterface; use KTXF\Module\ModuleInstanceInterface;
/** /**
@@ -174,9 +175,20 @@ class ModuleObject implements JsonSerializable
$this->instance?->upgrade(); $this->instance?->upgrade();
} }
public function configure(ModuleContextInterface $context): void public function registerBI(): array | null
{ {
$this->instance?->configure($context); if ($this->instance instanceof ModuleBrowserInterface) {
return $this->instance->registerBI();
}
return null;
}
public function registerCI(): array | null
{
if ($this->instance instanceof ModuleConsoleInterface) {
return $this->instance->registerCI();
}
return null;
} }
} }
-19
View File
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Preview;
use KTXF\Resource\BinaryResource;
final readonly class Preview
{
public function __construct(
public BinaryResource $resource,
public int $size,
public string $etag,
public ?int $width = null,
public ?int $height = null,
) {
}
}
-284
View File
@@ -1,284 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Preview;
use InvalidArgumentException;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use KTXC\SystemStore\SystemStoreManager;
use KTXF\Preview\MimeType;
use KTXF\Preview\PreviewGenerationException;
use KTXF\Preview\PreviewRequest;
use KTXF\Preview\PreviewResult;
use KTXF\Preview\PreviewSource;
use KTXF\Preview\Provider\PreviewProviderInterface;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\SystemStore\BlobInfo;
use Psr\Log\LoggerInterface;
use Throwable;
final readonly class PreviewManager
{
public function __construct(
private TenantService $tenants,
private ProviderManager $providers,
private SystemStoreManager $stores,
private LoggerInterface $logger,
) {
}
/** @return array{enabled:bool,storage:bool,generation:bool} */
public function availability(string $tenantId): array
{
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
return ['enabled' => false, 'storage' => false, 'generation' => false];
}
$configuration = $tenant->getConfiguration()->preview();
$enabled = $configuration->enabled();
$storage = $enabled
&& $tenant->getConfiguration()->stores()->store($configuration->store()) !== null;
$generation = $storage && $this->previewProviders() !== [];
if ($enabled && !$storage) {
$this->logger->debug('Preview storage is not configured', ['tenantId' => $tenantId]);
} elseif ($enabled && $storage && !$generation) {
$this->logger->debug('No preview generation provider is available', ['tenantId' => $tenantId]);
}
return compact('enabled', 'storage', 'generation');
}
public function fetch(
string $tenantId,
PreviewSource $source,
string $variant = 'inline',
): ?Preview {
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
return null;
}
$configuration = $tenant->getConfiguration()->preview();
$variantConfiguration = $configuration->variants()->variant($variant);
if (
!$configuration->enabled()
|| $variantConfiguration === null
|| ($source->size !== null && $source->size > $configuration->maxSourceSize())
|| $tenant->getConfiguration()->stores()->store($configuration->store()) === null
) {
return null;
}
$request = new PreviewRequest(
maxWidth: $variantConfiguration->width,
maxHeight: $variantConfiguration->height,
preferredMimeType: $variantConfiguration->format,
quality: $variantConfiguration->quality,
maxSourceSize: $configuration->maxSourceSize(),
);
try {
$cached = $this->readCache(
$tenantId,
$configuration->store(),
$source,
$request,
);
if ($cached !== null) {
return $cached;
}
$provider = $this->selectProvider($source->mimeType, $request);
if ($provider === null) {
return null;
}
$result = $provider->generate($source, $request);
$blob = $this->writeCache(
$tenantId,
$configuration->store(),
$source,
$request,
$result,
);
return $this->readStoredPreview(
$tenantId,
$configuration->store(),
$blob,
$request,
);
} catch (Throwable $exception) {
$this->logger->warning('Preview unavailable', [
'tenantId' => $tenantId,
'sourceType' => $source->sourceType,
'variant' => $variant,
'exception' => $exception,
]);
return null;
}
}
private function readCache(
string $tenantId,
string $store,
PreviewSource $source,
PreviewRequest $request,
): ?Preview {
$key = $this->cacheKey($tenantId, $source, $request);
$blob = $this->stores->stat($tenantId, $store, $key);
if (!$this->validBlob($blob, $key, $request)) {
return null;
}
return $this->readStoredPreview($tenantId, $store, $blob, $request);
}
private function readStoredPreview(
string $tenantId,
string $store,
BlobInfo $blob,
PreviewRequest $request,
): ?Preview {
$resource = $this->stores->read($tenantId, $store, $blob->key);
if ($resource === null) {
return null;
}
try {
if (MimeType::normalize($resource->mimeType()) !== $request->preferredMimeType) {
return null;
}
} catch (Throwable) {
return null;
}
return new Preview(
resource: $resource,
size: $blob->size,
etag: $blob->etag,
width: $blob->attributes['width'] ?? null,
height: $blob->attributes['height'] ?? null,
);
}
private function writeCache(
string $tenantId,
string $store,
PreviewSource $source,
PreviewRequest $request,
PreviewResult $result,
): BlobInfo {
$outputMimeType = MimeType::normalize($result->resource->mimeType());
if ($outputMimeType !== $request->preferredMimeType) {
throw new PreviewGenerationException('Generated preview MIME does not match the requested output MIME');
}
if (
($result->width !== null && $result->width > $request->maxWidth)
|| ($result->height !== null && $result->height > $request->maxHeight)
) {
throw new PreviewGenerationException('Generated preview dimensions exceed the requested limits');
}
return $this->stores->write(
$tenantId,
$store,
$this->cacheKey($tenantId, $source, $request),
$result->resource,
['width' => $result->width, 'height' => $result->height],
);
}
private function cacheKey(
string $tenantId,
PreviewSource $source,
PreviewRequest $request,
): string {
if (trim($tenantId) === '') {
throw new InvalidArgumentException('Preview cache keys require a tenant identifier');
}
$canonical = json_encode([
'version' => 2, // Regenerate previews with opaque document pages and outlines.
'tenantId' => $tenantId,
'source' => [
'type' => $source->sourceType,
'identity' => $source->identity,
'signature' => $source->signature,
'mimeType' => $source->mimeType,
],
'request' => [
'maxWidth' => $request->maxWidth,
'maxHeight' => $request->maxHeight,
'preferredMimeType' => $request->preferredMimeType,
'quality' => $request->quality,
],
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
$digest = hash('sha256', $canonical);
return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}";
}
private function validBlob(?BlobInfo $blob, string $key, PreviewRequest $request): bool
{
if ($blob === null || $blob->key !== $key || $blob->size < 1) {
return false;
}
$width = $blob->attributes['width'] ?? null;
$height = $blob->attributes['height'] ?? null;
if (
($width !== null && (!is_int($width) || $width < 1 || $width > $request->maxWidth))
|| ($height !== null && (!is_int($height) || $height < 1 || $height > $request->maxHeight))
) {
return false;
}
try {
return MimeType::normalize($blob->mimeType) === $request->preferredMimeType;
} catch (Throwable) {
return false;
}
}
private function selectProvider(
string $sourceMimeType,
PreviewRequest $request,
): ?PreviewProviderInterface {
$sourceMimeType = MimeType::normalize($sourceMimeType);
$candidates = [];
foreach ($this->previewProviders() as $identifier => $provider) {
if (
!$provider instanceof PreviewProviderInterface
|| !$provider->supports($sourceMimeType, $request)
) {
continue;
}
$candidates[] = ['identifier' => $identifier, 'provider' => $provider];
}
if ($candidates === []) {
return null;
}
usort($candidates, static function (array $left, array $right): int {
return ($right['provider']->priority() <=> $left['provider']->priority())
?: strcmp($left['identifier'], $right['identifier']);
});
return $candidates[0]['provider'];
}
/** @return array<string, PreviewProviderInterface> */
private function previewProviders(): array
{
return array_filter(
$this->providers->providers(ProviderInterface::TYPE_PREVIEW),
static fn(ProviderInterface $provider): bool => $provider instanceof PreviewProviderInterface,
);
}
}
+8 -6
View File
@@ -7,8 +7,8 @@ namespace KTXC\Runtime\Console;
use KTXC\Application\Execution\ExecutionDescriptor; use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Kernel; use KTXC\Kernel;
use KTXC\KernelInterface; use KTXC\KernelInterface;
use KTXC\Module\Configuration\ConsoleModuleContext;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -34,13 +34,15 @@ final class ConsoleRuntime
/** @var ModuleManager $moduleManager */ /** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class); $moduleManager = $container->get(ModuleManager::class);
$moduleContext = new ConsoleModuleContext();
foreach ($moduleManager->list() as $module) { foreach ($moduleManager->list() as $module) {
$module->configure($moduleContext); $instance = $module->instance();
} if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
foreach ($moduleContext->commands() as $commandClass) { foreach ($instance->registerCI() as $commandClass) {
$this->registerCommand($console, $container, $commandClass); $this->registerCommand($console, $container, $commandClass);
}
} }
return $console->run($input, $output); return $console->run($input, $output);
+17 -26
View File
@@ -11,7 +11,6 @@ use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\RouterMiddleware; use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware; use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\KernelInterface; use KTXC\KernelInterface;
@@ -26,34 +25,26 @@ final class HttpRuntime
public function run(?Request $request = null, bool $send = true): Response public function run(?Request $request = null, bool $send = true): Response
{ {
$request ??= Request::createFromGlobals(); $request ??= Request::createFromGlobals();
$requestContext = null;
try { return $this->kernel->executionRunner()->execute(
return $this->kernel->executionRunner()->execute( ExecutionDescriptor::http(),
ExecutionDescriptor::http(), function () use ($request, $send): Response {
function () use ($request, $send, &$requestContext): Response { $response = $this->pipeline()->handle($request);
$requestContext = $this->kernel->container()->get(RequestContext::class); if ($send) {
$requestContext->initialize($request); $response->send();
}
$response = $this->pipeline()->handle($request); return $response;
if ($send) { },
$response->send(); function (\Throwable $error) use ($send): Response {
} $response = $this->errorResponse($error);
if ($send) {
$response->send();
}
return $response; return $response;
}, },
function (\Throwable $error) use ($send): Response { );
$response = $this->errorResponse($error);
if ($send) {
$response->send();
}
return $response;
},
);
} finally {
$requestContext?->clear();
}
} }
private function pipeline(): MiddlewarePipeline private function pipeline(): MiddlewarePipeline
+1 -38
View File
@@ -8,14 +8,11 @@ use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager; use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService; use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface;
use KTXF\Security\Authentication\AuthenticationSession; use KTXF\Security\Authentication\AuthenticationSession;
use KTXF\Security\Authentication\ProviderContext; use KTXF\Security\Authentication\ProviderContext;
@@ -34,7 +31,6 @@ class AuthenticationManager
private readonly ProviderManager $providerManager, private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService, private readonly TokenService $tokenService,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly EventDispatcherInterface $events,
) { ) {
$this->securityCode = $this->tenantContext->configuration()->security()->code(); $this->securityCode = $this->tenantContext->configuration()->security()->code();
} }
@@ -191,10 +187,6 @@ class AuthenticationManager
if (!$result->isSuccess()) { if (!$result->isSuccess()) {
$this->saveSession($session); $this->saveSession($session);
$this->publishAuthenticationFailure(
$session,
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
);
return AuthenticationResponse::failed( return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS, AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Authentication failed. If you haven\'t set up this method, try another option.', 'Authentication failed. If you haven\'t set up this method, try another option.',
@@ -397,10 +389,6 @@ class AuthenticationManager
$result = $provider->completeRedirect($context, $request->params); $result = $provider->completeRedirect($context, $request->params);
if ($result->isFailed()) { if ($result->isFailed()) {
$this->publishAuthenticationFailure(
$session,
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
);
$this->deleteSession($session->id); $this->deleteSession($session->id);
return AuthenticationResponse::failed( return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS, AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
@@ -578,17 +566,6 @@ class AuthenticationManager
// Helper Methods // Helper Methods
// ========================================================================= // =========================================================================
private function publishAuthenticationFailure(
AuthenticationSession $session,
string $reason,
): void {
$this->events->dispatch(new AuthenticationFailedEvent(
userId: $session->userIdentifier,
reason: $reason,
tenantId: $session->tenantIdentifier,
));
}
/** /**
* Build provider context from session * Build provider context from session
*/ */
@@ -617,16 +594,7 @@ class AuthenticationManager
*/ */
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
{ {
$userId = $session->userIdentifier; $userData = $this->userService->fetchByIdentifier($session->userIdentifier);
if ($userId === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Authenticated user is missing',
401,
);
}
$userData = $this->userService->fetchByIdentifier($userId);
if ($userData === null) { if ($userData === null) {
return AuthenticationResponse::failed( return AuthenticationResponse::failed(
@@ -643,11 +611,6 @@ class AuthenticationManager
$this->deleteSession($session->id); $this->deleteSession($session->id);
$this->events->dispatch(new AuthenticationSucceededEvent(
$userId,
$session->tenantIdentifier,
));
return AuthenticationResponse::success( return AuthenticationResponse::success(
$this->buildUserData($user), $this->buildUserData($user),
$tokens $tokens
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Access denial requires an IP address.');
}
if ($ruleId === '') {
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
}
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
}
parent::__construct(
self::class,
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly ?string $userId = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
parent::__construct(
self::class,
['userId' => $userId, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $userId,
?string $tenantId = null,
) {
if ($userId === '') {
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
}
parent::__construct(
self::class,
['userId' => $userId],
$tenantId,
);
}
public function getUserId(): string
{
return $this->userId;
}
public function getReason(): ?string
{
return null;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $failureCount,
private readonly int $windowSeconds,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
}
if ($failureCount < 1) {
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
}
$this->reason = sprintf(
'%d failed attempts in %d seconds',
$failureCount,
$windowSeconds,
);
parent::__construct(
self::class,
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getFailureCount(): int
{
return $this->failureCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $deviceFingerprint,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($deviceFingerprint === '') {
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
}
parent::__construct(
self::class,
['device' => $deviceFingerprint, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): ?string
{
return null;
}
public function getDeviceFingerprint(): string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
final public function __construct(
private readonly string $ipAddress,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
}
parent::__construct(
static::class,
['ip' => $ipAddress, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return static::SEVERITY;
}
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
{
}
@@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
{
final protected function __construct(
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly string $ruleType,
private readonly string $ruleAction,
private readonly string $ruleValue,
private readonly ?string $reason,
private readonly string $origin,
private readonly ?string $expiresAt,
private readonly array $details,
?string $tenantId,
?string $identityId,
) {
if ($ruleId === '') {
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
parent::__construct(
static::class,
[
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'ruleType' => $ruleType,
'ruleAction' => $ruleAction,
'ruleValue' => $ruleValue,
'reason' => $reason,
'origin' => $origin,
'expiresAt' => $expiresAt,
...$details,
],
$tenantId,
$identityId,
);
}
public static function fromRule(
FirewallRuleObject $rule,
?string $actorId = null,
array $change = [],
): static {
$metadata = $rule->getMetadata() ?? [];
$details = [...$metadata, ...$change];
foreach ([
'ruleId',
'ruleScope',
'ruleType',
'ruleAction',
'ruleValue',
'reason',
'origin',
'expiresAt',
] as $reservedKey) {
unset($details[$reservedKey]);
}
return new static(
ruleId: (string) $rule->getId(),
ruleScope: (string) $rule->getScope(),
ruleType: (string) $rule->getType(),
ruleAction: (string) $rule->getAction(),
ruleValue: (string) $rule->getValue(),
reason: $rule->getReason(),
origin: (string) ($metadata['origin'] ?? 'manual'),
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
details: $details,
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getRuleType(): string
{
return $this->ruleType;
}
public function getRuleAction(): string
{
return $this->ruleAction;
}
public function getRuleValue(): string
{
return $this->ruleValue;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getExpiresAt(): ?string
{
return $this->expiresAt;
}
public function getDetails(): array
{
return $this->details;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
{
}
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $changeReason,
private readonly array $previous,
private readonly array $current,
string $tenantId,
?string $actorId = null,
private readonly string $changeOrigin = 'manual',
) {
if ($changeReason === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
}
if ($tenantId === '') {
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
}
if ($changeOrigin === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
}
parent::__construct(
self::class,
[
'changeReason' => $changeReason,
'changeOrigin' => $changeOrigin,
'previous' => $previous,
'current' => $current,
],
$tenantId,
$actorId,
);
}
public function getChangeReason(): string
{
return $this->changeReason;
}
public function getPrevious(): array
{
return $this->previous;
}
public function getCurrent(): array
{
return $this->current;
}
public function getChangeOrigin(): string
{
return $this->changeOrigin;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->changeReason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpAllowedEvent extends FirewallIpEvent
{
}
@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpBlockedEvent extends FirewallIpEvent
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
}
@@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $requestCount,
private readonly int $windowSeconds,
private readonly ?string $endpoint = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
}
if ($requestCount < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
}
if ($endpoint === '') {
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
}
$this->reason = sprintf(
'%d requests in %d seconds',
$requestCount,
$windowSeconds,
);
parent::__construct(
self::class,
[
'requestCount' => $requestCount,
'windowSeconds' => $windowSeconds,
'endpoint' => $endpoint,
],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRequestCount(): int
{
return $this->requestCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getEndpoint(): ?string
{
return $this->endpoint;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return $this->endpoint;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
interface SecurityEventInterface
{
public function label(): string;
public function get(string $key, mixed $default = null): mixed;
public function context(): array;
public function identifier(): string;
public function tenantIdentifier(): ?string;
public function actorIdentity(): ?string;
public function getUserId(): ?string;
public function getReason(): ?string;
public function getSeverity(): SecurityEventSeverity;
}
@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
enum SecurityEventSeverity: int
{
case DEBUG = 0;
case INFO = 1;
case WARNING = 2;
case ERROR = 3;
case CRITICAL = 4;
}
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
interface SecurityRequestEventInterface extends SecurityEventInterface
{
public function getIpAddress(): ?string;
public function getDeviceFingerprint(): ?string;
public function getUserAgent(): ?string;
public function getRequestPath(): ?string;
public function getRequestMethod(): ?string;
}
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $detector,
private readonly array $detectionData = [],
?string $tenantId = null,
?string $identityId = null,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $userAgent = null,
private readonly ?string $requestPath = null,
private readonly ?string $requestMethod = null,
private readonly ?string $userId = null,
private readonly ?string $reason = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
}
if ($detector === '') {
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
}
if (array_key_exists('detector', $detectionData)) {
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
}
if (array_key_exists('detector', $detectionData)) {
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
}
if ($requestPath === '') {
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
}
if ($requestMethod === '') {
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
}
parent::__construct(
self::class,
['detector' => $detector] + $detectionData,
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDetector(): string
{
return $this->detector;
}
public function getDetectionData(): array
{
return $this->detectionData;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function getRequestPath(): ?string
{
return $this->requestPath;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
-126
View File
@@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
final class FirewallLogService
{
public const MAX_LIMIT = 100;
private const EVENT_TYPES = [
FirewallLogObject::EVENT_AUTH_FAILURE,
FirewallLogObject::EVENT_RATE_LIMIT,
FirewallLogObject::EVENT_BRUTE_FORCE,
FirewallLogObject::EVENT_SUSPICIOUS,
FirewallLogObject::EVENT_RULE_MATCH,
FirewallLogObject::EVENT_ACCESS_CHECK,
FirewallLogObject::EVENT_RULE_CREATED,
FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallLogObject::EVENT_RULE_ENABLED,
FirewallLogObject::EVENT_RULE_DISABLED,
FirewallLogObject::EVENT_RULE_REMOVED,
FirewallLogObject::EVENT_SETTINGS_UPDATED,
];
public function __construct(private readonly FirewallStore $store)
{
}
public function tenant(string $tenantId, array $filters, int $limit, int $offset): array
{
return $this->store->queryTenantLogs(
$tenantId,
$this->validate($filters, $limit, $offset),
$limit,
$offset
);
}
public function system(?string $tenantId, array $filters, int $limit, int $offset): array
{
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
throw new \InvalidArgumentException('Invalid tenant filter.');
}
return $this->store->querySystemLogs(
$tenantId,
$this->validate($filters, $limit, $offset),
$limit,
$offset
);
}
private function validate(array $filters, int $limit, int $offset): array
{
if ($limit < 1 || $limit > self::MAX_LIMIT || $offset < 0) {
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
}
$ipAddress = self::nullableString($filters, 'ipAddress');
if ($ipAddress !== null && filter_var($ipAddress, FILTER_VALIDATE_IP) === false) {
throw new \InvalidArgumentException('Invalid IP address filter.');
}
$eventType = self::nullableString($filters, 'eventType');
if ($eventType !== null && !in_array($eventType, self::EVENT_TYPES, true)) {
throw new \InvalidArgumentException('Invalid firewall event type filter.');
}
$result = self::nullableString($filters, 'result');
if ($result !== null && !in_array($result, [
FirewallLogObject::RESULT_ALLOWED,
FirewallLogObject::RESULT_BLOCKED,
FirewallLogObject::RESULT_RECORDED,
], true)) {
throw new \InvalidArgumentException('Invalid firewall result filter.');
}
$ruleScope = self::nullableString($filters, 'ruleScope');
if ($ruleScope !== null && !in_array($ruleScope, [
FirewallRuleObject::SCOPE_TENANT,
FirewallRuleObject::SCOPE_SYSTEM,
], true)) {
throw new \InvalidArgumentException('Invalid rule scope filter.');
}
$from = self::date($filters, 'from');
$to = self::date($filters, 'to');
if ($from !== null && $to !== null && $from > $to) {
throw new \InvalidArgumentException('The from date must not be later than the to date.');
}
return [
'ipAddress' => $ipAddress,
'eventType' => $eventType,
'result' => $result,
'ruleId' => self::nullableString($filters, 'ruleId'),
'ruleScope' => $ruleScope,
'from' => $from,
'to' => $to,
];
}
private static function nullableString(array $filters, string $key): ?string
{
$value = $filters[$key] ?? null;
if ($value === null) {
return null;
}
if (!is_string($value) || $value === '' || strlen($value) > 255) {
throw new \InvalidArgumentException("Invalid {$key} filter.");
}
return $value;
}
private static function date(array $filters, string $key): ?\DateTimeImmutable
{
$value = self::nullableString($filters, $key);
if ($value === null) {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception) {
throw new \InvalidArgumentException("Invalid {$key} date filter.");
}
}
}
-39
View File
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Stores\FirewallStore;
final class FirewallRuleCache
{
/** @var array<string, FirewallRuleObject[]> */
private array $tenantRules = [];
/** @var FirewallRuleObject[]|null */
private ?array $systemRules = null;
public function __construct(private readonly FirewallStore $store)
{
}
/** @return FirewallRuleObject[] */
public function tenant(string $tenantId): array
{
return $this->tenantRules[$tenantId] ??= $this->store->listRules($tenantId);
}
/** @return FirewallRuleObject[] */
public function system(): array
{
return $this->systemRules ??= $this->store->listSystemRules();
}
public function invalidate(): void
{
$this->tenantRules = [];
$this->systemRules = null;
}
}
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
final class FirewallRuleConflictException extends \RuntimeException
{
public function __construct(
public readonly string $conflictCode,
string $message
) {
parent::__construct($message);
}
}
-517
View File
@@ -1,517 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXF\IpUtils;
final class FirewallRuleManager
{
public const QUERY_STATUSES = ['active', 'disabled', 'expired', 'all'];
public const MAX_QUERY_LIMIT = 100;
public const ORIGIN_MANUAL = 'manual';
public const ORIGIN_AUTOMATIC = 'automatic';
public function __construct(
private readonly FirewallStore $store,
private readonly FirewallRuleCache $cache,
private readonly EventDispatcherInterface $events,
) {
}
public function list(FirewallRuleScope $scope, bool $activeOnly = true): array
{
return $scope->scope === FirewallRuleObject::SCOPE_SYSTEM
? $this->store->listSystemRules($activeOnly)
: $this->store->listRules($scope->tenantId, $activeOnly);
}
public function query(
FirewallRuleScope $scope,
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
if (!in_array($status, self::QUERY_STATUSES, true)) {
throw new \InvalidArgumentException('Invalid rule status filter.');
}
if ($type !== null && !in_array($type, [
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::TYPE_DEVICE,
], true)) {
throw new \InvalidArgumentException('Invalid rule type filter.');
}
if ($action !== null && !in_array($action, [
FirewallRuleObject::ACTION_ALLOW,
FirewallRuleObject::ACTION_BLOCK,
], true)) {
throw new \InvalidArgumentException('Invalid rule action filter.');
}
if ($limit < 1 || $limit > self::MAX_QUERY_LIMIT || $offset < 0) {
throw new \InvalidArgumentException('Pagination requires limit 1-100 and offset 0 or greater.');
}
return $this->store->queryRules(
$scope->scope,
$scope->tenantId,
$status,
$type,
$action,
$limit,
$offset
);
}
public function fetch(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
return $this->ownedRule($scope, $ruleId);
}
/** @return array{precedence: string[], system: FirewallRuleObject[], tenant: FirewallRuleObject[]} */
public function effectivePolicy(string $tenantId): array
{
return [
'precedence' => ['system_block', 'tenant_allow', 'tenant_block', 'system_allow', 'default_allow'],
'system' => $this->store->listSystemRules(),
'tenant' => $this->store->listRules($tenantId),
];
}
public function createManualRule(
FirewallRuleScope $scope,
string $type,
string $action,
string $value,
string $reason,
?string $createdBy,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A rule reason containing 1-1000 bytes is required.');
}
if ($currentIp !== null) {
$currentIp = FirewallRuleValidator::ipAddress($currentIp);
}
if (
!$confirmCurrentIp
&& $currentIp !== null
&& $action === FirewallRuleObject::ACTION_BLOCK
&& $this->matchesIp($type, $value, $currentIp)
) {
throw new FirewallRuleConflictException(
'current_ip_confirmation_required',
'This rule would block your current IP address. Explicit confirmation is required.'
);
}
return match ([$type, $action]) {
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_BLOCK] =>
$this->blockIp($scope, $value, $reason, $createdBy, $durationSeconds),
[FirewallRuleObject::TYPE_IP, FirewallRuleObject::ACTION_ALLOW] =>
$durationSeconds === null
? $this->allowIp($scope, $value, $reason, $createdBy)
: throw new \InvalidArgumentException('Temporary allow rules are not supported.'),
[FirewallRuleObject::TYPE_IP_RANGE, FirewallRuleObject::ACTION_BLOCK] =>
$durationSeconds === null
? $this->blockIpRange($scope, $value, $reason, $createdBy)
: throw new \InvalidArgumentException('Temporary CIDR rules are not supported.'),
[FirewallRuleObject::TYPE_DEVICE, FirewallRuleObject::ACTION_BLOCK] =>
$this->blockDevice($scope, $value, $reason, $createdBy, $durationSeconds),
default => throw new \InvalidArgumentException('Unsupported firewall rule type and action combination.'),
};
}
private function matchesIp(string $type, string $value, string $currentIp): bool
{
if ($type === FirewallRuleObject::TYPE_IP) {
$value = FirewallRuleValidator::ipAddress($value);
return inet_pton($value) === inet_pton($currentIp);
}
if ($type === FirewallRuleObject::TYPE_IP_RANGE) {
return IpUtils::checkIp($currentIp, FirewallRuleValidator::cidr($value));
}
return false;
}
public function blockIp(
FirewallRuleScope $scope,
string $ipAddress,
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
FirewallRuleValidator::duration($durationSeconds);
$existing = $this->store->findExactIpRule(
$scope->tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK,
$scope->scope
);
if ($existing) {
if (
$origin === self::ORIGIN_AUTOMATIC
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
&& $durationSeconds !== null
) {
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
}
return $existing;
}
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_BLOCK,
$ipAddress,
$reason ?? 'Blocked by administrator',
$createdBy,
$durationSeconds,
$origin,
$metadata
);
$this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
return $rule;
}
public function allowIp(
FirewallRuleScope $scope,
string $ipAddress,
?string $reason,
?string $createdBy,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_IP,
FirewallRuleObject::ACTION_ALLOW,
$ipAddress,
$reason ?? 'Allowed by administrator',
$createdBy,
null,
$origin
);
$this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
return $rule;
}
public function blockIpRange(
FirewallRuleScope $scope,
string $cidr,
?string $reason,
?string $createdBy,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
return $this->create(
$scope,
FirewallRuleObject::TYPE_IP_RANGE,
FirewallRuleObject::ACTION_BLOCK,
FirewallRuleValidator::cidr($cidr),
$reason ?? 'Range blocked by administrator',
$createdBy,
null,
$origin
);
}
public function blockDevice(
FirewallRuleScope $scope,
string $fingerprint,
?string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL
): FirewallRuleObject {
FirewallRuleValidator::duration($durationSeconds);
$fingerprint = FirewallRuleValidator::deviceFingerprint($fingerprint);
$rule = $this->create(
$scope,
FirewallRuleObject::TYPE_DEVICE,
FirewallRuleObject::ACTION_BLOCK,
$fingerprint,
$reason ?? 'Device blocked by administrator',
$createdBy,
$durationSeconds,
$origin
);
$event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId);
$this->events->dispatch($event);
return $rule;
}
public function disableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
if ($rule->isEnabled()) {
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
FirewallRuleDisabledEvent::class,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
return $rule;
}
public function enableManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
if (
!$confirmCurrentIp
&& $currentIp !== null
&& $rule->getAction() === FirewallRuleObject::ACTION_BLOCK
&& $this->matchesIp($rule->getType(), (string)$rule->getValue(), FirewallRuleValidator::ipAddress($currentIp))
) {
throw new FirewallRuleConflictException(
'current_ip_confirmation_required',
'Enabling this rule would block your current IP address. Explicit confirmation is required.'
);
}
if (!$rule->isEnabled()) {
$rule->setEnabled(true);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
FirewallRuleEnabledEvent::class,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
}
return $rule;
}
public function extendManual(
FirewallRuleScope $scope,
string $ruleId,
int $durationSeconds,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
FirewallRuleValidator::duration($durationSeconds);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$previousExpiry = $rule->getExpiresAt();
if ($previousExpiry === null) {
throw new \InvalidArgumentException('Permanent firewall rules cannot be extended.');
}
$now = new \DateTimeImmutable();
$newExpiry = ($previousExpiry > $now ? $previousExpiry : $now)
->modify("+{$durationSeconds} seconds");
$metadata = $rule->getMetadata() ?? [];
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
$extensions[] = [
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
'origin' => self::ORIGIN_MANUAL,
'actorId' => $actorId,
'reason' => $reason,
];
$rule->setExpiresAt($newExpiry)->setMetadata([...$metadata, 'extensions' => $extensions]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
FirewallRuleExtendedEvent::class,
$rule,
$actorId,
[
'changeReason' => $reason,
'changeOrigin' => self::ORIGIN_MANUAL,
'previousExpiresAt' => $previousExpiry->format(\DateTimeInterface::ATOM),
]
);
return $rule;
}
public function removeManual(
FirewallRuleScope $scope,
string $ruleId,
string $reason,
?string $actorId
): ?FirewallRuleObject {
$reason = self::manualReason($reason);
$rule = $this->ownedRule($scope, $ruleId);
if (!$rule) {
return null;
}
$this->store->destroyRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
FirewallRuleRemovedEvent::class,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
);
return $rule;
}
private static function manualReason(string $reason): string
{
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
return $reason;
}
private function create(
FirewallRuleScope $scope,
string $type,
string $action,
string $value,
string $reason,
?string $createdBy,
?int $durationSeconds = null,
string $origin = self::ORIGIN_MANUAL,
array $metadata = []
): FirewallRuleObject {
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
}
$rule = (new FirewallRuleObject())
->setScope($scope->scope)
->setTenantId($scope->tenantId)
->setType($type)
->setAction($action)
->setValue($value)
->setReason($reason)
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
}
$metadata = [...$metadata, 'origin' => $origin];
if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) {
$metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM);
$metadata['extensions'] = [];
}
$rule->setMetadata($metadata);
$rule = $this->store->depositRule($rule)
?? throw new \RuntimeException('Failed to persist firewall rule.');
$this->cache->invalidate();
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
return $rule;
}
private function extendAutomaticBlock(
FirewallRuleObject $rule,
int $durationSeconds,
array $policy
): FirewallRuleObject {
$now = new \DateTimeImmutable();
$previousExpiry = $rule->getExpiresAt();
$newExpiry = $now->modify("+{$durationSeconds} seconds");
if ($previousExpiry !== null && $newExpiry <= $previousExpiry) {
return $rule;
}
$metadata = $rule->getMetadata() ?? [];
$extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : [];
$extensions[] = [
'extendedAt' => $now->format(\DateTimeInterface::ATOM),
'previousExpiresAt' => $previousExpiry?->format(\DateTimeInterface::ATOM),
'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM),
'failureCount' => $policy['lastFailureCount'] ?? null,
];
$rule->setExpiresAt($newExpiry)->setMetadata([
...$metadata,
...$policy,
'origin' => self::ORIGIN_AUTOMATIC,
'originalExpiresAt' => $metadata['originalExpiresAt']
?? $previousExpiry?->format(\DateTimeInterface::ATOM),
'extensions' => $extensions,
'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM),
]);
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
return $rule;
}
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
{
$rule = $this->store->fetchRule($ruleId);
return $rule && $scope->owns($rule) ? $rule : null;
}
/**
* @param class-string<FirewallRuleEvent> $eventClass
*/
private function publishLifecycleEvent(
string $eventClass,
FirewallRuleObject $rule,
?string $actorId = null,
array $change = []
): void
{
$event = $eventClass::fromRule($rule, $actorId, $change);
$this->events->dispatch($event);
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
final class FirewallRuleScope
{
private function __construct(
public readonly string $scope,
public readonly ?string $tenantId,
) {
}
public static function tenant(string $tenantId): self
{
if ($tenantId === '') {
throw new \InvalidArgumentException('Tenant firewall rule scope requires a tenant ID.');
}
return new self(FirewallRuleObject::SCOPE_TENANT, $tenantId);
}
public static function system(): self
{
return new self(FirewallRuleObject::SCOPE_SYSTEM, null);
}
public function owns(FirewallRuleObject $rule): bool
{
return $rule->getScope() === $this->scope && $rule->getTenantId() === $this->tenantId;
}
}
@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
final class FirewallRuleValidator
{
public const MAX_DEVICE_FINGERPRINT_LENGTH = 512;
private function __construct()
{
}
public static function ipAddress(string $ipAddress): string
{
$ipAddress = trim($ipAddress);
if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) {
throw new \InvalidArgumentException("Invalid IP address: {$ipAddress}");
}
return $ipAddress;
}
public static function cidr(string $cidr): string
{
$cidr = trim($cidr);
if (substr_count($cidr, '/') !== 1) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
[$address, $prefix] = explode('/', $cidr, 2);
if (filter_var($address, \FILTER_VALIDATE_IP) === false || !ctype_digit($prefix)) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
$maximumPrefix = str_contains($address, ':') ? 128 : 32;
if ((int)$prefix > $maximumPrefix) {
throw new \InvalidArgumentException("Invalid CIDR range: {$cidr}");
}
return $cidr;
}
public static function deviceFingerprint(string $fingerprint): string
{
$fingerprint = trim($fingerprint);
if ($fingerprint === '' || strlen($fingerprint) > self::MAX_DEVICE_FINGERPRINT_LENGTH) {
throw new \InvalidArgumentException(
sprintf('Device fingerprint must contain between 1 and %d bytes.', self::MAX_DEVICE_FINGERPRINT_LENGTH)
);
}
return $fingerprint;
}
public static function duration(?int $durationSeconds): void
{
if ($durationSeconds !== null && $durationSeconds < 1) {
throw new \InvalidArgumentException('Firewall rule duration must be greater than zero.');
}
}
}
+363 -200
View File
@@ -5,26 +5,12 @@ declare(strict_types=1);
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Security\Event\SecurityEventInterface;
use KTXC\Security\Event\SecurityRequestEventInterface;
use KTXF\Event\EventDispatcherInterface; use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use KTXF\IpUtils; use KTXF\IpUtils;
/** /**
@@ -43,9 +29,6 @@ class FirewallService
private const DEFAULT_MAX_AUTH_FAILURES = 5; private const DEFAULT_MAX_AUTH_FAILURES = 5;
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
private const MAX_AUTH_FAILURES = 1000;
private const MAX_AUTH_FAILURE_WINDOW = 86400; // 1 day
private const MAX_AUTO_BLOCK_DURATION = 31536000; // 1 year
// Configuration keys // Configuration keys
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures'; private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
@@ -53,13 +36,13 @@ class FirewallService
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration'; private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
private const CONFIG_ENABLED = 'firewall.enabled'; private const CONFIG_ENABLED = 'firewall.enabled';
/** @var FirewallRuleObject[]|null */
private ?array $rulesCache = null;
public function __construct( public function __construct(
private readonly FirewallStore $store, private readonly FirewallStore $store,
private readonly TenantContextInterface $tenantContext, private readonly TenantContextInterface $tenantContext,
private readonly EventDispatcherInterface $events, private readonly EventDispatcherInterface $events,
private readonly FirewallRuleManager $rules,
private readonly FirewallRuleCache $ruleCache,
private readonly RequestContext $requestContext,
) { ) {
} }
@@ -88,33 +71,36 @@ class FirewallService
string $ipAddress, string $ipAddress,
?string $deviceFingerprint = null ?string $deviceFingerprint = null
): FirewallAnalyzeResult { ): FirewallAnalyzeResult {
$tenantId = $this->tenantContext->identifier(); // Check if firewall is enabled for this tenant
$ruleGroups = [ if (!$this->isEnabled()) {
[$this->ruleCache->system(), FirewallRuleObject::ACTION_BLOCK], return new FirewallAnalyzeResult(true);
];
if ($tenantId !== null && $this->isEnabled()) {
$tenantRules = $this->ruleCache->tenant($tenantId);
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_ALLOW];
$ruleGroups[] = [$tenantRules, FirewallRuleObject::ACTION_BLOCK];
} }
$ruleGroups[] = [$this->ruleCache->system(), FirewallRuleObject::ACTION_ALLOW]; $tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return new FirewallAnalyzeResult(true);
}
foreach ($ruleGroups as [$rules, $action]) { $rules = $this->getActiveRules();
foreach ($rules as $rule) {
if ($rule->getAction() !== $action) {
continue;
}
if (!$this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) { // First check for explicit allow rules (whitelist takes precedence)
continue; foreach ($rules as $rule) {
} if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
continue;
if ($action === FirewallRuleObject::ACTION_ALLOW) { }
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
} if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
}
}
// Then check for block rules
foreach ($rules as $rule) {
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
continue;
}
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule); $this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason()); return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
} }
@@ -145,31 +131,23 @@ class FirewallService
/** /**
* Handle authentication failure event * Handle authentication failure event
*/ */
public function handleAuthFailure(AuthenticationFailedEvent $event): void public function handleAuthFailure(SecurityEvent $event): void
{ {
$request = $this->requestContext->current(); $ipAddress = $event->getIpAddress();
$ipAddress = $request?->getClientIp(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) { if (!$ipAddress || !$tenantId) {
return; return;
} }
$log = $this->securityLog($event, $request);
if ($log === null || !$this->store->createLogOnce($log)) {
return;
}
// Check for brute force // Check for brute force
$windowSeconds = $this->getBoundedIntegerConfig( $windowSeconds = $this->getConfig(
self::CONFIG_FAILURE_WINDOW, self::CONFIG_FAILURE_WINDOW,
self::DEFAULT_AUTH_FAILURE_WINDOW, self::DEFAULT_AUTH_FAILURE_WINDOW
self::MAX_AUTH_FAILURE_WINDOW
); );
$maxFailures = $this->getBoundedIntegerConfig( $maxFailures = $this->getConfig(
self::CONFIG_MAX_FAILURES, self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES, self::DEFAULT_MAX_AUTH_FAILURES
self::MAX_AUTH_FAILURES
); );
$failureCount = $this->store->countRecentFailures( $failureCount = $this->store->countRecentFailures(
@@ -178,24 +156,11 @@ class FirewallService
$windowSeconds $windowSeconds
); );
if ($failureCount >= $maxFailures) { // Include current failure in count
$blockDuration = $this->getBoundedIntegerConfig( $failureCount++;
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
return;
}
$this->handleBruteForce( if ($failureCount >= $maxFailures) {
$tenantId, $this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
$ipAddress,
$failureCount,
$windowSeconds,
$blockDuration
);
} }
} }
@@ -203,91 +168,53 @@ class FirewallService
* Handle detected brute force attack * Handle detected brute force attack
*/ */
private function handleBruteForce( private function handleBruteForce(
string $tenantId,
string $ipAddress, string $ipAddress,
int $failureCount, int $failureCount,
int $windowSeconds, int $windowSeconds
int $blockDuration
): void { ): void {
// Publish brute force event // Publish brute force event
$event = new BruteForceDetectedEvent( $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$ipAddress, $event->setTenantId($this->tenantContext->identifier());
$failureCount,
$windowSeconds,
$tenantId,
);
$this->events->dispatch($event); $this->events->dispatch($event);
$this->rules->blockIp( // Auto-block the IP
FirewallRuleScope::tenant($tenantId), $blockDuration = $this->getConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION
);
$this->blockIp(
$ipAddress, $ipAddress,
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
null, // System-created null, // System-created
$blockDuration, $blockDuration
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => $this->getBoundedIntegerConfig(
self::CONFIG_MAX_FAILURES,
self::DEFAULT_MAX_AUTH_FAILURES,
self::MAX_AUTH_FAILURES
),
'failureWindowSeconds' => $windowSeconds,
'lastFailureCount' => $failureCount,
'blockDurationSeconds' => $blockDuration,
]
); );
} }
/** /**
* Log security event to firewall logs * Log security event to firewall logs
*/ */
public function logSecurityEvent(SecurityEventInterface $event): void public function logSecurityEvent(SecurityEvent $event): void
{ {
$log = $this->securityLog($event); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if ($log !== null) { if (!$tenantId) {
$this->store->createLog($log); return;
} }
}
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
{
$log = $this->securityLog($event, $this->requestContext->current());
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog(
SecurityEventInterface $event,
?Request $request = null,
): ?FirewallLogObject
{
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
$ruleScope = $event->get('ruleScope');
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
return null;
}
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
$log = new FirewallLogObject(); $log = new FirewallLogObject();
return $log->setEventId($event->identifier()) $log->setTenantId($tenantId)
->setTenantId($tenantId) ->setIpAddress($event->getIpAddress())
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress()) ->setDeviceFingerprint($event->getDeviceFingerprint())
->setDeviceFingerprint( ->setUserAgent($event->getUserAgent())
$request?->headers->get('X-Device-Fingerprint') ->setRequestPath($event->getRequestPath())
?? $requestEvent?->getDeviceFingerprint() ->setRequestMethod($event->getRequestMethod())
) ->setEventType($this->mapEventToLogType($event->getName()))
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent()) ->setResult($this->mapEventToResult($event->getName()))
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath()) ->setIdentityId($event->getUserId())
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
->setEventType($this->mapEventToLogType($event->label()))
->setResult($this->mapEventToResult($event))
->setRuleId($event->get('ruleId'))
->setRuleScope($ruleScope)
->setIdentityId($event->getUserId() ?? $event->actorIdentity())
->setTimestamp(new \DateTimeImmutable()) ->setTimestamp(new \DateTimeImmutable())
->setMetadata($event->context()); ->setMetadata($event->getData());
$this->store->createLog($log);
} }
/** /**
@@ -296,18 +223,12 @@ class FirewallService
private function mapEventToLogType(string $eventName): string private function mapEventToLogType(string $eventName): string
{ {
return match ($eventName) { return match ($eventName) {
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE, SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK, SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT, SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS, SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK, default => FirewallLogObject::EVENT_ACCESS_CHECK,
}; };
} }
@@ -315,16 +236,11 @@ class FirewallService
/** /**
* Map security event to result * Map security event to result
*/ */
private function mapEventToResult(SecurityEventInterface $event): string private function mapEventToResult(string $eventName): string
{ {
return match ($event->label()) { return match ($eventName) {
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED, SecurityEvent::AUTH_SUCCESS,
FirewallRuleCreatedEvent::class, SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED, default => FirewallLogObject::RESULT_BLOCKED,
}; };
} }
@@ -337,17 +253,272 @@ class FirewallService
?string $deviceFingerprint, ?string $deviceFingerprint,
FirewallRuleObject $rule FirewallRuleObject $rule
): void { ): void {
$event = new AccessDeniedEvent( $event = SecurityEvent::accessDenied(
ipAddress: $ipAddress, $ipAddress,
ruleId: $rule->getId(), $deviceFingerprint,
ruleScope: $rule->getScope(), $rule->getId(),
deviceFingerprint: $deviceFingerprint, $rule->getReason()
reason: $rule->getReason(),
tenantId: $this->tenantContext->identifier(),
); );
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event); $this->events->dispatch($event);
} }
// ========================================
// Rule Management
// ========================================
/**
* Block an IP address
*/
public function blockIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
// Check if already blocked
$existing = $this->store->findExactIpRule(
$tenantId,
$ipAddress,
FirewallRuleObject::ACTION_BLOCK
);
if ($existing) {
return $existing;
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($ipAddress)
->setReason($reason ?? 'Blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Allow an IP address (whitelist)
*/
public function allowIp(
string $ipAddress,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_ALLOW)
->setValue($ipAddress)
->setReason($reason ?? 'Allowed by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
$event->setIpAddress($ipAddress)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Block an IP range (CIDR notation)
*/
public function blockIpRange(
string $cidr,
?string $reason = null,
?string $createdBy = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_IP_RANGE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($cidr)
->setReason($reason ?? 'Range blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
$this->store->depositRule($rule);
$this->clearRulesCache();
return $rule;
}
/**
* Block a device fingerprint
*/
public function blockDevice(
string $fingerprint,
?string $reason = null,
?string $createdBy = null,
?int $durationSeconds = null
): FirewallRuleObject {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
}
$rule = new FirewallRuleObject();
$rule->setTenantId($tenantId)
->setType(FirewallRuleObject::TYPE_DEVICE)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue($fingerprint)
->setReason($reason ?? 'Device blocked by administrator')
->setCreatedBy($createdBy)
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
if ($durationSeconds !== null) {
$rule->setExpiresAt(
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
);
}
$this->store->depositRule($rule);
$this->clearRulesCache();
// Publish event
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
$event->setDeviceFingerprint($fingerprint)
->setReason($reason)
->setTenantId($tenantId);
$this->events->dispatch($event);
return $rule;
}
/**
* Remove a rule by ID
*/
public function removeRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$this->store->destroyRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Disable a rule (soft delete)
*/
public function disableRule(string $ruleId): bool
{
$rule = $this->store->fetchRule($ruleId);
if (!$rule) {
return false;
}
// Verify tenant ownership
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false;
}
$rule->setEnabled(false);
$this->store->depositRule($rule);
$this->clearRulesCache();
return true;
}
/**
* Get all rules for current tenant
*/
public function listRules(bool $activeOnly = true): array
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listRules($tenantId, $activeOnly);
}
/**
* Get firewall logs for current tenant
*/
public function getLogs(
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
int $limit = 100
): array {
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return [];
}
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
}
/**
* Get blocked requests count
*/
public function getBlockedCount(?\DateTimeImmutable $since = null): int
{
$tenantId = $this->tenantContext->identifier();
if (!$tenantId) {
return 0;
}
return $this->store->countBlockedRequests($tenantId, $since);
}
// ========================================
// Helpers
// ========================================
/** /**
* Check if firewall is enabled for current tenant * Check if firewall is enabled for current tenant
*/ */
@@ -362,9 +533,6 @@ class FirewallService
private function getConfig(string $key, mixed $default = null): mixed private function getConfig(string $key, mixed $default = null): mixed
{ {
$config = $this->tenantContext->configuration(); $config = $this->tenantContext->configuration();
if ($config instanceof \JsonSerializable) {
$config = $config->jsonSerialize();
}
$parts = explode('.', $key); $parts = explode('.', $key);
foreach ($parts as $part) { foreach ($parts as $part) {
@@ -377,14 +545,27 @@ class FirewallService
return $config; return $config;
} }
private function getBoundedIntegerConfig(string $key, int $default, int $maximum): int /**
* Get active rules (cached)
* @return FirewallRuleObject[]
*/
private function getActiveRules(): array
{ {
$value = $this->getConfig($key, $default); if ($this->rulesCache === null) {
if (!is_int($value) || $value < 1 || $value > $maximum) { $tenantId = $this->tenantContext->identifier();
return $default; $this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true)
: [];
} }
return $this->rulesCache;
}
return $value; /**
* Clear rules cache
*/
private function clearRulesCache(): void
{
$this->rulesCache = null;
} }
/** /**
@@ -392,31 +573,13 @@ class FirewallService
*/ */
public function cleanup(): array public function cleanup(): array
{ {
$startedAt = new \DateTimeImmutable(); $expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
try { return [
$result = [ 'expiredRules' => $expiredRules,
'expiredRules' => $this->store->cleanupExpiredRules(), 'oldLogs' => $oldLogs,
'oldLogs' => $this->store->cleanupOldLogs(30), ];
'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(),
];
$this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result);
return $result;
} catch (\Throwable $error) {
try {
$this->store->recordMaintenanceStatus(
$startedAt,
new \DateTimeImmutable(),
'failed',
[],
$error->getMessage()
);
} catch (\Throwable) {
// Preserve the cleanup failure when the status store is also unavailable.
}
throw $error;
}
} }
} }
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
final class FirewallSettingsService
{
public function __construct(
private readonly TenantService $tenants,
private readonly EventDispatcherInterface $events,
) {
}
public function update(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason,
?string $actorId
): ?array {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures');
self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window');
self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration');
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
return null;
}
$previous = $tenant->getConfiguration()->firewall()->jsonSerialize();
$current = [
'enabled' => $enabled,
'maxAuthFailures' => $maxAuthFailures,
'authFailureWindow' => $authFailureWindow,
'autoBlockDuration' => $autoBlockDuration,
];
$configuration = (new TenantConfiguration())->jsonDeserialize([
...$tenant->getConfiguration()->jsonSerialize(),
'firewall' => $current,
]);
$tenant->setConfiguration($configuration);
$this->tenants->deposit($tenant);
$event = new FirewallSettingsUpdatedEvent(
changeReason: $reason,
previous: $previous,
current: $current,
tenantId: $tenantId,
actorId: $actorId,
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
);
$this->events->dispatch($event);
return $current;
}
private static function bounded(int $value, int $minimum, int $maximum, string $label): void
{
if ($value < $minimum || $value > $maximum) {
throw new \InvalidArgumentException(
"{$label} must be between {$minimum} and {$maximum}."
);
}
}
}
@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Stores\FirewallStore;
final class FirewallStatusService
{
public function __construct(private readonly FirewallStore $store)
{
}
public function tenantMetrics(string $tenantId, ?string $since = null): array
{
$sinceDate = self::date($since);
return [
'tenantId' => $tenantId,
'blockedRequests' => $this->store->countBlockedRequests($tenantId, $sinceDate),
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
];
}
public function systemMetrics(?string $tenantId = null, ?string $since = null): array
{
if ($tenantId !== null && ($tenantId === '' || strlen($tenantId) > 128)) {
throw new \InvalidArgumentException('Invalid tenant filter.');
}
$sinceDate = self::date($since);
return [
'tenantId' => $tenantId,
'blockedRequests' => $this->store->countSystemBlockedRequests($tenantId, $sinceDate),
'since' => $sinceDate?->format(\DateTimeInterface::ATOM),
];
}
public function maintenanceStatus(): array
{
return $this->store->maintenanceStatus() ?? [
'status' => 'never_run',
'startedAt' => null,
'completedAt' => null,
'result' => null,
'error' => null,
];
}
private static function date(?string $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value === '' || strlen($value) > 255) {
throw new \InvalidArgumentException('Invalid since date filter.');
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception) {
throw new \InvalidArgumentException('Invalid since date filter.');
}
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
final class SystemFirewallLogService
{
public const PERMISSION_READ = 'firewall.system.logs.read';
public function __construct(
private readonly FirewallLogService $logs,
private readonly IdentityContextInterface $identity,
) {
}
public function query(?string $tenantId, array $filters, int $limit = 50, int $offset = 0): array
{
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
}
return $this->logs->system($tenantId, $filters, $limit, $offset);
}
}
@@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
final class SystemFirewallRuleService
{
public const PERMISSION_READ = 'firewall.system.rules.read';
public const PERMISSION_MANAGE = 'firewall.system.rules.manage';
public function __construct(
private readonly FirewallRuleManager $rules,
private readonly IdentityContextInterface $identity,
) {
}
public function listRules(bool $activeOnly = true): array
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->list(FirewallRuleScope::system(), $activeOnly);
}
public function queryRules(
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->query(FirewallRuleScope::system(), $status, $type, $action, $limit, $offset);
}
public function fetchRule(string $ruleId): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->fetch(FirewallRuleScope::system(), $ruleId);
}
public function createRule(
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->createManualRule(
FirewallRuleScope::system(),
$type,
$action,
$value,
$reason,
$this->identity->identifier(),
$durationSeconds,
$currentIp,
$confirmCurrentIp
);
}
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->blockIp(
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier(), $durationSeconds
);
}
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->allowIp(
FirewallRuleScope::system(), $ip, $reason, $this->identity->identifier()
);
}
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->blockIpRange(
FirewallRuleScope::system(), $cidr, $reason, $this->identity->identifier()
);
}
public function blockDevice(
string $fingerprint,
?string $reason = null,
?int $durationSeconds = null
): FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->blockDevice(
FirewallRuleScope::system(),
$fingerprint,
$reason,
$this->identity->identifier(),
$durationSeconds
);
}
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disableManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->enableManual(
FirewallRuleScope::system(),
$ruleId,
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->extendManual(
FirewallRuleScope::system(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual(
FirewallRuleScope::system(), $ruleId, $reason, $this->identity->identifier()
);
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
final class SystemFirewallStatusService
{
public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read';
public const PERMISSION_SETTINGS_MANAGE = 'firewall.system.settings.manage';
public function __construct(
private readonly FirewallStatusService $status,
private readonly IdentityContextInterface $identity,
private readonly FirewallSettingsService $settings,
) {
}
public function metrics(?string $tenantId = null, ?string $since = null): array
{
$this->requirePermission(SystemFirewallLogService::PERMISSION_READ);
return $this->status->systemMetrics($tenantId, $since);
}
public function maintenanceStatus(): array
{
$this->requirePermission(self::PERMISSION_MAINTENANCE_READ);
return $this->status->maintenanceStatus();
}
public function updateTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): ?array {
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
return $this->settings->update(
$tenantId,
$enabled,
$maxAuthFailures,
$authFailureWindow,
$autoBlockDuration,
$reason,
$this->identity->identifier()
);
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
final class TenantFirewallLogService
{
public const PERMISSION_READ = 'firewall.tenant.logs.read';
public function __construct(
private readonly FirewallLogService $logs,
private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity,
) {
}
public function query(array $filters, int $limit = 50, int $offset = 0): array
{
if (!$this->identity->hasPermission(self::PERMISSION_READ)) {
throw new \RuntimeException('Missing required permission: '.self::PERMISSION_READ);
}
return $this->logs->tenant($this->tenant->requireIdentifier(), $filters, $limit, $offset);
}
}
@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Firewall\FirewallRuleObject;
final class TenantFirewallRuleService
{
public const PERMISSION_READ = 'firewall.tenant.rules.read';
public const PERMISSION_MANAGE = 'firewall.tenant.rules.manage';
public function __construct(
private readonly FirewallRuleManager $rules,
private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity,
) {
}
public function listRules(bool $activeOnly = true): array
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->list($this->scope(), $activeOnly);
}
public function queryRules(
string $status = 'active',
?string $type = null,
?string $action = null,
int $limit = 50,
int $offset = 0
): array {
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->query($this->scope(), $status, $type, $action, $limit, $offset);
}
public function fetchRule(string $ruleId): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->fetch($this->scope(), $ruleId);
}
public function createRule(
string $type,
string $action,
string $value,
string $reason,
?int $durationSeconds = null,
?string $currentIp = null,
bool $confirmCurrentIp = false
): FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->createManualRule(
$this->scope(),
$type,
$action,
$value,
$reason,
$this->identity->identifier(),
$durationSeconds,
$currentIp,
$confirmCurrentIp
);
}
public function effectivePolicy(): array
{
$this->requirePermission(self::PERMISSION_READ);
return $this->rules->effectivePolicy($this->tenant->requireIdentifier());
}
public function blockIp(string $ip, ?string $reason = null, ?int $durationSeconds = null): FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->blockIp(
$this->scope(), $ip, $reason, $this->identity->identifier(), $durationSeconds
);
}
public function allowIp(string $ip, ?string $reason = null): FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->allowIp($this->scope(), $ip, $reason, $this->identity->identifier());
}
public function blockIpRange(string $cidr, ?string $reason = null): FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->blockIpRange($this->scope(), $cidr, $reason, $this->identity->identifier());
}
public function blockDevice(
string $fingerprint,
?string $reason = null,
?int $durationSeconds = null
): FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->blockDevice(
$this->scope(), $fingerprint, $reason, $this->identity->identifier(), $durationSeconds
);
}
public function disableRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->disableManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
public function enableRule(
string $ruleId,
string $reason,
?string $currentIp = null,
bool $confirmCurrentIp = false
): ?FirewallRuleObject {
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->enableManual(
$this->scope(),
$ruleId,
$reason,
$this->identity->identifier(),
$currentIp,
$confirmCurrentIp
);
}
public function extendRule(string $ruleId, int $durationSeconds, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->extendManual(
$this->scope(), $ruleId, $durationSeconds, $reason, $this->identity->identifier()
);
}
public function removeRule(string $ruleId, string $reason): ?FirewallRuleObject
{
$this->requirePermission(self::PERMISSION_MANAGE);
return $this->rules->removeManual($this->scope(), $ruleId, $reason, $this->identity->identifier());
}
private function scope(): FirewallRuleScope
{
return FirewallRuleScope::tenant($this->tenant->requireIdentifier());
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
final class TenantFirewallStatusService
{
public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read';
public const PERMISSION_SETTINGS_MANAGE = 'firewall.tenant.settings.manage';
public function __construct(
private readonly FirewallStatusService $status,
private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity,
private readonly FirewallSettingsService $settings,
) {
}
public function metrics(?string $since = null): array
{
$this->requirePermission(TenantFirewallLogService::PERMISSION_READ);
return $this->status->tenantMetrics($this->tenant->requireIdentifier(), $since);
}
public function configuration(): array
{
$this->requirePermission(self::PERMISSION_SETTINGS_READ);
return $this->tenant->configuration()?->firewall()->jsonSerialize()
?? [
'enabled' => true,
'maxAuthFailures' => 5,
'authFailureWindow' => 300,
'autoBlockDuration' => 3600,
];
}
public function updateConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): ?array {
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
return $this->settings->update(
$this->tenant->requireIdentifier(),
$enabled,
$maxAuthFailures,
$authFailureWindow,
$autoBlockDuration,
$reason,
$this->identity->identifier()
);
}
private function requirePermission(string $permission): void
{
if (!$this->identity->hasPermission($permission)) {
throw new \RuntimeException("Missing required permission: {$permission}");
}
}
}
-11
View File
@@ -73,15 +73,4 @@ class TenantService
{ {
return $this->store->storeSettings($identifier, $settings); return $this->store->storeSettings($identifier, $settings);
} }
public function fetchServiceConfiguration(string $identifier, string $name): ?array
{
return $this->store->fetchServiceConfiguration($identifier, $name);
}
public function storeServiceConfiguration(string $identifier, string $name, array $configuration): bool
{
return $this->store->storeServiceConfiguration($identifier, $name, $configuration);
}
} }
+11 -48
View File
@@ -6,19 +6,14 @@ use KTXC\Models\Identity\User;
use KTXC\Context\IdentityContextInterface; use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXC\User\Event\UserUpdatedEvent;
use KTXF\Event\EventDispatcherInterface;
class UserAccountsService class UserAccountsService
{ {
public function __construct( public function __construct(
private readonly TenantContextInterface $tenantContext, private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsStore $userStore, private readonly UserAccountsStore $userStore
private readonly EventDispatcherInterface $events,
) { ) {
} }
@@ -70,53 +65,17 @@ class UserAccountsService
public function createUser(array $userData): array public function createUser(array $userData): array
{ {
$tenantId = $this->tenantContext->requireIdentifier(); return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
$user = $this->userStore->createUser($tenantId, $userData);
$this->events->dispatch(UserCreatedEvent::fromUser(
$user,
$tenantId,
$this->identityContext->identifier(),
));
return $user;
} }
public function updateUser(string $userId, array $updates): bool public function updateUser(string $uid, array $updates): bool
{ {
$tenantId = $this->tenantContext->requireIdentifier(); return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
if (!$this->userStore->updateUser($tenantId, $userId, $updates)) {
return false;
}
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
if ($user === null) {
throw new \RuntimeException("Updated user '{$userId}' could not be retrieved.");
}
$this->events->dispatch(UserUpdatedEvent::fromUser(
$user,
$tenantId,
$this->identityContext->identifier(),
));
return true;
} }
public function deleteUser(string $userId): bool public function deleteUser(string $uid): bool
{ {
$tenantId = $this->tenantContext->requireIdentifier(); return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
if ($user === null) {
return false;
}
$this->events->dispatch(UserDeletingEvent::fromUser(
$user,
$tenantId,
$this->identityContext->identifier(),
));
return $this->userStore->deleteUser($tenantId, $userId);
} }
// ========================================================================= // =========================================================================
@@ -167,6 +126,10 @@ class UserAccountsService
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings); return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
} }
// =========================================================================
// Helper Methods
// =========================================================================
/** /**
* Check if a profile field is editable by the user * Check if a profile field is editable by the user
* *
+21 -389
View File
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace KTXC\Stores; namespace KTXC\Stores;
use KTXC\Db\DataStore; use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
use KTXC\Db\UTCDateTime;
use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Firewall\FirewallLogObject;
@@ -17,147 +15,28 @@ class FirewallStore
{ {
protected const RULES_COLLECTION = 'firewall_rules'; protected const RULES_COLLECTION = 'firewall_rules';
protected const LOGS_COLLECTION = 'firewall_logs'; protected const LOGS_COLLECTION = 'firewall_logs';
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
public function __construct( public function __construct(
protected readonly DataStore $dataStore protected readonly DataStore $dataStore
) {} ) {}
/**
* Install the indexes used by firewall enforcement, audit queries, and expiry.
*
* MongoDB createIndex is idempotent when the name and specification match.
*
* @return string[]
*/
public function ensureIndexes(): array
{
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
return [
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_by_scope_tenant_active']
),
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_exact_lookup']
),
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'createdAt' => -1],
['name' => 'rules_browse']
),
$logs->createIndex(
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_auth_failures']
),
$logs->createIndex(
['tenantId' => 1, 'timestamp' => -1],
['name' => 'logs_tenant_timeline']
),
$logs->createIndex(
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
['name' => 'logs_blocked_counts']
),
$logs->createIndex(
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_event_type']
),
$logs->createIndex(
['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1],
['name' => 'logs_rule']
),
$logs->createIndex(
['timestamp' => -1],
['name' => 'logs_global_timeline']
),
$claims->createIndex(
['expiresAt' => 1],
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
),
];
}
// ======================================== // ========================================
// Rule Operations // Rule Operations
// ======================================== // ========================================
/**
* Query rules within one ownership scope.
*
* @return array{items: FirewallRuleObject[], total: int, limit: int, offset: int}
*/
public function queryRules(
string $scope,
?string $tenantId,
string $status,
?string $type,
?string $action,
int $limit,
int $offset
): array {
$filter = [
'scope' => $scope,
'tenantId' => $scope === FirewallRuleObject::SCOPE_SYSTEM ? null : $tenantId,
];
$now = self::bsonDate(new \DateTimeImmutable());
if ($status === 'active') {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => $now]],
];
} elseif ($status === 'disabled') {
$filter['enabled'] = false;
} elseif ($status === 'expired') {
$filter['expiresAt'] = ['$ne' => null, '$lte' => $now];
}
if ($type !== null) {
$filter['type'] = $type;
}
if ($action !== null) {
$filter['action'] = $action;
}
$collection = $this->dataStore->selectCollection(self::RULES_COLLECTION);
$items = [];
foreach ($collection->find($filter, [
'sort' => ['createdAt' => -1, '_id' => -1],
'limit' => $limit,
'skip' => $offset,
]) as $entry) {
$items[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
return [
'items' => $items,
'total' => $collection->countDocuments($filter),
'limit' => $limit,
'offset' => $offset,
];
}
/** /**
* List all rules for a tenant * List all rules for a tenant
*/ */
public function listRules(string $tenantId, bool $activeOnly = true): array public function listRules(string $tenantId, bool $activeOnly = true): array
{ {
$filter = [ $filter = ['tenantId' => $tenantId];
'tenantId' => $tenantId,
'scope' => FirewallRuleObject::SCOPE_TENANT,
];
if ($activeOnly) { if ($activeOnly) {
$filter['enabled'] = true; $filter['enabled'] = true;
$filter['$and'] = [[ $filter['$or'] = [
'$or' => [
['expiresAt' => null], ['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]] ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
], ];
]];
} }
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
@@ -171,26 +50,6 @@ class FirewallStore
return $list; return $list;
} }
public function listSystemRules(bool $activeOnly = true): array
{
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
$list = [];
foreach ($cursor as $entry) {
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
return $list;
}
/** /**
* Find rules by IP address * Find rules by IP address
*/ */
@@ -202,7 +61,7 @@ class FirewallStore
'enabled' => true, 'enabled' => true,
'$or' => [ '$or' => [
['expiresAt' => null], ['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]] ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
] ]
]; ];
@@ -229,7 +88,7 @@ class FirewallStore
'enabled' => true, 'enabled' => true,
'$or' => [ '$or' => [
['expiresAt' => null], ['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]] ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
] ]
]; ];
@@ -249,7 +108,7 @@ class FirewallStore
*/ */
public function fetchRule(string $id): ?FirewallRuleObject public function fetchRule(string $id): ?FirewallRuleObject
{ {
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id)); $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
if (!$entry) { if (!$entry) {
return null; return null;
} }
@@ -259,33 +118,15 @@ class FirewallStore
/** /**
* Check if exact IP rule exists * Check if exact IP rule exists
*/ */
public function findExactIpRule( public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
?string $tenantId,
string $ipAddress,
string $action,
string $scope = FirewallRuleObject::SCOPE_TENANT
): ?FirewallRuleObject
{ {
$filter = [ $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
'tenantId' => $tenantId,
'type' => FirewallRuleObject::TYPE_IP, 'type' => FirewallRuleObject::TYPE_IP,
'value' => $ipAddress, 'value' => $ipAddress,
'action' => $action, 'action' => $action,
'enabled' => true, 'enabled' => true,
'$or' => [ ]);
['expiresAt' => null],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
],
];
if ($scope === FirewallRuleObject::SCOPE_SYSTEM) {
$filter['scope'] = FirewallRuleObject::SCOPE_SYSTEM;
$filter['tenantId'] = null;
} else {
$filter['tenantId'] = $tenantId;
$filter['scope'] = FirewallRuleObject::SCOPE_TENANT;
}
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne($filter);
if (!$entry) { if (!$entry) {
return null; return null;
@@ -298,8 +139,6 @@ class FirewallStore
*/ */
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
{ {
$rule->assertValidScopeOwnership();
if ($rule->getId()) { if ($rule->getId()) {
return $this->updateRule($rule); return $this->updateRule($rule);
} else { } else {
@@ -309,7 +148,7 @@ class FirewallStore
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
{ {
$data = self::ruleDocument($rule); $data = $rule->jsonSerialize();
unset($data['id']); // Remove id for insert unset($data['id']); // Remove id for insert
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data); $result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
@@ -324,11 +163,11 @@ class FirewallStore
return null; return null;
} }
$data = self::ruleDocument($rule); $data = $rule->jsonSerialize();
unset($data['id']); unset($data['id']);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne( $this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
self::ruleIdFilter($id), ['_id' => $id],
['$set' => $data] ['$set' => $data]
); );
return $rule; return $rule;
@@ -343,7 +182,7 @@ class FirewallStore
if (!$id) { if (!$id) {
return; return;
} }
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id)); $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
} }
/** /**
@@ -352,10 +191,8 @@ class FirewallStore
public function cleanupExpiredRules(): int public function cleanupExpiredRules(): int
{ {
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([ $result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
'expiresAt' => [ 'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'$lt' => self::bsonDate(new \DateTimeImmutable()), 'expiresAt' => ['$ne' => null]
'$ne' => null,
],
]); ]);
return $result->getDeletedCount(); return $result->getDeletedCount();
@@ -365,68 +202,12 @@ class FirewallStore
// Log Operations // Log Operations
// ======================================== // ========================================
public function queryTenantLogs(
string $tenantId,
array $filters,
int $limit,
int $offset
): array {
return $this->queryLogs(['tenantId' => $tenantId], $filters, $limit, $offset);
}
public function querySystemLogs(
?string $tenantId,
array $filters,
int $limit,
int $offset
): array {
return $this->queryLogs($tenantId === null ? [] : ['tenantId' => $tenantId], $filters, $limit, $offset);
}
/** @return array{items: FirewallLogObject[], total: int, limit: int, offset: int} */
private function queryLogs(array $scopeFilter, array $filters, int $limit, int $offset): array
{
$filter = $scopeFilter;
foreach (['ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope'] as $field) {
if (($filters[$field] ?? null) !== null) {
$filter[$field] = $filters[$field];
}
}
$timestamp = [];
if (($filters['from'] ?? null) instanceof \DateTimeInterface) {
$timestamp['$gte'] = self::bsonDate($filters['from']);
}
if (($filters['to'] ?? null) instanceof \DateTimeInterface) {
$timestamp['$lte'] = self::bsonDate($filters['to']);
}
if ($timestamp !== []) {
$filter['timestamp'] = $timestamp;
}
$collection = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
$items = [];
foreach ($collection->find($filter, [
'sort' => ['timestamp' => -1, '_id' => -1],
'limit' => $limit,
'skip' => $offset,
]) as $entry) {
$items[] = (new FirewallLogObject())->jsonDeserialize((array)$entry);
}
return [
'items' => $items,
'total' => $collection->countDocuments($filter),
'limit' => $limit,
'offset' => $offset,
];
}
/** /**
* Log a firewall event * Log a firewall event
*/ */
public function createLog(FirewallLogObject $log): FirewallLogObject public function createLog(FirewallLogObject $log): FirewallLogObject
{ {
$data = self::logDocument($log); $data = $log->jsonSerialize();
unset($data['id']); unset($data['id']);
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data); $result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
@@ -434,30 +215,6 @@ class FirewallStore
return $log; return $log;
} }
/**
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
*/
public function createLogOnce(FirewallLogObject $log): bool
{
$eventId = $log->getEventId();
if ($eventId === null || $eventId === '') {
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
}
$data = self::logDocument($log);
unset($data['id']);
$data['_id'] = $eventId;
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
['_id' => $eventId],
['$setOnInsert' => $data],
['upsert' => true]
);
$log->setId($eventId);
return $result->getUpsertedCount() === 1;
}
/** /**
* Get logs for a tenant with optional filters * Get logs for a tenant with optional filters
*/ */
@@ -513,50 +270,10 @@ class FirewallStore
'tenantId' => $tenantId, 'tenantId' => $tenantId,
'ipAddress' => $ipAddress, 'ipAddress' => $ipAddress,
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE, 'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => self::bsonDate($since)] 'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
]); ]);
} }
/**
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
*/
public function claimBruteForce(
string $tenantId,
string $ipAddress,
int $claimDurationSeconds
): bool {
if ($claimDurationSeconds < 1) {
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
}
$now = new \DateTimeImmutable();
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
$collection->deleteOne([
'_id' => $claimId,
'expiresAt' => ['$lte' => self::bsonDate($now)],
]);
try {
$collection->insertOne([
'_id' => $claimId,
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'createdAt' => self::bsonDate($now),
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
]);
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
if ($error->getCode() === 11000) {
return false;
}
throw $error;
}
return true;
}
/** /**
* Get blocked requests count for dashboard * Get blocked requests count for dashboard
*/ */
@@ -570,27 +287,12 @@ class FirewallStore
]; ];
if ($since !== null) { if ($since !== null) {
$filter['timestamp'] = ['$gte' => self::bsonDate($since)]; $filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
} }
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter); return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
} }
public function countSystemBlockedRequests(
?string $tenantId = null,
?\DateTimeImmutable $since = null
): int {
$filter = ['result' => FirewallLogObject::RESULT_BLOCKED];
if ($tenantId !== null) {
$filter['tenantId'] = $tenantId;
}
if ($since !== null) {
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
}
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
}
/** /**
* Clean up old logs * Clean up old logs
*/ */
@@ -599,79 +301,9 @@ class FirewallStore
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days"); $cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([ $result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
'timestamp' => ['$lt' => self::bsonDate($cutoff)] 'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
]); ]);
return $result->getDeletedCount(); return $result->getDeletedCount();
} }
public function cleanupExpiredBruteForceClaims(): int
{
$result = $this->dataStore
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
->deleteMany([
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
]);
return $result->getDeletedCount();
}
public function recordMaintenanceStatus(
\DateTimeImmutable $startedAt,
\DateTimeImmutable $completedAt,
string $status,
array $result,
?string $error = null
): void {
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
['_id' => 'cleanup'],
['$set' => [
'startedAt' => self::bsonDate($startedAt),
'completedAt' => self::bsonDate($completedAt),
'status' => $status,
'result' => $result,
'error' => $error,
]],
['upsert' => true]
);
}
public function maintenanceStatus(): ?array
{
return $this->dataStore
->selectCollection(self::MAINTENANCE_COLLECTION)
->findOne(['_id' => 'cleanup']);
}
private static function ruleDocument(FirewallRuleObject $rule): array
{
$data = $rule->jsonSerialize();
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
return $data;
}
private static function logDocument(FirewallLogObject $log): array
{
$data = $log->jsonSerialize();
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
return $data;
}
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
{
return $date === null ? null : self::bsonDate($date);
}
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
{
return UTCDateTime::fromDateTime($date);
}
private static function ruleIdFilter(string $id): array
{
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
}
} }
-93
View File
@@ -77,49 +77,6 @@ class TenantStore
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]); $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
} }
// =========================================================================
// Configuration Operations
// =========================================================================
public function fetchConfiguration(string $identifier, string $path): ?array
{
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
['identifier' => $identifier],
['projection' => ['configuration.' . $path => 1]],
);
if (!$entry) {
return null;
}
$value = $this->readPath($entry, "configuration.{$path}");
return $value === null ? null : (array) $value;
}
public function storeConfiguration(string $identifier, string $path, array $value): bool
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['identifier' => $identifier],
['$set' => ['configuration.' . $path => $value]],
);
return $result->getMatchedCount() > 0;
}
public function removeConfiguration(string $identifier, string $path): ?bool
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['identifier' => $identifier],
['$unset' => ['configuration.' . $path => '']],
);
if ($result->getMatchedCount() === 0) {
return null;
}
return $result->getModifiedCount() > 0;
}
// ========================================================================= // =========================================================================
// Settings Operations // Settings Operations
// ========================================================================= // =========================================================================
@@ -173,54 +130,4 @@ class TenantStore
return $result->getMatchedCount() > 0; return $result->getMatchedCount() > 0;
} }
/**
* Atomically creates or replaces one logical store reference.
*
* @param array{provider: string, service: string|int, namespace: string} $reference
*/
public function storeConfigurationStore(string $identifier, string $name, array $reference): bool
{
return $this->storeConfiguration($identifier, "stores.{$name}", $reference);
}
/**
* Atomically removes one logical store reference.
*/
public function removeConfigurationStore(string $identifier, string $name): ?bool
{
return $this->removeConfiguration($identifier, "stores.{$name}");
}
public function fetchServiceConfiguration(string $identifier, string $name): ?array
{
$this->validateServiceName($name);
return $this->fetchConfiguration($identifier, "services.{$name}");
}
/** Save one service atomically. */
public function storeServiceConfiguration(string $identifier, string $name, array $configuration): bool
{
$this->validateServiceName($name);
return $this->storeConfiguration($identifier, "services.{$name}", $configuration);
}
private function validateServiceName(string $name): void
{
if (preg_match('/^[a-z][a-z0-9_]*$/D', $name) !== 1) {
throw new \InvalidArgumentException('Invalid service name.');
}
}
private function readPath(array $data, string $path): mixed
{
$value = $data;
foreach (explode('.', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
} }
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\SystemStore;
use KTXC\Stores\TenantStore;
use KTXF\SystemStore\StoreReference;
use KTXF\SystemStore\SystemStoreException;
/**
* Superuser-facing configuration operations for tenant logical stores.
*/
class SystemStoreConfigurationService
{
public function __construct(private readonly TenantStore $tenants)
{
}
/** @return array<string, StoreReference> */
public function list(string $tenantId): array
{
$tenant = $this->tenants->fetch($tenantId);
if ($tenant === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
return $tenant->getConfiguration()->stores()->all();
}
public function set(
string $tenantId,
string $name,
string $provider,
string|int $service,
string $namespace,
): StoreReference {
self::validateName($name);
$reference = new StoreReference($provider, $service, $namespace);
if (!$this->tenants->storeConfigurationStore($tenantId, $name, $reference->toArray())) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
return $reference;
}
public function remove(string $tenantId, string $name): bool
{
self::validateName($name);
$removed = $this->tenants->removeConfigurationStore($tenantId, $name);
if ($removed === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
return $removed;
}
private static function validateName(string $name): void
{
if (preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
throw new \InvalidArgumentException('Logical store names must use lowercase letters, numbers, and hyphens');
}
}
}
-167
View File
@@ -1,167 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\SystemStore;
use KTXC\Resource\ProviderManager;
use KTXC\Service\TenantService;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\SystemIdentity;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\InvalidKeyException;
use KTXF\SystemStore\Provider\ProviderBaseInterface;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\StoreReference;
use KTXF\SystemStore\SystemStoreException;
use KTXF\SystemStore\SystemStoreManagerInterface;
use KTXF\SystemStore\WriteCondition;
final readonly class SystemStoreManager implements SystemStoreManagerInterface
{
public function __construct(
private TenantService $tenants,
private ProviderManager $providers,
) {
}
public function stat(string $tenantId, string $store, string $key): ?BlobInfo
{
[$service, $reference] = $this->resolve($tenantId, $store);
$info = $service->stat($this->qualify($reference, $key));
return $info === null ? null : $this->logicalInfo($info, $reference);
}
public function read(string $tenantId, string $store, string $key): ?BinaryResource
{
[$service, $reference] = $this->resolve($tenantId, $store);
return $service->read($this->qualify($reference, $key));
}
public function write(
string $tenantId,
string $store,
string $key,
BinaryResource $content,
array $metadata = [],
?WriteCondition $condition = null,
): BlobInfo {
[$service, $reference] = $this->resolve($tenantId, $store);
$info = $service->write(
$this->qualify($reference, $key),
$content,
$metadata,
$condition,
);
return $this->logicalInfo($info, $reference);
}
public function delete(
string $tenantId,
string $store,
string $key,
?WriteCondition $condition = null,
): bool {
[$service, $reference] = $this->resolve($tenantId, $store);
return $service->delete($this->qualify($reference, $key), $condition);
}
public function list(string $tenantId, string $store, string $prefix = ''): iterable
{
[$service, $reference] = $this->resolve($tenantId, $store);
$physicalPrefix = $reference->namespace . '/';
if ($prefix !== '') {
$physicalPrefix .= $this->normalizeKey($prefix, true);
}
return $this->logicalItems($service->list($physicalPrefix), $reference);
}
/** @return array{SystemStoreServiceInterface, StoreReference} */
private function resolve(string $tenantId, string $store): array
{
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
}
$reference = $tenant->getConfiguration()->stores()->store($store);
if ($reference === null) {
throw new SystemStoreException("System store '{$store}' is not configured for tenant '{$tenantId}'");
}
$provider = $this->providers->resolve(ProviderInterface::TYPE_SYSTEM_STORE, $reference->provider);
if (!$provider instanceof ProviderBaseInterface) {
throw new SystemStoreException("System-store provider '{$reference->provider}' is unavailable or incompatible");
}
$service = $provider->serviceFetch($tenantId, SystemIdentity::USER, $reference->service);
if (!$service instanceof SystemStoreServiceInterface) {
throw new SystemStoreException("System-store service '{$reference->service}' is unavailable or incompatible");
}
return [$service, $reference];
}
private function qualify(StoreReference $reference, string $key): string
{
return $reference->namespace . '/' . $this->normalizeKey($key);
}
private function normalizeKey(string $key, bool $allowTrailingSlash = false): string
{
if (
$key === ''
|| str_starts_with($key, '/')
|| (!$allowTrailingSlash && str_ends_with($key, '/'))
|| str_contains($key, '\\')
|| str_contains($key, "\0")
) {
throw new InvalidKeyException('System-store keys must be non-empty normalized relative keys');
}
$segments = explode('/', $key);
if ($allowTrailingSlash && end($segments) === '') {
array_pop($segments);
}
if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) {
throw new InvalidKeyException('System-store keys cannot contain empty or traversal segments');
}
return $key;
}
private function logicalInfo(BlobInfo $info, StoreReference $reference): BlobInfo
{
$prefix = $reference->namespace . '/';
if (!str_starts_with($info->key, $prefix)) {
throw new SystemStoreException('System-store provider returned a blob outside the configured namespace');
}
return new BlobInfo(
key: substr($info->key, strlen($prefix)),
mimeType: $info->mimeType,
size: $info->size,
etag: $info->etag,
modifiedAt: $info->modifiedAt,
attributes: $info->attributes,
);
}
/**
* @param iterable<BlobInfo> $items
* @return iterable<BlobInfo>
*/
private function logicalItems(iterable $items, StoreReference $reference): iterable
{
foreach ($items as $item) {
if (!$item instanceof BlobInfo) {
throw new SystemStoreException('System-store provider returned invalid listing metadata');
}
yield $this->logicalInfo($item, $reference);
}
}
}
-9
View File
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserCreatedEvent extends UserEvent
{
}
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserDeletingEvent extends UserEvent
{
}
-94
View File
@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
use KTXF\Event\Event;
abstract class UserEvent extends Event
{
final public function __construct(
private readonly string $userIdentifier,
private readonly string $userIdentity,
private readonly string $userLabel,
private readonly bool $userEnabled,
private readonly array $userRoles,
private readonly string $tenantIdentifier,
private readonly ?string $actorIdentifier = null,
) {
if ($userIdentifier === '') {
throw new \InvalidArgumentException('User lifecycle events require a user ID.');
}
if ($userIdentity === '') {
throw new \InvalidArgumentException('User lifecycle events require a user identity.');
}
if ($tenantIdentifier === '') {
throw new \InvalidArgumentException('User lifecycle events require a tenant ID.');
}
parent::__construct(
static::class,
[
'identifier' => $userIdentifier,
'identity' => $userIdentity,
'label' => $userLabel,
'roles' => $userRoles,
'enabled' => $userEnabled,
],
$tenantIdentifier,
$actorIdentifier,
);
}
public static function fromUser(
array $user,
string $tenantIdentifier,
?string $actorIdentifier = null,
): static {
return new static(
(string) ($user['uid'] ?? ''),
(string) ($user['identity'] ?? ''),
(string) ($user['label'] ?? $user['identity'] ?? ''),
(bool) ($user['enabled'] ?? true),
array_values((array) ($user['roles'] ?? [])),
$tenantIdentifier,
$actorIdentifier,
);
}
public function userIdentifier(): string
{
return $this->userIdentifier;
}
public function tenantIdentifier(): string
{
return $this->tenantIdentifier;
}
public function userIdentity(): string
{
return $this->userIdentity;
}
public function userLabel(): string
{
return $this->userLabel;
}
public function userRoles(): array
{
return $this->userRoles;
}
public function userEnabled(): bool
{
return $this->userEnabled;
}
public function actorIdentifier(): ?string
{
return $this->actorIdentifier;
}
}
-9
View File
@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserUpdatedEvent extends UserEvent
{
}
+4 -1
View File
@@ -88,9 +88,12 @@ const userAvatar = computed(() => userStore.getProfileField('avatar') || default
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn class="profileBtn" variant="text" rounded="sm" v-bind="props"> <v-btn class="profileBtn" variant="text" rounded="sm" v-bind="props">
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-avatar size="32"> <v-avatar class="mr-sm-2 mr-0" size="32">
<v-img :src="userAvatar" :alt="userAuth?.label || 'User'" cover /> <v-img :src="userAvatar" :alt="userAuth?.label || 'User'" cover />
</v-avatar> </v-avatar>
<h6 class="text-subtitle-1 mb-0 d-sm-block d-none">
{{ userAuth?.label }}
</h6>
</div> </div>
</v-btn> </v-btn>
</template> </template>
+39 -50
View File
@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed } from 'vue';
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore'; import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import type { IntegrationPointType } from '@KTXC/types/integrationTypes';
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';
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue'; import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
@@ -13,33 +12,25 @@ const layoutStore = useLayoutStore();
const integrationStore = useIntegrationStore(); const integrationStore = useIntegrationStore();
const { t } = useL10n('core'); const { t } = useL10n('core');
const menuPointByMode: Record<MenuMode, IntegrationPointType> = {
apps: 'app_menu',
'user-settings': 'user_settings_menu',
'admin-settings': 'admin_settings_menu',
};
// Get all entries based on current menu mode // Get all entries based on current menu mode
const menuEntries = computed(() => integrationStore.getPoint(menuPointByMode[layoutStore.menuMode])); const menuEntries = computed(() => {
switch (layoutStore.menuMode) {
// Only show menu modes that currently have visible items. case 'user-settings':
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => { return integrationStore.getPoint('user_settings_menu');
const modes: Array<{ value: MenuMode; icon: string; label: string }> = [ case 'admin-settings':
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') }, return integrationStore.getPoint('admin_settings_menu');
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') }, case 'apps':
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') }, default:
]; return integrationStore.getPoint('app_menu');
}
return modes.filter(mode => integrationStore.getPoint(menuPointByMode[mode.value]).length > 0);
}); });
// If the active menu becomes empty (for example, after a module is disabled), // Static list of menu modes shown as icon buttons
// move to the first menu that still has content. const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => [
watch(menuModes, (availableModes) => { { value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
if (availableModes.length > 0 && !availableModes.some(mode => mode.value === layoutStore.menuMode)) { { value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
layoutStore.setMenuMode(availableModes[0].value); { value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
} ]);
}, { immediate: true });
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -93,30 +84,28 @@ export default {
<!-- Menu Mode Switcher --> <!-- Menu Mode Switcher -->
<template v-slot:append> <template v-slot:append>
<div v-if="menuModes.length"> <v-divider />
<v-divider /> <div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2"> <v-tooltip
<v-tooltip v-for="mode in menuModes"
v-for="mode in menuModes" :key="mode.value"
:key="mode.value" location="right"
location="right" >
> <template v-slot:activator="{ props }">
<template v-slot:activator="{ props }"> <v-btn
<v-btn v-bind="props"
v-bind="props" icon
icon variant="text"
variant="text" density="comfortable"
density="comfortable" :color="layoutStore.menuMode === mode.value ? 'primary' : undefined"
:color="layoutStore.menuMode === mode.value ? 'primary' : undefined" :class="['menu-mode-btn', { 'menu-mode-btn--active': layoutStore.menuMode === mode.value }]"
:class="['menu-mode-btn', { 'menu-mode-btn--active': layoutStore.menuMode === mode.value }]" @click="layoutStore.setMenuMode(mode.value)"
@click="layoutStore.setMenuMode(mode.value)" >
> <v-icon>{{ mode.icon }}</v-icon>
<v-icon>{{ mode.icon }}</v-icon> </v-btn>
</v-btn> </template>
</template> <span>{{ mode.label }}</span>
<span>{{ mode.label }}</span> </v-tooltip>
</v-tooltip>
</div>
</div> </div>
</template> </template>
</v-navigation-drawer> </v-navigation-drawer>
+18 -9
View File
@@ -3,11 +3,13 @@ import { computed } from 'vue';
import { useUserStore } from '@KTXC/stores/userStore'; import { useUserStore } from '@KTXC/stores/userStore';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { useLayoutStore } from '@KTXC/stores/layoutStore'; import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useRouter } from 'vue-router';
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n'; import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png'; import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
const { t } = useL10n('core'); const { t } = useL10n('core');
const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const integrationStore = useIntegrationStore(); const integrationStore = useIntegrationStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
@@ -41,6 +43,12 @@ const cycleTheme = () => {
layoutStore.setTheme(nextThemeMode.value); layoutStore.setTheme(nextThemeMode.value);
}; };
// Navigate to settings
const goToSettings = () => {
layoutStore.setMenuMode('settings');
// Navigate to first settings item or a default settings route
router.push('/modules'); // TODO: Make this dynamic based on first settings menu item
};
</script> </script>
<template> <template>
@@ -54,9 +62,9 @@ const cycleTheme = () => {
<v-img :src="userAvatar" :alt="userName" cover /> <v-img :src="userAvatar" :alt="userName" cover />
</v-avatar> </v-avatar>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h5 class="user-menu-name mb-0 font-weight-bold"> <h6 class="text-h6 mb-0 font-weight-medium">
{{ userName }} {{ userName }}
</h5> </h6>
<p class="text-caption mb-0 text-medium-emphasis">{{ userEmail }}</p> <p class="text-caption mb-0 text-medium-emphasis">{{ userEmail }}</p>
</div> </div>
</div> </div>
@@ -90,6 +98,14 @@ const cycleTheme = () => {
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title> <v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
</v-list-item> </v-list-item>
<!-- Go to Settings -->
<v-list-item @click="goToSettings" color="primary" rounded="0">
<template v-slot:prepend>
<v-icon>mdi-cog-outline</v-icon>
</template>
<v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
</v-list-item>
<v-divider class="my-2" /> <v-divider class="my-2" />
<!-- Logout --> <!-- Logout -->
@@ -103,10 +119,3 @@ const cycleTheme = () => {
</perfect-scrollbar> </perfect-scrollbar>
</div> </div>
</template> </template>
<style scoped>
.user-menu-name {
font-size: 18px !important;
line-height: 1.2;
}
</style>
-3
View File
@@ -6,7 +6,6 @@ import { createPinia } from 'pinia'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { useModuleStore } from '@KTXC/stores/moduleStore' import { useModuleStore } from '@KTXC/stores/moduleStore'
import { useTenantStore } from '@KTXC/stores/tenantStore' import { useTenantStore } from '@KTXC/stores/tenantStore'
import { usePreviewStore } from '@KTXC/stores/previewStore'
import { useUserStore } from '@KTXC/stores/userStore' import { useUserStore } from '@KTXC/stores/userStore'
import { useL10nStore } from '@KTXC/stores/l10nStore' import { useL10nStore } from '@KTXC/stores/l10nStore'
import { useThemeStore } from '@KTXC/stores/themeStore' import { useThemeStore } from '@KTXC/stores/themeStore'
@@ -42,7 +41,6 @@ globalWindow.Pinia = PiniaLib as unknown
(async () => { (async () => {
const moduleStore = useModuleStore(); const moduleStore = useModuleStore();
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const previewStore = usePreviewStore();
const userStore = useUserStore(); const userStore = useUserStore();
const l10nStore = useL10nStore(); const l10nStore = useL10nStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
@@ -51,7 +49,6 @@ globalWindow.Pinia = PiniaLib as unknown
try { try {
const payload = await fetchWrapper.get('/init'); const payload = await fetchWrapper.get('/init');
moduleStore.init(payload?.modules ?? {}); moduleStore.init(payload?.modules ?? {});
previewStore.init(payload?.preview ?? null);
tenantStore.init(payload?.tenant ?? null); tenantStore.init(payload?.tenant ?? null);
userStore.init(payload?.user ?? {}); userStore.init(payload?.user ?? {});
layoutStore.hydrateFromSettings(); layoutStore.hydrateFromSettings();
-2
View File
@@ -9,8 +9,6 @@
// Stores // Stores
export { useModuleStore } from '../stores/moduleStore' export { useModuleStore } from '../stores/moduleStore'
export { useTenantStore } from '../stores/tenantStore' export { useTenantStore } from '../stores/tenantStore'
export { usePreviewStore } from '../stores/previewStore'
export type { PreviewAvailability } from '../stores/previewStore'
export { useUserStore } from '../stores/userStore' export { useUserStore } from '../stores/userStore'
export { useIntegrationStore } from '../stores/integrationStore' export { useIntegrationStore } from '../stores/integrationStore'
export { useLayoutStore } from '../stores/layoutStore' export { useLayoutStore } from '../stores/layoutStore'
-4
View File
@@ -156,10 +156,6 @@ export const useIntegrationStore = defineStore('integrationStore', {
return Array.from(point.items.values()) return Array.from(point.items.values())
.filter(entry => entry.visible !== false) .filter(entry => entry.visible !== false)
.map(entry => 'items' in entry
? { ...entry, items: entry.items.filter(item => item.visible !== false) }
: entry)
.filter(entry => !('items' in entry) || entry.items.length > 0)
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
}, },
+2 -2
View File
@@ -32,7 +32,7 @@ export const useLayoutStore = defineStore('layout', () => {
} }
const sidebarDrawer = ref<boolean>(booleanSetting('sidebar_drawer', true)); const sidebarDrawer = ref<boolean>(booleanSetting('sidebar_drawer', true));
const miniSidebar = ref<boolean>(booleanSetting('mini_sidebar', true)); const miniSidebar = ref<boolean>(booleanSetting('mini_sidebar', false));
const menuMode = ref<MenuMode>('apps'); const menuMode = ref<MenuMode>('apps');
// Theme mode - user choice, falling back to the tenant default // Theme mode - user choice, falling back to the tenant default
@@ -60,7 +60,7 @@ export const useLayoutStore = defineStore('layout', () => {
hydratingFromSettings = true; hydratingFromSettings = true;
try { try {
sidebarDrawer.value = booleanSetting('sidebar_drawer', true); sidebarDrawer.value = booleanSetting('sidebar_drawer', true);
miniSidebar.value = booleanSetting('mini_sidebar', true); miniSidebar.value = booleanSetting('mini_sidebar', false);
theme.value = initialTheme(); theme.value = initialTheme();
} finally { } finally {
hydratingFromSettings = false; hydratingFromSettings = false;
-36
View File
@@ -1,36 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface PreviewAvailability {
enabled: boolean
storage: boolean
generation: boolean
}
const unavailable = (): PreviewAvailability => ({
enabled: false,
storage: false,
generation: false,
})
export const usePreviewStore = defineStore('previewStore', () => {
const availability = ref<PreviewAvailability>(unavailable())
function init(data?: Partial<PreviewAvailability> | null): void {
availability.value = {
enabled: data?.enabled ?? false,
storage: data?.storage ?? false,
generation: data?.generation ?? false,
}
}
function reset(): void {
availability.value = unavailable()
}
return {
availability,
init,
reset,
}
})
+20 -31
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch, nextTick } from 'vue'; import { ref, onMounted, computed, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useUserStore } from '@KTXC/stores/userStore'; import { useUserStore } from '@KTXC/stores/userStore';
import { authenticationService } from '@KTXC/services/authenticationService'; import { authenticationService } from '@KTXC/services/authenticationService';
@@ -15,7 +15,6 @@ type LoginPhase = 'identity' | 'method' | 'mfa';
// Form state // Form state
const identity = ref(''); const identity = ref('');
const authResponse = ref(''); // password, code, etc. const authResponse = ref(''); // password, code, etc.
const authInput = ref<{ focus: () => void } | null>(null);
const showPassword = ref(false); const showPassword = ref(false);
const rememberMe = ref(false); const rememberMe = ref(false);
@@ -61,19 +60,21 @@ const pageTitle = computed(() => {
}); });
// Input label/type based on selected method // Input label/type based on selected method
const isPasswordMethod = computed(() => selectedMethod.value?.id === 'password');
const authInputLabel = computed(() => { const authInputLabel = computed(() => {
return isPasswordMethod.value ? 'Password' : 'Verification Code'; if (!selectedMethod.value) return 'Password';
return selectedMethod.value.method === 'credential' ? 'Password' : 'Verification Code';
}); });
const authInputType = computed(() => { const authInputType = computed(() => {
return isPasswordMethod.value ? 'password' : 'text'; if (!selectedMethod.value) return 'password';
return selectedMethod.value.method === 'credential' ? 'password' : 'text';
}); });
// Validation rules // Validation rules
const identityRules = [ const identityRules = [
(v: string) => !!v.trim() || 'Login ID is required', (v: string) => !!v.trim() || 'Email is required',
(v: string) => !/\s/.test(v.trim()) || 'Email must not contain spaces',
(v: string) => /.+@.+\..+/.test(v.trim()) || 'Email must be valid'
]; ];
const authResponseRules = [ const authResponseRules = [
@@ -116,9 +117,6 @@ onMounted(async () => {
// Watch for method selection changes (for challenge-based methods) // Watch for method selection changes (for challenge-based methods)
watch(selectedMethod, async (newMethod) => { watch(selectedMethod, async (newMethod) => {
await nextTick();
authInput.value?.focus();
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) { if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
// Initiate challenge for methods that need it (SMS, email, TOTP) // Initiate challenge for methods that need it (SMS, email, TOTP)
await initiateChallenge(newMethod.id); await initiateChallenge(newMethod.id);
@@ -315,16 +313,7 @@ function backToIdentity() {
} }
function getMethodIcon(method: AuthenticationMethod): string { function getMethodIcon(method: AuthenticationMethod): string {
if (method.icon?.startsWith('mdi-') || method.icon?.startsWith('$')) { if (method.icon) return method.icon;
return method.icon;
}
// Authentication providers may return a bare Material Design icon name
// (for example, "lock" or "mail") instead of Vuetify's "mdi-*" form.
if (method.icon && /^[a-z0-9-]+$/i.test(method.icon)) {
return `mdi-${method.icon}`;
}
switch (method.method) { switch (method.method) {
case 'credential': return 'mdi-key'; case 'credential': return 'mdi-key';
case 'challenge': return 'mdi-shield-check'; case 'challenge': return 'mdi-shield-check';
@@ -362,15 +351,16 @@ function getMethodIcon(method: AuthenticationMethod): string {
v-slot="{ errors, isSubmitting }" v-slot="{ errors, isSubmitting }"
> >
<div class="mb-6"> <div class="mb-6">
<v-label>Email Address</v-label>
<v-text-field <v-text-field
v-model="identity" v-model="identity"
:rules="identityRules" :rules="identityRules"
aria-label="Login ID" class="mt-2"
required required
hide-details="auto" hide-details="auto"
variant="outlined" variant="outlined"
color="primary" color="primary"
autocomplete="username" autocomplete="email"
autofocus autofocus
></v-text-field> ></v-text-field>
</div> </div>
@@ -401,7 +391,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
size="large" size="large"
@click="initiateSsoLogin(method.id)" @click="initiateSsoLogin(method.id)"
> >
<v-icon start>{{ getMethodIcon(method) }}</v-icon> <v-icon v-if="method.icon" start>{{ method.icon }}</v-icon>
{{ method.label }} {{ method.label }}
</v-btn> </v-btn>
</div> </div>
@@ -455,18 +445,18 @@ function getMethodIcon(method: AuthenticationMethod): string {
v-slot="{ errors, isSubmitting }" v-slot="{ errors, isSubmitting }"
> >
<div class="mb-6"> <div class="mb-6">
<v-label>{{ authInputLabel }}</v-label>
<v-text-field <v-text-field
ref="authInput"
v-model="authResponse" v-model="authResponse"
:rules="authResponseRules" :rules="authResponseRules"
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'" :type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
:aria-label="authInputLabel" class="mt-2"
required required
hide-details="auto" hide-details="auto"
variant="outlined" variant="outlined"
color="primary" color="primary"
:autocomplete="isPasswordMethod ? 'current-password' : 'off'" :autocomplete="selectedMethod?.method === 'credential' ? 'current-password' : 'one-time-code'"
:inputmode="isPasswordMethod ? undefined : 'numeric'" :inputmode="selectedMethod?.method !== 'credential' ? 'numeric' : undefined"
autofocus autofocus
> >
<template v-if="authInputType === 'password'" v-slot:append-inner> <template v-if="authInputType === 'password'" v-slot:append-inner>
@@ -480,7 +470,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
</v-text-field> </v-text-field>
</div> </div>
<div v-if="isPasswordMethod" class="d-flex align-center mt-4 mb-7 mb-sm-0"> <div v-if="selectedMethod?.method === 'credential'" class="d-flex align-center mt-4 mb-7 mb-sm-0">
<v-checkbox <v-checkbox
v-model="rememberMe" v-model="rememberMe"
label="Keep me logged in" label="Keep me logged in"
@@ -503,7 +493,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
size="large" size="large"
type="submit" type="submit"
> >
{{ isPasswordMethod ? 'Login' : 'Verify' }} {{ selectedMethod?.method === 'credential' ? 'Login' : 'Verify' }}
</v-btn> </v-btn>
<v-btn <v-btn
@@ -544,7 +534,6 @@ function getMethodIcon(method: AuthenticationMethod): string {
<div class="mb-6"> <div class="mb-6">
<v-label>Verification Code</v-label> <v-label>Verification Code</v-label>
<v-text-field <v-text-field
ref="authInput"
v-model="authResponse" v-model="authResponse"
:rules="authResponseRules" :rules="authResponseRules"
type="text" type="text"
@@ -553,7 +542,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
hide-details="auto" hide-details="auto"
variant="outlined" variant="outlined"
color="primary" color="primary"
autocomplete="off" autocomplete="one-time-code"
inputmode="numeric" inputmode="numeric"
autofocus autofocus
></v-text-field> ></v-text-field>
-2
View File
@@ -1,2 +0,0 @@
# Install in /etc/cron.d/ktrix-firewall after adjusting the user/path if needed.
*/15 * * * * www-data cd /var/www/ktrix/main && bin/console firewall:maintenance
-24
View File
@@ -33,30 +33,6 @@ require_command()
fi fi
} }
# Cron runs with a bare PATH, so nvm-installed npm (added to PATH only by
# .bashrc sourcing nvm.sh in an interactive shell) is invisible here even
# though it works fine when this script is run by hand. Resolve nvm's
# current npm via bash (nvm.sh is not POSIX sh compatible) and prepend it,
# so a later `nvm use`/`nvm install` doesn't require updating this script
# or the crontab.
ensure_npm_on_path()
{
command -v npm >/dev/null 2>&1 && return 0
nvm_dir=${NVM_DIR:-${HOME:-/root}/.nvm}
[ -s "$nvm_dir/nvm.sh" ] || return 0
command -v bash >/dev/null 2>&1 || return 0
npm_path=$(bash -c ". \"\$1/nvm.sh\" >/dev/null 2>&1 && command -v npm" _ "$nvm_dir" 2>/dev/null) || return 0
[ -n "$npm_path" ] || return 0
PATH=$(dirname -- "$npm_path"):$PATH
export PATH
log "Resolved npm via nvm: $npm_path"
}
ensure_npm_on_path
git_in() git_in()
{ {
repository=$1 repository=$1
+101 -158
View File
@@ -39,7 +39,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"jsdom": "^30.0.0", "jsdom": "^29.1.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"sass-loader": "^17.0.0", "sass-loader": "^17.0.0",
@@ -53,38 +53,56 @@
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "6.0.5", "version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@csstools/css-calc": "^3.2.1", "@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-color-parser": "^4.1.9", "@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0"
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^22.13.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.0", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3", "bidi-js": "^1.0.3",
"css-tree": "^3.2.1", "css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1"
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^22.13.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
@@ -218,9 +236,9 @@
} }
}, },
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.1.0", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -238,9 +256,9 @@
} }
}, },
"node_modules/@csstools/css-calc": { "node_modules/@csstools/css-calc": {
"version": "3.3.0", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -262,9 +280,9 @@
} }
}, },
"node_modules/@csstools/css-color-parser": { "node_modules/@csstools/css-color-parser": {
"version": "4.1.10", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -278,8 +296,8 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@csstools/color-helpers": "^6.1.0", "@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.3.0" "@csstools/css-calc": "^3.2.1"
}, },
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
@@ -313,9 +331,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -509,9 +527,9 @@
} }
}, },
"node_modules/@exodus/bytes": { "node_modules/@exodus/bytes": {
"version": "1.15.1", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -624,14 +642,14 @@
} }
}, },
"node_modules/@intlify/core-base": { "node_modules/@intlify/core-base": {
"version": "11.4.10", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.10.tgz", "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
"integrity": "sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==", "integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/devtools-types": "11.4.10", "@intlify/devtools-types": "11.4.8",
"@intlify/message-compiler": "11.4.10", "@intlify/message-compiler": "11.4.8",
"@intlify/shared": "11.4.10" "@intlify/shared": "11.4.8"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@@ -641,13 +659,13 @@
} }
}, },
"node_modules/@intlify/devtools-types": { "node_modules/@intlify/devtools-types": {
"version": "11.4.10", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.10.tgz", "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
"integrity": "sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==", "integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.4.10", "@intlify/core-base": "11.4.8",
"@intlify/shared": "11.4.10" "@intlify/shared": "11.4.8"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@@ -657,12 +675,12 @@
} }
}, },
"node_modules/@intlify/message-compiler": { "node_modules/@intlify/message-compiler": {
"version": "11.4.10", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.10.tgz", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
"integrity": "sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==", "integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/shared": "11.4.10", "@intlify/shared": "11.4.8",
"source-map-js": "^1.0.2" "source-map-js": "^1.0.2"
}, },
"engines": { "engines": {
@@ -673,9 +691,9 @@
} }
}, },
"node_modules/@intlify/shared": { "node_modules/@intlify/shared": {
"version": "11.4.10", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.10.tgz", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
"integrity": "sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==", "integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@@ -2174,63 +2192,6 @@
"vuetify": ">=3" "vuetify": ">=3"
} }
}, },
"node_modules/@vuetify/v0": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vuetify/v0/-/v0-1.2.2.tgz",
"integrity": "sha512-7AvM89l6l/pShSYKa+a5/zsht/xj/l4jFpWFOR6UqSelBrPfKxEQ8heO8iCNEGNK5yljwsdARx0JmDJjJRJdVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
{
"type": "opencollective",
"url": "https://opencollective.com/vuetify"
}
],
"license": "MIT",
"peerDependencies": {
"@adobe/leonardo-contrast-colors": ">=1.0.0",
"@ant-design/colors": ">=7.0.0",
"@flagsmith/flagsmith": ">=11.0.0",
"@floating-ui/dom": ">=1.8.0",
"@js-temporal/polyfill": ">=0.5.0",
"@material/material-color-utilities": ">=0.3.0",
"launchdarkly-js-client-sdk": "^3.0.0",
"posthog-js": "^1.0.0",
"vue": ">=3.5.0 || >=3.6.0-0",
"vue-i18n": ">=10.0.0"
},
"peerDependenciesMeta": {
"@adobe/leonardo-contrast-colors": {
"optional": true
},
"@ant-design/colors": {
"optional": true
},
"@flagsmith/flagsmith": {
"optional": true
},
"@floating-ui/dom": {
"optional": true
},
"@js-temporal/polyfill": {
"optional": true
},
"@material/material-color-utilities": {
"optional": true
},
"launchdarkly-js-client-sdk": {
"optional": true
},
"posthog-js": {
"optional": true
},
"vue-i18n": {
"optional": true
}
}
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -4048,39 +4009,39 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "30.0.0", "version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^8.2.5", "@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2", "@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.6", "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.1", "@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1", "css-tree": "^3.2.1",
"data-urls": "^7.0.0", "data-urls": "^7.0.0",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0", "html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2", "lru-cache": "^11.3.5",
"parse5": "^8.0.1", "parse5": "^8.0.1",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2", "tough-cookie": "^6.0.1",
"undici": "^8.7.0", "undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0", "w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1", "webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0", "whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0", "whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"canvas": "^3.2.3" "canvas": "^3.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"canvas": { "canvas": {
@@ -4088,21 +4049,6 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4479,9 +4425,9 @@
} }
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.2", "version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -5926,9 +5872,9 @@
} }
}, },
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "6.0.2", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -6038,13 +5984,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "8.9.0", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=20.18.1"
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
@@ -6591,14 +6537,14 @@
} }
}, },
"node_modules/vue-i18n": { "node_modules/vue-i18n": {
"version": "11.4.10", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.10.tgz", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
"integrity": "sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==", "integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.4.10", "@intlify/core-base": "11.4.8",
"@intlify/devtools-types": "11.4.10", "@intlify/devtools-types": "11.4.8",
"@intlify/shared": "11.4.10", "@intlify/shared": "11.4.8",
"@vue/devtools-api": "^6.5.0" "@vue/devtools-api": "^6.5.0"
}, },
"engines": { "engines": {
@@ -6696,13 +6642,10 @@
} }
}, },
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "4.2.1", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.2.1.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
"integrity": "sha512-K3ZIqu7YkorQFB6//dR5KEBpbfOo+y/LkcGtknsnneE3JN1pW3R7o/JB9QDkJuP1ELhENIdBZIkiSmXu0dQ9lQ==", "integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@vuetify/v0": "^1.2.1"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/johnleider" "url": "https://github.com/sponsors/johnleider"
@@ -6710,7 +6653,7 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=4.7", "typescript": ">=4.7",
"vite-plugin-vuetify": ">=2.1.0", "vite-plugin-vuetify": ">=2.1.0",
"vue": "^3.5.0 || ^3.6.0-0", "vue": "^3.5.0",
"webpack-plugin-vuetify": ">=3.1.0" "webpack-plugin-vuetify": ">=3.1.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
+1 -1
View File
@@ -53,7 +53,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"jsdom": "^30.0.0", "jsdom": "^29.1.1",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"sass-loader": "^17.0.0", "sass-loader": "^17.0.0",
+8 -63
View File
@@ -23,10 +23,7 @@ use finfo;
class Signature { class Signature {
/** Minimum bytes needed for reliable detection */ /** Minimum bytes needed for reliable detection */
public const SAMPLE_SIZE = 65536; public const HEADER_SIZE = 256;
/** Cached finfo instance */
private static ?finfo $finfo = null;
/** /**
* Fallback magic byte signatures for when finfo is unavailable * Fallback magic byte signatures for when finfo is unavailable
@@ -44,32 +41,16 @@ class Signature {
['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP ['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP
]; ];
/** /** Cached finfo instance */
* Zip-container marker strings used to distinguish OOXML/ODF/EPUB documents private static ?finfo $finfo = null;
* from a generic ZIP archive. Filenames inside a ZIP's local file headers
* (and ODF's mandatory uncompressed "mimetype" entry content) are stored
* as plain text, so a simple substring search reliably identifies these
* formats without needing to parse the archive structure.
*/
private const ZIP_CONTAINER_MARKERS = [
'word/document.xml' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'],
'xl/workbook.xml' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
'ppt/presentation.xml' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'pptx'],
'application/vnd.oasis.opendocument.text' => ['application/vnd.oasis.opendocument.text', 'odt'],
'application/vnd.oasis.opendocument.spreadsheet' => ['application/vnd.oasis.opendocument.spreadsheet', 'ods'],
'application/vnd.oasis.opendocument.presentation' => ['application/vnd.oasis.opendocument.presentation', 'odp'],
'application/epub+zip' => ['application/epub+zip', 'epub'],
];
/** /**
* Detect both MIME type and format from content bytes in a single operation * Detect both MIME type and format from content bytes in a single operation
* *
* @param string $headerBytes First bytes of the file content * @param string $headerBytes First bytes of the file content (256 recommended)
* @param string|null $content Full (or larger) content, when available, used to
* distinguish OOXML/ODF/EPUB documents from a generic ZIP archive
* @return array{mime: string, format: string} Array with 'mime' and 'format' keys * @return array{mime: string, format: string} Array with 'mime' and 'format' keys
*/ */
public static function detect(string $headerBytes, ?string $content = null): array { public static function detect(string $headerBytes): array {
if (strlen($headerBytes) === 0) { if (strlen($headerBytes) === 0) {
return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY]; return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY];
} }
@@ -94,16 +75,6 @@ class Signature {
$format = self::detectFromMagicBytes($headerBytes); $format = self::detectFromMagicBytes($headerBytes);
} }
// A bare "zip" result is a generic container; when more of the file is
// available, check for OOXML/ODF/EPUB markers rather than reporting the
// misleadingly generic zip mime/format for what is really a document.
if ($format === 'zip' && $content !== null) {
$container = self::detectZipContainer($content);
if ($container !== null) {
[$mime, $format] = $container;
}
}
// Ensure MIME type is set // Ensure MIME type is set
if ($mime === null || $mime === MimeTypes::MIME_BINARY) { if ($mime === null || $mime === MimeTypes::MIME_BINARY) {
$mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY; $mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY;
@@ -112,32 +83,6 @@ class Signature {
return ['mime' => $mime, 'format' => $format]; return ['mime' => $mime, 'format' => $format];
} }
/**
* Look for known OOXML/ODF/EPUB entry markers within ZIP content
*
* Only a fixed-size window from the head and tail of the content is scanned,
* regardless of total length, so detection cost does not grow with the size
* of large archive uploads. Every entry name is mirrored in full in the ZIP
* central directory near the end of the archive, so sampling the head and
* tail is enough without scanning the whole file.
*
* @param string $data ZIP content to scan (filenames/mimetype entry are stored uncompressed)
* @return array{0: string, 1: string}|null [mime, format] pair, or null if no marker matched
*/
private static function detectZipContainer(string $data): ?array {
$length = strlen($data);
$sample = $length <= self::SAMPLE_SIZE * 2
? $data
: substr($data, 0, self::SAMPLE_SIZE) . substr($data, -self::SAMPLE_SIZE);
foreach (self::ZIP_CONTAINER_MARKERS as $marker => $result) {
if (str_contains($sample, $marker)) {
return $result;
}
}
return null;
}
/** /**
* Detect both MIME type and format from a stream in a single operation * Detect both MIME type and format from a stream in a single operation
* *
@@ -146,7 +91,7 @@ class Signature {
*/ */
public static function detectFromStream($stream): array { public static function detectFromStream($stream): array {
$position = ftell($stream); $position = ftell($stream);
$headerBytes = fread($stream, self::SAMPLE_SIZE); $headerBytes = fread($stream, self::HEADER_SIZE);
fseek($stream, $position); fseek($stream, $position);
if ($headerBytes === false || $headerBytes === '') { if ($headerBytes === false || $headerBytes === '') {
@@ -195,7 +140,7 @@ class Signature {
*/ */
public static function detectFormatFromStream($stream): string { public static function detectFormatFromStream($stream): string {
$position = ftell($stream); $position = ftell($stream);
$headerBytes = fread($stream, self::SAMPLE_SIZE); $headerBytes = fread($stream, self::HEADER_SIZE);
fseek($stream, $position); fseek($stream, $position);
if ($headerBytes === false || $headerBytes === '') { if ($headerBytes === false || $headerBytes === '') {
@@ -213,7 +158,7 @@ class Signature {
*/ */
public static function detectMimeTypeFromStream($stream): ?string { public static function detectMimeTypeFromStream($stream): ?string {
$position = ftell($stream); $position = ftell($stream);
$headerBytes = fread($stream, self::SAMPLE_SIZE); $headerBytes = fread($stream, self::HEADER_SIZE);
fseek($stream, $position); fseek($stream, $position);
if ($headerBytes === false || $headerBytes === '') { if ($headerBytes === false || $headerBytes === '') {
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Documents\Service;
use Generator;
use KTXF\Documents\Collection\CollectionBaseInterface;
use KTXF\Documents\Entity\EntityBaseInterface;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Range\IRange;
use KTXF\Resource\Range\RangeType;
use KTXF\Resource\Sort\ISort;
/**
* Service Node List Interface
*
* @since 2026.09.01
*/
interface ServiceNodeListInterface {
// Node capabilities
public const CAPABILITY_NODE_LIST = 'NodeList';
public const CAPABILITY_NODE_LIST_FILTER = 'NodeListFilter';
public const CAPABILITY_NODE_LIST_SORT = 'NodeListSort';
public const CAPABILITY_NODE_LIST_RANGE = 'NodeListRange';
// Filter capabilities
public const CAPABILITY_NODE_FILTER_LABEL = 'label';
// Sort capabilities
public const CAPABILITY_NODE_SORT_LABEL = 'label';
public const CAPABILITY_NODE_SORT_SIZE = 'size';
public const CAPABILITY_NODE_SORT_CREATED_ON = 'createdOn';
public const CAPABILITY_NODE_SORT_MODIFIED_ON = 'modifiedOn';
// Range capabilities
public const CAPABILITY_NODE_RANGE_TALLY = 'tally';
public const CAPABILITY_NODE_RANGE_TALLY_ABSOLUTE = 'absolute';
public const CAPABILITY_NODE_RANGE_TALLY_RELATIVE = 'relative';
/**
* Lists collections and entities within a location as one unified, ordered set
*
* Folders are always ordered before files, regardless of the sort applied within
* each group.
*
* @since 2026.09.01
*
* @param string|int|null $location Parent collection identifier to list within (null for root)
* @param IFilter|null $filter Optional filter criteria
* @param ISort|null $sort Optional sort order
* @param IRange|null $range Optional pagination
*
* @return Generator<string|int,CollectionBaseInterface|EntityBaseInterface> Nodes yielded by identifier, folders before files
*/
public function nodeList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator;
/**
* Creates a filter builder for the unified node list
*
* @since 2026.09.01
*/
public function nodeListFilter(): IFilter;
/**
* Creates a sort builder for the unified node list
*
* @since 2026.09.01
*/
public function nodeListSort(): ISort;
/**
* Creates a range builder for the unified node list
*
* @since 2026.09.01
*
* @param RangeType $type Range type
*/
public function nodeListRange(RangeType $type): IRange;
}
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace KTXC\Event; namespace KTXF\Event;
interface DeferredEventProcessorInterface interface DeferredEventProcessorInterface
{ {
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace KTXC\Event; namespace KTXF\Event;
final readonly class DeferredProcessingResult final readonly class DeferredProcessingResult
{ {
@@ -11,9 +11,6 @@ final readonly class DeferredProcessingResult
public int $remaining, public int $remaining,
public bool $deadlineExceeded, public bool $deadlineExceeded,
public bool $limitExceeded = false, public bool $limitExceeded = false,
public int $listenerInvocations = 0,
public bool $eventLimitExceeded = false,
public bool $listenerInvocationLimitExceeded = false,
) { ) {
} }
} }
+64 -47
View File
@@ -10,76 +10,76 @@ namespace KTXF\Event;
class Event class Event
{ {
private bool $propagationStopped = false; private bool $propagationStopped = false;
private readonly array $context; private array $data = [];
private readonly float $timestamp; private float $timestamp;
private readonly string $identifier; private ?string $tenantId = null;
private ?string $identityId = null;
public function __construct( public function __construct(
private readonly string $label, private readonly string $name,
array $context = [], array $data = []
private readonly ?string $tenantIdentifier = null,
private readonly ?string $actorIdentity = null,
) { ) {
self::validateContext($context); $this->data = $data;
$this->context = $context;
$this->timestamp = microtime(true); $this->timestamp = microtime(true);
$this->identifier = bin2hex(random_bytes(16));
} }
/** /**
* Get the event label * Get the event name
*/ */
public function label(): string public function getName(): string
{ {
return $this->label; return $this->name;
} }
/** /**
* Get a context value by key * Get a data value by key
*/ */
public function get(string $key, mixed $default = null): mixed public function get(string $key, mixed $default = null): mixed
{ {
return $this->context[$key] ?? $default; return $this->data[$key] ?? $default;
} }
/** /**
* Check if a context key exists * Set a data value
*/
public function set(string $key, mixed $value): self
{
$this->data[$key] = $value;
return $this;
}
/**
* Check if a data key exists
*/ */
public function has(string $key): bool public function has(string $key): bool
{ {
return array_key_exists($key, $this->context); return array_key_exists($key, $this->data);
} }
/** /**
* Get the event context * Get all data
*/ */
public function context(): array public function getData(): array
{ {
return $this->context; return $this->data;
} }
/** /**
* Get all event context * Alias for getData() for backward compatibility
*/ */
public function all(): array public function all(): array
{ {
return $this->context; return $this->data;
} }
/** /**
* Get the event timestamp * Get the event timestamp
*/ */
public function timestamp(): float public function getTimestamp(): float
{ {
return $this->timestamp; return $this->timestamp;
} }
public function identifier(): string
{
return $this->identifier;
}
/** /**
* Stop event propagation to subsequent listeners * Stop event propagation to subsequent listeners
*/ */
@@ -99,31 +99,48 @@ class Event
/** /**
* Get tenant ID for multi-tenant context * Get tenant ID for multi-tenant context
*/ */
public function tenantIdentifier(): ?string public function getTenantId(): ?string
{ {
return $this->tenantIdentifier; return $this->tenantId;
} }
/** /**
* Get the identity of the actor who triggered the event * Set tenant ID for multi-tenant context
*/ */
public function actorIdentity(): ?string public function setTenantId(?string $tenantId): self
{ {
return $this->actorIdentity; $this->tenantId = $tenantId;
return $this;
} }
private static function validateContext(array $context): void /**
* Get identity ID (user who triggered the event)
*/
public function getIdentityId(): ?string
{ {
foreach ($context as $value) { return $this->identityId;
if (is_array($value)) {
self::validateContext($value);
continue;
}
if ($value !== null && !is_scalar($value)) {
throw new \InvalidArgumentException(
'Event context must contain only scalar, null, or array values.',
);
}
}
} }
}
/**
* Set identity ID
*/
public function setIdentityId(?string $identityId): self
{
$this->identityId = $identityId;
return $this;
}
/**
* Convert event to array for serialization/logging
*/
public function toArray(): array
{
return [
'name' => $this->name,
'data' => $this->data,
'timestamp' => $this->timestamp,
'tenantId' => $this->tenantId,
'identityId' => $this->identityId,
];
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
{
/** @var array<string, list<Event>> */
private array $deferred = [];
private ?string $activeExecution = null;
private int $dispatchDepth = 0;
public function __construct(
private readonly EventListenerRegistry $registry,
private readonly ContainerInterface $container,
private readonly LoggerInterface $logger,
) {
}
public function dispatch(Event $event): void
{
if (++$this->dispatchDepth > 32) {
--$this->dispatchDepth;
throw new \RuntimeException('Event dispatch recursion limit exceeded.');
}
try {
$this->invoke($event, DeliveryMode::Immediate);
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
if ($this->activeExecution === null) {
throw new \LogicException('Deferred events require an active execution scope.');
}
$this->deferred[$this->activeExecution][] = $event;
}
} finally {
--$this->dispatchDepth;
}
}
public function beginExecution(string $executionId): void
{
if ($this->activeExecution !== null) {
throw new \LogicException('An event execution scope is already active.');
}
$this->activeExecution = $executionId;
$this->deferred[$executionId] = [];
}
public function processDeferred(string $executionId): DeferredProcessingResult
{
if ($this->activeExecution !== $executionId) {
throw new \LogicException('Cannot process deferred events for an inactive execution.');
}
$processed = 0;
$deadline = microtime(true) + 1.0;
$deadlineExceeded = false;
$limitExceeded = false;
while (($event = array_shift($this->deferred[$executionId])) !== null) {
if ($processed >= 1000) {
$limitExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
if (microtime(true) >= $deadline) {
$deadlineExceeded = true;
array_unshift($this->deferred[$executionId], $event);
break;
}
$processed += $this->invoke($event, DeliveryMode::Deferred);
}
$remaining = count($this->deferred[$executionId]);
unset($this->deferred[$executionId]);
$this->activeExecution = null;
return new DeferredProcessingResult(
$processed,
$remaining,
$deadlineExceeded,
$limitExceeded,
);
}
public function discardDeferred(string $executionId): void
{
unset($this->deferred[$executionId]);
if ($this->activeExecution === $executionId) {
$this->activeExecution = null;
}
}
private function invoke(Event $event, DeliveryMode $delivery): int
{
$processed = 0;
foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) {
if ($event->isPropagationStopped()) {
break;
}
try {
$service = $this->container->get($listener->service);
$service->{$listener->method}($event);
$processed++;
} catch (\Throwable $error) {
$this->logger->error('Event listener failed.', [
'event' => $event->getName(),
'module' => $listener->module,
'listener' => $listener->service . '::' . $listener->method,
'exception' => $error,
]);
if ($listener->failurePolicy === FailurePolicy::Propagate) {
throw $error;
}
}
}
return $processed;
}
}
@@ -2,10 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace KTXC\Event; namespace KTXF\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\FailurePolicy;
final readonly class EventListenerDefinition final readonly class EventListenerDefinition
{ {

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