feat: make event state constructor-only and immutable

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-05 22:46:33 -04:00
parent ba4deccea9
commit 52afd35d6f
8 changed files with 266 additions and 193 deletions
+104 -111
View File
@@ -9,7 +9,7 @@ use KTXF\Event\Event;
/** /**
* Security-specific event for authentication and access control events * Security-specific event for authentication and access control events
*/ */
class SecurityEvent extends Event final class SecurityEvent extends Event
{ {
// Event names // Event names
public const AUTH_SUCCESS = 'security.auth.success'; public const AUTH_SUCCESS = 'security.auth.success';
@@ -35,15 +35,6 @@ class SecurityEvent extends Event
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed'; public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated'; public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
private ?string $ipAddress = null;
private ?string $deviceFingerprint = null;
private ?string $userAgent = null;
private ?string $requestPath = null;
private ?string $requestMethod = null;
private ?string $userId = null;
private ?string $reason = null;
private int $severity = self::SEVERITY_INFO;
// Severity levels // Severity levels
public const SEVERITY_DEBUG = 0; public const SEVERITY_DEBUG = 0;
public const SEVERITY_INFO = 1; public const SEVERITY_INFO = 1;
@@ -51,11 +42,25 @@ class SecurityEvent extends Event
public const SEVERITY_ERROR = 3; public const SEVERITY_ERROR = 3;
public const SEVERITY_CRITICAL = 4; public const SEVERITY_CRITICAL = 4;
public function __construct(string $name, array $data = []) private readonly int $severity;
{
parent::__construct($name, $data);
$this->severity = self::getSeverityForEvent($name); public function __construct(
string $name,
array $data = [],
?string $tenantId = null,
?string $identityId = null,
private readonly ?string $ipAddress = null,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $userAgent = null,
private readonly ?string $requestPath = null,
private readonly ?string $requestMethod = null,
private readonly ?string $userId = null,
private readonly ?string $reason = null,
?int $severity = null,
) {
parent::__construct($name, $data, $tenantId, $identityId);
$this->severity = $severity ?? self::getSeverityForEvent($name);
} }
/** /**
@@ -65,13 +70,30 @@ class SecurityEvent extends Event
string $name, string $name,
?string $ipAddress = null, ?string $ipAddress = null,
?string $deviceFingerprint = null, ?string $deviceFingerprint = null,
array $data = [] array $data = [],
?string $tenantId = null,
?string $identityId = null,
?string $userAgent = null,
?string $requestPath = null,
?string $requestMethod = null,
?string $userId = null,
?string $reason = null,
?int $severity = null,
): self { ): self {
$event = new self($name, $data); return new self(
$event->ipAddress = $ipAddress; $name,
$event->deviceFingerprint = $deviceFingerprint; $data,
$tenantId,
return $event; $identityId,
$ipAddress,
$deviceFingerprint,
$userAgent,
$requestPath,
$requestMethod,
$userId,
$reason,
$severity,
);
} }
/** /**
@@ -81,15 +103,26 @@ class SecurityEvent extends Event
string $ipAddress, string $ipAddress,
?string $deviceFingerprint = null, ?string $deviceFingerprint = null,
?string $userId = null, ?string $userId = null,
?string $reason = null ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
?string $userAgent = null,
?string $requestPath = null,
?string $requestMethod = null,
): self { ): self {
$event = self::create(self::AUTH_FAILURE, $ipAddress, $deviceFingerprint, [ return self::create(
'userId' => $userId, self::AUTH_FAILURE,
'reason' => $reason, $ipAddress,
]); $deviceFingerprint,
$event->userId = $userId; ['userId' => $userId, 'reason' => $reason],
$event->reason = $reason; $tenantId,
return $event; $identityId,
$userAgent,
$requestPath,
$requestMethod,
$userId,
$reason,
);
} }
/** /**
@@ -98,13 +131,17 @@ class SecurityEvent extends Event
public static function authSuccess( public static function authSuccess(
string $ipAddress, string $ipAddress,
?string $deviceFingerprint = null, ?string $deviceFingerprint = null,
string $userId = null ?string $userId = null,
?string $tenantId = null,
): self { ): self {
$event = self::create(self::AUTH_SUCCESS, $ipAddress, $deviceFingerprint, [ return self::create(
'userId' => $userId, self::AUTH_SUCCESS,
]); $ipAddress,
$event->userId = $userId; $deviceFingerprint,
return $event; ['userId' => $userId],
tenantId: $tenantId,
userId: $userId,
);
} }
/** /**
@@ -113,18 +150,16 @@ class SecurityEvent extends Event
public static function bruteForceDetected( public static function bruteForceDetected(
string $ipAddress, string $ipAddress,
int $failureCount, int $failureCount,
int $windowSeconds int $windowSeconds,
?string $tenantId = null,
): self { ): self {
$event = self::create(self::BRUTE_FORCE_DETECTED, $ipAddress, null, [ return self::create(
'failureCount' => $failureCount, self::BRUTE_FORCE_DETECTED,
'windowSeconds' => $windowSeconds, $ipAddress,
]); data: ['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
$event->reason = sprintf( tenantId: $tenantId,
'%d failed attempts in %d seconds', reason: sprintf('%d failed attempts in %d seconds', $failureCount, $windowSeconds),
$failureCount,
$windowSeconds
); );
return $event;
} }
/** /**
@@ -134,20 +169,21 @@ class SecurityEvent extends Event
string $ipAddress, string $ipAddress,
int $requestCount, int $requestCount,
int $windowSeconds, int $windowSeconds,
?string $endpoint = null ?string $endpoint = null,
?string $tenantId = null,
): self { ): self {
$event = self::create(self::RATE_LIMIT_EXCEEDED, $ipAddress, null, [ return self::create(
'requestCount' => $requestCount, self::RATE_LIMIT_EXCEEDED,
'windowSeconds' => $windowSeconds, $ipAddress,
'endpoint' => $endpoint, data: [
]); 'requestCount' => $requestCount,
$event->requestPath = $endpoint; 'windowSeconds' => $windowSeconds,
$event->reason = sprintf( 'endpoint' => $endpoint,
'%d requests in %d seconds', ],
$requestCount, tenantId: $tenantId,
$windowSeconds requestPath: $endpoint,
reason: sprintf('%d requests in %d seconds', $requestCount, $windowSeconds),
); );
return $event;
} }
/** /**
@@ -158,15 +194,19 @@ class SecurityEvent extends Event
?string $deviceFingerprint = null, ?string $deviceFingerprint = null,
?string $ruleId = null, ?string $ruleId = null,
?string $ruleScope = null, ?string $ruleScope = null,
?string $reason = null ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
): self { ): self {
$event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [ return self::create(
'ruleId' => $ruleId, self::ACCESS_DENIED,
'ruleScope' => $ruleScope, $ipAddress,
'reason' => $reason, $deviceFingerprint,
]); ['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
$event->reason = $reason; $tenantId,
return $event; $identityId,
reason: $reason,
);
} }
/** /**
@@ -202,86 +242,39 @@ class SecurityEvent extends Event
return $this->ipAddress; return $this->ipAddress;
} }
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
public function getDeviceFingerprint(): ?string public function getDeviceFingerprint(): ?string
{ {
return $this->deviceFingerprint; return $this->deviceFingerprint;
} }
public function setDeviceFingerprint(?string $deviceFingerprint): self
{
$this->deviceFingerprint = $deviceFingerprint;
return $this;
}
public function getUserAgent(): ?string public function getUserAgent(): ?string
{ {
return $this->userAgent; return $this->userAgent;
} }
public function setUserAgent(?string $userAgent): self
{
$this->userAgent = $userAgent;
return $this;
}
public function getRequestPath(): ?string public function getRequestPath(): ?string
{ {
return $this->requestPath; return $this->requestPath;
} }
public function setRequestPath(?string $requestPath): self
{
$this->requestPath = $requestPath;
return $this;
}
public function getRequestMethod(): ?string public function getRequestMethod(): ?string
{ {
return $this->requestMethod; return $this->requestMethod;
} }
public function setRequestMethod(?string $requestMethod): self
{
$this->requestMethod = $requestMethod;
return $this;
}
public function getUserId(): ?string public function getUserId(): ?string
{ {
return $this->userId; return $this->userId;
} }
public function setUserId(?string $userId): self
{
$this->userId = $userId;
return $this;
}
public function getReason(): ?string public function getReason(): ?string
{ {
return $this->reason; return $this->reason;
} }
public function setReason(?string $reason): self
{
$this->reason = $reason;
return $this;
}
public function getSeverity(): int public function getSeverity(): int
{ {
return $this->severity; return $this->severity;
} }
public function setSeverity(int $severity): self
{
$this->severity = $severity;
return $this;
}
} }
+31 -18
View File
@@ -254,8 +254,13 @@ final class FirewallRuleManager
$origin $origin
); );
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]); $event = new SecurityEvent(
$event->setDeviceFingerprint($fingerprint)->setReason($reason)->setTenantId($scope->tenantId); SecurityEvent::DEVICE_BLOCKED,
['device' => $fingerprint, 'reason' => $reason],
tenantId: $scope->tenantId,
deviceFingerprint: $fingerprint,
reason: $reason,
);
$this->events->dispatch($event); $this->events->dispatch($event);
return $rule; return $rule;
@@ -499,8 +504,13 @@ final class FirewallRuleManager
string $ipAddress, string $ipAddress,
?string $reason ?string $reason
): void { ): void {
$event = new SecurityEvent($name, ['ip' => $ipAddress, 'reason' => $reason]); $event = new SecurityEvent(
$event->setIpAddress($ipAddress)->setReason($reason)->setTenantId($scope->tenantId); $name,
['ip' => $ipAddress, 'reason' => $reason],
tenantId: $scope->tenantId,
ipAddress: $ipAddress,
reason: $reason,
);
$this->events->dispatch($event); $this->events->dispatch($event);
} }
@@ -511,20 +521,23 @@ final class FirewallRuleManager
array $change = [] array $change = []
): void ): void
{ {
$event = new SecurityEvent($name, [ $event = new SecurityEvent(
'ruleId' => $rule->getId(), $name,
'ruleScope' => $rule->getScope(), [
'ruleType' => $rule->getType(), 'ruleId' => $rule->getId(),
'ruleAction' => $rule->getAction(), 'ruleScope' => $rule->getScope(),
'ruleValue' => $rule->getValue(), 'ruleType' => $rule->getType(),
'reason' => $rule->getReason(), 'ruleAction' => $rule->getAction(),
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL, 'ruleValue' => $rule->getValue(),
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM), 'reason' => $rule->getReason(),
...($rule->getMetadata() ?? []), 'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
...$change, 'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
]); ...($rule->getMetadata() ?? []),
$event->setTenantId($rule->getTenantId()) ...$change,
->setIdentityId($actorId ?? $rule->getCreatedBy()); ],
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
$this->events->dispatch($event); $this->events->dispatch($event);
} }
} }
+8 -5
View File
@@ -139,7 +139,6 @@ class FirewallService
return; return;
} }
$event->setTenantId($tenantId);
$log = $this->securityLog($event); $log = $this->securityLog($event);
if ($log === null || !$this->store->createLogOnce($log)) { if ($log === null || !$this->store->createLogOnce($log)) {
return; return;
@@ -195,8 +194,12 @@ class FirewallService
int $blockDuration int $blockDuration
): void { ): void {
// Publish brute force event // Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); $event = SecurityEvent::bruteForceDetected(
$event->setTenantId($tenantId); $ipAddress,
$failureCount,
$windowSeconds,
$tenantId,
);
$this->events->dispatch($event); $this->events->dispatch($event);
$this->rules->blockIp( $this->rules->blockIp(
@@ -308,9 +311,9 @@ class FirewallService
$deviceFingerprint, $deviceFingerprint,
$rule->getId(), $rule->getId(),
$rule->getScope(), $rule->getScope(),
$rule->getReason() $rule->getReason(),
$this->tenantContext->identifier(),
); );
$event->setTenantId($this->tenantContext->identifier());
$this->events->dispatch($event); $this->events->dispatch($event);
} }
+11 -7
View File
@@ -52,13 +52,17 @@ final class FirewallSettingsService
$tenant->setConfiguration($configuration); $tenant->setConfiguration($configuration);
$this->tenants->deposit($tenant); $this->tenants->deposit($tenant);
$event = new SecurityEvent(SecurityEvent::FIREWALL_SETTINGS_UPDATED, [ $event = new SecurityEvent(
'changeReason' => $reason, SecurityEvent::FIREWALL_SETTINGS_UPDATED,
'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL, [
'previous' => $previous, 'changeReason' => $reason,
'current' => $current, 'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL,
]); 'previous' => $previous,
$event->setTenantId($tenantId)->setIdentityId($actorId); 'current' => $current,
],
tenantId: $tenantId,
identityId: $actorId,
);
$this->events->dispatch($event); $this->events->dispatch($event);
return $current; return $current;
+20 -30
View File
@@ -10,16 +10,18 @@ namespace KTXF\Event;
class Event class Event
{ {
private bool $propagationStopped = false; private bool $propagationStopped = false;
private array $data = []; private readonly array $data;
private float $timestamp; private readonly float $timestamp;
private string $eventId; private readonly string $eventId;
private ?string $tenantId = null;
private ?string $identityId = null;
public function __construct( public function __construct(
private readonly string $name, private readonly string $name,
array $data = [] array $data = [],
private readonly ?string $tenantId = null,
private readonly ?string $identityId = null,
) { ) {
self::validateData($data);
$this->data = $data; $this->data = $data;
$this->timestamp = microtime(true); $this->timestamp = microtime(true);
$this->eventId = bin2hex(random_bytes(16)); $this->eventId = bin2hex(random_bytes(16));
@@ -41,15 +43,6 @@ class Event
return $this->data[$key] ?? $default; return $this->data[$key] ?? $default;
} }
/**
* Set a data value
*/
public function set(string $key, mixed $value): self
{
$this->data[$key] = $value;
return $this;
}
/** /**
* Check if a data key exists * Check if a data key exists
*/ */
@@ -111,15 +104,6 @@ class Event
return $this->tenantId; return $this->tenantId;
} }
/**
* Set tenant ID for multi-tenant context
*/
public function setTenantId(?string $tenantId): self
{
$this->tenantId = $tenantId;
return $this;
}
/** /**
* Get identity ID (user who triggered the event) * Get identity ID (user who triggered the event)
*/ */
@@ -128,12 +112,18 @@ class Event
return $this->identityId; return $this->identityId;
} }
/** private static function validateData(array $data): void
* Set identity ID
*/
public function setIdentityId(?string $identityId): self
{ {
$this->identityId = $identityId; foreach ($data as $value) {
return $this; if (is_array($value)) {
self::validateData($value);
continue;
}
if ($value !== null && !is_scalar($value)) {
throw new \InvalidArgumentException(
'Event data must contain only scalar, null, or array values.',
);
}
}
} }
} }
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXF\Event\Event;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class EventTest extends TestCase
{
#[Test]
#[TestDox('Event data and context are supplied during construction')]
public function constructsCompleteEventState(): void
{
$event = new Event(
'test.event',
['nested' => ['value' => 'original']],
'tenant-a',
'identity-a',
);
$copy = $event->getData();
$copy['nested']['value'] = 'changed';
self::assertSame('original', $event->get('nested')['value']);
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
}
#[Test]
#[TestDox('Event data rejects mutable object references')]
public function rejectsMutablePayloadValues(): void
{
$this->expectException(\InvalidArgumentException::class);
new Event('test.event', ['mutable' => new \stdClass()]);
}
}
+26 -4
View File
@@ -34,13 +34,35 @@ final class SecurityEventTest extends TestCase
} }
#[Test] #[Test]
#[TestDox('Explicit severity can override the event type default')] #[TestDox('Construction can override the event type default severity')]
public function allowsSeverityOverride(): void public function allowsSeverityOverride(): void
{ {
$event = new SecurityEvent(SecurityEvent::AUTH_FAILURE); $event = new SecurityEvent(
SecurityEvent::AUTH_FAILURE,
$event->setSeverity(SecurityEvent::SEVERITY_CRITICAL); severity: SecurityEvent::SEVERITY_CRITICAL,
);
self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $event->getSeverity()); self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $event->getSeverity());
} }
#[Test]
#[TestDox('Event state exposes no mutation methods')]
public function exposesNoMutationMethods(): void
{
foreach ([
'set',
'setTenantId',
'setIdentityId',
'setIpAddress',
'setDeviceFingerprint',
'setUserAgent',
'setRequestPath',
'setRequestMethod',
'setUserId',
'setReason',
'setSeverity',
] as $method) {
self::assertFalse(method_exists(SecurityEvent::class, $method));
}
}
} }
+25 -18
View File
@@ -215,9 +215,9 @@ class FirewallServiceTest extends TestCase
null, null,
'tenant-rule', 'tenant-rule',
FirewallRuleObject::SCOPE_TENANT, FirewallRuleObject::SCOPE_TENANT,
'Tenant block' 'Tenant block',
'tenant-a',
); );
$event->setTenantId('tenant-a');
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
} }
@@ -275,9 +275,9 @@ class FirewallServiceTest extends TestCase
'203.0.113.10', '203.0.113.10',
101, 101,
60, 60,
'/login' '/login',
'tenant-a',
); );
$event->setTenantId('tenant-a');
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
} }
@@ -300,11 +300,11 @@ class FirewallServiceTest extends TestCase
\KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY, \KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY,
'203.0.113.20', '203.0.113.20',
null, null,
['detector' => 'payload-signature'] ['detector' => 'payload-signature'],
tenantId: 'tenant-a',
requestPath: '/admin',
requestMethod: 'POST',
); );
$event->setTenantId('tenant-a')
->setRequestPath('/admin')
->setRequestMethod('POST');
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
} }
@@ -329,9 +329,9 @@ class FirewallServiceTest extends TestCase
'ruleId' => 'rule-123', 'ruleId' => 'rule-123',
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM, 'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
'origin' => FirewallRuleManager::ORIGIN_MANUAL, 'origin' => FirewallRuleManager::ORIGIN_MANUAL,
] ],
identityId: 'operator',
); );
$event->setIdentityId('operator');
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
} }
@@ -351,9 +351,10 @@ class FirewallServiceTest extends TestCase
->willReturnArgument(0); ->willReturnArgument(0);
$event = new \KTXC\Security\Event\SecurityEvent( $event = new \KTXC\Security\Event\SecurityEvent(
\KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED, \KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
['changeReason' => 'Tighten controls'] ['changeReason' => 'Tighten controls'],
tenantId: 'tenant-a',
identityId: 'operator',
); );
$event->setTenantId('tenant-a')->setIdentityId('operator');
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
} }
@@ -376,8 +377,10 @@ class FirewallServiceTest extends TestCase
->willReturn(4); ->willReturn(4);
$this->events->expects($this->never())->method('dispatch'); $this->events->expects($this->never())->method('dispatch');
$event = \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10'); $event = \KTXC\Security\Event\SecurityEvent::authFailure(
$event->setTenantId('tenant-a'); '203.0.113.10',
tenantId: 'tenant-a',
);
$this->service->handleAuthFailure($event); $this->service->handleAuthFailure($event);
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures()); self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
@@ -400,8 +403,10 @@ class FirewallServiceTest extends TestCase
->with('tenant-a', '203.0.113.10', 300) ->with('tenant-a', '203.0.113.10', 300)
->willReturn(0); ->willReturn(0);
$event = \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10'); $event = \KTXC\Security\Event\SecurityEvent::authFailure(
$event->setTenantId('tenant-a'); '203.0.113.10',
tenantId: 'tenant-a',
);
$this->service->handleAuthFailure($event); $this->service->handleAuthFailure($event);
} }
@@ -454,8 +459,10 @@ class FirewallServiceTest extends TestCase
} }
}); });
$event = \KTXC\Security\Event\SecurityEvent::authFailure('203.0.113.10'); $event = \KTXC\Security\Event\SecurityEvent::authFailure(
$event->setTenantId('tenant-event'); '203.0.113.10',
tenantId: 'tenant-event',
);
$this->service->handleAuthFailure($event); $this->service->handleAuthFailure($event);
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants); self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);