8 Commits

Author SHA1 Message Date
Sebastian 71ddd3442f fix: improve secret input
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-08 22:39:48 -04:00
Sebastian 0dd735045d fix: remove extra password labek
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-08 22:30:20 -04:00
Sebastian 5646591c74 fix: auth icons
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-08 22:26:47 -04:00
Sebastian a5acea72c3 fix: allow any login name not just email
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-08 22:22:43 -04:00
Sebastian 62b416f13e refactor: base event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-08 22:15:24 -04:00
Sebastian c8e6efe203 feat: improve deferred event processing
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-08 00:20:32 -04:00
Sebastian f147ffc5c7 feat: introduce user management events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-07 22:32:45 -04:00
Sebastian 985e4a6450 fix: find npm path
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-07 00:46:36 -04:00
34 changed files with 720 additions and 141 deletions
@@ -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,
+7 -5
View File
@@ -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,
+11 -1
View File
@@ -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;
} }
@@ -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,
) { ) {
} }
} }
+47 -14
View File
@@ -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.');
} }
@@ -61,13 +77,15 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
} }
try { try {
$processed = 0; $processedEvents = 0;
$deadline = microtime(true) + 1.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;
} }
@@ -76,14 +94,29 @@ 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;
}
$listenerInvocations += $this->invoke($event, DeliveryMode::Deferred);
$processedEvents++;
} }
return new DeferredProcessingResult( return new DeferredProcessingResult(
$processed, processed: $processedEvents,
count($this->deferred[$executionId]), remaining: count($this->deferred[$executionId]),
$deadlineExceeded, deadlineExceeded: $deadlineExceeded,
$limitExceeded, limitExceeded: $eventLimitExceeded || $listenerInvocationLimitExceeded,
listenerInvocations: $listenerInvocations,
eventLimitExceeded: $eventLimitExceeded,
listenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
); );
} finally { } finally {
$this->discardDeferred($executionId); $this->discardDeferred($executionId);
@@ -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,
+9
View File
@@ -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,
); );
} }
@@ -6,17 +6,17 @@ 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 getUserId(): ?string; public function getUserId(): ?string;
+7 -7
View File
@@ -149,7 +149,7 @@ class FirewallService
{ {
$request = $this->requestContext->current(); $request = $this->requestContext->current();
$ipAddress = $request?->getClientIp(); $ipAddress = $request?->getClientIp();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); $tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) { if (!$ipAddress || !$tenantId) {
return; return;
@@ -262,7 +262,7 @@ class FirewallService
?Request $request = null, ?Request $request = null,
): ?FirewallLogObject ): ?FirewallLogObject
{ {
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); $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;
@@ -271,7 +271,7 @@ class FirewallService
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : 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($request?->getClientIp() ?? $requestEvent?->getIpAddress()) ->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
->setDeviceFingerprint( ->setDeviceFingerprint(
@@ -281,13 +281,13 @@ class FirewallService
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent()) ->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath()) ->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod()) ->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
->setEventType($this->mapEventToLogType($event->getName())) ->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());
} }
/** /**
@@ -317,7 +317,7 @@ class FirewallService
*/ */
private function mapEventToResult(SecurityEventInterface $event): string private function mapEventToResult(SecurityEventInterface $event): string
{ {
return match ($event->getName()) { return match ($event->label()) {
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED, AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
FirewallRuleCreatedEvent::class, FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class, FirewallRuleExtendedEvent::class,
+48 -11
View File
@@ -6,14 +6,19 @@ use KTXC\Models\Identity\User;
use KTXC\Context\IdentityContextInterface; use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXC\User\Event\UserUpdatedEvent;
use KTXF\Event\EventDispatcherInterface;
class UserAccountsService class UserAccountsService
{ {
public function __construct( public function __construct(
private readonly TenantContextInterface $tenantContext, private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsStore $userStore private readonly UserAccountsStore $userStore,
private readonly EventDispatcherInterface $events,
) { ) {
} }
@@ -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;
}
$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 $uid): bool 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
* *
+9
View File
@@ -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
{
}
+94
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\User\Event;
final class UserUpdatedEvent extends UserEvent
{
}
+31 -20
View File
@@ -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>
+24
View File
@@ -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
+33 -33
View File
@@ -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(),
) )
); );
@@ -27,14 +27,14 @@ final class AccessDeniedEventTest extends TestCase
'identity-a', 'identity-a',
); );
self::assertSame(AccessDeniedEvent::class, $event->getName()); self::assertSame(AccessDeniedEvent::class, $event->label());
self::assertSame('203.0.113.10', $event->getIpAddress()); self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame('device-a', $event->getDeviceFingerprint()); self::assertSame('device-a', $event->getDeviceFingerprint());
self::assertSame('rule-a', $event->getRuleId()); self::assertSame('rule-a', $event->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope()); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
self::assertSame('Blocked by policy', $event->getReason()); self::assertSame('Blocked by policy', $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(SecurityEventSeverity::WARNING, $event->getSeverity()); self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
} }
@@ -24,11 +24,11 @@ final class AuthenticationFailedEventTest extends TestCase
'identity-a', 'identity-a',
); );
self::assertSame(AuthenticationFailedEvent::class, $event->getName()); self::assertSame(AuthenticationFailedEvent::class, $event->label());
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(SecurityEventSeverity::WARNING, $event->getSeverity()); self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event); self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
} }
@@ -22,10 +22,10 @@ final class AuthenticationSucceededEventTest extends TestCase
'tenant-a', 'tenant-a',
); );
self::assertSame(AuthenticationSucceededEvent::class, $event->getName()); self::assertSame(AuthenticationSucceededEvent::class, $event->label());
self::assertSame('user-a', $event->getUserId()); self::assertSame('user-a', $event->getUserId());
self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('tenant-a', $event->tenantIdentifier());
self::assertSame(['userId' => 'user-a'], $event->getData()); self::assertSame(['userId' => 'user-a'], $event->context());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity()); self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event); self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
} }
@@ -23,15 +23,15 @@ final class BruteForceDetectedEventTest extends TestCase
'tenant-a', 'tenant-a',
); );
self::assertSame(BruteForceDetectedEvent::class, $event->getName()); self::assertSame(BruteForceDetectedEvent::class, $event->label());
self::assertSame('203.0.113.10', $event->getIpAddress()); self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame(5, $event->getFailureCount()); self::assertSame(5, $event->getFailureCount());
self::assertSame(300, $event->getWindowSeconds()); self::assertSame(300, $event->getWindowSeconds());
self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('tenant-a', $event->tenantIdentifier());
self::assertSame('5 failed attempts in 300 seconds', $event->getReason()); self::assertSame('5 failed attempts in 300 seconds', $event->getReason());
self::assertSame( self::assertSame(
['failureCount' => 5, 'windowSeconds' => 300], ['failureCount' => 5, 'windowSeconds' => 300],
$event->getData(), $event->context(),
); );
self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity()); self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity());
} }
@@ -230,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);
} }
} }
+6 -3
View File
@@ -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]
@@ -23,14 +23,14 @@ final class FirewallPolicyEventTest extends TestCase
$allowed = new IpAllowedEvent('203.0.113.11', 'Trusted service', 'tenant-a'); $allowed = new IpAllowedEvent('203.0.113.11', 'Trusted service', 'tenant-a');
$device = new DeviceBlockedEvent('device-a', 'Compromised device', 'tenant-a'); $device = new DeviceBlockedEvent('device-a', 'Compromised device', 'tenant-a');
self::assertSame(IpBlockedEvent::class, $blocked->getName()); self::assertSame(IpBlockedEvent::class, $blocked->label());
self::assertSame('203.0.113.10', $blocked->getIpAddress()); self::assertSame('203.0.113.10', $blocked->getIpAddress());
self::assertSame('Repeated abuse', $blocked->getReason()); self::assertSame('Repeated abuse', $blocked->getReason());
self::assertSame(SecurityEventSeverity::CRITICAL, $blocked->getSeverity()); self::assertSame(SecurityEventSeverity::CRITICAL, $blocked->getSeverity());
self::assertSame(IpAllowedEvent::class, $allowed->getName()); self::assertSame(IpAllowedEvent::class, $allowed->label());
self::assertSame('203.0.113.11', $allowed->getIpAddress()); self::assertSame('203.0.113.11', $allowed->getIpAddress());
self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity()); self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity());
self::assertSame(DeviceBlockedEvent::class, $device->getName()); self::assertSame(DeviceBlockedEvent::class, $device->label());
self::assertSame('device-a', $device->getDeviceFingerprint()); self::assertSame('device-a', $device->getDeviceFingerprint());
self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity()); self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity());
} }
@@ -47,12 +47,12 @@ final class FirewallPolicyEventTest extends TestCase
'operator-a', 'operator-a',
); );
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->getName()); self::assertSame(FirewallSettingsUpdatedEvent::class, $event->label());
self::assertSame('Tighten controls', $event->getChangeReason()); self::assertSame('Tighten controls', $event->getChangeReason());
self::assertSame(['maxAuthFailures' => 5], $event->getPrevious()); self::assertSame(['maxAuthFailures' => 5], $event->getPrevious());
self::assertSame(['maxAuthFailures' => 3], $event->getCurrent()); self::assertSame(['maxAuthFailures' => 3], $event->getCurrent());
self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('tenant-a', $event->tenantIdentifier());
self::assertSame('operator-a', $event->getIdentityId()); self::assertSame('operator-a', $event->actorIdentity());
self::assertSame('manual', $event->getChangeOrigin()); self::assertSame('manual', $event->getChangeOrigin());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity()); self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
} }
@@ -41,7 +41,7 @@ final class FirewallRuleEventTest extends TestCase
); );
$rule->setReason('Mutated after publication'); $rule->setReason('Mutated after publication');
self::assertSame(FirewallRuleExtendedEvent::class, $event->getName()); self::assertSame(FirewallRuleExtendedEvent::class, $event->label());
self::assertSame('rule-a', $event->getRuleId()); self::assertSame('rule-a', $event->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope()); self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType()); self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
@@ -50,8 +50,8 @@ final class FirewallRuleEventTest extends TestCase
self::assertSame('Repeated abuse', $event->getReason()); self::assertSame('Repeated abuse', $event->getReason());
self::assertSame('automatic', $event->getOrigin()); self::assertSame('automatic', $event->getOrigin());
self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt()); self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt());
self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('tenant-a', $event->tenantIdentifier());
self::assertSame('operator-a', $event->getIdentityId()); self::assertSame('operator-a', $event->actorIdentity());
self::assertSame(5, $event->get('failureCount')); self::assertSame(5, $event->get('failureCount'));
self::assertSame('Continue monitoring', $event->get('changeReason')); self::assertSame('Continue monitoring', $event->get('changeReason'));
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity()); self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
@@ -75,7 +75,7 @@ final class FirewallRuleEventTest extends TestCase
FirewallRuleDisabledEvent::class, FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class, FirewallRuleRemovedEvent::class,
] as $eventClass) { ] as $eventClass) {
self::assertSame($eventClass, $eventClass::fromRule($rule)->getName()); self::assertSame($eventClass, $eventClass::fromRule($rule)->label());
} }
} }
} }
@@ -24,13 +24,13 @@ final class RateLimitExceededEventTest extends TestCase
'tenant-a', 'tenant-a',
); );
self::assertSame(RateLimitExceededEvent::class, $event->getName()); self::assertSame(RateLimitExceededEvent::class, $event->label());
self::assertSame('203.0.113.10', $event->getIpAddress()); self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame(101, $event->getRequestCount()); self::assertSame(101, $event->getRequestCount());
self::assertSame(60, $event->getWindowSeconds()); self::assertSame(60, $event->getWindowSeconds());
self::assertSame('/login', $event->getEndpoint()); self::assertSame('/login', $event->getEndpoint());
self::assertSame('/login', $event->getRequestPath()); self::assertSame('/login', $event->getRequestPath());
self::assertSame('tenant-a', $event->getTenantId()); self::assertSame('tenant-a', $event->tenantIdentifier());
self::assertSame('101 requests in 60 seconds', $event->getReason()); self::assertSame('101 requests in 60 seconds', $event->getReason());
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity()); self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
} }
@@ -30,13 +30,13 @@ final class SuspiciousActivityEventTest extends TestCase
reason: 'Matched a blocked payload signature', reason: 'Matched a blocked payload signature',
); );
self::assertSame(SuspiciousActivityEvent::class, $event->getName()); self::assertSame(SuspiciousActivityEvent::class, $event->label());
self::assertSame('203.0.113.20', $event->getIpAddress()); self::assertSame('203.0.113.20', $event->getIpAddress());
self::assertSame('payload-signature', $event->getDetector()); self::assertSame('payload-signature', $event->getDetector());
self::assertSame(['score' => 98], $event->getDetectionData()); self::assertSame(['score' => 98], $event->getDetectionData());
self::assertSame(['detector' => 'payload-signature', 'score' => 98], $event->getData()); self::assertSame(['detector' => 'payload-signature', 'score' => 98], $event->context());
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('device-a', $event->getDeviceFingerprint()); self::assertSame('device-a', $event->getDeviceFingerprint());
self::assertSame('Test Agent', $event->getUserAgent()); self::assertSame('Test Agent', $event->getUserAgent());
self::assertSame('/admin', $event->getRequestPath()); self::assertSame('/admin', $event->getRequestPath());
+64
View File
@@ -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');
}
}
@@ -78,7 +78,7 @@ final class AuthenticationManagerTest extends TestCase
->with(self::callback(static fn($event): bool => ->with(self::callback(static fn($event): bool =>
$event instanceof AuthenticationSucceededEvent $event instanceof AuthenticationSucceededEvent
&& $event->getUserId() === 'user-a' && $event->getUserId() === 'user-a'
&& $event->getTenantId() === 'tenant-a' && $event->tenantIdentifier() === 'tenant-a'
)); ));
$manager = new AuthenticationManager( $manager = new AuthenticationManager(
@@ -198,7 +198,7 @@ final class AuthenticationManagerTest extends TestCase
return $event instanceof AuthenticationFailedEvent return $event instanceof AuthenticationFailedEvent
&& $event->getUserId() === 'user-a' && $event->getUserId() === 'user-a'
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR && $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
&& $event->getTenantId() === 'tenant-a'; && $event->tenantIdentifier() === 'tenant-a';
})); }));
$manager = new AuthenticationManager( $manager = new AuthenticationManager(
@@ -285,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(
@@ -302,7 +302,7 @@ class FirewallRuleManagerTest extends TestCase
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::assertInstanceOf(IpBlockedEvent::class, $events[IpBlockedEvent::class]);
self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress()); self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress());
@@ -385,13 +385,13 @@ class FirewallRuleManagerTest extends TestCase
FirewallRuleEnabledEvent::class, FirewallRuleEnabledEvent::class,
FirewallRuleExtendedEvent::class, FirewallRuleExtendedEvent::class,
FirewallRuleRemovedEvent::class, 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
)))); ))));
} }
@@ -539,7 +539,7 @@ class FirewallServiceTest extends TestCase
&$lifecycleOrigin, &$lifecycleOrigin,
&$bruteForceEvent, &$bruteForceEvent,
): void { ): void {
$publishedTenants[] = $event->getTenantId(); $publishedTenants[] = $event->tenantIdentifier();
if ($event instanceof BruteForceDetectedEvent) { if ($event instanceof BruteForceDetectedEvent) {
$bruteForceEvent = $event; $bruteForceEvent = $event;
} }
@@ -616,12 +616,12 @@ class FirewallServiceTest extends TestCase
->with('tenant-a', '203.0.113.10', 300) ->with('tenant-a', '203.0.113.10', 300)
->willReturn(1); ->willReturn(1);
$event = new AuthenticationFailedEvent(); $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')]
@@ -38,9 +38,9 @@ final class FirewallSettingsServiceTest extends TestCase
$events->expects(self::once()) $events->expects(self::once())
->method('dispatch') ->method('dispatch')
->with(self::callback(static fn(FirewallSettingsUpdatedEvent $event): bool => ->with(self::callback(static fn(FirewallSettingsUpdatedEvent $event): bool =>
$event->getName() === FirewallSettingsUpdatedEvent::class $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'));
}
}