Compare commits
1 Commits
main
..
99da3598f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 99da3598f7 |
@@ -15,9 +15,6 @@ final readonly class TerminationReport
|
||||
public array $failures = [],
|
||||
public bool $deadlineExceeded = false,
|
||||
public bool $limitExceeded = false,
|
||||
public int $deferredListenerInvocations = 0,
|
||||
public bool $deferredEventLimitExceeded = false,
|
||||
public bool $deferredListenerInvocationLimitExceeded = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ final readonly class DeferredProcessingResult
|
||||
public int $remaining,
|
||||
public bool $deadlineExceeded,
|
||||
public bool $limitExceeded = false,
|
||||
public int $listenerInvocations = 0,
|
||||
public bool $eventLimitExceeded = false,
|
||||
public bool $listenerInvocationLimitExceeded = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ use Psr\Log\LoggerInterface;
|
||||
|
||||
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
||||
{
|
||||
private const DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS = 300.0;
|
||||
private const DEFAULT_MAX_DEFERRED_EVENTS = 1000;
|
||||
private const DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS = 50000;
|
||||
|
||||
/** @var array<string, list<Event>> */
|
||||
private array $deferred = [];
|
||||
private ?string $activeExecution = null;
|
||||
@@ -26,19 +22,7 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
||||
private readonly EventListenerRegistry $registry,
|
||||
private readonly ContainerInterface $container,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly float $deferredProcessingTimeoutSeconds = self::DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS,
|
||||
private readonly int $maxDeferredEvents = self::DEFAULT_MAX_DEFERRED_EVENTS,
|
||||
private readonly int $maxDeferredListenerInvocations = self::DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS,
|
||||
) {
|
||||
if ($this->deferredProcessingTimeoutSeconds <= 0) {
|
||||
throw new \InvalidArgumentException('The deferred processing timeout must be greater than zero.');
|
||||
}
|
||||
if ($this->maxDeferredEvents <= 0) {
|
||||
throw new \InvalidArgumentException('The deferred event limit must be greater than zero.');
|
||||
}
|
||||
if ($this->maxDeferredListenerInvocations <= 0) {
|
||||
throw new \InvalidArgumentException('The deferred listener invocation limit must be greater than zero.');
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatch(Event $event): void
|
||||
@@ -50,7 +34,7 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
||||
|
||||
try {
|
||||
$this->invoke($event, DeliveryMode::Immediate);
|
||||
if ($this->registry->listeners($event->label(), DeliveryMode::Deferred) !== []) {
|
||||
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
|
||||
if ($this->activeExecution === null) {
|
||||
throw new \LogicException('Deferred events require an active execution scope.');
|
||||
}
|
||||
@@ -77,15 +61,13 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
||||
}
|
||||
|
||||
try {
|
||||
$processedEvents = 0;
|
||||
$listenerInvocations = 0;
|
||||
$deadline = microtime(true) + $this->deferredProcessingTimeoutSeconds;
|
||||
$processed = 0;
|
||||
$deadline = microtime(true) + 1.0;
|
||||
$deadlineExceeded = false;
|
||||
$eventLimitExceeded = false;
|
||||
$listenerInvocationLimitExceeded = false;
|
||||
$limitExceeded = false;
|
||||
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
||||
if ($processedEvents >= $this->maxDeferredEvents) {
|
||||
$eventLimitExceeded = true;
|
||||
if ($processed >= 1000) {
|
||||
$limitExceeded = true;
|
||||
array_unshift($this->deferred[$executionId], $event);
|
||||
break;
|
||||
}
|
||||
@@ -94,29 +76,14 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
||||
array_unshift($this->deferred[$executionId], $event);
|
||||
break;
|
||||
}
|
||||
|
||||
$eventListenerCount = count($this->registry->listeners(
|
||||
$event->label(),
|
||||
DeliveryMode::Deferred,
|
||||
));
|
||||
if ($listenerInvocations + $eventListenerCount > $this->maxDeferredListenerInvocations) {
|
||||
$listenerInvocationLimitExceeded = true;
|
||||
array_unshift($this->deferred[$executionId], $event);
|
||||
break;
|
||||
}
|
||||
|
||||
$listenerInvocations += $this->invoke($event, DeliveryMode::Deferred);
|
||||
$processedEvents++;
|
||||
$processed += $this->invoke($event, DeliveryMode::Deferred);
|
||||
}
|
||||
|
||||
return new DeferredProcessingResult(
|
||||
processed: $processedEvents,
|
||||
remaining: count($this->deferred[$executionId]),
|
||||
deadlineExceeded: $deadlineExceeded,
|
||||
limitExceeded: $eventLimitExceeded || $listenerInvocationLimitExceeded,
|
||||
listenerInvocations: $listenerInvocations,
|
||||
eventLimitExceeded: $eventLimitExceeded,
|
||||
listenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||
$processed,
|
||||
count($this->deferred[$executionId]),
|
||||
$deadlineExceeded,
|
||||
$limitExceeded,
|
||||
);
|
||||
} finally {
|
||||
$this->discardDeferred($executionId);
|
||||
@@ -134,18 +101,18 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
||||
private function invoke(Event $event, DeliveryMode $delivery): int
|
||||
{
|
||||
$processed = 0;
|
||||
foreach ($this->registry->listeners($event->label(), $delivery) as $listener) {
|
||||
foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) {
|
||||
if ($event->isPropagationStopped()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$processed++;
|
||||
try {
|
||||
$service = $this->container->get($listener->service);
|
||||
$service->{$listener->method}($event);
|
||||
$processed++;
|
||||
} catch (\Throwable $error) {
|
||||
$this->logger->error('Event listener failed.', [
|
||||
'event' => $event->label(),
|
||||
'event' => $event->getName(),
|
||||
'module' => $listener->module,
|
||||
'listener' => $listener->service . '::' . $listener->method,
|
||||
'exception' => $error,
|
||||
|
||||
@@ -238,9 +238,6 @@ class Kernel implements KernelInterface
|
||||
$remaining = 0;
|
||||
$deadlineExceeded = false;
|
||||
$limitExceeded = false;
|
||||
$listenerInvocations = 0;
|
||||
$eventLimitExceeded = false;
|
||||
$listenerInvocationLimitExceeded = false;
|
||||
$failures = [];
|
||||
|
||||
try {
|
||||
@@ -252,9 +249,6 @@ class Kernel implements KernelInterface
|
||||
$remaining = $result->remaining;
|
||||
$deadlineExceeded = $result->deadlineExceeded;
|
||||
$limitExceeded = $result->limitExceeded;
|
||||
$listenerInvocations = $result->listenerInvocations;
|
||||
$eventLimitExceeded = $result->eventLimitExceeded;
|
||||
$listenerInvocationLimitExceeded = $result->listenerInvocationLimitExceeded;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = $e;
|
||||
@@ -293,9 +287,6 @@ class Kernel implements KernelInterface
|
||||
failures: $failures,
|
||||
deadlineExceeded: $deadlineExceeded,
|
||||
limitExceeded: $limitExceeded,
|
||||
deferredListenerInvocations: $listenerInvocations,
|
||||
deferredEventLimitExceeded: $eventLimitExceeded,
|
||||
deferredListenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@ namespace KTXC\Security\Event;
|
||||
|
||||
interface SecurityEventInterface
|
||||
{
|
||||
public function label(): string;
|
||||
public function getName(): string;
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed;
|
||||
|
||||
public function context(): array;
|
||||
public function getData(): array;
|
||||
|
||||
public function identifier(): string;
|
||||
public function getEventId(): string;
|
||||
|
||||
public function tenantIdentifier(): ?string;
|
||||
public function getTenantId(): ?string;
|
||||
|
||||
public function actorIdentity(): ?string;
|
||||
public function getIdentityId(): ?string;
|
||||
|
||||
public function getUserId(): ?string;
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ class FirewallService
|
||||
{
|
||||
$request = $this->requestContext->current();
|
||||
$ipAddress = $request?->getClientIp();
|
||||
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||
|
||||
if (!$ipAddress || !$tenantId) {
|
||||
return;
|
||||
@@ -262,7 +262,7 @@ class FirewallService
|
||||
?Request $request = null,
|
||||
): ?FirewallLogObject
|
||||
{
|
||||
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||
$ruleScope = $event->get('ruleScope');
|
||||
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
||||
return null;
|
||||
@@ -271,7 +271,7 @@ class FirewallService
|
||||
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
|
||||
|
||||
$log = new FirewallLogObject();
|
||||
return $log->setEventId($event->identifier())
|
||||
return $log->setEventId($event->getEventId())
|
||||
->setTenantId($tenantId)
|
||||
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
|
||||
->setDeviceFingerprint(
|
||||
@@ -281,13 +281,13 @@ class FirewallService
|
||||
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
|
||||
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
|
||||
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
|
||||
->setEventType($this->mapEventToLogType($event->label()))
|
||||
->setEventType($this->mapEventToLogType($event->getName()))
|
||||
->setResult($this->mapEventToResult($event))
|
||||
->setRuleId($event->get('ruleId'))
|
||||
->setRuleScope($ruleScope)
|
||||
->setIdentityId($event->getUserId() ?? $event->actorIdentity())
|
||||
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->context());
|
||||
->setMetadata($event->getData());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,7 +317,7 @@ class FirewallService
|
||||
*/
|
||||
private function mapEventToResult(SecurityEventInterface $event): string
|
||||
{
|
||||
return match ($event->label()) {
|
||||
return match ($event->getName()) {
|
||||
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
|
||||
FirewallRuleCreatedEvent::class,
|
||||
FirewallRuleExtendedEvent::class,
|
||||
|
||||
@@ -61,11 +61,6 @@ abstract class UserEvent extends Event
|
||||
return $this->userIdentifier;
|
||||
}
|
||||
|
||||
public function tenantIdentifier(): string
|
||||
{
|
||||
return $this->tenantIdentifier;
|
||||
}
|
||||
|
||||
public function userIdentity(): string
|
||||
{
|
||||
return $this->userIdentity;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { authenticationService } from '@KTXC/services/authenticationService';
|
||||
@@ -15,7 +15,6 @@ type LoginPhase = 'identity' | 'method' | 'mfa';
|
||||
// Form state
|
||||
const identity = ref('');
|
||||
const authResponse = ref(''); // password, code, etc.
|
||||
const authInput = ref<{ focus: () => void } | null>(null);
|
||||
const showPassword = ref(false);
|
||||
const rememberMe = ref(false);
|
||||
|
||||
@@ -61,19 +60,21 @@ const pageTitle = computed(() => {
|
||||
});
|
||||
|
||||
// Input label/type based on selected method
|
||||
const isPasswordMethod = computed(() => selectedMethod.value?.id === 'password');
|
||||
|
||||
const authInputLabel = computed(() => {
|
||||
return isPasswordMethod.value ? 'Password' : 'Verification Code';
|
||||
if (!selectedMethod.value) return 'Password';
|
||||
return selectedMethod.value.method === 'credential' ? 'Password' : 'Verification Code';
|
||||
});
|
||||
|
||||
const authInputType = computed(() => {
|
||||
return isPasswordMethod.value ? 'password' : 'text';
|
||||
if (!selectedMethod.value) return 'password';
|
||||
return selectedMethod.value.method === 'credential' ? 'password' : 'text';
|
||||
});
|
||||
|
||||
// Validation rules
|
||||
const identityRules = [
|
||||
(v: string) => !!v.trim() || 'Login ID is required',
|
||||
(v: string) => !!v.trim() || 'Email is required',
|
||||
(v: string) => !/\s/.test(v.trim()) || 'Email must not contain spaces',
|
||||
(v: string) => /.+@.+\..+/.test(v.trim()) || 'Email must be valid'
|
||||
];
|
||||
|
||||
const authResponseRules = [
|
||||
@@ -116,9 +117,6 @@ onMounted(async () => {
|
||||
|
||||
// Watch for method selection changes (for challenge-based methods)
|
||||
watch(selectedMethod, async (newMethod) => {
|
||||
await nextTick();
|
||||
authInput.value?.focus();
|
||||
|
||||
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
|
||||
// Initiate challenge for methods that need it (SMS, email, TOTP)
|
||||
await initiateChallenge(newMethod.id);
|
||||
@@ -315,16 +313,7 @@ function backToIdentity() {
|
||||
}
|
||||
|
||||
function getMethodIcon(method: AuthenticationMethod): string {
|
||||
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}`;
|
||||
}
|
||||
|
||||
if (method.icon) return method.icon;
|
||||
switch (method.method) {
|
||||
case 'credential': return 'mdi-key';
|
||||
case 'challenge': return 'mdi-shield-check';
|
||||
@@ -362,15 +351,16 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
v-slot="{ errors, isSubmitting }"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<v-label>Email Address</v-label>
|
||||
<v-text-field
|
||||
v-model="identity"
|
||||
:rules="identityRules"
|
||||
aria-label="Login ID"
|
||||
class="mt-2"
|
||||
required
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
autocomplete="username"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
></v-text-field>
|
||||
</div>
|
||||
@@ -401,7 +391,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
size="large"
|
||||
@click="initiateSsoLogin(method.id)"
|
||||
>
|
||||
<v-icon start>{{ getMethodIcon(method) }}</v-icon>
|
||||
<v-icon v-if="method.icon" start>{{ method.icon }}</v-icon>
|
||||
{{ method.label }}
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -455,18 +445,18 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
v-slot="{ errors, isSubmitting }"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<v-label>{{ authInputLabel }}</v-label>
|
||||
<v-text-field
|
||||
ref="authInput"
|
||||
v-model="authResponse"
|
||||
:rules="authResponseRules"
|
||||
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
|
||||
:aria-label="authInputLabel"
|
||||
class="mt-2"
|
||||
required
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
:autocomplete="isPasswordMethod ? 'current-password' : 'off'"
|
||||
:inputmode="isPasswordMethod ? undefined : 'numeric'"
|
||||
:autocomplete="selectedMethod?.method === 'credential' ? 'current-password' : 'one-time-code'"
|
||||
:inputmode="selectedMethod?.method !== 'credential' ? 'numeric' : undefined"
|
||||
autofocus
|
||||
>
|
||||
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
||||
@@ -480,7 +470,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<div v-if="isPasswordMethod" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
||||
<div v-if="selectedMethod?.method === 'credential'" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
||||
<v-checkbox
|
||||
v-model="rememberMe"
|
||||
label="Keep me logged in"
|
||||
@@ -503,7 +493,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
size="large"
|
||||
type="submit"
|
||||
>
|
||||
{{ isPasswordMethod ? 'Login' : 'Verify' }}
|
||||
{{ selectedMethod?.method === 'credential' ? 'Login' : 'Verify' }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@@ -544,7 +534,6 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
<div class="mb-6">
|
||||
<v-label>Verification Code</v-label>
|
||||
<v-text-field
|
||||
ref="authInput"
|
||||
v-model="authResponse"
|
||||
:rules="authResponseRules"
|
||||
type="text"
|
||||
@@ -553,7 +542,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
autocomplete="off"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
autofocus
|
||||
></v-text-field>
|
||||
|
||||
Generated
+4
-4
@@ -6642,9 +6642,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vuetify": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
|
||||
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.8.tgz",
|
||||
"integrity": "sha512-003D/8b5462uZCD5CMRTZF3smADmOn47mzthNY0aP/xkslovR5yIc1qXjPdFN0LHcu+p3GEAVmMQabldaIQ5pg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -6653,7 +6653,7 @@
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.7",
|
||||
"vite-plugin-vuetify": ">=2.1.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue": "^3.5.0 || ^3.6.0-0",
|
||||
"webpack-plugin-vuetify": ">=3.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
+33
-33
@@ -10,74 +10,74 @@ namespace KTXF\Event;
|
||||
class Event
|
||||
{
|
||||
private bool $propagationStopped = false;
|
||||
private readonly array $context;
|
||||
private readonly array $data;
|
||||
private readonly float $timestamp;
|
||||
private readonly string $identifier;
|
||||
private readonly string $eventId;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $label,
|
||||
array $context = [],
|
||||
private readonly ?string $tenantIdentifier = null,
|
||||
private readonly ?string $actorIdentity = null,
|
||||
private readonly string $name,
|
||||
array $data = [],
|
||||
private readonly ?string $tenantId = null,
|
||||
private readonly ?string $identityId = null,
|
||||
) {
|
||||
self::validateContext($context);
|
||||
self::validateData($data);
|
||||
|
||||
$this->context = $context;
|
||||
$this->data = $data;
|
||||
$this->timestamp = microtime(true);
|
||||
$this->identifier = bin2hex(random_bytes(16));
|
||||
$this->eventId = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event label
|
||||
* Get the event name
|
||||
*/
|
||||
public function label(): string
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->label;
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a context value by key
|
||||
* Get a data value by key
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->context[$key] ?? $default;
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a context key exists
|
||||
* Check if a data key exists
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->context);
|
||||
return array_key_exists($key, $this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event context
|
||||
* Get all data
|
||||
*/
|
||||
public function context(): array
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->context;
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all event context
|
||||
* Alias for getData() for backward compatibility
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->context;
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the event timestamp
|
||||
*/
|
||||
public function timestamp(): float
|
||||
public function getTimestamp(): float
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function identifier(): string
|
||||
public function getEventId(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
return $this->eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,29 +99,29 @@ class Event
|
||||
/**
|
||||
* Get tenant ID for multi-tenant context
|
||||
*/
|
||||
public function tenantIdentifier(): ?string
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantIdentifier;
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the identity of the actor who triggered the event
|
||||
* Get identity ID (user who triggered the event)
|
||||
*/
|
||||
public function actorIdentity(): ?string
|
||||
public function getIdentityId(): ?string
|
||||
{
|
||||
return $this->actorIdentity;
|
||||
return $this->identityId;
|
||||
}
|
||||
|
||||
private static function validateContext(array $context): void
|
||||
private static function validateData(array $data): void
|
||||
{
|
||||
foreach ($context as $value) {
|
||||
foreach ($data as $value) {
|
||||
if (is_array($value)) {
|
||||
self::validateContext($value);
|
||||
self::validateData($value);
|
||||
continue;
|
||||
}
|
||||
if ($value !== null && !is_scalar($value)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Event context must contain only scalar, null, or array values.',
|
||||
'Event data must contain only scalar, null, or array values.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ final class AccessDeniedEventTest extends TestCase
|
||||
'identity-a',
|
||||
);
|
||||
|
||||
self::assertSame(AccessDeniedEvent::class, $event->label());
|
||||
self::assertSame(AccessDeniedEvent::class, $event->getName());
|
||||
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('tenant-a', $event->getTenantId());
|
||||
self::assertSame('identity-a', $event->getIdentityId());
|
||||
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ final class AuthenticationFailedEventTest extends TestCase
|
||||
'identity-a',
|
||||
);
|
||||
|
||||
self::assertSame(AuthenticationFailedEvent::class, $event->label());
|
||||
self::assertSame(AuthenticationFailedEvent::class, $event->getName());
|
||||
self::assertSame('user-a', $event->getUserId());
|
||||
self::assertSame('Invalid credentials', $event->getReason());
|
||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||
self::assertSame('identity-a', $event->actorIdentity());
|
||||
self::assertSame('tenant-a', $event->getTenantId());
|
||||
self::assertSame('identity-a', $event->getIdentityId());
|
||||
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
|
||||
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ final class AuthenticationSucceededEventTest extends TestCase
|
||||
'tenant-a',
|
||||
);
|
||||
|
||||
self::assertSame(AuthenticationSucceededEvent::class, $event->label());
|
||||
self::assertSame(AuthenticationSucceededEvent::class, $event->getName());
|
||||
self::assertSame('user-a', $event->getUserId());
|
||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||
self::assertSame(['userId' => 'user-a'], $event->context());
|
||||
self::assertSame('tenant-a', $event->getTenantId());
|
||||
self::assertSame(['userId' => 'user-a'], $event->getData());
|
||||
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
||||
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,15 @@ final class BruteForceDetectedEventTest extends TestCase
|
||||
'tenant-a',
|
||||
);
|
||||
|
||||
self::assertSame(BruteForceDetectedEvent::class, $event->label());
|
||||
self::assertSame(BruteForceDetectedEvent::class, $event->getName());
|
||||
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('tenant-a', $event->getTenantId());
|
||||
self::assertSame('5 failed attempts in 300 seconds', $event->getReason());
|
||||
self::assertSame(
|
||||
['failureCount' => 5, 'windowSeconds' => 300],
|
||||
$event->context(),
|
||||
$event->getData(),
|
||||
);
|
||||
self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity());
|
||||
}
|
||||
|
||||
@@ -230,57 +230,9 @@ final class EventDispatcherTest extends TestCase
|
||||
$result = $dispatcher->processDeferred('test');
|
||||
|
||||
self::assertSame(1000, $result->processed);
|
||||
self::assertSame(1000, $result->listenerInvocations);
|
||||
self::assertSame(1, $result->remaining);
|
||||
self::assertFalse($result->deadlineExceeded);
|
||||
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,15 +22,12 @@ final class EventTest extends TestCase
|
||||
'identity-a',
|
||||
);
|
||||
|
||||
$copy = $event->context();
|
||||
$copy = $event->getData();
|
||||
$copy['nested']['value'] = 'changed';
|
||||
|
||||
self::assertSame('original', $event->get('nested')['value']);
|
||||
self::assertSame('test.event', $event->label());
|
||||
self::assertNotSame('', $event->identifier());
|
||||
self::assertGreaterThan(0, $event->timestamp());
|
||||
self::assertSame('tenant-a', $event->tenantIdentifier());
|
||||
self::assertSame('identity-a', $event->actorIdentity());
|
||||
self::assertSame('tenant-a', $event->getTenantId());
|
||||
self::assertSame('identity-a', $event->getIdentityId());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -23,14 +23,14 @@ final class FirewallPolicyEventTest extends TestCase
|
||||
$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(IpBlockedEvent::class, $blocked->getName());
|
||||
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(IpAllowedEvent::class, $allowed->getName());
|
||||
self::assertSame('203.0.113.11', $allowed->getIpAddress());
|
||||
self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity());
|
||||
self::assertSame(DeviceBlockedEvent::class, $device->label());
|
||||
self::assertSame(DeviceBlockedEvent::class, $device->getName());
|
||||
self::assertSame('device-a', $device->getDeviceFingerprint());
|
||||
self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity());
|
||||
}
|
||||
@@ -47,12 +47,12 @@ final class FirewallPolicyEventTest extends TestCase
|
||||
'operator-a',
|
||||
);
|
||||
|
||||
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->label());
|
||||
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->getName());
|
||||
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('tenant-a', $event->getTenantId());
|
||||
self::assertSame('operator-a', $event->getIdentityId());
|
||||
self::assertSame('manual', $event->getChangeOrigin());
|
||||
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ final class FirewallRuleEventTest extends TestCase
|
||||
);
|
||||
$rule->setReason('Mutated after publication');
|
||||
|
||||
self::assertSame(FirewallRuleExtendedEvent::class, $event->label());
|
||||
self::assertSame(FirewallRuleExtendedEvent::class, $event->getName());
|
||||
self::assertSame('rule-a', $event->getRuleId());
|
||||
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
|
||||
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
|
||||
@@ -50,8 +50,8 @@ final class FirewallRuleEventTest extends TestCase
|
||||
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('tenant-a', $event->getTenantId());
|
||||
self::assertSame('operator-a', $event->getIdentityId());
|
||||
self::assertSame(5, $event->get('failureCount'));
|
||||
self::assertSame('Continue monitoring', $event->get('changeReason'));
|
||||
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
|
||||
@@ -75,7 +75,7 @@ final class FirewallRuleEventTest extends TestCase
|
||||
FirewallRuleDisabledEvent::class,
|
||||
FirewallRuleRemovedEvent::class,
|
||||
] as $eventClass) {
|
||||
self::assertSame($eventClass, $eventClass::fromRule($rule)->label());
|
||||
self::assertSame($eventClass, $eventClass::fromRule($rule)->getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ final class RateLimitExceededEventTest extends TestCase
|
||||
'tenant-a',
|
||||
);
|
||||
|
||||
self::assertSame(RateLimitExceededEvent::class, $event->label());
|
||||
self::assertSame(RateLimitExceededEvent::class, $event->getName());
|
||||
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('tenant-a', $event->getTenantId());
|
||||
self::assertSame('101 requests in 60 seconds', $event->getReason());
|
||||
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ final class SuspiciousActivityEventTest extends TestCase
|
||||
reason: 'Matched a blocked payload signature',
|
||||
);
|
||||
|
||||
self::assertSame(SuspiciousActivityEvent::class, $event->label());
|
||||
self::assertSame(SuspiciousActivityEvent::class, $event->getName());
|
||||
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(['detector' => 'payload-signature', 'score' => 98], $event->getData());
|
||||
self::assertSame('tenant-a', $event->getTenantId());
|
||||
self::assertSame('identity-a', $event->getIdentityId());
|
||||
self::assertSame('device-a', $event->getDeviceFingerprint());
|
||||
self::assertSame('Test Agent', $event->getUserAgent());
|
||||
self::assertSame('/admin', $event->getRequestPath());
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -30,16 +29,15 @@ final class UserEventTest extends TestCase
|
||||
'actor-a',
|
||||
);
|
||||
|
||||
self::assertSame(UserCreatedEvent::class, $event->label());
|
||||
self::assertSame(UserCreatedEvent::class, $event->getName());
|
||||
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('tenant-a', $event->getTenantId());
|
||||
self::assertSame('actor-a', $event->actorIdentifier());
|
||||
self::assertSame('actor-a', $event->actorIdentity());
|
||||
self::assertSame('string', (string) (new \ReflectionMethod(UserEvent::class, 'tenantIdentifier'))->getReturnType());
|
||||
self::assertSame('actor-a', $event->getIdentityId());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -48,9 +46,9 @@ final class UserEventTest extends TestCase
|
||||
{
|
||||
$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());
|
||||
self::assertSame(UserCreatedEvent::class, UserCreatedEvent::fromUser($user, 'tenant-a')->getName());
|
||||
self::assertSame(UserUpdatedEvent::class, UserUpdatedEvent::fromUser($user, 'tenant-a')->getName());
|
||||
self::assertSame(UserDeletingEvent::class, UserDeletingEvent::fromUser($user, 'tenant-a')->getName());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -78,7 +78,7 @@ final class AuthenticationManagerTest extends TestCase
|
||||
->with(self::callback(static fn($event): bool =>
|
||||
$event instanceof AuthenticationSucceededEvent
|
||||
&& $event->getUserId() === 'user-a'
|
||||
&& $event->tenantIdentifier() === 'tenant-a'
|
||||
&& $event->getTenantId() === 'tenant-a'
|
||||
));
|
||||
|
||||
$manager = new AuthenticationManager(
|
||||
@@ -198,7 +198,7 @@ final class AuthenticationManagerTest extends TestCase
|
||||
return $event instanceof AuthenticationFailedEvent
|
||||
&& $event->getUserId() === 'user-a'
|
||||
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
|
||||
&& $event->tenantIdentifier() === 'tenant-a';
|
||||
&& $event->getTenantId() === 'tenant-a';
|
||||
}));
|
||||
|
||||
$manager = new AuthenticationManager(
|
||||
|
||||
@@ -285,7 +285,7 @@ class FirewallRuleManagerTest extends TestCase
|
||||
$this->events->expects($this->exactly(2))
|
||||
->method('dispatch')
|
||||
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
|
||||
$events[$event->label()] = $event;
|
||||
$events[$event->getName()] = $event;
|
||||
});
|
||||
|
||||
$this->manager->blockIp(
|
||||
@@ -302,7 +302,7 @@ class FirewallRuleManagerTest extends TestCase
|
||||
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
|
||||
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $audit->get('ruleAction'));
|
||||
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
|
||||
self::assertSame('admin-a', $audit->actorIdentity());
|
||||
self::assertSame('admin-a', $audit->getIdentityId());
|
||||
self::assertNotNull($audit->get('expiresAt'));
|
||||
self::assertInstanceOf(IpBlockedEvent::class, $events[IpBlockedEvent::class]);
|
||||
self::assertSame('203.0.113.10', $events[IpBlockedEvent::class]->getIpAddress());
|
||||
@@ -385,13 +385,13 @@ class FirewallRuleManagerTest extends TestCase
|
||||
FirewallRuleEnabledEvent::class,
|
||||
FirewallRuleExtendedEvent::class,
|
||||
FirewallRuleRemovedEvent::class,
|
||||
], array_map(static fn($event): string => $event->label(), $audits));
|
||||
], array_map(static fn($event): string => $event->getName(), $audits));
|
||||
self::assertSame(
|
||||
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
|
||||
array_map(static fn($event): string => $event->get('changeReason'), $audits)
|
||||
);
|
||||
self::assertSame(['operator'], array_values(array_unique(array_map(
|
||||
static fn($event): ?string => $event->actorIdentity(),
|
||||
static fn($event): ?string => $event->getIdentityId(),
|
||||
$audits
|
||||
))));
|
||||
}
|
||||
|
||||
@@ -539,7 +539,7 @@ class FirewallServiceTest extends TestCase
|
||||
&$lifecycleOrigin,
|
||||
&$bruteForceEvent,
|
||||
): void {
|
||||
$publishedTenants[] = $event->tenantIdentifier();
|
||||
$publishedTenants[] = $event->getTenantId();
|
||||
if ($event instanceof BruteForceDetectedEvent) {
|
||||
$bruteForceEvent = $event;
|
||||
}
|
||||
@@ -616,12 +616,12 @@ class FirewallServiceTest extends TestCase
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(1);
|
||||
$event = new AuthenticationFailedEvent();
|
||||
$eventId = $event->identifier();
|
||||
$eventId = $event->getEventId();
|
||||
|
||||
$this->service->handleAuthFailure($event);
|
||||
$this->service->handleAuthFailure($event);
|
||||
|
||||
self::assertSame($eventId, $event->identifier());
|
||||
self::assertSame($eventId, $event->getEventId());
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup records successful maintenance counts')]
|
||||
|
||||
@@ -38,9 +38,9 @@ final class FirewallSettingsServiceTest extends TestCase
|
||||
$events->expects(self::once())
|
||||
->method('dispatch')
|
||||
->with(self::callback(static fn(FirewallSettingsUpdatedEvent $event): bool =>
|
||||
$event->label() === FirewallSettingsUpdatedEvent::class
|
||||
&& $event->tenantIdentifier() === 'tenant-a'
|
||||
&& $event->actorIdentity() === 'admin-a'
|
||||
$event->getName() === FirewallSettingsUpdatedEvent::class
|
||||
&& $event->getTenantId() === 'tenant-a'
|
||||
&& $event->getIdentityId() === 'admin-a'
|
||||
&& $event->get('changeReason') === 'Tighten authentication controls'
|
||||
&& $event->get('previous')['maxAuthFailures'] === 5
|
||||
&& $event->get('current')['maxAuthFailures'] === 8
|
||||
|
||||
@@ -70,7 +70,7 @@ final class UserAccountsServiceTest extends TestCase
|
||||
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('tenant-a', $emitted[1]->getTenantId());
|
||||
self::assertSame(['event', 'delete'], $operations);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user