Compare commits
20 Commits
093a109aa8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71ddd3442f | |||
| 0dd735045d | |||
| 5646591c74 | |||
| a5acea72c3 | |||
| 62b416f13e | |||
| c8e6efe203 | |||
| f147ffc5c7 | |||
| 985e4a6450 | |||
| b14bd302a3 | |||
| 5b3e8f6588 | |||
| 84eb0e2c21 | |||
| 3f9c2500d9 | |||
| 2494cef02f | |||
| a5be782c51 | |||
| f4df769b3c | |||
| 688afbe5a1 | |||
| 7c2a8dfbd3 | |||
| e223ae7543 | |||
| a8e29d0305 | |||
| a363a1a4bc |
@@ -15,6 +15,9 @@ 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,10 +4,12 @@ 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;
|
||||||
@@ -35,6 +37,8 @@ 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();
|
||||||
@@ -97,6 +101,10 @@ 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,
|
||||||
@@ -134,7 +142,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->userStore->createUser($identifier, [
|
$this->userService->createUser([
|
||||||
'identity' => $adminIdentity,
|
'identity' => $adminIdentity,
|
||||||
'label' => 'Administrator',
|
'label' => 'Administrator',
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\User;
|
namespace KTXC\Console\User;
|
||||||
|
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Context\TenantContext;
|
||||||
|
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;
|
||||||
@@ -28,8 +29,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class UserCreateCommand extends Command
|
class UserCreateCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantService $tenantService,
|
private readonly TenantContext $tenantContext,
|
||||||
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
|
||||||
) {
|
) {
|
||||||
@@ -59,11 +61,11 @@ class UserCreateCommand extends Command
|
|||||||
$io->title('Create User');
|
$io->title('Create User');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure the tenant exists
|
if (!$this->tenantContext->resolveIdentifier($tenant)) {
|
||||||
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)) {
|
||||||
@@ -95,7 +97,7 @@ class UserCreateCommand extends Command
|
|||||||
$userData['uid'] = $input->getOption('uid');
|
$userData['uid'] = $input->getOption('uid');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->userStore->createUser($tenant, $userData);
|
$user = $this->userService->createUser($userData);
|
||||||
|
|
||||||
$this->logger->info('User created via console', [
|
$this->logger->info('User created via console', [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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;
|
||||||
@@ -26,7 +28,9 @@ 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();
|
||||||
@@ -52,6 +56,12 @@ 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) {
|
||||||
@@ -64,7 +74,7 @@ class UserDeleteCommand extends Command
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
|
if (!$this->userService->deleteUser($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);
|
||||||
$authResponse = $this->authManager->handle($request);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
return $this->buildJsonResponse($authResponse);
|
return $this->buildJsonResponse($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
$host = $request->getHost();
|
$host = $request->getHost();
|
||||||
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
|
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
if ($response->isSuccess()) {
|
if ($response->isSuccess()) {
|
||||||
$returnUrl = $response->returnUrl ?? '/';
|
$returnUrl = $response->returnUrl ?? '/';
|
||||||
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::status($sessionId);
|
$request = AuthenticationRequest::status($sessionId);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
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', '');
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::cancel($sessionId);
|
$request = AuthenticationRequest::cancel($sessionId);
|
||||||
$this->authManager->handle($authRequest);
|
$this->authManager->handle($request);
|
||||||
|
|
||||||
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::refresh($refreshToken);
|
$request = AuthenticationRequest::refresh($refreshToken);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
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');
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::logout($token, false);
|
$request = AuthenticationRequest::logout($token, false);
|
||||||
$this->authManager->handle($authRequest);
|
$this->authManager->handle($request);
|
||||||
|
|
||||||
$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');
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::logout($token, true);
|
$request = AuthenticationRequest::logout($token, true);
|
||||||
$this->authManager->handle($authRequest);
|
$this->authManager->handle($request);
|
||||||
|
|
||||||
$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);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ use Psr\Log\LoggerInterface;
|
|||||||
|
|
||||||
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
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>> */
|
/** @var array<string, list<Event>> */
|
||||||
private array $deferred = [];
|
private array $deferred = [];
|
||||||
private ?string $activeExecution = null;
|
private ?string $activeExecution = null;
|
||||||
@@ -22,7 +26,19 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
private readonly EventListenerRegistry $registry,
|
private readonly EventListenerRegistry $registry,
|
||||||
private readonly ContainerInterface $container,
|
private readonly ContainerInterface $container,
|
||||||
private readonly LoggerInterface $logger,
|
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
|
public function dispatch(Event $event): void
|
||||||
@@ -34,7 +50,7 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->invoke($event, DeliveryMode::Immediate);
|
$this->invoke($event, DeliveryMode::Immediate);
|
||||||
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
|
if ($this->registry->listeners($event->label(), DeliveryMode::Deferred) !== []) {
|
||||||
if ($this->activeExecution === null) {
|
if ($this->activeExecution === null) {
|
||||||
throw new \LogicException('Deferred events require an active execution scope.');
|
throw new \LogicException('Deferred events require an active execution scope.');
|
||||||
}
|
}
|
||||||
@@ -60,13 +76,16 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$processed = 0;
|
try {
|
||||||
$deadline = microtime(true) + 1.0;
|
$processedEvents = 0;
|
||||||
|
$listenerInvocations = 0;
|
||||||
|
$deadline = microtime(true) + $this->deferredProcessingTimeoutSeconds;
|
||||||
$deadlineExceeded = false;
|
$deadlineExceeded = false;
|
||||||
$limitExceeded = false;
|
$eventLimitExceeded = false;
|
||||||
|
$listenerInvocationLimitExceeded = false;
|
||||||
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
||||||
if ($processed >= 1000) {
|
if ($processedEvents >= $this->maxDeferredEvents) {
|
||||||
$limitExceeded = true;
|
$eventLimitExceeded = true;
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -75,19 +94,33 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
array_unshift($this->deferred[$executionId], $event);
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$processed += $this->invoke($event, DeliveryMode::Deferred);
|
|
||||||
|
$eventListenerCount = count($this->registry->listeners(
|
||||||
|
$event->label(),
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
));
|
||||||
|
if ($listenerInvocations + $eventListenerCount > $this->maxDeferredListenerInvocations) {
|
||||||
|
$listenerInvocationLimitExceeded = true;
|
||||||
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remaining = count($this->deferred[$executionId]);
|
$listenerInvocations += $this->invoke($event, DeliveryMode::Deferred);
|
||||||
unset($this->deferred[$executionId]);
|
$processedEvents++;
|
||||||
$this->activeExecution = null;
|
}
|
||||||
|
|
||||||
return new DeferredProcessingResult(
|
return new DeferredProcessingResult(
|
||||||
$processed,
|
processed: $processedEvents,
|
||||||
$remaining,
|
remaining: count($this->deferred[$executionId]),
|
||||||
$deadlineExceeded,
|
deadlineExceeded: $deadlineExceeded,
|
||||||
$limitExceeded,
|
limitExceeded: $eventLimitExceeded || $listenerInvocationLimitExceeded,
|
||||||
|
listenerInvocations: $listenerInvocations,
|
||||||
|
eventLimitExceeded: $eventLimitExceeded,
|
||||||
|
listenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
$this->discardDeferred($executionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function discardDeferred(string $executionId): void
|
public function discardDeferred(string $executionId): void
|
||||||
@@ -101,18 +134,18 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
private function invoke(Event $event, DeliveryMode $delivery): int
|
private function invoke(Event $event, DeliveryMode $delivery): int
|
||||||
{
|
{
|
||||||
$processed = 0;
|
$processed = 0;
|
||||||
foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) {
|
foreach ($this->registry->listeners($event->label(), $delivery) as $listener) {
|
||||||
if ($event->isPropagationStopped()) {
|
if ($event->isPropagationStopped()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
try {
|
try {
|
||||||
$service = $this->container->get($listener->service);
|
$service = $this->container->get($listener->service);
|
||||||
$service->{$listener->method}($event);
|
$service->{$listener->method}($event);
|
||||||
$processed++;
|
|
||||||
} catch (\Throwable $error) {
|
} catch (\Throwable $error) {
|
||||||
$this->logger->error('Event listener failed.', [
|
$this->logger->error('Event listener failed.', [
|
||||||
'event' => $event->getName(),
|
'event' => $event->label(),
|
||||||
'module' => $listener->module,
|
'module' => $listener->module,
|
||||||
'listener' => $listener->service . '::' . $listener->method,
|
'listener' => $listener->service . '::' . $listener->method,
|
||||||
'exception' => $error,
|
'exception' => $error,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the HTTP request for the duration of the current runtime execution.
|
||||||
|
*/
|
||||||
|
final class RequestContext
|
||||||
|
{
|
||||||
|
private ?Request $request = null;
|
||||||
|
|
||||||
|
public function initialize(Request $request): void
|
||||||
|
{
|
||||||
|
if ($this->request !== null) {
|
||||||
|
throw new \LogicException('The request context has already been initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->request = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): ?Request
|
||||||
|
{
|
||||||
|
return $this->request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->request = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,6 +238,9 @@ 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 {
|
||||||
@@ -249,6 +252,9 @@ 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;
|
||||||
@@ -287,6 +293,9 @@ class Kernel implements KernelInterface
|
|||||||
failures: $failures,
|
failures: $failures,
|
||||||
deadlineExceeded: $deadlineExceeded,
|
deadlineExceeded: $deadlineExceeded,
|
||||||
limitExceeded: $limitExceeded,
|
limitExceeded: $limitExceeded,
|
||||||
|
deferredListenerInvocations: $listenerInvocations,
|
||||||
|
deferredEventLimitExceeded: $eventLimitExceeded,
|
||||||
|
deferredListenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-12
@@ -11,8 +11,18 @@ use KTXC\Service\SystemFirewallStatusService;
|
|||||||
use KTXC\Service\TenantFirewallLogService;
|
use KTXC\Service\TenantFirewallLogService;
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
use KTXC\Service\TenantFirewallStatusService;
|
use KTXC\Service\TenantFirewallStatusService;
|
||||||
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
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\EventListenerRegistrarInterface;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
@@ -38,21 +48,29 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
AuthenticationFailedEvent::class,
|
AuthenticationFailedEvent::class,
|
||||||
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 ([
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
AccessDeniedEvent::class,
|
||||||
SecurityEvent::ACCESS_DENIED,
|
BruteForceDetectedEvent::class,
|
||||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
RateLimitExceededEvent::class,
|
||||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
SuspiciousActivityEvent::class,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
FirewallRuleCreatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
FirewallRuleExtendedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleEnabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleRemovedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallSettingsUpdatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
|
||||||
] as $event) {
|
] as $event) {
|
||||||
$this->events->listen(
|
$this->events->listen(
|
||||||
'core',
|
'core',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
|
|
||||||
@@ -25,10 +26,15 @@ 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 {
|
||||||
|
$requestContext = $this->kernel->container()->get(RequestContext::class);
|
||||||
|
$requestContext->initialize($request);
|
||||||
|
|
||||||
$response = $this->pipeline()->handle($request);
|
$response = $this->pipeline()->handle($request);
|
||||||
if ($send) {
|
if ($send) {
|
||||||
$response->send();
|
$response->send();
|
||||||
@@ -45,6 +51,9 @@ final class HttpRuntime
|
|||||||
return $response;
|
return $response;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
$requestContext?->clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pipeline(): MiddlewarePipeline
|
private function pipeline(): MiddlewarePipeline
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ 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;
|
||||||
@@ -31,6 +34,7 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -187,6 +191,10 @@ 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.',
|
||||||
@@ -389,6 +397,10 @@ 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,
|
||||||
@@ -566,6 +578,17 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -594,7 +617,16 @@ class AuthenticationManager
|
|||||||
*/
|
*/
|
||||||
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
||||||
{
|
{
|
||||||
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
|
$userId = $session->userIdentifier;
|
||||||
|
if ($userId === null) {
|
||||||
|
return AuthenticationResponse::failed(
|
||||||
|
AuthenticationResponse::ERROR_INVALID_SESSION,
|
||||||
|
'Authenticated user is missing',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userData = $this->userService->fetchByIdentifier($userId);
|
||||||
|
|
||||||
if ($userData === null) {
|
if ($userData === null) {
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
@@ -611,6 +643,11 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly string $ruleId,
|
||||||
|
private readonly string $ruleScope,
|
||||||
|
private readonly ?string $deviceFingerprint = null,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
?string $identityId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Access denial requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($ruleId === '') {
|
||||||
|
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
|
||||||
|
}
|
||||||
|
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
|
||||||
|
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
|
||||||
|
$tenantId,
|
||||||
|
$identityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleId(): string
|
||||||
|
{
|
||||||
|
return $this->ruleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleScope(): string
|
||||||
|
{
|
||||||
|
return $this->ruleScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::WARNING;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,15 +9,10 @@ use KTXF\Event\Event;
|
|||||||
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly ?string $deviceFingerprint = null,
|
|
||||||
private readonly ?string $userId = null,
|
private readonly ?string $userId = null,
|
||||||
private readonly ?string $reason = null,
|
private readonly ?string $reason = null,
|
||||||
?string $tenantId = null,
|
?string $tenantId = null,
|
||||||
?string $identityId = null,
|
?string $identityId = null,
|
||||||
private readonly ?string $userAgent = null,
|
|
||||||
private readonly ?string $requestPath = null,
|
|
||||||
private readonly ?string $requestMethod = null,
|
|
||||||
) {
|
) {
|
||||||
parent::__construct(
|
parent::__construct(
|
||||||
self::class,
|
self::class,
|
||||||
@@ -27,31 +22,6 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
public function getUserId(): ?string
|
||||||
{
|
{
|
||||||
return $this->userId;
|
return $this->userId;
|
||||||
@@ -62,8 +32,8 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
|
|||||||
return $this->reason;
|
return $this->reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSeverity(): int
|
public function getSeverity(): SecurityEventSeverity
|
||||||
{
|
{
|
||||||
return SecurityEvent::SEVERITY_WARNING;
|
return SecurityEventSeverity::WARNING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $userId,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($userId === '') {
|
||||||
|
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['userId' => $userId],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): string
|
||||||
|
{
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
private readonly string $reason;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly int $failureCount,
|
||||||
|
private readonly int $windowSeconds,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($failureCount < 1) {
|
||||||
|
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
|
||||||
|
}
|
||||||
|
if ($windowSeconds < 1) {
|
||||||
|
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reason = sprintf(
|
||||||
|
'%d failed attempts in %d seconds',
|
||||||
|
$failureCount,
|
||||||
|
$windowSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFailureCount(): int
|
||||||
|
{
|
||||||
|
return $this->failureCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWindowSeconds(): int
|
||||||
|
{
|
||||||
|
return $this->windowSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::CRITICAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $deviceFingerprint,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($deviceFingerprint === '') {
|
||||||
|
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['device' => $deviceFingerprint, 'reason' => $reason],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::CRITICAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
|
||||||
|
|
||||||
|
final public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
static::class,
|
||||||
|
['ip' => $ipAddress, 'reason' => $reason],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return static::SEVERITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
|
||||||
|
{
|
||||||
|
final protected function __construct(
|
||||||
|
private readonly string $ruleId,
|
||||||
|
private readonly string $ruleScope,
|
||||||
|
private readonly string $ruleType,
|
||||||
|
private readonly string $ruleAction,
|
||||||
|
private readonly string $ruleValue,
|
||||||
|
private readonly ?string $reason,
|
||||||
|
private readonly string $origin,
|
||||||
|
private readonly ?string $expiresAt,
|
||||||
|
private readonly array $details,
|
||||||
|
?string $tenantId,
|
||||||
|
?string $identityId,
|
||||||
|
) {
|
||||||
|
if ($ruleId === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'scope' => $ruleScope,
|
||||||
|
'type' => $ruleType,
|
||||||
|
'action' => $ruleAction,
|
||||||
|
'value' => $ruleValue,
|
||||||
|
'origin' => $origin,
|
||||||
|
] as $field => $value) {
|
||||||
|
if ($value === '') {
|
||||||
|
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'scope' => $ruleScope,
|
||||||
|
'type' => $ruleType,
|
||||||
|
'action' => $ruleAction,
|
||||||
|
'value' => $ruleValue,
|
||||||
|
'origin' => $origin,
|
||||||
|
] as $field => $value) {
|
||||||
|
if ($value === '') {
|
||||||
|
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
static::class,
|
||||||
|
[
|
||||||
|
'ruleId' => $ruleId,
|
||||||
|
'ruleScope' => $ruleScope,
|
||||||
|
'ruleType' => $ruleType,
|
||||||
|
'ruleAction' => $ruleAction,
|
||||||
|
'ruleValue' => $ruleValue,
|
||||||
|
'reason' => $reason,
|
||||||
|
'origin' => $origin,
|
||||||
|
'expiresAt' => $expiresAt,
|
||||||
|
...$details,
|
||||||
|
],
|
||||||
|
$tenantId,
|
||||||
|
$identityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromRule(
|
||||||
|
FirewallRuleObject $rule,
|
||||||
|
?string $actorId = null,
|
||||||
|
array $change = [],
|
||||||
|
): static {
|
||||||
|
$metadata = $rule->getMetadata() ?? [];
|
||||||
|
$details = [...$metadata, ...$change];
|
||||||
|
foreach ([
|
||||||
|
'ruleId',
|
||||||
|
'ruleScope',
|
||||||
|
'ruleType',
|
||||||
|
'ruleAction',
|
||||||
|
'ruleValue',
|
||||||
|
'reason',
|
||||||
|
'origin',
|
||||||
|
'expiresAt',
|
||||||
|
] as $reservedKey) {
|
||||||
|
unset($details[$reservedKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new static(
|
||||||
|
ruleId: (string) $rule->getId(),
|
||||||
|
ruleScope: (string) $rule->getScope(),
|
||||||
|
ruleType: (string) $rule->getType(),
|
||||||
|
ruleAction: (string) $rule->getAction(),
|
||||||
|
ruleValue: (string) $rule->getValue(),
|
||||||
|
reason: $rule->getReason(),
|
||||||
|
origin: (string) ($metadata['origin'] ?? 'manual'),
|
||||||
|
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
||||||
|
details: $details,
|
||||||
|
tenantId: $rule->getTenantId(),
|
||||||
|
identityId: $actorId ?? $rule->getCreatedBy(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleId(): string
|
||||||
|
{
|
||||||
|
return $this->ruleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleScope(): string
|
||||||
|
{
|
||||||
|
return $this->ruleScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleType(): string
|
||||||
|
{
|
||||||
|
return $this->ruleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleAction(): string
|
||||||
|
{
|
||||||
|
return $this->ruleAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleValue(): string
|
||||||
|
{
|
||||||
|
return $this->ruleValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrigin(): string
|
||||||
|
{
|
||||||
|
return $this->origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExpiresAt(): ?string
|
||||||
|
{
|
||||||
|
return $this->expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetails(): array
|
||||||
|
{
|
||||||
|
return $this->details;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $changeReason,
|
||||||
|
private readonly array $previous,
|
||||||
|
private readonly array $current,
|
||||||
|
string $tenantId,
|
||||||
|
?string $actorId = null,
|
||||||
|
private readonly string $changeOrigin = 'manual',
|
||||||
|
) {
|
||||||
|
if ($changeReason === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
|
||||||
|
}
|
||||||
|
if ($tenantId === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
|
||||||
|
}
|
||||||
|
if ($changeOrigin === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
[
|
||||||
|
'changeReason' => $changeReason,
|
||||||
|
'changeOrigin' => $changeOrigin,
|
||||||
|
'previous' => $previous,
|
||||||
|
'current' => $current,
|
||||||
|
],
|
||||||
|
$tenantId,
|
||||||
|
$actorId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChangeReason(): string
|
||||||
|
{
|
||||||
|
return $this->changeReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrevious(): array
|
||||||
|
{
|
||||||
|
return $this->previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrent(): array
|
||||||
|
{
|
||||||
|
return $this->current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChangeOrigin(): string
|
||||||
|
{
|
||||||
|
return $this->changeOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): string
|
||||||
|
{
|
||||||
|
return $this->changeReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class IpAllowedEvent extends FirewallIpEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class IpBlockedEvent extends FirewallIpEvent
|
||||||
|
{
|
||||||
|
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
private readonly string $reason;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly int $requestCount,
|
||||||
|
private readonly int $windowSeconds,
|
||||||
|
private readonly ?string $endpoint = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($requestCount < 1) {
|
||||||
|
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
|
||||||
|
}
|
||||||
|
if ($windowSeconds < 1) {
|
||||||
|
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
|
||||||
|
}
|
||||||
|
if ($endpoint === '') {
|
||||||
|
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reason = sprintf(
|
||||||
|
'%d requests in %d seconds',
|
||||||
|
$requestCount,
|
||||||
|
$windowSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
[
|
||||||
|
'requestCount' => $requestCount,
|
||||||
|
'windowSeconds' => $windowSeconds,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestCount(): int
|
||||||
|
{
|
||||||
|
return $this->requestCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWindowSeconds(): int
|
||||||
|
{
|
||||||
|
return $this->windowSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ?string
|
||||||
|
{
|
||||||
|
return $this->endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security-specific event for authentication and access control events
|
|
||||||
*/
|
|
||||||
final class SecurityEvent extends Event implements SecurityEventInterface
|
|
||||||
{
|
|
||||||
// Event names
|
|
||||||
public const AUTH_SUCCESS = 'security.auth.success';
|
|
||||||
public const AUTH_LOGOUT = 'security.auth.logout';
|
|
||||||
public const TOKEN_REFRESH = 'security.token.refresh';
|
|
||||||
public const TOKEN_REVOKED = 'security.token.revoked';
|
|
||||||
|
|
||||||
public const ACCESS_DENIED = 'security.access.denied';
|
|
||||||
public const ACCESS_GRANTED = 'security.access.granted';
|
|
||||||
|
|
||||||
public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected';
|
|
||||||
public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded';
|
|
||||||
public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity';
|
|
||||||
|
|
||||||
public const IP_BLOCKED = 'security.ip.blocked';
|
|
||||||
public const IP_ALLOWED = 'security.ip.allowed';
|
|
||||||
public const DEVICE_BLOCKED = 'security.device.blocked';
|
|
||||||
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
|
|
||||||
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
|
|
||||||
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
|
|
||||||
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
|
|
||||||
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
|
||||||
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
|
|
||||||
|
|
||||||
// Severity levels
|
|
||||||
public const SEVERITY_DEBUG = 0;
|
|
||||||
public const SEVERITY_INFO = 1;
|
|
||||||
public const SEVERITY_WARNING = 2;
|
|
||||||
public const SEVERITY_ERROR = 3;
|
|
||||||
public const SEVERITY_CRITICAL = 4;
|
|
||||||
|
|
||||||
private readonly int $severity;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
string $name,
|
|
||||||
array $data = [],
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
private readonly ?string $ipAddress = 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,
|
|
||||||
?int $severity = null,
|
|
||||||
) {
|
|
||||||
parent::__construct($name, $data, $tenantId, $identityId);
|
|
||||||
|
|
||||||
$this->severity = $severity ?? self::getSeverityForEvent($name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a security event with common parameters
|
|
||||||
*/
|
|
||||||
public static function create(
|
|
||||||
string $name,
|
|
||||||
?string $ipAddress = null,
|
|
||||||
?string $deviceFingerprint = null,
|
|
||||||
array $data = [],
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
?string $userAgent = null,
|
|
||||||
?string $requestPath = null,
|
|
||||||
?string $requestMethod = null,
|
|
||||||
?string $userId = null,
|
|
||||||
?string $reason = null,
|
|
||||||
?int $severity = null,
|
|
||||||
): self {
|
|
||||||
return new self(
|
|
||||||
$name,
|
|
||||||
$data,
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
$ipAddress,
|
|
||||||
$deviceFingerprint,
|
|
||||||
$userAgent,
|
|
||||||
$requestPath,
|
|
||||||
$requestMethod,
|
|
||||||
$userId,
|
|
||||||
$reason,
|
|
||||||
$severity,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an authentication success event
|
|
||||||
*/
|
|
||||||
public static function authSuccess(
|
|
||||||
string $ipAddress,
|
|
||||||
?string $deviceFingerprint = null,
|
|
||||||
?string $userId = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::AUTH_SUCCESS,
|
|
||||||
$ipAddress,
|
|
||||||
$deviceFingerprint,
|
|
||||||
['userId' => $userId],
|
|
||||||
tenantId: $tenantId,
|
|
||||||
userId: $userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a brute force detection event
|
|
||||||
*/
|
|
||||||
public static function bruteForceDetected(
|
|
||||||
string $ipAddress,
|
|
||||||
int $failureCount,
|
|
||||||
int $windowSeconds,
|
|
||||||
?string $tenantId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::BRUTE_FORCE_DETECTED,
|
|
||||||
$ipAddress,
|
|
||||||
data: ['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
|
|
||||||
tenantId: $tenantId,
|
|
||||||
reason: sprintf('%d failed attempts in %d seconds', $failureCount, $windowSeconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a rate limit exceeded event
|
|
||||||
*/
|
|
||||||
public static function rateLimitExceeded(
|
|
||||||
string $ipAddress,
|
|
||||||
int $requestCount,
|
|
||||||
int $windowSeconds,
|
|
||||||
?string $endpoint = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::RATE_LIMIT_EXCEEDED,
|
|
||||||
$ipAddress,
|
|
||||||
data: [
|
|
||||||
'requestCount' => $requestCount,
|
|
||||||
'windowSeconds' => $windowSeconds,
|
|
||||||
'endpoint' => $endpoint,
|
|
||||||
],
|
|
||||||
tenantId: $tenantId,
|
|
||||||
requestPath: $endpoint,
|
|
||||||
reason: sprintf('%d requests in %d seconds', $requestCount, $windowSeconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an access denied event
|
|
||||||
*/
|
|
||||||
public static function accessDenied(
|
|
||||||
string $ipAddress,
|
|
||||||
?string $deviceFingerprint = null,
|
|
||||||
?string $ruleId = null,
|
|
||||||
?string $ruleScope = null,
|
|
||||||
?string $reason = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::ACCESS_DENIED,
|
|
||||||
$ipAddress,
|
|
||||||
$deviceFingerprint,
|
|
||||||
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default severity for event types
|
|
||||||
*/
|
|
||||||
private static function getSeverityForEvent(string $eventName): int
|
|
||||||
{
|
|
||||||
return match ($eventName) {
|
|
||||||
self::AUTH_SUCCESS,
|
|
||||||
self::ACCESS_GRANTED,
|
|
||||||
self::TOKEN_REFRESH => self::SEVERITY_INFO,
|
|
||||||
|
|
||||||
self::ACCESS_DENIED,
|
|
||||||
self::AUTH_LOGOUT,
|
|
||||||
self::TOKEN_REVOKED => self::SEVERITY_WARNING,
|
|
||||||
|
|
||||||
self::RATE_LIMIT_EXCEEDED,
|
|
||||||
self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR,
|
|
||||||
|
|
||||||
self::BRUTE_FORCE_DETECTED,
|
|
||||||
self::IP_BLOCKED,
|
|
||||||
self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL,
|
|
||||||
|
|
||||||
default => self::SEVERITY_INFO,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters and setters
|
|
||||||
|
|
||||||
public function getIpAddress(): ?string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(): int
|
|
||||||
{
|
|
||||||
return $this->severity;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -6,31 +6,21 @@ namespace KTXC\Security\Event;
|
|||||||
|
|
||||||
interface SecurityEventInterface
|
interface SecurityEventInterface
|
||||||
{
|
{
|
||||||
public function getName(): string;
|
public function label(): string;
|
||||||
|
|
||||||
public function get(string $key, mixed $default = null): mixed;
|
public function get(string $key, mixed $default = null): mixed;
|
||||||
|
|
||||||
public function getData(): array;
|
public function context(): array;
|
||||||
|
|
||||||
public function getEventId(): string;
|
public function identifier(): string;
|
||||||
|
|
||||||
public function getTenantId(): ?string;
|
public function tenantIdentifier(): ?string;
|
||||||
|
|
||||||
public function getIdentityId(): ?string;
|
public function actorIdentity(): ?string;
|
||||||
|
|
||||||
public function getIpAddress(): ?string;
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string;
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string;
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string;
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string;
|
|
||||||
|
|
||||||
public function getUserId(): ?string;
|
public function getUserId(): ?string;
|
||||||
|
|
||||||
public function getReason(): ?string;
|
public function getReason(): ?string;
|
||||||
|
|
||||||
public function getSeverity(): int;
|
public function getSeverity(): SecurityEventSeverity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
enum SecurityEventSeverity: int
|
||||||
|
{
|
||||||
|
case DEBUG = 0;
|
||||||
|
case INFO = 1;
|
||||||
|
case WARNING = 2;
|
||||||
|
case ERROR = 3;
|
||||||
|
case CRITICAL = 4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
interface SecurityRequestEventInterface extends SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function getIpAddress(): ?string;
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string;
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string;
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string;
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly string $detector,
|
||||||
|
private readonly array $detectionData = [],
|
||||||
|
?string $tenantId = null,
|
||||||
|
?string $identityId = null,
|
||||||
|
private readonly ?string $deviceFingerprint = null,
|
||||||
|
private readonly ?string $userAgent = null,
|
||||||
|
private readonly ?string $requestPath = null,
|
||||||
|
private readonly ?string $requestMethod = null,
|
||||||
|
private readonly ?string $userId = null,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($detector === '') {
|
||||||
|
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('detector', $detectionData)) {
|
||||||
|
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('detector', $detectionData)) {
|
||||||
|
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
||||||
|
}
|
||||||
|
if ($requestPath === '') {
|
||||||
|
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
|
||||||
|
}
|
||||||
|
if ($requestMethod === '') {
|
||||||
|
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['detector' => $detector] + $detectionData,
|
||||||
|
$tenantId,
|
||||||
|
$identityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetector(): string
|
||||||
|
{
|
||||||
|
return $this->detector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetectionData(): array
|
||||||
|
{
|
||||||
|
return $this->detectionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
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 KTXC\Stores\FirewallStore;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
final class FirewallRuleManager
|
final class FirewallRuleManager
|
||||||
@@ -186,7 +194,7 @@ final class FirewallRuleManager
|
|||||||
$origin,
|
$origin,
|
||||||
$metadata
|
$metadata
|
||||||
);
|
);
|
||||||
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
|
$this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -209,7 +217,7 @@ final class FirewallRuleManager
|
|||||||
null,
|
null,
|
||||||
$origin
|
$origin
|
||||||
);
|
);
|
||||||
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
|
$this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -254,13 +262,7 @@ final class FirewallRuleManager
|
|||||||
$origin
|
$origin
|
||||||
);
|
);
|
||||||
|
|
||||||
$event = new SecurityEvent(
|
$event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId);
|
||||||
SecurityEvent::DEVICE_BLOCKED,
|
|
||||||
['device' => $fingerprint, 'reason' => $reason],
|
|
||||||
tenantId: $scope->tenantId,
|
|
||||||
deviceFingerprint: $fingerprint,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
@@ -282,7 +284,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||||
@@ -321,7 +323,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleEnabledEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||||
@@ -365,7 +367,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleExtendedEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
[
|
[
|
||||||
@@ -392,7 +394,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->destroyRule($rule);
|
$this->store->destroyRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallRuleRemovedEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||||
@@ -447,9 +449,10 @@ final class FirewallRuleManager
|
|||||||
}
|
}
|
||||||
$rule->setMetadata($metadata);
|
$rule->setMetadata($metadata);
|
||||||
|
|
||||||
$this->store->depositRule($rule);
|
$rule = $this->store->depositRule($rule)
|
||||||
|
?? throw new \RuntimeException('Failed to persist firewall rule.');
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule);
|
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -486,7 +489,7 @@ final class FirewallRuleManager
|
|||||||
|
|
||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule);
|
$this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -498,46 +501,17 @@ final class FirewallRuleManager
|
|||||||
return $rule && $scope->owns($rule) ? $rule : null;
|
return $rule && $scope->owns($rule) ? $rule : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function publishIpEvent(
|
/**
|
||||||
string $name,
|
* @param class-string<FirewallRuleEvent> $eventClass
|
||||||
FirewallRuleScope $scope,
|
*/
|
||||||
string $ipAddress,
|
|
||||||
?string $reason
|
|
||||||
): void {
|
|
||||||
$event = new SecurityEvent(
|
|
||||||
$name,
|
|
||||||
['ip' => $ipAddress, 'reason' => $reason],
|
|
||||||
tenantId: $scope->tenantId,
|
|
||||||
ipAddress: $ipAddress,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function publishLifecycleEvent(
|
private function publishLifecycleEvent(
|
||||||
string $name,
|
string $eventClass,
|
||||||
FirewallRuleObject $rule,
|
FirewallRuleObject $rule,
|
||||||
?string $actorId = null,
|
?string $actorId = null,
|
||||||
array $change = []
|
array $change = []
|
||||||
): void
|
): void
|
||||||
{
|
{
|
||||||
$event = new SecurityEvent(
|
$event = $eventClass::fromRule($rule, $actorId, $change);
|
||||||
$name,
|
|
||||||
[
|
|
||||||
'ruleId' => $rule->getId(),
|
|
||||||
'ruleScope' => $rule->getScope(),
|
|
||||||
'ruleType' => $rule->getType(),
|
|
||||||
'ruleAction' => $rule->getAction(),
|
|
||||||
'ruleValue' => $rule->getValue(),
|
|
||||||
'reason' => $rule->getReason(),
|
|
||||||
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
|
||||||
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
|
||||||
...($rule->getMetadata() ?? []),
|
|
||||||
...$change,
|
|
||||||
],
|
|
||||||
tenantId: $rule->getTenantId(),
|
|
||||||
identityId: $actorId ?? $rule->getCreatedBy(),
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ 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\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
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\SecurityEventInterface;
|
||||||
|
use KTXC\Security\Event\SecurityRequestEventInterface;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
@@ -47,6 +59,7 @@ class FirewallService
|
|||||||
private readonly EventDispatcherInterface $events,
|
private readonly EventDispatcherInterface $events,
|
||||||
private readonly FirewallRuleManager $rules,
|
private readonly FirewallRuleManager $rules,
|
||||||
private readonly FirewallRuleCache $ruleCache,
|
private readonly FirewallRuleCache $ruleCache,
|
||||||
|
private readonly RequestContext $requestContext,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,14 +147,15 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
||||||
{
|
{
|
||||||
$ipAddress = $event->getIpAddress();
|
$request = $this->requestContext->current();
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$ipAddress = $request?->getClientIp();
|
||||||
|
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||||
|
|
||||||
if (!$ipAddress || !$tenantId) {
|
if (!$ipAddress || !$tenantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$log = $this->securityLog($event);
|
$log = $this->securityLog($event, $request);
|
||||||
if ($log === null || !$this->store->createLogOnce($log)) {
|
if ($log === null || !$this->store->createLogOnce($log)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -196,7 +210,7 @@ class FirewallService
|
|||||||
int $blockDuration
|
int $blockDuration
|
||||||
): void {
|
): void {
|
||||||
// Publish brute force event
|
// Publish brute force event
|
||||||
$event = SecurityEvent::bruteForceDetected(
|
$event = new BruteForceDetectedEvent(
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$failureCount,
|
$failureCount,
|
||||||
$windowSeconds,
|
$windowSeconds,
|
||||||
@@ -235,29 +249,45 @@ class FirewallService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function securityLog(SecurityEventInterface $event): ?FirewallLogObject
|
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
|
||||||
{
|
{
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$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');
|
$ruleScope = $event->get('ruleScope');
|
||||||
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
|
||||||
|
|
||||||
$log = new FirewallLogObject();
|
$log = new FirewallLogObject();
|
||||||
return $log->setEventId($event->getEventId())
|
return $log->setEventId($event->identifier())
|
||||||
->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())
|
||||||
|
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
|
||||||
|
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
|
||||||
|
->setEventType($this->mapEventToLogType($event->label()))
|
||||||
->setResult($this->mapEventToResult($event))
|
->setResult($this->mapEventToResult($event))
|
||||||
->setRuleId($event->get('ruleId'))
|
->setRuleId($event->get('ruleId'))
|
||||||
->setRuleScope($ruleScope)
|
->setRuleScope($ruleScope)
|
||||||
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
|
->setIdentityId($event->getUserId() ?? $event->actorIdentity())
|
||||||
->setTimestamp(new \DateTimeImmutable())
|
->setTimestamp(new \DateTimeImmutable())
|
||||||
->setMetadata($event->getData());
|
->setMetadata($event->context());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,17 +297,17 @@ class FirewallService
|
|||||||
{
|
{
|
||||||
return match ($eventName) {
|
return match ($eventName) {
|
||||||
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
|
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||||
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
|
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
|
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||||
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||||
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
|
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
|
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
|
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
||||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -287,15 +317,14 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
private function mapEventToResult(SecurityEventInterface $event): string
|
private function mapEventToResult(SecurityEventInterface $event): string
|
||||||
{
|
{
|
||||||
return match ($event->getName()) {
|
return match ($event->label()) {
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
|
||||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
FirewallRuleCreatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
FirewallRuleExtendedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleEnabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleRemovedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
|
|
||||||
default => FirewallLogObject::RESULT_BLOCKED,
|
default => FirewallLogObject::RESULT_BLOCKED,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -308,13 +337,13 @@ class FirewallService
|
|||||||
?string $deviceFingerprint,
|
?string $deviceFingerprint,
|
||||||
FirewallRuleObject $rule
|
FirewallRuleObject $rule
|
||||||
): void {
|
): void {
|
||||||
$event = SecurityEvent::accessDenied(
|
$event = new AccessDeniedEvent(
|
||||||
$ipAddress,
|
ipAddress: $ipAddress,
|
||||||
$deviceFingerprint,
|
ruleId: $rule->getId(),
|
||||||
$rule->getId(),
|
ruleScope: $rule->getScope(),
|
||||||
$rule->getScope(),
|
deviceFingerprint: $deviceFingerprint,
|
||||||
$rule->getReason(),
|
reason: $rule->getReason(),
|
||||||
$this->tenantContext->identifier(),
|
tenantId: $this->tenantContext->identifier(),
|
||||||
);
|
);
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace KTXC\Service;
|
|||||||
|
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||||
|
|
||||||
final class FirewallSettingsService
|
final class FirewallSettingsService
|
||||||
{
|
{
|
||||||
@@ -52,16 +52,13 @@ final class FirewallSettingsService
|
|||||||
$tenant->setConfiguration($configuration);
|
$tenant->setConfiguration($configuration);
|
||||||
$this->tenants->deposit($tenant);
|
$this->tenants->deposit($tenant);
|
||||||
|
|
||||||
$event = new SecurityEvent(
|
$event = new FirewallSettingsUpdatedEvent(
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
changeReason: $reason,
|
||||||
[
|
previous: $previous,
|
||||||
'changeReason' => $reason,
|
current: $current,
|
||||||
'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL,
|
|
||||||
'previous' => $previous,
|
|
||||||
'current' => $current,
|
|
||||||
],
|
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
identityId: $actorId,
|
actorId: $actorId,
|
||||||
|
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
|
||||||
);
|
);
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ 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
|
||||||
{
|
{
|
||||||
@@ -13,7 +17,8 @@ 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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +70,53 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function createUser(array $userData): array
|
public function createUser(array $userData): array
|
||||||
{
|
{
|
||||||
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
|
$tenantId = $this->tenantContext->requireIdentifier();
|
||||||
|
$user = $this->userStore->createUser($tenantId, $userData);
|
||||||
|
$this->events->dispatch(UserCreatedEvent::fromUser(
|
||||||
|
$user,
|
||||||
|
$tenantId,
|
||||||
|
$this->identityContext->identifier(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateUser(string $uid, array $updates): bool
|
public function updateUser(string $userId, array $updates): bool
|
||||||
{
|
{
|
||||||
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
|
$tenantId = $this->tenantContext->requireIdentifier();
|
||||||
|
if (!$this->userStore->updateUser($tenantId, $userId, $updates)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteUser(string $uid): bool
|
$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
|
||||||
{
|
{
|
||||||
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
|
$tenantId = $this->tenantContext->requireIdentifier();
|
||||||
|
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
|
||||||
|
if ($user === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->events->dispatch(UserDeletingEvent::fromUser(
|
||||||
|
$user,
|
||||||
|
$tenantId,
|
||||||
|
$this->identityContext->identifier(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->userStore->deleteUser($tenantId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -126,10 +167,6 @@ 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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
final class UserCreatedEvent extends UserEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
final class UserDeletingEvent extends UserEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
final class UserUpdatedEvent extends UserEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue';
|
import { ref, onMounted, computed, watch, nextTick } 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,6 +15,7 @@ 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);
|
||||||
|
|
||||||
@@ -60,21 +61,19 @@ 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(() => {
|
||||||
if (!selectedMethod.value) return 'Password';
|
return isPasswordMethod.value ? 'Password' : 'Verification Code';
|
||||||
return selectedMethod.value.method === 'credential' ? 'Password' : 'Verification Code';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const authInputType = computed(() => {
|
const authInputType = computed(() => {
|
||||||
if (!selectedMethod.value) return 'password';
|
return isPasswordMethod.value ? 'password' : 'text';
|
||||||
return selectedMethod.value.method === 'credential' ? 'password' : 'text';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const identityRules = [
|
const identityRules = [
|
||||||
(v: string) => !!v.trim() || 'Email is required',
|
(v: string) => !!v.trim() || 'Login ID 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 = [
|
||||||
@@ -117,6 +116,9 @@ 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);
|
||||||
@@ -313,7 +315,16 @@ function backToIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMethodIcon(method: AuthenticationMethod): string {
|
function getMethodIcon(method: AuthenticationMethod): string {
|
||||||
if (method.icon) return method.icon;
|
if (method.icon?.startsWith('mdi-') || method.icon?.startsWith('$')) {
|
||||||
|
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';
|
||||||
@@ -351,16 +362,15 @@ 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"
|
||||||
class="mt-2"
|
aria-label="Login ID"
|
||||||
required
|
required
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
autocomplete="email"
|
autocomplete="username"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +401,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
size="large"
|
size="large"
|
||||||
@click="initiateSsoLogin(method.id)"
|
@click="initiateSsoLogin(method.id)"
|
||||||
>
|
>
|
||||||
<v-icon v-if="method.icon" start>{{ method.icon }}</v-icon>
|
<v-icon start>{{ getMethodIcon(method) }}</v-icon>
|
||||||
{{ method.label }}
|
{{ method.label }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,18 +455,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'"
|
||||||
class="mt-2"
|
:aria-label="authInputLabel"
|
||||||
required
|
required
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
:autocomplete="selectedMethod?.method === 'credential' ? 'current-password' : 'one-time-code'"
|
:autocomplete="isPasswordMethod ? 'current-password' : 'off'"
|
||||||
:inputmode="selectedMethod?.method !== 'credential' ? 'numeric' : undefined"
|
:inputmode="isPasswordMethod ? undefined : 'numeric'"
|
||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
||||||
@@ -470,7 +480,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
</v-text-field>
|
</v-text-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedMethod?.method === 'credential'" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
<div v-if="isPasswordMethod" 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"
|
||||||
@@ -493,7 +503,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
size="large"
|
size="large"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{{ selectedMethod?.method === 'credential' ? 'Login' : 'Verify' }}
|
{{ isPasswordMethod ? 'Login' : 'Verify' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -534,6 +544,7 @@ 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"
|
||||||
@@ -542,7 +553,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
autocomplete="one-time-code"
|
autocomplete="off"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ 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
|
||||||
|
|||||||
Generated
+3
-3
@@ -6642,9 +6642,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vuetify": {
|
"node_modules/vuetify": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
|
||||||
"integrity": "sha512-07qxu0S1oxPjmknP/Yn5276PtIhlCvHnpcp7HuOKEHni89/6BGiM2aVdKlEGeyM5RfytDkiv0SNieabGT2D8qQ==",
|
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
+33
-33
@@ -10,74 +10,74 @@ namespace KTXF\Event;
|
|||||||
class Event
|
class Event
|
||||||
{
|
{
|
||||||
private bool $propagationStopped = false;
|
private bool $propagationStopped = false;
|
||||||
private readonly array $data;
|
private readonly array $context;
|
||||||
private readonly float $timestamp;
|
private readonly float $timestamp;
|
||||||
private readonly string $eventId;
|
private readonly string $identifier;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $name,
|
private readonly string $label,
|
||||||
array $data = [],
|
array $context = [],
|
||||||
private readonly ?string $tenantId = null,
|
private readonly ?string $tenantIdentifier = null,
|
||||||
private readonly ?string $identityId = null,
|
private readonly ?string $actorIdentity = null,
|
||||||
) {
|
) {
|
||||||
self::validateData($data);
|
self::validateContext($context);
|
||||||
|
|
||||||
$this->data = $data;
|
$this->context = $context;
|
||||||
$this->timestamp = microtime(true);
|
$this->timestamp = microtime(true);
|
||||||
$this->eventId = bin2hex(random_bytes(16));
|
$this->identifier = bin2hex(random_bytes(16));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event name
|
* Get the event label
|
||||||
*/
|
*/
|
||||||
public function getName(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return $this->name;
|
return $this->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a data value by key
|
* Get a context value by key
|
||||||
*/
|
*/
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->data[$key] ?? $default;
|
return $this->context[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a data key exists
|
* Check if a context key exists
|
||||||
*/
|
*/
|
||||||
public function has(string $key): bool
|
public function has(string $key): bool
|
||||||
{
|
{
|
||||||
return array_key_exists($key, $this->data);
|
return array_key_exists($key, $this->context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all data
|
* Get the event context
|
||||||
*/
|
*/
|
||||||
public function getData(): array
|
public function context(): array
|
||||||
{
|
{
|
||||||
return $this->data;
|
return $this->context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alias for getData() for backward compatibility
|
* Get all event context
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function all(): array
|
||||||
{
|
{
|
||||||
return $this->data;
|
return $this->context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event timestamp
|
* Get the event timestamp
|
||||||
*/
|
*/
|
||||||
public function getTimestamp(): float
|
public function timestamp(): float
|
||||||
{
|
{
|
||||||
return $this->timestamp;
|
return $this->timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEventId(): string
|
public function identifier(): string
|
||||||
{
|
{
|
||||||
return $this->eventId;
|
return $this->identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,29 +99,29 @@ class Event
|
|||||||
/**
|
/**
|
||||||
* Get tenant ID for multi-tenant context
|
* Get tenant ID for multi-tenant context
|
||||||
*/
|
*/
|
||||||
public function getTenantId(): ?string
|
public function tenantIdentifier(): ?string
|
||||||
{
|
{
|
||||||
return $this->tenantId;
|
return $this->tenantIdentifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get identity ID (user who triggered the event)
|
* Get the identity of the actor who triggered the event
|
||||||
*/
|
*/
|
||||||
public function getIdentityId(): ?string
|
public function actorIdentity(): ?string
|
||||||
{
|
{
|
||||||
return $this->identityId;
|
return $this->actorIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function validateData(array $data): void
|
private static function validateContext(array $context): void
|
||||||
{
|
{
|
||||||
foreach ($data as $value) {
|
foreach ($context as $value) {
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
self::validateData($value);
|
self::validateContext($value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($value !== null && !is_scalar($value)) {
|
if ($value !== null && !is_scalar($value)) {
|
||||||
throw new \InvalidArgumentException(
|
throw new \InvalidArgumentException(
|
||||||
'Event data must contain only scalar, null, or array values.',
|
'Event context must contain only scalar, null, or array values.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace KTXT\Unit\Console\Tenant;
|
namespace KTXT\Unit\Console\Tenant;
|
||||||
|
|
||||||
use KTXC\Console\Tenant\TenantCreateCommand;
|
use KTXC\Console\Tenant\TenantCreateCommand;
|
||||||
|
use KTXC\Context\TenantContext;
|
||||||
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 PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
@@ -22,6 +24,7 @@ class TenantCreateCommandTest extends TestCase
|
|||||||
private TenantService&MockObject $tenantService;
|
private TenantService&MockObject $tenantService;
|
||||||
private UserRolesStore $rolesStore;
|
private UserRolesStore $rolesStore;
|
||||||
private UserAccountsStore $userStore;
|
private UserAccountsStore $userStore;
|
||||||
|
private UserAccountsService $userService;
|
||||||
private CommandTester $tester;
|
private CommandTester $tester;
|
||||||
private ?TenantObject $deposited = null;
|
private ?TenantObject $deposited = null;
|
||||||
|
|
||||||
@@ -31,12 +34,21 @@ class TenantCreateCommandTest extends TestCase
|
|||||||
$this->rolesStore = $this->createStub(UserRolesStore::class);
|
$this->rolesStore = $this->createStub(UserRolesStore::class);
|
||||||
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
|
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
|
||||||
$this->userStore = $this->createStub(UserAccountsStore::class);
|
$this->userStore = $this->createStub(UserAccountsStore::class);
|
||||||
$this->userStore->method('createUser')->willReturn(['uid' => 'admin']);
|
$this->userService = $this->createStub(UserAccountsService::class);
|
||||||
|
$this->userService->method('createUser')->willReturn(['uid' => 'admin']);
|
||||||
|
$contextTenantService = $this->createStub(TenantService::class);
|
||||||
|
$contextTenantService->method('fetchById')->willReturnCallback(
|
||||||
|
fn(string $identifier): ?TenantObject => $this->deposited?->getIdentifier() === $identifier
|
||||||
|
? $this->deposited
|
||||||
|
: null,
|
||||||
|
);
|
||||||
$this->tester = new CommandTester(
|
$this->tester = new CommandTester(
|
||||||
new TenantCreateCommand(
|
new TenantCreateCommand(
|
||||||
$this->tenantService,
|
$this->tenantService,
|
||||||
$this->rolesStore,
|
$this->rolesStore,
|
||||||
$this->userStore,
|
$this->userStore,
|
||||||
|
$this->userService,
|
||||||
|
new TenantContext($contextTenantService),
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AccessDeniedEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Access-denial state is typed and complete at construction')]
|
||||||
|
public function constructsTypedState(): void
|
||||||
|
{
|
||||||
|
$event = new AccessDeniedEvent(
|
||||||
|
'203.0.113.10',
|
||||||
|
'rule-a',
|
||||||
|
FirewallRuleObject::SCOPE_TENANT,
|
||||||
|
'device-a',
|
||||||
|
'Blocked by policy',
|
||||||
|
'tenant-a',
|
||||||
|
'identity-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(AccessDeniedEvent::class, $event->label());
|
||||||
|
self::assertSame('203.0.113.10', $event->getIpAddress());
|
||||||
|
self::assertSame('device-a', $event->getDeviceFingerprint());
|
||||||
|
self::assertSame('rule-a', $event->getRuleId());
|
||||||
|
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
|
||||||
|
self::assertSame('Blocked by policy', $event->getReason());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('identity-a', $event->actorIdentity());
|
||||||
|
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Access denial rejects incomplete rule context')]
|
||||||
|
public function rejectsIncompleteRuleContext(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
['', 'rule-a', FirewallRuleObject::SCOPE_TENANT],
|
||||||
|
['203.0.113.10', '', FirewallRuleObject::SCOPE_TENANT],
|
||||||
|
['203.0.113.10', 'rule-a', 'unknown'],
|
||||||
|
] as $arguments) {
|
||||||
|
try {
|
||||||
|
new AccessDeniedEvent(...$arguments);
|
||||||
|
self::fail('Incomplete access-denial context was accepted.');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace KTXT\Unit\Event;
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use KTXC\Security\Event\SecurityRequestEventInterface;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -17,27 +18,18 @@ final class AuthenticationFailedEventTest extends TestCase
|
|||||||
public function constructsTypedState(): void
|
public function constructsTypedState(): void
|
||||||
{
|
{
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(
|
||||||
'203.0.113.10',
|
|
||||||
'device-a',
|
|
||||||
'user-a',
|
'user-a',
|
||||||
'Invalid credentials',
|
'Invalid credentials',
|
||||||
'tenant-a',
|
'tenant-a',
|
||||||
'identity-a',
|
'identity-a',
|
||||||
'Test Agent',
|
|
||||||
'/login',
|
|
||||||
'POST',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(AuthenticationFailedEvent::class, $event->getName());
|
self::assertSame(AuthenticationFailedEvent::class, $event->label());
|
||||||
self::assertSame('203.0.113.10', $event->getIpAddress());
|
|
||||||
self::assertSame('device-a', $event->getDeviceFingerprint());
|
|
||||||
self::assertSame('user-a', $event->getUserId());
|
self::assertSame('user-a', $event->getUserId());
|
||||||
self::assertSame('Invalid credentials', $event->getReason());
|
self::assertSame('Invalid credentials', $event->getReason());
|
||||||
self::assertSame('tenant-a', $event->getTenantId());
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
self::assertSame('identity-a', $event->getIdentityId());
|
self::assertSame('identity-a', $event->actorIdentity());
|
||||||
self::assertSame('Test Agent', $event->getUserAgent());
|
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
|
||||||
self::assertSame('/login', $event->getRequestPath());
|
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
||||||
self::assertSame('POST', $event->getRequestMethod());
|
|
||||||
self::assertSame(SecurityEvent::SEVERITY_WARNING, $event->getSeverity());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use KTXC\Security\Event\SecurityRequestEventInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AuthenticationSucceededEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Successful-authentication state is typed and complete at construction')]
|
||||||
|
public function constructsTypedState(): void
|
||||||
|
{
|
||||||
|
$event = new AuthenticationSucceededEvent(
|
||||||
|
'user-a',
|
||||||
|
'tenant-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(AuthenticationSucceededEvent::class, $event->label());
|
||||||
|
self::assertSame('user-a', $event->getUserId());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame(['userId' => 'user-a'], $event->context());
|
||||||
|
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
||||||
|
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Successful authentication requires a user ID')]
|
||||||
|
public function rejectsIncompleteAuthenticationContext(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
new AuthenticationSucceededEvent('');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Security\Event\BruteForceDetectedEvent;
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class BruteForceDetectedEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Brute-force detection state is typed and complete at construction')]
|
||||||
|
public function constructsTypedState(): void
|
||||||
|
{
|
||||||
|
$event = new BruteForceDetectedEvent(
|
||||||
|
'203.0.113.10',
|
||||||
|
5,
|
||||||
|
300,
|
||||||
|
'tenant-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(BruteForceDetectedEvent::class, $event->label());
|
||||||
|
self::assertSame('203.0.113.10', $event->getIpAddress());
|
||||||
|
self::assertSame(5, $event->getFailureCount());
|
||||||
|
self::assertSame(300, $event->getWindowSeconds());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('5 failed attempts in 300 seconds', $event->getReason());
|
||||||
|
self::assertSame(
|
||||||
|
['failureCount' => 5, 'windowSeconds' => 300],
|
||||||
|
$event->context(),
|
||||||
|
);
|
||||||
|
self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Brute-force detection rejects invalid measurements')]
|
||||||
|
public function rejectsInvalidMeasurements(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
['', 5, 300],
|
||||||
|
['203.0.113.10', 0, 300],
|
||||||
|
['203.0.113.10', 5, 0],
|
||||||
|
] as $arguments) {
|
||||||
|
try {
|
||||||
|
new BruteForceDetectedEvent(...$arguments);
|
||||||
|
self::fail('Invalid brute-force measurements were accepted.');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,6 +167,43 @@ final class EventDispatcherTest extends TestCase
|
|||||||
$dispatcher->dispatch(new Event('test.event'));
|
$dispatcher->dispatch(new Event('test.event'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Propagated deferred failures close their execution scope')]
|
||||||
|
public function recoversFromDeferredFailure(): void
|
||||||
|
{
|
||||||
|
$registry = new EventListenerRegistry();
|
||||||
|
$registry->listen(
|
||||||
|
'test',
|
||||||
|
'test.event',
|
||||||
|
FailingListener::class,
|
||||||
|
'fail',
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
failurePolicy: FailurePolicy::Propagate,
|
||||||
|
);
|
||||||
|
$registry->freeze();
|
||||||
|
|
||||||
|
$dispatcher = new EventDispatcher(
|
||||||
|
$registry,
|
||||||
|
new RecordingContainer([FailingListener::class => new FailingListener()]),
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
$dispatcher->beginExecution('failed');
|
||||||
|
$dispatcher->dispatch(new Event('test.event'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dispatcher->processDeferred('failed');
|
||||||
|
self::fail('Expected deferred listener failure to propagate.');
|
||||||
|
} catch (\RuntimeException $error) {
|
||||||
|
self::assertSame('Listener failed.', $error->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$dispatcher->beginExecution('next');
|
||||||
|
$result = $dispatcher->processDeferred('next');
|
||||||
|
|
||||||
|
self::assertSame(0, $result->processed);
|
||||||
|
self::assertSame(0, $result->remaining);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Deferred processing stops at its configured count limit')]
|
#[TestDox('Deferred processing stops at its configured count limit')]
|
||||||
public function boundsDeferredWork(): void
|
public function boundsDeferredWork(): void
|
||||||
@@ -193,9 +230,57 @@ final class EventDispatcherTest extends TestCase
|
|||||||
$result = $dispatcher->processDeferred('test');
|
$result = $dispatcher->processDeferred('test');
|
||||||
|
|
||||||
self::assertSame(1000, $result->processed);
|
self::assertSame(1000, $result->processed);
|
||||||
|
self::assertSame(1000, $result->listenerInvocations);
|
||||||
self::assertSame(1, $result->remaining);
|
self::assertSame(1, $result->remaining);
|
||||||
self::assertFalse($result->deadlineExceeded);
|
self::assertFalse($result->deadlineExceeded);
|
||||||
self::assertTrue($result->limitExceeded);
|
self::assertTrue($result->limitExceeded);
|
||||||
|
self::assertTrue($result->eventLimitExceeded);
|
||||||
|
self::assertFalse($result->listenerInvocationLimitExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Deferred event and listener invocation limits are tracked separately')]
|
||||||
|
public function boundsDeferredListenerInvocations(): void
|
||||||
|
{
|
||||||
|
$recursive = new RecursiveListener();
|
||||||
|
$recording = new RecordingListener();
|
||||||
|
$registry = new EventListenerRegistry();
|
||||||
|
$registry->listen(
|
||||||
|
'test',
|
||||||
|
'test.event',
|
||||||
|
RecursiveListener::class,
|
||||||
|
'deferred',
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
);
|
||||||
|
$registry->listen(
|
||||||
|
'test',
|
||||||
|
'test.event',
|
||||||
|
RecordingListener::class,
|
||||||
|
'deferred',
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
);
|
||||||
|
$registry->freeze();
|
||||||
|
$dispatcher = new EventDispatcher(
|
||||||
|
$registry,
|
||||||
|
new RecordingContainer([
|
||||||
|
RecursiveListener::class => $recursive,
|
||||||
|
RecordingListener::class => $recording,
|
||||||
|
]),
|
||||||
|
new NullLogger(),
|
||||||
|
maxDeferredListenerInvocations: 3,
|
||||||
|
);
|
||||||
|
$recursive->dispatcher = $dispatcher;
|
||||||
|
$dispatcher->beginExecution('test');
|
||||||
|
$dispatcher->dispatch(new Event('test.event'));
|
||||||
|
|
||||||
|
$result = $dispatcher->processDeferred('test');
|
||||||
|
|
||||||
|
self::assertSame(1, $result->processed);
|
||||||
|
self::assertSame(2, $result->listenerInvocations);
|
||||||
|
self::assertSame(1, $result->remaining);
|
||||||
|
self::assertTrue($result->limitExceeded);
|
||||||
|
self::assertFalse($result->eventLimitExceeded);
|
||||||
|
self::assertTrue($result->listenerInvocationLimitExceeded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ final class EventTest extends TestCase
|
|||||||
'identity-a',
|
'identity-a',
|
||||||
);
|
);
|
||||||
|
|
||||||
$copy = $event->getData();
|
$copy = $event->context();
|
||||||
$copy['nested']['value'] = 'changed';
|
$copy['nested']['value'] = 'changed';
|
||||||
|
|
||||||
self::assertSame('original', $event->get('nested')['value']);
|
self::assertSame('original', $event->get('nested')['value']);
|
||||||
self::assertSame('tenant-a', $event->getTenantId());
|
self::assertSame('test.event', $event->label());
|
||||||
self::assertSame('identity-a', $event->getIdentityId());
|
self::assertNotSame('', $event->identifier());
|
||||||
|
self::assertGreaterThan(0, $event->timestamp());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('identity-a', $event->actorIdentity());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Security\Event\DeviceBlockedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||||
|
use KTXC\Security\Event\IpAllowedEvent;
|
||||||
|
use KTXC\Security\Event\IpBlockedEvent;
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FirewallPolicyEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('IP and device policy events expose typed immutable state')]
|
||||||
|
public function constructsSubjectPolicyEvents(): void
|
||||||
|
{
|
||||||
|
$blocked = new IpBlockedEvent('203.0.113.10', 'Repeated abuse', 'tenant-a');
|
||||||
|
$allowed = new IpAllowedEvent('203.0.113.11', 'Trusted service', 'tenant-a');
|
||||||
|
$device = new DeviceBlockedEvent('device-a', 'Compromised device', 'tenant-a');
|
||||||
|
|
||||||
|
self::assertSame(IpBlockedEvent::class, $blocked->label());
|
||||||
|
self::assertSame('203.0.113.10', $blocked->getIpAddress());
|
||||||
|
self::assertSame('Repeated abuse', $blocked->getReason());
|
||||||
|
self::assertSame(SecurityEventSeverity::CRITICAL, $blocked->getSeverity());
|
||||||
|
self::assertSame(IpAllowedEvent::class, $allowed->label());
|
||||||
|
self::assertSame('203.0.113.11', $allowed->getIpAddress());
|
||||||
|
self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity());
|
||||||
|
self::assertSame(DeviceBlockedEvent::class, $device->label());
|
||||||
|
self::assertSame('device-a', $device->getDeviceFingerprint());
|
||||||
|
self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Firewall settings events capture the complete configuration transition')]
|
||||||
|
public function constructsSettingsEvent(): void
|
||||||
|
{
|
||||||
|
$event = new FirewallSettingsUpdatedEvent(
|
||||||
|
'Tighten controls',
|
||||||
|
['maxAuthFailures' => 5],
|
||||||
|
['maxAuthFailures' => 3],
|
||||||
|
'tenant-a',
|
||||||
|
'operator-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->label());
|
||||||
|
self::assertSame('Tighten controls', $event->getChangeReason());
|
||||||
|
self::assertSame(['maxAuthFailures' => 5], $event->getPrevious());
|
||||||
|
self::assertSame(['maxAuthFailures' => 3], $event->getCurrent());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('operator-a', $event->actorIdentity());
|
||||||
|
self::assertSame('manual', $event->getChangeOrigin());
|
||||||
|
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Firewall policy events reject missing subject and ownership context')]
|
||||||
|
public function rejectsIncompletePolicyContext(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
static fn() => new IpBlockedEvent(''),
|
||||||
|
static fn() => new IpAllowedEvent(''),
|
||||||
|
static fn() => new DeviceBlockedEvent(''),
|
||||||
|
static fn() => new FirewallSettingsUpdatedEvent('', [], [], 'tenant-a'),
|
||||||
|
static fn() => new FirewallSettingsUpdatedEvent('Reason', [], [], ''),
|
||||||
|
] as $construction) {
|
||||||
|
try {
|
||||||
|
$construction();
|
||||||
|
self::fail('Incomplete firewall policy context was accepted.');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
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\SecurityEventSeverity;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FirewallRuleEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Firewall rule events capture immutable rule snapshots')]
|
||||||
|
public function capturesRuleSnapshot(): void
|
||||||
|
{
|
||||||
|
$expiresAt = new \DateTimeImmutable('+5 minutes');
|
||||||
|
$rule = (new FirewallRuleObject())
|
||||||
|
->setId('rule-a')
|
||||||
|
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||||
|
->setTenantId('tenant-a')
|
||||||
|
->setType(FirewallRuleObject::TYPE_IP)
|
||||||
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
|
->setValue('203.0.113.10')
|
||||||
|
->setReason('Repeated abuse')
|
||||||
|
->setCreatedBy('creator-a')
|
||||||
|
->setExpiresAt($expiresAt)
|
||||||
|
->setMetadata(['origin' => 'automatic', 'failureCount' => 5]);
|
||||||
|
|
||||||
|
$event = FirewallRuleExtendedEvent::fromRule(
|
||||||
|
$rule,
|
||||||
|
'operator-a',
|
||||||
|
['changeReason' => 'Continue monitoring'],
|
||||||
|
);
|
||||||
|
$rule->setReason('Mutated after publication');
|
||||||
|
|
||||||
|
self::assertSame(FirewallRuleExtendedEvent::class, $event->label());
|
||||||
|
self::assertSame('rule-a', $event->getRuleId());
|
||||||
|
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
|
||||||
|
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
|
||||||
|
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $event->getRuleAction());
|
||||||
|
self::assertSame('203.0.113.10', $event->getRuleValue());
|
||||||
|
self::assertSame('Repeated abuse', $event->getReason());
|
||||||
|
self::assertSame('automatic', $event->getOrigin());
|
||||||
|
self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('operator-a', $event->actorIdentity());
|
||||||
|
self::assertSame(5, $event->get('failureCount'));
|
||||||
|
self::assertSame('Continue monitoring', $event->get('changeReason'));
|
||||||
|
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Each firewall rule lifecycle operation has a dedicated event key')]
|
||||||
|
public function exposesDedicatedLifecycleKeys(): void
|
||||||
|
{
|
||||||
|
$rule = (new FirewallRuleObject())
|
||||||
|
->setId('rule-a')
|
||||||
|
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
||||||
|
->setType(FirewallRuleObject::TYPE_IP)
|
||||||
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
|
->setValue('203.0.113.10');
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
FirewallRuleCreatedEvent::class,
|
||||||
|
FirewallRuleExtendedEvent::class,
|
||||||
|
FirewallRuleEnabledEvent::class,
|
||||||
|
FirewallRuleDisabledEvent::class,
|
||||||
|
FirewallRuleRemovedEvent::class,
|
||||||
|
] as $eventClass) {
|
||||||
|
self::assertSame($eventClass, $eventClass::fromRule($rule)->label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Security\Event\RateLimitExceededEvent;
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RateLimitExceededEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Rate-limit state is typed and complete at construction')]
|
||||||
|
public function constructsTypedState(): void
|
||||||
|
{
|
||||||
|
$event = new RateLimitExceededEvent(
|
||||||
|
'203.0.113.10',
|
||||||
|
101,
|
||||||
|
60,
|
||||||
|
'/login',
|
||||||
|
'tenant-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(RateLimitExceededEvent::class, $event->label());
|
||||||
|
self::assertSame('203.0.113.10', $event->getIpAddress());
|
||||||
|
self::assertSame(101, $event->getRequestCount());
|
||||||
|
self::assertSame(60, $event->getWindowSeconds());
|
||||||
|
self::assertSame('/login', $event->getEndpoint());
|
||||||
|
self::assertSame('/login', $event->getRequestPath());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('101 requests in 60 seconds', $event->getReason());
|
||||||
|
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Rate-limit detection rejects invalid measurements')]
|
||||||
|
public function rejectsInvalidMeasurements(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
['', 101, 60, '/login'],
|
||||||
|
['203.0.113.10', 0, 60, '/login'],
|
||||||
|
['203.0.113.10', 101, 0, '/login'],
|
||||||
|
['203.0.113.10', 101, 60, ''],
|
||||||
|
] as $arguments) {
|
||||||
|
try {
|
||||||
|
new RateLimitExceededEvent(...$arguments);
|
||||||
|
self::fail('Invalid rate-limit measurements were accepted.');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SecurityEventSeverityTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Security severity levels expose stable numeric values')]
|
||||||
|
public function exposesStableNumericValues(): void
|
||||||
|
{
|
||||||
|
self::assertSame(0, SecurityEventSeverity::DEBUG->value);
|
||||||
|
self::assertSame(1, SecurityEventSeverity::INFO->value);
|
||||||
|
self::assertSame(2, SecurityEventSeverity::WARNING->value);
|
||||||
|
self::assertSame(3, SecurityEventSeverity::ERROR->value);
|
||||||
|
self::assertSame(4, SecurityEventSeverity::CRITICAL->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXT\Unit\Event;
|
|
||||||
|
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
final class SecurityEventTest extends TestCase
|
|
||||||
{
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Direct construction applies the event type default severity')]
|
|
||||||
public function appliesDefaultSeverityDuringConstruction(): void
|
|
||||||
{
|
|
||||||
self::assertSame(
|
|
||||||
SecurityEvent::SEVERITY_WARNING,
|
|
||||||
(new SecurityEvent(SecurityEvent::AUTH_LOGOUT))->getSeverity(),
|
|
||||||
);
|
|
||||||
self::assertSame(
|
|
||||||
SecurityEvent::SEVERITY_ERROR,
|
|
||||||
(new SecurityEvent(SecurityEvent::RATE_LIMIT_EXCEEDED))->getSeverity(),
|
|
||||||
);
|
|
||||||
self::assertSame(
|
|
||||||
SecurityEvent::SEVERITY_CRITICAL,
|
|
||||||
(new SecurityEvent(SecurityEvent::DEVICE_BLOCKED))->getSeverity(),
|
|
||||||
);
|
|
||||||
self::assertSame(
|
|
||||||
SecurityEvent::SEVERITY_INFO,
|
|
||||||
(new SecurityEvent(SecurityEvent::FIREWALL_RULE_CREATED))->getSeverity(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Construction can override the event type default severity')]
|
|
||||||
public function allowsSeverityOverride(): void
|
|
||||||
{
|
|
||||||
$event = new SecurityEvent(
|
|
||||||
SecurityEvent::AUTH_LOGOUT,
|
|
||||||
severity: SecurityEvent::SEVERITY_CRITICAL,
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $event->getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
#[TestDox('Event state exposes no mutation methods')]
|
|
||||||
public function exposesNoMutationMethods(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
'set',
|
|
||||||
'setTenantId',
|
|
||||||
'setIdentityId',
|
|
||||||
'setIpAddress',
|
|
||||||
'setDeviceFingerprint',
|
|
||||||
'setUserAgent',
|
|
||||||
'setRequestPath',
|
|
||||||
'setRequestMethod',
|
|
||||||
'setUserId',
|
|
||||||
'setReason',
|
|
||||||
'setSeverity',
|
|
||||||
] as $method) {
|
|
||||||
self::assertFalse(method_exists(SecurityEvent::class, $method));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\Security\Event\SecurityEventSeverity;
|
||||||
|
use KTXC\Security\Event\SuspiciousActivityEvent;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SuspiciousActivityEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Suspicious-activity state is typed and complete at construction')]
|
||||||
|
public function constructsTypedState(): void
|
||||||
|
{
|
||||||
|
$event = new SuspiciousActivityEvent(
|
||||||
|
ipAddress: '203.0.113.20',
|
||||||
|
detector: 'payload-signature',
|
||||||
|
detectionData: ['score' => 98],
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
identityId: 'identity-a',
|
||||||
|
deviceFingerprint: 'device-a',
|
||||||
|
userAgent: 'Test Agent',
|
||||||
|
requestPath: '/admin',
|
||||||
|
requestMethod: 'POST',
|
||||||
|
userId: 'user-a',
|
||||||
|
reason: 'Matched a blocked payload signature',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(SuspiciousActivityEvent::class, $event->label());
|
||||||
|
self::assertSame('203.0.113.20', $event->getIpAddress());
|
||||||
|
self::assertSame('payload-signature', $event->getDetector());
|
||||||
|
self::assertSame(['score' => 98], $event->getDetectionData());
|
||||||
|
self::assertSame(['detector' => 'payload-signature', 'score' => 98], $event->context());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('identity-a', $event->actorIdentity());
|
||||||
|
self::assertSame('device-a', $event->getDeviceFingerprint());
|
||||||
|
self::assertSame('Test Agent', $event->getUserAgent());
|
||||||
|
self::assertSame('/admin', $event->getRequestPath());
|
||||||
|
self::assertSame('POST', $event->getRequestMethod());
|
||||||
|
self::assertSame('user-a', $event->getUserId());
|
||||||
|
self::assertSame('Matched a blocked payload signature', $event->getReason());
|
||||||
|
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Suspicious activity rejects incomplete or conflicting detection context')]
|
||||||
|
public function rejectsInvalidDetectionContext(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
['', 'payload-signature', []],
|
||||||
|
['203.0.113.20', '', []],
|
||||||
|
['203.0.113.20', 'payload-signature', ['detector' => 'replacement']],
|
||||||
|
] as $arguments) {
|
||||||
|
try {
|
||||||
|
new SuspiciousActivityEvent(...$arguments);
|
||||||
|
self::fail('Invalid suspicious-activity context was accepted.');
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Event;
|
||||||
|
|
||||||
|
use KTXC\User\Event\UserCreatedEvent;
|
||||||
|
use KTXC\User\Event\UserDeletingEvent;
|
||||||
|
use KTXC\User\Event\UserEvent;
|
||||||
|
use KTXC\User\Event\UserUpdatedEvent;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UserEventTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('User lifecycle events contain an immutable user snapshot')]
|
||||||
|
public function containsUserSnapshot(): void
|
||||||
|
{
|
||||||
|
$event = UserCreatedEvent::fromUser(
|
||||||
|
[
|
||||||
|
'uid' => 'user-a',
|
||||||
|
'identity' => 'person@example.test',
|
||||||
|
'label' => 'Person',
|
||||||
|
'enabled' => true,
|
||||||
|
'roles' => ['member'],
|
||||||
|
],
|
||||||
|
'tenant-a',
|
||||||
|
'actor-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(UserCreatedEvent::class, $event->label());
|
||||||
|
self::assertSame('user-a', $event->userIdentifier());
|
||||||
|
self::assertSame('person@example.test', $event->userIdentity());
|
||||||
|
self::assertSame('Person', $event->userLabel());
|
||||||
|
self::assertTrue($event->userEnabled());
|
||||||
|
self::assertSame(['member'], $event->userRoles());
|
||||||
|
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||||
|
self::assertSame('actor-a', $event->actorIdentifier());
|
||||||
|
self::assertSame('actor-a', $event->actorIdentity());
|
||||||
|
self::assertSame('string', (string) (new \ReflectionMethod(UserEvent::class, 'tenantIdentifier'))->getReturnType());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Created, updated, and deleting users have distinct event names')]
|
||||||
|
public function distinguishesLifecycleStage(): void
|
||||||
|
{
|
||||||
|
$user = ['uid' => 'user-a', 'identity' => 'person@example.test'];
|
||||||
|
|
||||||
|
self::assertSame(UserCreatedEvent::class, UserCreatedEvent::fromUser($user, 'tenant-a')->label());
|
||||||
|
self::assertSame(UserUpdatedEvent::class, UserUpdatedEvent::fromUser($user, 'tenant-a')->label());
|
||||||
|
self::assertSame(UserDeletingEvent::class, UserDeletingEvent::fromUser($user, 'tenant-a')->label());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('User lifecycle events reject incomplete snapshots')]
|
||||||
|
public function rejectsIncompleteSnapshot(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
UserCreatedEvent::fromUser(['identity' => 'person@example.test'], 'tenant-a');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,18 @@ use KTXC\Service\TenantFirewallStatusService;
|
|||||||
use KTXC\Service\SystemFirewallStatusService;
|
use KTXC\Service\SystemFirewallStatusService;
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
use KTXC\Event\EventListenerRegistry;
|
use KTXC\Event\EventListenerRegistry;
|
||||||
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
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 PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
@@ -42,15 +52,24 @@ final class CoreModuleTest extends TestCase
|
|||||||
$registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service,
|
$registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service,
|
||||||
);
|
);
|
||||||
self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred));
|
self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred));
|
||||||
|
$successListeners = $registry->listeners(
|
||||||
|
AuthenticationSucceededEvent::class,
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
);
|
||||||
|
self::assertCount(1, $successListeners);
|
||||||
|
self::assertSame(FirewallService::class, $successListeners[0]->service);
|
||||||
|
self::assertSame('logAuthenticationSuccess', $successListeners[0]->method);
|
||||||
foreach ([
|
foreach ([
|
||||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
AccessDeniedEvent::class,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
BruteForceDetectedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
RateLimitExceededEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
SuspiciousActivityEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleCreatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleExtendedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallRuleDisabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
FirewallRuleEnabledEvent::class,
|
||||||
|
FirewallRuleRemovedEvent::class,
|
||||||
|
FirewallSettingsUpdatedEvent::class,
|
||||||
] as $event) {
|
] as $event) {
|
||||||
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
||||||
self::assertCount(1, $listeners);
|
self::assertCount(1, $listeners);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use KTXC\Http\Middleware\RequestHandlerInterface;
|
|||||||
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\Http\Response\StreamedResponse;
|
use KTXC\Http\Response\StreamedResponse;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
@@ -78,6 +79,31 @@ final class HttpRuntimeTest extends TestCase
|
|||||||
self::assertTrue($kernel->outcome?->successful);
|
self::assertTrue($kernel->outcome?->successful);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('HTTP requests are available only for the active runtime execution')]
|
||||||
|
public function scopesRequestContext(): void
|
||||||
|
{
|
||||||
|
$requestContext = new RequestContext();
|
||||||
|
$observer = new RequestContextMiddleware($requestContext);
|
||||||
|
$kernel = new RuntimeKernel(
|
||||||
|
new RuntimeContainer([
|
||||||
|
RequestContext::class => $requestContext,
|
||||||
|
TenantMiddleware::class => new PassMiddleware(),
|
||||||
|
FirewallMiddleware::class => new PassMiddleware(),
|
||||||
|
AuthenticationMiddleware::class => new PassMiddleware(),
|
||||||
|
RouterMiddleware::class => $observer,
|
||||||
|
]),
|
||||||
|
new TenantContext($this->createStub(TenantService::class)),
|
||||||
|
new IdentityContext(),
|
||||||
|
);
|
||||||
|
$request = Request::create('/auth/verify', 'POST');
|
||||||
|
|
||||||
|
(new HttpRuntime($kernel, false))->run($request, send: false);
|
||||||
|
|
||||||
|
self::assertSame($request, $observer->observed);
|
||||||
|
self::assertNull($requestContext->current());
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('HTTP exceptions render a response and still terminate')]
|
#[TestDox('HTTP exceptions render a response and still terminate')]
|
||||||
public function fails(): void
|
public function fails(): void
|
||||||
@@ -198,9 +224,12 @@ final class RuntimeKernel implements KernelInterface
|
|||||||
|
|
||||||
final class RuntimeContainer implements ContainerInterface
|
final class RuntimeContainer implements ContainerInterface
|
||||||
{
|
{
|
||||||
|
private readonly array $services;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly array $services,
|
array $services,
|
||||||
) {
|
) {
|
||||||
|
$this->services = [RequestContext::class => new RequestContext(), ...$services];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $id): mixed
|
public function get(string $id): mixed
|
||||||
@@ -250,6 +279,21 @@ final class ResponseMiddleware extends PassMiddleware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class RequestContextMiddleware extends PassMiddleware
|
||||||
|
{
|
||||||
|
public ?Request $observed = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestContext $requestContext,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
|
{
|
||||||
|
$this->observed = $this->requestContext->current();
|
||||||
|
return new Response('ok');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class ThrowingMiddleware extends PassMiddleware
|
final class ThrowingMiddleware extends PassMiddleware
|
||||||
{
|
{
|
||||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Security;
|
||||||
|
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||||
|
use KTXC\Security\AuthenticationManager;
|
||||||
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
|
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||||
|
use KTXC\Service\TokenService;
|
||||||
|
use KTXC\Service\UserAccountsService;
|
||||||
|
use KTXF\Cache\CacheScope;
|
||||||
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
|
use KTXF\Security\Authentication\AuthenticationSession;
|
||||||
|
use KTXF\Security\Authentication\ProviderContext;
|
||||||
|
use KTXF\Security\Authentication\ProviderResult;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AuthenticationManagerTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function completedAuthenticationPublishesAuthenticationSucceededEvent(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('identifier')->willReturn('tenant-a');
|
||||||
|
$tenant->method('configuration')->willReturn(new TenantConfiguration());
|
||||||
|
|
||||||
|
$session = new AuthenticationSession(
|
||||||
|
id: 'auth-session',
|
||||||
|
state: AuthenticationSession::STATE_IDENTIFIED,
|
||||||
|
tenantIdentifier: 'tenant-a',
|
||||||
|
userIdentifier: 'user-a',
|
||||||
|
userIdentity: 'person@example.com',
|
||||||
|
methodsAvailable: ['password'],
|
||||||
|
createdAt: time(),
|
||||||
|
expiresAt: time() + 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
$cache = $this->createMock(EphemeralCacheInterface::class);
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('get')
|
||||||
|
->with('auth-session', CacheScope::Tenant, 'auth')
|
||||||
|
->willReturn($session);
|
||||||
|
$cache->expects($this->once())->method('set')->willReturn(true);
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('delete')
|
||||||
|
->with('auth-session', CacheScope::Tenant, 'auth')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$provider = new SuccessfulAuthenticationProvider();
|
||||||
|
$providers = $this->createStub(ProviderManager::class);
|
||||||
|
$providers->method('resolve')->willReturn($provider);
|
||||||
|
|
||||||
|
$users = $this->createMock(UserAccountsService::class);
|
||||||
|
$users->expects($this->once())
|
||||||
|
->method('fetchByIdentifier')
|
||||||
|
->with('user-a')
|
||||||
|
->willReturn([
|
||||||
|
'uid' => 'user-a',
|
||||||
|
'identity' => 'person@example.com',
|
||||||
|
'label' => 'Person',
|
||||||
|
'permissions' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tokens = $this->createStub(TokenService::class);
|
||||||
|
$tokens->method('createToken')->willReturn('token');
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->once())
|
||||||
|
->method('dispatch')
|
||||||
|
->with(self::callback(static fn($event): bool =>
|
||||||
|
$event instanceof AuthenticationSucceededEvent
|
||||||
|
&& $event->getUserId() === 'user-a'
|
||||||
|
&& $event->tenantIdentifier() === 'tenant-a'
|
||||||
|
));
|
||||||
|
|
||||||
|
$manager = new AuthenticationManager(
|
||||||
|
$tenant,
|
||||||
|
$cache,
|
||||||
|
$providers,
|
||||||
|
$tokens,
|
||||||
|
$users,
|
||||||
|
$events,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $manager->handle(AuthenticationRequest::verify(
|
||||||
|
'auth-session',
|
||||||
|
'password',
|
||||||
|
'correct',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertTrue($response->isSuccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('configuration')->willReturn(new TenantConfiguration());
|
||||||
|
|
||||||
|
$session = new AuthenticationSession(
|
||||||
|
id: 'auth-session',
|
||||||
|
state: AuthenticationSession::STATE_IDENTIFIED,
|
||||||
|
tenantIdentifier: 'tenant-a',
|
||||||
|
userIdentifier: 'user-a',
|
||||||
|
userIdentity: 'person@example.com',
|
||||||
|
methodsAvailable: ['password'],
|
||||||
|
createdAt: time(),
|
||||||
|
expiresAt: time() + 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
$cache = $this->createMock(EphemeralCacheInterface::class);
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('get')
|
||||||
|
->with('auth-session', CacheScope::Tenant, 'auth')
|
||||||
|
->willReturn($session);
|
||||||
|
$cache->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$provider = new class implements AuthenticationProviderInterface {
|
||||||
|
public int $verificationCount = 0;
|
||||||
|
|
||||||
|
public function type(): string
|
||||||
|
{
|
||||||
|
return 'authentication';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identifier(): string
|
||||||
|
{
|
||||||
|
return 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Test provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function method(): string
|
||||||
|
{
|
||||||
|
return self::METHOD_CREDENTIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function icon(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(ProviderContext $context, string $secret): ProviderResult
|
||||||
|
{
|
||||||
|
$this->verificationCount++;
|
||||||
|
return ProviderResult::failed(ProviderResult::ERROR_INVALID_FACTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginChallenge(ProviderContext $context): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginRedirect(
|
||||||
|
ProviderContext $context,
|
||||||
|
string $callbackUrl,
|
||||||
|
?string $returnUrl = null,
|
||||||
|
): ProviderResult {
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$providers = $this->createMock(ProviderManager::class);
|
||||||
|
$providers->method('resolve')->with('authentication', 'password')->willReturn($provider);
|
||||||
|
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->once())
|
||||||
|
->method('dispatch')
|
||||||
|
->with(self::callback(static function ($event): bool {
|
||||||
|
return $event instanceof AuthenticationFailedEvent
|
||||||
|
&& $event->getUserId() === 'user-a'
|
||||||
|
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
|
||||||
|
&& $event->tenantIdentifier() === 'tenant-a';
|
||||||
|
}));
|
||||||
|
|
||||||
|
$manager = new AuthenticationManager(
|
||||||
|
$tenant,
|
||||||
|
$cache,
|
||||||
|
$providers,
|
||||||
|
$this->createStub(TokenService::class),
|
||||||
|
$this->createStub(UserAccountsService::class),
|
||||||
|
$events,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $manager->handle(AuthenticationRequest::verify(
|
||||||
|
'auth-session',
|
||||||
|
'password',
|
||||||
|
'incorrect',
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertTrue($response->isFailed());
|
||||||
|
self::assertSame(1, $provider->verificationCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SuccessfulAuthenticationProvider implements AuthenticationProviderInterface
|
||||||
|
{
|
||||||
|
public function type(): string
|
||||||
|
{
|
||||||
|
return 'authentication';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identifier(): string
|
||||||
|
{
|
||||||
|
return 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Password';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Successful test provider';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function method(): string
|
||||||
|
{
|
||||||
|
return self::METHOD_CREDENTIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function icon(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(ProviderContext $context, string $secret): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginChallenge(ProviderContext $context): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginRedirect(
|
||||||
|
ProviderContext $context,
|
||||||
|
string $callbackUrl,
|
||||||
|
?string $returnUrl = null,
|
||||||
|
): ProviderResult {
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
||||||
|
{
|
||||||
|
return ProviderResult::failed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,14 @@ use KTXC\Service\FirewallRuleManager;
|
|||||||
use KTXC\Service\FirewallRuleScope;
|
use KTXC\Service\FirewallRuleScope;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
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\DeviceBlockedEvent;
|
||||||
|
use KTXC\Security\Event\IpAllowedEvent;
|
||||||
|
use KTXC\Security\Event\IpBlockedEvent;
|
||||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
@@ -41,7 +48,10 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
$this->store->method('findExactIpRule')->willReturn(null);
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
$this->store->expects($this->exactly(2))
|
$this->store->expects($this->exactly(2))
|
||||||
->method('depositRule')
|
->method('depositRule')
|
||||||
->willReturnArgument(0);
|
->willReturnCallback(static function (FirewallRuleObject $rule): FirewallRuleObject {
|
||||||
|
static $sequence = 0;
|
||||||
|
return $rule->setId('rule-' . ++$sequence);
|
||||||
|
});
|
||||||
|
|
||||||
$tenant = $this->manager->blockIp(
|
$tenant = $this->manager->blockIp(
|
||||||
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
|
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
|
||||||
@@ -135,7 +145,11 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
public function testConfirmedCurrentIpBlock(): void
|
public function testConfirmedCurrentIpBlock(): void
|
||||||
{
|
{
|
||||||
$this->store->method('findExactIpRule')->willReturn(null);
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
|
$this->store->expects(self::once())
|
||||||
|
->method('depositRule')
|
||||||
|
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
|
||||||
|
$rule->setId('rule-confirmed')
|
||||||
|
);
|
||||||
|
|
||||||
$rule = $this->manager->createManualRule(
|
$rule = $this->manager->createManualRule(
|
||||||
FirewallRuleScope::tenant('tenant-a'),
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
@@ -249,7 +263,9 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
$cache = new FirewallRuleCache($this->store);
|
$cache = new FirewallRuleCache($this->store);
|
||||||
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
|
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
|
||||||
$this->store->method('findExactIpRule')->willReturn(null);
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
$this->store->method('depositRule')->willReturnArgument(0);
|
$this->store->method('depositRule')->willReturnCallback(
|
||||||
|
static fn(FirewallRuleObject $rule): FirewallRuleObject => $rule->setId('rule-cache')
|
||||||
|
);
|
||||||
|
|
||||||
self::assertSame([], $cache->tenant('tenant-a'));
|
self::assertSame([], $cache->tenant('tenant-a'));
|
||||||
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
|
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
|
||||||
@@ -269,7 +285,7 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
$this->events->expects($this->exactly(2))
|
$this->events->expects($this->exactly(2))
|
||||||
->method('dispatch')
|
->method('dispatch')
|
||||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
||||||
$events[$event->getName()] = $event;
|
$events[$event->label()] = $event;
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->manager->blockIp(
|
$this->manager->blockIp(
|
||||||
@@ -280,14 +296,50 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
300
|
300
|
||||||
);
|
);
|
||||||
|
|
||||||
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED];
|
$audit = $events[FirewallRuleCreatedEvent::class];
|
||||||
self::assertSame('rule-123', $audit->get('ruleId'));
|
self::assertSame('rule-123', $audit->get('ruleId'));
|
||||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
|
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
|
||||||
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
|
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
|
||||||
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
|
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
|
||||||
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
|
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
|
||||||
self::assertSame('admin-a', $audit->getIdentityId());
|
self::assertSame('admin-a', $audit->actorIdentity());
|
||||||
self::assertNotNull($audit->get('expiresAt'));
|
self::assertNotNull($audit->get('expiresAt'));
|
||||||
|
self::assertInstanceOf(IpBlockedEvent::class, $events[IpBlockedEvent::class]);
|
||||||
|
self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('IP allowance and device blocking publish dedicated policy events')]
|
||||||
|
public function testSubjectPolicyEvents(): void
|
||||||
|
{
|
||||||
|
$this->store->method('findExactIpRule')->willReturn(null);
|
||||||
|
$this->store->method('depositRule')->willReturnCallback(
|
||||||
|
static function (FirewallRuleObject $rule): FirewallRuleObject {
|
||||||
|
static $sequence = 0;
|
||||||
|
return $rule->setId('policy-rule-' . ++$sequence);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
$events = [];
|
||||||
|
$this->events->expects($this->exactly(4))
|
||||||
|
->method('dispatch')
|
||||||
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
||||||
|
$events[] = $event;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->manager->allowIp(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
'203.0.113.11',
|
||||||
|
'Trusted service',
|
||||||
|
'admin-a',
|
||||||
|
);
|
||||||
|
$this->manager->blockDevice(
|
||||||
|
FirewallRuleScope::tenant('tenant-a'),
|
||||||
|
'device-a',
|
||||||
|
'Compromised device',
|
||||||
|
'admin-a',
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof IpAllowedEvent));
|
||||||
|
self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof DeviceBlockedEvent));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')]
|
#[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')]
|
||||||
@@ -329,17 +381,17 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
|
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
|
||||||
self::assertCount(1, $extended->getMetadata()['extensions']);
|
self::assertCount(1, $extended->getMetadata()['extensions']);
|
||||||
self::assertSame([
|
self::assertSame([
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleEnabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleExtendedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallRuleRemovedEvent::class,
|
||||||
], array_map(static fn($event): string => $event->getName(), $audits));
|
], array_map(static fn($event): string => $event->label(), $audits));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
|
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
|
||||||
array_map(static fn($event): string => $event->get('changeReason'), $audits)
|
array_map(static fn($event): string => $event->get('changeReason'), $audits)
|
||||||
);
|
);
|
||||||
self::assertSame(['operator'], array_values(array_unique(array_map(
|
self::assertSame(['operator'], array_values(array_unique(array_map(
|
||||||
static fn($event): ?string => $event->getIdentityId(),
|
static fn($event): ?string => $event->actorIdentity(),
|
||||||
$audits
|
$audits
|
||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
@@ -401,7 +453,7 @@ class FirewallRuleManagerTest extends TestCase
|
|||||||
$this->events->expects(self::once())
|
$this->events->expects(self::once())
|
||||||
->method('dispatch')
|
->method('dispatch')
|
||||||
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
|
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
|
||||||
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
|
$event instanceof FirewallRuleExtendedEvent
|
||||||
&& $event->get('lastFailureCount') === 8
|
&& $event->get('lastFailureCount') === 8
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ class FirewallRuleServicesTest extends TestCase
|
|||||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||||
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
|
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
|
||||||
))
|
))
|
||||||
->willReturnArgument(0);
|
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
|
||||||
|
$rule->setId('tenant-rule')
|
||||||
|
);
|
||||||
|
|
||||||
$this->tenantService()->blockIp('203.0.113.10');
|
$this->tenantService()->blockIp('203.0.113.10');
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,9 @@ class FirewallRuleServicesTest extends TestCase
|
|||||||
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
|
||||||
$rule->isSystemScoped() && $rule->getTenantId() === null
|
$rule->isSystemScoped() && $rule->getTenantId() === null
|
||||||
))
|
))
|
||||||
->willReturnArgument(0);
|
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
|
||||||
|
$rule->setId('system-rule')
|
||||||
|
);
|
||||||
|
|
||||||
$this->systemService()->blockIp('203.0.113.10');
|
$this->systemService()->blockIp('203.0.113.10');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ declare(strict_types=1);
|
|||||||
namespace KTXT\Unit\Service;
|
namespace KTXT\Unit\Service;
|
||||||
|
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
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\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
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\FirewallSettingsUpdatedEvent;
|
||||||
|
use KTXC\Security\Event\RateLimitExceededEvent;
|
||||||
|
use KTXC\Security\Event\SuspiciousActivityEvent;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
use KTXC\Service\FirewallRuleCache;
|
use KTXC\Service\FirewallRuleCache;
|
||||||
use KTXC\Service\FirewallRuleManager;
|
use KTXC\Service\FirewallRuleManager;
|
||||||
@@ -25,6 +35,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
private FirewallStore&MockObject $store;
|
private FirewallStore&MockObject $store;
|
||||||
private TenantContextInterface&MockObject $tenantContext;
|
private TenantContextInterface&MockObject $tenantContext;
|
||||||
private EventDispatcherInterface&MockObject $events;
|
private EventDispatcherInterface&MockObject $events;
|
||||||
|
private RequestContext $requestContext;
|
||||||
private FirewallService $service;
|
private FirewallService $service;
|
||||||
private ?string $currentTenant;
|
private ?string $currentTenant;
|
||||||
private ?TenantConfiguration $currentConfiguration;
|
private ?TenantConfiguration $currentConfiguration;
|
||||||
@@ -34,6 +45,16 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store = $this->createMock(FirewallStore::class);
|
$this->store = $this->createMock(FirewallStore::class);
|
||||||
$this->tenantContext = $this->createMock(TenantContextInterface::class);
|
$this->tenantContext = $this->createMock(TenantContextInterface::class);
|
||||||
$this->events = $this->createMock(EventDispatcherInterface::class);
|
$this->events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$this->requestContext = new RequestContext();
|
||||||
|
$this->requestContext->initialize(Request::create(
|
||||||
|
'/login',
|
||||||
|
'POST',
|
||||||
|
server: [
|
||||||
|
'REMOTE_ADDR' => '203.0.113.10',
|
||||||
|
'HTTP_USER_AGENT' => 'Test Agent',
|
||||||
|
'HTTP_X_DEVICE_FINGERPRINT' => 'device-a',
|
||||||
|
],
|
||||||
|
));
|
||||||
$this->currentTenant = 'tenant-a';
|
$this->currentTenant = 'tenant-a';
|
||||||
$this->currentConfiguration = null;
|
$this->currentConfiguration = null;
|
||||||
$this->tenantContext->method('identifier')->willReturnCallback(
|
$this->tenantContext->method('identifier')->willReturnCallback(
|
||||||
@@ -49,7 +70,8 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->tenantContext,
|
$this->tenantContext,
|
||||||
$this->events,
|
$this->events,
|
||||||
$manager,
|
$manager,
|
||||||
$cache
|
$cache,
|
||||||
|
$this->requestContext,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +93,15 @@ class FirewallServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]);
|
$this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]);
|
||||||
$this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]);
|
$this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]);
|
||||||
$this->events->expects($this->once())->method('dispatch');
|
$this->events->expects($this->once())
|
||||||
|
->method('dispatch')
|
||||||
|
->with(self::callback(static function ($event): bool {
|
||||||
|
return $event instanceof AccessDeniedEvent
|
||||||
|
&& $event->getIpAddress() === '203.0.113.10'
|
||||||
|
&& $event->getRuleId() === 'system-block'
|
||||||
|
&& $event->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
|
||||||
|
&& $event->getReason() === 'system-block';
|
||||||
|
}));
|
||||||
|
|
||||||
$result = $this->service->analyze('203.0.113.10');
|
$result = $this->service->analyze('203.0.113.10');
|
||||||
|
|
||||||
@@ -211,13 +241,12 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH;
|
&& $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH;
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
$event = \KTXC\Security\Event\SecurityEvent::accessDenied(
|
$event = new AccessDeniedEvent(
|
||||||
'203.0.113.10',
|
ipAddress: '203.0.113.10',
|
||||||
null,
|
ruleId: 'tenant-rule',
|
||||||
'tenant-rule',
|
ruleScope: FirewallRuleObject::SCOPE_TENANT,
|
||||||
FirewallRuleObject::SCOPE_TENANT,
|
reason: 'Tenant block',
|
||||||
'Tenant block',
|
tenantId: 'tenant-a',
|
||||||
'tenant-a',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->service->logSecurityEvent($event);
|
$this->service->logSecurityEvent($event);
|
||||||
@@ -235,12 +264,11 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM;
|
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM;
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
$event = \KTXC\Security\Event\SecurityEvent::accessDenied(
|
$event = new AccessDeniedEvent(
|
||||||
'203.0.113.10',
|
ipAddress: '203.0.113.10',
|
||||||
null,
|
ruleId: 'system-rule',
|
||||||
'system-rule',
|
ruleScope: FirewallRuleObject::SCOPE_SYSTEM,
|
||||||
FirewallRuleObject::SCOPE_SYSTEM,
|
reason: 'System block',
|
||||||
'System block'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->service->logSecurityEvent($event);
|
$this->service->logSecurityEvent($event);
|
||||||
@@ -253,7 +281,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store->expects($this->never())->method('createLog');
|
$this->store->expects($this->never())->method('createLog');
|
||||||
|
|
||||||
$this->service->logSecurityEvent(
|
$this->service->logSecurityEvent(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +300,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $metadata['windowSeconds'] === 60;
|
&& $metadata['windowSeconds'] === 60;
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
$event = \KTXC\Security\Event\SecurityEvent::rateLimitExceeded(
|
$event = new RateLimitExceededEvent(
|
||||||
'203.0.113.10',
|
'203.0.113.10',
|
||||||
101,
|
101,
|
||||||
60,
|
60,
|
||||||
@@ -283,6 +311,25 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->service->logSecurityEvent($event);
|
$this->service->logSecurityEvent($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Successful authentication maps to an allowed access log')]
|
||||||
|
public function testAuthenticationSuccessAudit(): void
|
||||||
|
{
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('createLog')
|
||||||
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||||
|
return $log->getEventType() === FirewallLogObject::EVENT_ACCESS_CHECK
|
||||||
|
&& $log->getResult() === FirewallLogObject::RESULT_ALLOWED
|
||||||
|
&& $log->getIpAddress() === '203.0.113.10'
|
||||||
|
&& $log->getIdentityId() === 'user-a';
|
||||||
|
}))
|
||||||
|
->willReturnArgument(0);
|
||||||
|
|
||||||
|
$this->service->logAuthenticationSuccess(new AuthenticationSucceededEvent(
|
||||||
|
'user-a',
|
||||||
|
'tenant-a',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[TestDox('Suspicious-activity events retain request and detection metadata')]
|
#[TestDox('Suspicious-activity events retain request and detection metadata')]
|
||||||
public function testSuspiciousActivityAudit(): void
|
public function testSuspiciousActivityAudit(): void
|
||||||
{
|
{
|
||||||
@@ -297,11 +344,9 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $log->getMetadata()['detector'] === 'payload-signature';
|
&& $log->getMetadata()['detector'] === 'payload-signature';
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
$event = \KTXC\Security\Event\SecurityEvent::create(
|
$event = new SuspiciousActivityEvent(
|
||||||
\KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY,
|
ipAddress: '203.0.113.20',
|
||||||
'203.0.113.20',
|
detector: 'payload-signature',
|
||||||
null,
|
|
||||||
['detector' => 'payload-signature'],
|
|
||||||
tenantId: 'tenant-a',
|
tenantId: 'tenant-a',
|
||||||
requestPath: '/admin',
|
requestPath: '/admin',
|
||||||
requestMethod: 'POST',
|
requestMethod: 'POST',
|
||||||
@@ -324,14 +369,15 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $log->getIdentityId() === 'operator';
|
&& $log->getIdentityId() === 'operator';
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
$event = new \KTXC\Security\Event\SecurityEvent(
|
$event = FirewallRuleDisabledEvent::fromRule(
|
||||||
\KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_DISABLED,
|
(new FirewallRuleObject())
|
||||||
[
|
->setId('rule-123')
|
||||||
'ruleId' => 'rule-123',
|
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
|
||||||
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
|
->setType(FirewallRuleObject::TYPE_IP)
|
||||||
'origin' => FirewallRuleManager::ORIGIN_MANUAL,
|
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||||
],
|
->setValue('203.0.113.10')
|
||||||
identityId: 'operator',
|
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]),
|
||||||
|
'operator',
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->service->logSecurityEvent($event);
|
$this->service->logSecurityEvent($event);
|
||||||
@@ -350,11 +396,12 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $log->getMetadata()['changeReason'] === 'Tighten controls'
|
&& $log->getMetadata()['changeReason'] === 'Tighten controls'
|
||||||
))
|
))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
$event = new \KTXC\Security\Event\SecurityEvent(
|
$event = new FirewallSettingsUpdatedEvent(
|
||||||
\KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
changeReason: 'Tighten controls',
|
||||||
['changeReason' => 'Tighten controls'],
|
previous: ['maxAuthFailures' => 5],
|
||||||
|
current: ['maxAuthFailures' => 3],
|
||||||
tenantId: 'tenant-a',
|
tenantId: 'tenant-a',
|
||||||
identityId: 'operator',
|
actorId: 'operator',
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->service->logSecurityEvent($event);
|
$this->service->logSecurityEvent($event);
|
||||||
@@ -378,16 +425,53 @@ class FirewallServiceTest extends TestCase
|
|||||||
->willReturn(4);
|
->willReturn(4);
|
||||||
$this->events->expects($this->never())->method('dispatch');
|
$this->events->expects($this->never())->method('dispatch');
|
||||||
|
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-a');
|
||||||
'203.0.113.10',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
);
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
|
|
||||||
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
|
||||||
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[TestDox('Authentication failures combine event facts with the current request')]
|
||||||
|
public function testAuthenticationFailureRequestContext(): void
|
||||||
|
{
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('createLogOnce')
|
||||||
|
->with(self::callback(static function (FirewallLogObject $log): bool {
|
||||||
|
return $log->getIpAddress() === '203.0.113.10'
|
||||||
|
&& $log->getDeviceFingerprint() === 'device-a'
|
||||||
|
&& $log->getUserAgent() === 'Test Agent'
|
||||||
|
&& $log->getRequestPath() === '/login'
|
||||||
|
&& $log->getRequestMethod() === 'POST'
|
||||||
|
&& $log->getIdentityId() === 'user-a'
|
||||||
|
&& $log->getMetadata()['reason'] === 'invalid_credentials';
|
||||||
|
}))
|
||||||
|
->willReturn(true);
|
||||||
|
$this->store->expects($this->once())
|
||||||
|
->method('countRecentFailures')
|
||||||
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
|
->willReturn(0);
|
||||||
|
|
||||||
|
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
|
||||||
|
userId: 'user-a',
|
||||||
|
reason: 'invalid_credentials',
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('Authentication failures outside HTTP request context are ignored')]
|
||||||
|
public function testAuthenticationFailureWithoutRequestContext(): void
|
||||||
|
{
|
||||||
|
$this->requestContext->clear();
|
||||||
|
$this->store->expects($this->never())->method('createLogOnce');
|
||||||
|
$this->store->expects($this->never())->method('countRecentFailures');
|
||||||
|
|
||||||
|
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
|
||||||
|
reason: 'invalid_credentials',
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
|
||||||
public function testConfigurationBounds(): void
|
public function testConfigurationBounds(): void
|
||||||
{
|
{
|
||||||
@@ -404,10 +488,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->with('tenant-a', '203.0.113.10', 300)
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
->willReturn(0);
|
->willReturn(0);
|
||||||
|
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-a');
|
||||||
'203.0.113.10',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
);
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,29 +525,37 @@ class FirewallServiceTest extends TestCase
|
|||||||
&& $metadata['lastFailureCount'] === 5
|
&& $metadata['lastFailureCount'] === 5
|
||||||
&& $metadata['blockDurationSeconds'] === 3600;
|
&& $metadata['blockDurationSeconds'] === 3600;
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
|
||||||
|
$rule->setId('automatic-rule')
|
||||||
|
);
|
||||||
|
|
||||||
$publishedTenants = [];
|
$publishedTenants = [];
|
||||||
$lifecycleOrigin = null;
|
$lifecycleOrigin = null;
|
||||||
|
$bruteForceEvent = null;
|
||||||
$this->events->expects($this->exactly(3))
|
$this->events->expects($this->exactly(3))
|
||||||
->method('dispatch')
|
->method('dispatch')
|
||||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
|
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
|
||||||
&$publishedTenants,
|
&$publishedTenants,
|
||||||
&$lifecycleOrigin
|
&$lifecycleOrigin,
|
||||||
|
&$bruteForceEvent,
|
||||||
): void {
|
): void {
|
||||||
$publishedTenants[] = $event->getTenantId();
|
$publishedTenants[] = $event->tenantIdentifier();
|
||||||
if ($event->getName() === \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_CREATED) {
|
if ($event instanceof BruteForceDetectedEvent) {
|
||||||
|
$bruteForceEvent = $event;
|
||||||
|
}
|
||||||
|
if ($event instanceof FirewallRuleCreatedEvent) {
|
||||||
$lifecycleOrigin = $event->get('origin');
|
$lifecycleOrigin = $event->get('origin');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$event = new AuthenticationFailedEvent(
|
$event = new AuthenticationFailedEvent(tenantId: 'tenant-event');
|
||||||
'203.0.113.10',
|
|
||||||
tenantId: 'tenant-event',
|
|
||||||
);
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
|
|
||||||
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
|
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
|
||||||
|
self::assertInstanceOf(BruteForceDetectedEvent::class, $bruteForceEvent);
|
||||||
|
self::assertSame('203.0.113.10', $bruteForceEvent->getIpAddress());
|
||||||
|
self::assertSame(5, $bruteForceEvent->getFailureCount());
|
||||||
|
self::assertSame(300, $bruteForceEvent->getWindowSeconds());
|
||||||
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
|
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +575,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->events->expects($this->never())->method('dispatch');
|
$this->events->expects($this->never())->method('dispatch');
|
||||||
|
|
||||||
$this->service->handleAuthFailure(
|
$this->service->handleAuthFailure(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +589,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->willReturn(0);
|
->willReturn(0);
|
||||||
|
|
||||||
$this->service->handleAuthFailure(
|
$this->service->handleAuthFailure(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +601,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store->expects($this->never())->method('depositRule');
|
$this->store->expects($this->never())->method('depositRule');
|
||||||
|
|
||||||
$this->service->handleAuthFailure(
|
$this->service->handleAuthFailure(
|
||||||
new AuthenticationFailedEvent('203.0.113.10')
|
new AuthenticationFailedEvent()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,13 +615,13 @@ class FirewallServiceTest extends TestCase
|
|||||||
->method('countRecentFailures')
|
->method('countRecentFailures')
|
||||||
->with('tenant-a', '203.0.113.10', 300)
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
->willReturn(1);
|
->willReturn(1);
|
||||||
$event = new AuthenticationFailedEvent('203.0.113.10');
|
$event = new AuthenticationFailedEvent();
|
||||||
$eventId = $event->getEventId();
|
$eventId = $event->identifier();
|
||||||
|
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
$this->service->handleAuthFailure($event);
|
$this->service->handleAuthFailure($event);
|
||||||
|
|
||||||
self::assertSame($eventId, $event->getEventId());
|
self::assertSame($eventId, $event->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[TestDox('Cleanup records successful maintenance counts')]
|
#[TestDox('Cleanup records successful maintenance counts')]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use KTXC\Models\Tenant\TenantObject;
|
|||||||
use KTXC\Service\FirewallSettingsService;
|
use KTXC\Service\FirewallSettingsService;
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Service\TenantService;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -37,10 +37,10 @@ final class FirewallSettingsServiceTest extends TestCase
|
|||||||
$events = $this->createMock(EventDispatcherInterface::class);
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
$events->expects(self::once())
|
$events->expects(self::once())
|
||||||
->method('dispatch')
|
->method('dispatch')
|
||||||
->with(self::callback(static fn(SecurityEvent $event): bool =>
|
->with(self::callback(static fn(FirewallSettingsUpdatedEvent $event): bool =>
|
||||||
$event->getName() === SecurityEvent::FIREWALL_SETTINGS_UPDATED
|
$event->label() === FirewallSettingsUpdatedEvent::class
|
||||||
&& $event->getTenantId() === 'tenant-a'
|
&& $event->tenantIdentifier() === 'tenant-a'
|
||||||
&& $event->getIdentityId() === 'admin-a'
|
&& $event->actorIdentity() === 'admin-a'
|
||||||
&& $event->get('changeReason') === 'Tighten authentication controls'
|
&& $event->get('changeReason') === 'Tighten authentication controls'
|
||||||
&& $event->get('previous')['maxAuthFailures'] === 5
|
&& $event->get('previous')['maxAuthFailures'] === 5
|
||||||
&& $event->get('current')['maxAuthFailures'] === 8
|
&& $event->get('current')['maxAuthFailures'] === 8
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Service;
|
||||||
|
|
||||||
|
use KTXC\Context\IdentityContextInterface;
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Service\UserAccountsService;
|
||||||
|
use KTXC\Stores\UserAccountsStore;
|
||||||
|
use KTXC\User\Event\UserCreatedEvent;
|
||||||
|
use KTXC\User\Event\UserDeletingEvent;
|
||||||
|
use KTXC\User\Event\UserUpdatedEvent;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class UserAccountsServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Successful user creation and deletion emit complete lifecycle events')]
|
||||||
|
public function emitsLifecycleEvents(): void
|
||||||
|
{
|
||||||
|
$user = [
|
||||||
|
'uid' => 'user-a',
|
||||||
|
'identity' => 'person@example.test',
|
||||||
|
'label' => 'Person',
|
||||||
|
'enabled' => true,
|
||||||
|
'roles' => ['member'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||||
|
$identity = $this->createStub(IdentityContextInterface::class);
|
||||||
|
$identity->method('identifier')->willReturn('actor-a');
|
||||||
|
$store = $this->createMock(UserAccountsStore::class);
|
||||||
|
$store->expects($this->once())
|
||||||
|
->method('createUser')
|
||||||
|
->with('tenant-a', ['identity' => 'person@example.test'])
|
||||||
|
->willReturn($user);
|
||||||
|
$store->expects($this->once())
|
||||||
|
->method('fetchByIdentifier')
|
||||||
|
->with('tenant-a', 'user-a')
|
||||||
|
->willReturn($user);
|
||||||
|
$operations = [];
|
||||||
|
$store->expects($this->once())
|
||||||
|
->method('deleteUser')
|
||||||
|
->with('tenant-a', 'user-a')
|
||||||
|
->willReturnCallback(static function () use (&$operations): bool {
|
||||||
|
$operations[] = 'delete';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$emitted = [];
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->exactly(2))
|
||||||
|
->method('dispatch')
|
||||||
|
->willReturnCallback(static function ($event) use (&$emitted, &$operations): void {
|
||||||
|
$emitted[] = $event;
|
||||||
|
if ($event instanceof UserDeletingEvent) {
|
||||||
|
$operations[] = 'event';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new UserAccountsService($tenant, $identity, $store, $events);
|
||||||
|
|
||||||
|
self::assertSame($user, $service->createUser(['identity' => 'person@example.test']));
|
||||||
|
self::assertTrue($service->deleteUser('user-a'));
|
||||||
|
self::assertInstanceOf(UserCreatedEvent::class, $emitted[0]);
|
||||||
|
self::assertInstanceOf(UserDeletingEvent::class, $emitted[1]);
|
||||||
|
self::assertSame('actor-a', $emitted[0]->actorIdentifier());
|
||||||
|
self::assertSame('tenant-a', $emitted[1]->tenantIdentifier());
|
||||||
|
self::assertSame(['event', 'delete'], $operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Deleting event precedes a failed persistence attempt')]
|
||||||
|
public function emitsBeforeFailedDeletion(): void
|
||||||
|
{
|
||||||
|
$store = $this->createStub(UserAccountsStore::class);
|
||||||
|
$store->method('fetchByIdentifier')->willReturn([
|
||||||
|
'uid' => 'user-a',
|
||||||
|
'identity' => 'person@example.test',
|
||||||
|
]);
|
||||||
|
$store->method('deleteUser')->willReturn(false);
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->once())
|
||||||
|
->method('dispatch')
|
||||||
|
->with(self::isInstanceOf(UserDeletingEvent::class));
|
||||||
|
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||||
|
$service = new UserAccountsService(
|
||||||
|
$tenant,
|
||||||
|
$this->createStub(IdentityContextInterface::class),
|
||||||
|
$store,
|
||||||
|
$events,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($service->deleteUser('user-a'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Successful updates emit the persisted user snapshot after storage')]
|
||||||
|
public function emitsAfterSuccessfulUpdate(): void
|
||||||
|
{
|
||||||
|
$updatedUser = [
|
||||||
|
'uid' => 'user-a',
|
||||||
|
'identity' => 'person@example.test',
|
||||||
|
'label' => 'Updated Person',
|
||||||
|
'enabled' => true,
|
||||||
|
'roles' => ['admin'],
|
||||||
|
];
|
||||||
|
$operations = [];
|
||||||
|
$store = $this->createMock(UserAccountsStore::class);
|
||||||
|
$store->expects($this->once())
|
||||||
|
->method('updateUser')
|
||||||
|
->with('tenant-a', 'user-a', ['label' => 'Updated Person'])
|
||||||
|
->willReturnCallback(static function () use (&$operations): bool {
|
||||||
|
$operations[] = 'update';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$store->expects($this->once())
|
||||||
|
->method('fetchByIdentifier')
|
||||||
|
->with('tenant-a', 'user-a')
|
||||||
|
->willReturnCallback(static function () use (&$operations, $updatedUser): array {
|
||||||
|
$operations[] = 'fetch';
|
||||||
|
return $updatedUser;
|
||||||
|
});
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->once())
|
||||||
|
->method('dispatch')
|
||||||
|
->with(self::callback(static function ($event) use (&$operations): bool {
|
||||||
|
$operations[] = 'event';
|
||||||
|
return $event instanceof UserUpdatedEvent
|
||||||
|
&& $event->userLabel() === 'Updated Person'
|
||||||
|
&& $event->userRoles() === ['admin'];
|
||||||
|
}));
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||||
|
$identity = $this->createStub(IdentityContextInterface::class);
|
||||||
|
$identity->method('identifier')->willReturn('actor-a');
|
||||||
|
$service = new UserAccountsService($tenant, $identity, $store, $events);
|
||||||
|
|
||||||
|
self::assertTrue($service->updateUser('user-a', ['label' => 'Updated Person']));
|
||||||
|
self::assertSame(['update', 'fetch', 'event'], $operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Unchanged users do not emit an updated event')]
|
||||||
|
public function ignoresUnchangedUser(): void
|
||||||
|
{
|
||||||
|
$store = $this->createMock(UserAccountsStore::class);
|
||||||
|
$store->expects($this->once())->method('updateUser')->willReturn(false);
|
||||||
|
$store->expects($this->never())->method('fetchByIdentifier');
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->never())->method('dispatch');
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||||
|
$service = new UserAccountsService(
|
||||||
|
$tenant,
|
||||||
|
$this->createStub(IdentityContextInterface::class),
|
||||||
|
$store,
|
||||||
|
$events,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($service->updateUser('user-a', ['label' => 'Person']));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Missing users do not emit a deleting event')]
|
||||||
|
public function ignoresMissingUser(): void
|
||||||
|
{
|
||||||
|
$store = $this->createStub(UserAccountsStore::class);
|
||||||
|
$store->method('fetchByIdentifier')->willReturn(null);
|
||||||
|
$events = $this->createMock(EventDispatcherInterface::class);
|
||||||
|
$events->expects($this->never())->method('dispatch');
|
||||||
|
|
||||||
|
$tenant = $this->createStub(TenantContextInterface::class);
|
||||||
|
$tenant->method('requireIdentifier')->willReturn('tenant-a');
|
||||||
|
$service = new UserAccountsService(
|
||||||
|
$tenant,
|
||||||
|
$this->createStub(IdentityContextInterface::class),
|
||||||
|
$store,
|
||||||
|
$events,
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($service->deleteUser('missing'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user