13 Commits

Author SHA1 Message Date
Sebastian 59bd193783 chore(deps): update dependency vue-i18n to v11.4.8
JS Unit Tests / test (pull_request) Successful in 23s
Build Test / build (pull_request) Successful in 26s
PHP Unit Tests / test (pull_request) Successful in 1m7s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1h0m3s
2026-08-07 03:03:10 +00:00
Sebastian b14bd302a3 fix(security): emit authentication success events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-06 00:08:58 -04:00
Sebastian 5b3e8f6588 fix(events): close deferred scope after listener failure
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-06 00:02:15 -04:00
Sebastian 84eb0e2c21 refactor(security): replace generic event with severity enum
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:59:49 -04:00
Sebastian 3f9c2500d9 refactor(security): type firewall policy events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:56:28 -04:00
Sebastian 2494cef02f refactor(security): type firewall rule lifecycle events
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:51:40 -04:00
Sebastian a5be782c51 refactor(security): add typed authentication-success event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:42:24 -04:00
Sebastian f4df769b3c refactor(security): add typed suspicious-activity event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:38:59 -04:00
Sebastian 688afbe5a1 refactor(security): add typed rate-limit event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:36:44 -04:00
Sebastian 7c2a8dfbd3 refactor(security): add typed access-denied event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:34:05 -04:00
Sebastian e223ae7543 refactor(security): add typed brute-force detection event
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:31:13 -04:00
Sebastian a8e29d0305 feat(security): emit authentication failures for firewall handling
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:26:01 -04:00
Sebastian a363a1a4bc refactor: use consistant naming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-05 23:17:04 -04:00
49 changed files with 2250 additions and 643 deletions
@@ -92,9 +92,9 @@ class AuthenticationController extends ControllerAbstract
}
$request = AuthenticationRequest::verify($session, $method, $response);
$authResponse = $this->authManager->handle($request);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($authResponse);
return $this->buildJsonResponse($response);
}
/**
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
$host = $request->getHost();
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
return $this->redirectWithError('Missing state parameter');
}
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
$response = $this->authManager->handle($request);
if ($response->isSuccess()) {
$returnUrl = $response->returnUrl ?? '/';
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
);
}
$authRequest = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::status($sessionId);
$response = $this->authManager->handle($request);
return $this->buildJsonResponse($response);
}
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
{
$sessionId = $request->query->get('session', '');
$authRequest = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($authRequest);
$request = AuthenticationRequest::cancel($sessionId);
$this->authManager->handle($request);
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
}
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
);
}
$authRequest = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($authRequest);
$request = AuthenticationRequest::refresh($refreshToken);
$response = $this->authManager->handle($request);
if ($response->isFailed()) {
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
{
$token = $request->cookies->get('accessToken');
$authRequest = AuthenticationRequest::logout($token, false);
$this->authManager->handle($authRequest);
$request = AuthenticationRequest::logout($token, false);
$this->authManager->handle($request);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
return $this->clearTokenCookies($response);
@@ -274,8 +274,8 @@ class AuthenticationController extends ControllerAbstract
{
$token = $request->cookies->get('accessToken');
$authRequest = AuthenticationRequest::logout($token, true);
$this->authManager->handle($authRequest);
$request = AuthenticationRequest::logout($token, true);
$this->authManager->handle($request);
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
return $this->clearTokenCookies($response);
+5 -5
View File
@@ -60,6 +60,7 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
throw new \LogicException('Cannot process deferred events for an inactive execution.');
}
try {
$processed = 0;
$deadline = microtime(true) + 1.0;
$deadlineExceeded = false;
@@ -78,16 +79,15 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
$processed += $this->invoke($event, DeliveryMode::Deferred);
}
$remaining = count($this->deferred[$executionId]);
unset($this->deferred[$executionId]);
$this->activeExecution = null;
return new DeferredProcessingResult(
$processed,
$remaining,
count($this->deferred[$executionId]),
$deadlineExceeded,
$limitExceeded,
);
} finally {
$this->discardDeferred($executionId);
}
}
public function discardDeferred(string $executionId): void
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace KTXC\Http\Request;
/**
* Holds the HTTP request for the duration of the current runtime execution.
*/
final class RequestContext
{
private ?Request $request = null;
public function initialize(Request $request): void
{
if ($this->request !== null) {
throw new \LogicException('The request context has already been initialized.');
}
$this->request = $request;
}
public function current(): ?Request
{
return $this->request;
}
public function clear(): void
{
$this->request = null;
}
}
+30 -12
View File
@@ -11,8 +11,18 @@ use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\ModuleBrowserInterface;
@@ -38,21 +48,29 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
AuthenticationFailedEvent::class,
FirewallService::class,
'handleAuthFailure',
DeliveryMode::Immediate,
priority: 100,
);
$this->events->listen(
'core',
AuthenticationSucceededEvent::class,
FirewallService::class,
'logAuthenticationSuccess',
DeliveryMode::Deferred,
);
foreach ([
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_DENIED,
SecurityEvent::BRUTE_FORCE_DETECTED,
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
AccessDeniedEvent::class,
BruteForceDetectedEvent::class,
RateLimitExceededEvent::class,
SuspiciousActivityEvent::class,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class,
] as $event) {
$this->events->listen(
'core',
+10 -1
View File
@@ -11,6 +11,7 @@ use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Http\Response\Response;
use KTXC\KernelInterface;
@@ -25,10 +26,15 @@ final class HttpRuntime
public function run(?Request $request = null, bool $send = true): Response
{
$request ??= Request::createFromGlobals();
$requestContext = null;
try {
return $this->kernel->executionRunner()->execute(
ExecutionDescriptor::http(),
function () use ($request, $send): Response {
function () use ($request, $send, &$requestContext): Response {
$requestContext = $this->kernel->container()->get(RequestContext::class);
$requestContext->initialize($request);
$response = $this->pipeline()->handle($request);
if ($send) {
$response->send();
@@ -45,6 +51,9 @@ final class HttpRuntime
return $response;
},
);
} finally {
$requestContext?->clear();
}
}
private function pipeline(): MiddlewarePipeline
+38 -1
View File
@@ -8,11 +8,14 @@ use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
use KTXF\Security\Authentication\AuthenticationSession;
use KTXF\Security\Authentication\ProviderContext;
@@ -31,6 +34,7 @@ class AuthenticationManager
private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService,
private readonly UserAccountsService $userService,
private readonly EventDispatcherInterface $events,
) {
$this->securityCode = $this->tenantContext->configuration()->security()->code();
}
@@ -187,6 +191,10 @@ class AuthenticationManager
if (!$result->isSuccess()) {
$this->saveSession($session);
$this->publishAuthenticationFailure(
$session,
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
'Authentication failed. If you haven\'t set up this method, try another option.',
@@ -389,6 +397,10 @@ class AuthenticationManager
$result = $provider->completeRedirect($context, $request->params);
if ($result->isFailed()) {
$this->publishAuthenticationFailure(
$session,
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
);
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
@@ -566,6 +578,17 @@ class AuthenticationManager
// Helper Methods
// =========================================================================
private function publishAuthenticationFailure(
AuthenticationSession $session,
string $reason,
): void {
$this->events->dispatch(new AuthenticationFailedEvent(
userId: $session->userIdentifier,
reason: $reason,
tenantId: $session->tenantIdentifier,
));
}
/**
* Build provider context from session
*/
@@ -594,7 +617,16 @@ class AuthenticationManager
*/
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
{
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
$userId = $session->userIdentifier;
if ($userId === null) {
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INVALID_SESSION,
'Authenticated user is missing',
401,
);
}
$userData = $this->userService->fetchByIdentifier($userId);
if ($userData === null) {
return AuthenticationResponse::failed(
@@ -611,6 +643,11 @@ class AuthenticationManager
$this->deleteSession($session->id);
$this->events->dispatch(new AuthenticationSucceededEvent(
$userId,
$session->tenantIdentifier,
));
return AuthenticationResponse::success(
$this->buildUserData($user),
$tokens
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Access denial requires an IP address.');
}
if ($ruleId === '') {
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
}
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
}
parent::__construct(
self::class,
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::WARNING;
}
}
@@ -9,15 +9,10 @@ use KTXF\Event\Event;
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $userId = null,
private readonly ?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
private readonly ?string $userAgent = null,
private readonly ?string $requestPath = null,
private readonly ?string $requestMethod = null,
) {
parent::__construct(
self::class,
@@ -27,31 +22,6 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function getRequestPath(): ?string
{
return $this->requestPath;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function getUserId(): ?string
{
return $this->userId;
@@ -62,8 +32,8 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
return $this->reason;
}
public function getSeverity(): int
public function getSeverity(): SecurityEventSeverity
{
return SecurityEvent::SEVERITY_WARNING;
return SecurityEventSeverity::WARNING;
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $userId,
?string $tenantId = null,
) {
if ($userId === '') {
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
}
parent::__construct(
self::class,
['userId' => $userId],
$tenantId,
);
}
public function getUserId(): string
{
return $this->userId;
}
public function getReason(): ?string
{
return null;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $failureCount,
private readonly int $windowSeconds,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
}
if ($failureCount < 1) {
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
}
$this->reason = sprintf(
'%d failed attempts in %d seconds',
$failureCount,
$windowSeconds,
);
parent::__construct(
self::class,
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getFailureCount(): int
{
return $this->failureCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $deviceFingerprint,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($deviceFingerprint === '') {
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
}
parent::__construct(
self::class,
['device' => $deviceFingerprint, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): ?string
{
return null;
}
public function getDeviceFingerprint(): string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::CRITICAL;
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
final public function __construct(
private readonly string $ipAddress,
private readonly ?string $reason = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
}
parent::__construct(
static::class,
['ip' => $ipAddress, 'reason' => $reason],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return null;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return static::SEVERITY;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXF\Event\Event;
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
{
final protected function __construct(
private readonly string $ruleId,
private readonly string $ruleScope,
private readonly string $ruleType,
private readonly string $ruleAction,
private readonly string $ruleValue,
private readonly ?string $reason,
private readonly string $origin,
private readonly ?string $expiresAt,
private readonly array $details,
?string $tenantId,
?string $identityId,
) {
if ($ruleId === '') {
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
foreach ([
'scope' => $ruleScope,
'type' => $ruleType,
'action' => $ruleAction,
'value' => $ruleValue,
'origin' => $origin,
] as $field => $value) {
if ($value === '') {
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
}
}
parent::__construct(
static::class,
[
'ruleId' => $ruleId,
'ruleScope' => $ruleScope,
'ruleType' => $ruleType,
'ruleAction' => $ruleAction,
'ruleValue' => $ruleValue,
'reason' => $reason,
'origin' => $origin,
'expiresAt' => $expiresAt,
...$details,
],
$tenantId,
$identityId,
);
}
public static function fromRule(
FirewallRuleObject $rule,
?string $actorId = null,
array $change = [],
): static {
$metadata = $rule->getMetadata() ?? [];
$details = [...$metadata, ...$change];
foreach ([
'ruleId',
'ruleScope',
'ruleType',
'ruleAction',
'ruleValue',
'reason',
'origin',
'expiresAt',
] as $reservedKey) {
unset($details[$reservedKey]);
}
return new static(
ruleId: (string) $rule->getId(),
ruleScope: (string) $rule->getScope(),
ruleType: (string) $rule->getType(),
ruleAction: (string) $rule->getAction(),
ruleValue: (string) $rule->getValue(),
reason: $rule->getReason(),
origin: (string) ($metadata['origin'] ?? 'manual'),
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
details: $details,
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
}
public function getRuleId(): string
{
return $this->ruleId;
}
public function getRuleScope(): string
{
return $this->ruleScope;
}
public function getRuleType(): string
{
return $this->ruleType;
}
public function getRuleAction(): string
{
return $this->ruleAction;
}
public function getRuleValue(): string
{
return $this->ruleValue;
}
public function getOrigin(): string
{
return $this->origin;
}
public function getExpiresAt(): ?string
{
return $this->expiresAt;
}
public function getDetails(): array
{
return $this->details;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
{
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
{
public function __construct(
private readonly string $changeReason,
private readonly array $previous,
private readonly array $current,
string $tenantId,
?string $actorId = null,
private readonly string $changeOrigin = 'manual',
) {
if ($changeReason === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
}
if ($tenantId === '') {
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
}
if ($changeOrigin === '') {
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
}
parent::__construct(
self::class,
[
'changeReason' => $changeReason,
'changeOrigin' => $changeOrigin,
'previous' => $previous,
'current' => $current,
],
$tenantId,
$actorId,
);
}
public function getChangeReason(): string
{
return $this->changeReason;
}
public function getPrevious(): array
{
return $this->previous;
}
public function getCurrent(): array
{
return $this->current;
}
public function getChangeOrigin(): string
{
return $this->changeOrigin;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->changeReason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::INFO;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpAllowedEvent extends FirewallIpEvent
{
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
final class IpBlockedEvent extends FirewallIpEvent
{
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
{
private readonly string $reason;
public function __construct(
private readonly string $ipAddress,
private readonly int $requestCount,
private readonly int $windowSeconds,
private readonly ?string $endpoint = null,
?string $tenantId = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
}
if ($requestCount < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
}
if ($windowSeconds < 1) {
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
}
if ($endpoint === '') {
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
}
$this->reason = sprintf(
'%d requests in %d seconds',
$requestCount,
$windowSeconds,
);
parent::__construct(
self::class,
[
'requestCount' => $requestCount,
'windowSeconds' => $windowSeconds,
'endpoint' => $endpoint,
],
$tenantId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getRequestCount(): int
{
return $this->requestCount;
}
public function getWindowSeconds(): int
{
return $this->windowSeconds;
}
public function getEndpoint(): ?string
{
return $this->endpoint;
}
public function getDeviceFingerprint(): ?string
{
return null;
}
public function getUserAgent(): ?string
{
return null;
}
public function getRequestPath(): ?string
{
return $this->endpoint;
}
public function getRequestMethod(): ?string
{
return null;
}
public function getUserId(): ?string
{
return null;
}
public function getReason(): string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
-249
View File
@@ -1,249 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
/**
* Security-specific event for authentication and access control events
*/
final class SecurityEvent extends Event implements SecurityEventInterface
{
// Event names
public const AUTH_SUCCESS = 'security.auth.success';
public const AUTH_LOGOUT = 'security.auth.logout';
public const TOKEN_REFRESH = 'security.token.refresh';
public const TOKEN_REVOKED = 'security.token.revoked';
public const ACCESS_DENIED = 'security.access.denied';
public const ACCESS_GRANTED = 'security.access.granted';
public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected';
public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded';
public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity';
public const IP_BLOCKED = 'security.ip.blocked';
public const IP_ALLOWED = 'security.ip.allowed';
public const DEVICE_BLOCKED = 'security.device.blocked';
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
// Severity levels
public const SEVERITY_DEBUG = 0;
public const SEVERITY_INFO = 1;
public const SEVERITY_WARNING = 2;
public const SEVERITY_ERROR = 3;
public const SEVERITY_CRITICAL = 4;
private readonly int $severity;
public function __construct(
string $name,
array $data = [],
?string $tenantId = null,
?string $identityId = null,
private readonly ?string $ipAddress = null,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $userAgent = null,
private readonly ?string $requestPath = null,
private readonly ?string $requestMethod = null,
private readonly ?string $userId = null,
private readonly ?string $reason = null,
?int $severity = null,
) {
parent::__construct($name, $data, $tenantId, $identityId);
$this->severity = $severity ?? self::getSeverityForEvent($name);
}
/**
* Create a security event with common parameters
*/
public static function create(
string $name,
?string $ipAddress = null,
?string $deviceFingerprint = null,
array $data = [],
?string $tenantId = null,
?string $identityId = null,
?string $userAgent = null,
?string $requestPath = null,
?string $requestMethod = null,
?string $userId = null,
?string $reason = null,
?int $severity = null,
): self {
return new self(
$name,
$data,
$tenantId,
$identityId,
$ipAddress,
$deviceFingerprint,
$userAgent,
$requestPath,
$requestMethod,
$userId,
$reason,
$severity,
);
}
/**
* Create an authentication success event
*/
public static function authSuccess(
string $ipAddress,
?string $deviceFingerprint = null,
?string $userId = null,
?string $tenantId = null,
): self {
return self::create(
self::AUTH_SUCCESS,
$ipAddress,
$deviceFingerprint,
['userId' => $userId],
tenantId: $tenantId,
userId: $userId,
);
}
/**
* Create a brute force detection event
*/
public static function bruteForceDetected(
string $ipAddress,
int $failureCount,
int $windowSeconds,
?string $tenantId = null,
): self {
return self::create(
self::BRUTE_FORCE_DETECTED,
$ipAddress,
data: ['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
tenantId: $tenantId,
reason: sprintf('%d failed attempts in %d seconds', $failureCount, $windowSeconds),
);
}
/**
* Create a rate limit exceeded event
*/
public static function rateLimitExceeded(
string $ipAddress,
int $requestCount,
int $windowSeconds,
?string $endpoint = null,
?string $tenantId = null,
): self {
return self::create(
self::RATE_LIMIT_EXCEEDED,
$ipAddress,
data: [
'requestCount' => $requestCount,
'windowSeconds' => $windowSeconds,
'endpoint' => $endpoint,
],
tenantId: $tenantId,
requestPath: $endpoint,
reason: sprintf('%d requests in %d seconds', $requestCount, $windowSeconds),
);
}
/**
* Create an access denied event
*/
public static function accessDenied(
string $ipAddress,
?string $deviceFingerprint = null,
?string $ruleId = null,
?string $ruleScope = null,
?string $reason = null,
?string $tenantId = null,
?string $identityId = null,
): self {
return self::create(
self::ACCESS_DENIED,
$ipAddress,
$deviceFingerprint,
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
$tenantId,
$identityId,
reason: $reason,
);
}
/**
* Get default severity for event types
*/
private static function getSeverityForEvent(string $eventName): int
{
return match ($eventName) {
self::AUTH_SUCCESS,
self::ACCESS_GRANTED,
self::TOKEN_REFRESH => self::SEVERITY_INFO,
self::ACCESS_DENIED,
self::AUTH_LOGOUT,
self::TOKEN_REVOKED => self::SEVERITY_WARNING,
self::RATE_LIMIT_EXCEEDED,
self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR,
self::BRUTE_FORCE_DETECTED,
self::IP_BLOCKED,
self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL,
default => self::SEVERITY_INFO,
};
}
// Getters and setters
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function getRequestPath(): ?string
{
return $this->requestPath;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): int
{
return $this->severity;
}
}
@@ -18,19 +18,9 @@ interface SecurityEventInterface
public function getIdentityId(): ?string;
public function getIpAddress(): ?string;
public function getDeviceFingerprint(): ?string;
public function getUserAgent(): ?string;
public function getRequestPath(): ?string;
public function getRequestMethod(): ?string;
public function getUserId(): ?string;
public function getReason(): ?string;
public function getSeverity(): int;
public function getSeverity(): SecurityEventSeverity;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
enum SecurityEventSeverity: int
{
case DEBUG = 0;
case INFO = 1;
case WARNING = 2;
case ERROR = 3;
case CRITICAL = 4;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
interface SecurityRequestEventInterface extends SecurityEventInterface
{
public function getIpAddress(): ?string;
public function getDeviceFingerprint(): ?string;
public function getUserAgent(): ?string;
public function getRequestPath(): ?string;
public function getRequestMethod(): ?string;
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace KTXC\Security\Event;
use KTXF\Event\Event;
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
{
public function __construct(
private readonly string $ipAddress,
private readonly string $detector,
private readonly array $detectionData = [],
?string $tenantId = null,
?string $identityId = null,
private readonly ?string $deviceFingerprint = null,
private readonly ?string $userAgent = null,
private readonly ?string $requestPath = null,
private readonly ?string $requestMethod = null,
private readonly ?string $userId = null,
private readonly ?string $reason = null,
) {
if ($ipAddress === '') {
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
}
if ($detector === '') {
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
}
if (array_key_exists('detector', $detectionData)) {
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
}
if (array_key_exists('detector', $detectionData)) {
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
}
if ($requestPath === '') {
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
}
if ($requestMethod === '') {
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
}
parent::__construct(
self::class,
['detector' => $detector] + $detectionData,
$tenantId,
$identityId,
);
}
public function getIpAddress(): string
{
return $this->ipAddress;
}
public function getDetector(): string
{
return $this->detector;
}
public function getDetectionData(): array
{
return $this->detectionData;
}
public function getDeviceFingerprint(): ?string
{
return $this->deviceFingerprint;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function getRequestPath(): ?string
{
return $this->requestPath;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getReason(): ?string
{
return $this->reason;
}
public function getSeverity(): SecurityEventSeverity
{
return SecurityEventSeverity::ERROR;
}
}
+25 -51
View File
@@ -5,9 +5,17 @@ declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\SecurityEvent;
use KTXF\IpUtils;
final class FirewallRuleManager
@@ -186,7 +194,7 @@ final class FirewallRuleManager
$origin,
$metadata
);
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
$this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
return $rule;
}
@@ -209,7 +217,7 @@ final class FirewallRuleManager
null,
$origin
);
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
$this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
return $rule;
}
@@ -254,13 +262,7 @@ final class FirewallRuleManager
$origin
);
$event = new SecurityEvent(
SecurityEvent::DEVICE_BLOCKED,
['device' => $fingerprint, 'reason' => $reason],
tenantId: $scope->tenantId,
deviceFingerprint: $fingerprint,
reason: $reason,
);
$event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId);
$this->events->dispatch($event);
return $rule;
@@ -282,7 +284,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_DISABLED,
FirewallRuleDisabledEvent::class,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
@@ -321,7 +323,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_ENABLED,
FirewallRuleEnabledEvent::class,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
@@ -365,7 +367,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_EXTENDED,
FirewallRuleExtendedEvent::class,
$rule,
$actorId,
[
@@ -392,7 +394,7 @@ final class FirewallRuleManager
$this->store->destroyRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(
SecurityEvent::FIREWALL_RULE_REMOVED,
FirewallRuleRemovedEvent::class,
$rule,
$actorId,
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
@@ -447,9 +449,10 @@ final class FirewallRuleManager
}
$rule->setMetadata($metadata);
$this->store->depositRule($rule);
$rule = $this->store->depositRule($rule)
?? throw new \RuntimeException('Failed to persist firewall rule.');
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule);
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
return $rule;
}
@@ -486,7 +489,7 @@ final class FirewallRuleManager
$this->store->depositRule($rule);
$this->cache->invalidate();
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule);
$this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
return $rule;
}
@@ -498,46 +501,17 @@ final class FirewallRuleManager
return $rule && $scope->owns($rule) ? $rule : null;
}
private function publishIpEvent(
string $name,
FirewallRuleScope $scope,
string $ipAddress,
?string $reason
): void {
$event = new SecurityEvent(
$name,
['ip' => $ipAddress, 'reason' => $reason],
tenantId: $scope->tenantId,
ipAddress: $ipAddress,
reason: $reason,
);
$this->events->dispatch($event);
}
/**
* @param class-string<FirewallRuleEvent> $eventClass
*/
private function publishLifecycleEvent(
string $name,
string $eventClass,
FirewallRuleObject $rule,
?string $actorId = null,
array $change = []
): void
{
$event = new SecurityEvent(
$name,
[
'ruleId' => $rule->getId(),
'ruleScope' => $rule->getScope(),
'ruleType' => $rule->getType(),
'ruleAction' => $rule->getAction(),
'ruleValue' => $rule->getValue(),
'reason' => $rule->getReason(),
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
...($rule->getMetadata() ?? []),
...$change,
],
tenantId: $rule->getTenantId(),
identityId: $actorId ?? $rule->getCreatedBy(),
);
$event = $eventClass::fromRule($rule, $actorId, $change);
$this->events->dispatch($event);
}
}
+65 -36
View File
@@ -5,13 +5,25 @@ declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore;
use KTXC\Context\TenantContextInterface;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Security\Event\SecurityEventInterface;
use KTXC\Security\Event\SecurityRequestEventInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\IpUtils;
@@ -47,6 +59,7 @@ class FirewallService
private readonly EventDispatcherInterface $events,
private readonly FirewallRuleManager $rules,
private readonly FirewallRuleCache $ruleCache,
private readonly RequestContext $requestContext,
) {
}
@@ -134,14 +147,15 @@ class FirewallService
*/
public function handleAuthFailure(AuthenticationFailedEvent $event): void
{
$ipAddress = $event->getIpAddress();
$request = $this->requestContext->current();
$ipAddress = $request?->getClientIp();
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) {
return;
}
$log = $this->securityLog($event);
$log = $this->securityLog($event, $request);
if ($log === null || !$this->store->createLogOnce($log)) {
return;
}
@@ -196,7 +210,7 @@ class FirewallService
int $blockDuration
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected(
$event = new BruteForceDetectedEvent(
$ipAddress,
$failureCount,
$windowSeconds,
@@ -235,7 +249,18 @@ class FirewallService
}
}
private function securityLog(SecurityEventInterface $event): ?FirewallLogObject
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
{
$log = $this->securityLog($event, $this->requestContext->current());
if ($log !== null) {
$this->store->createLog($log);
}
}
private function securityLog(
SecurityEventInterface $event,
?Request $request = null,
): ?FirewallLogObject
{
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
$ruleScope = $event->get('ruleScope');
@@ -243,14 +268,19 @@ class FirewallService
return null;
}
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
$log = new FirewallLogObject();
return $log->setEventId($event->getEventId())
->setTenantId($tenantId)
->setIpAddress($event->getIpAddress())
->setDeviceFingerprint($event->getDeviceFingerprint())
->setUserAgent($event->getUserAgent())
->setRequestPath($event->getRequestPath())
->setRequestMethod($event->getRequestMethod())
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
->setDeviceFingerprint(
$request?->headers->get('X-Device-Fingerprint')
?? $requestEvent?->getDeviceFingerprint()
)
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
->setEventType($this->mapEventToLogType($event->getName()))
->setResult($this->mapEventToResult($event))
->setRuleId($event->get('ruleId'))
@@ -267,17 +297,17 @@ class FirewallService
{
return match ($eventName) {
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK,
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE,
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK,
};
}
@@ -288,14 +318,13 @@ class FirewallService
private function mapEventToResult(SecurityEventInterface $event): string
{
return match ($event->getName()) {
SecurityEvent::AUTH_SUCCESS,
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED,
};
}
@@ -308,13 +337,13 @@ class FirewallService
?string $deviceFingerprint,
FirewallRuleObject $rule
): void {
$event = SecurityEvent::accessDenied(
$ipAddress,
$deviceFingerprint,
$rule->getId(),
$rule->getScope(),
$rule->getReason(),
$this->tenantContext->identifier(),
$event = new AccessDeniedEvent(
ipAddress: $ipAddress,
ruleId: $rule->getId(),
ruleScope: $rule->getScope(),
deviceFingerprint: $deviceFingerprint,
reason: $rule->getReason(),
tenantId: $this->tenantContext->identifier(),
);
$this->events->dispatch($event);
}
+7 -10
View File
@@ -6,7 +6,7 @@ namespace KTXC\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
final class FirewallSettingsService
{
@@ -52,16 +52,13 @@ final class FirewallSettingsService
$tenant->setConfiguration($configuration);
$this->tenants->deposit($tenant);
$event = new SecurityEvent(
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
[
'changeReason' => $reason,
'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL,
'previous' => $previous,
'current' => $current,
],
$event = new FirewallSettingsUpdatedEvent(
changeReason: $reason,
previous: $previous,
current: $current,
tenantId: $tenantId,
identityId: $actorId,
actorId: $actorId,
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
);
$this->events->dispatch($event);
+24 -24
View File
@@ -642,14 +642,14 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.7",
"@intlify/message-compiler": "11.4.7",
"@intlify/shared": "11.4.7"
"@intlify/devtools-types": "11.4.8",
"@intlify/message-compiler": "11.4.8",
"@intlify/shared": "11.4.8"
},
"engines": {
"node": ">= 22"
@@ -659,13 +659,13 @@
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.7",
"@intlify/shared": "11.4.7"
"@intlify/core-base": "11.4.8",
"@intlify/shared": "11.4.8"
},
"engines": {
"node": ">= 22"
@@ -675,12 +675,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.7",
"@intlify/shared": "11.4.8",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -691,9 +691,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
"license": "MIT",
"engines": {
"node": ">= 22"
@@ -6537,14 +6537,14 @@
}
},
"node_modules/vue-i18n": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.7",
"@intlify/devtools-types": "11.4.7",
"@intlify/shared": "11.4.7",
"@intlify/core-base": "11.4.8",
"@intlify/devtools-types": "11.4.8",
"@intlify/shared": "11.4.8",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class AccessDeniedEventTest extends TestCase
{
#[Test]
#[TestDox('Access-denial state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new AccessDeniedEvent(
'203.0.113.10',
'rule-a',
FirewallRuleObject::SCOPE_TENANT,
'device-a',
'Blocked by policy',
'tenant-a',
'identity-a',
);
self::assertSame(AccessDeniedEvent::class, $event->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->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
}
#[Test]
#[TestDox('Access denial rejects incomplete rule context')]
public function rejectsIncompleteRuleContext(): void
{
foreach ([
['', 'rule-a', FirewallRuleObject::SCOPE_TENANT],
['203.0.113.10', '', FirewallRuleObject::SCOPE_TENANT],
['203.0.113.10', 'rule-a', 'unknown'],
] as $arguments) {
try {
new AccessDeniedEvent(...$arguments);
self::fail('Incomplete access-denial context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -5,7 +5,8 @@ declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SecurityRequestEventInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
@@ -17,27 +18,18 @@ final class AuthenticationFailedEventTest extends TestCase
public function constructsTypedState(): void
{
$event = new AuthenticationFailedEvent(
'203.0.113.10',
'device-a',
'user-a',
'Invalid credentials',
'tenant-a',
'identity-a',
'Test Agent',
'/login',
'POST',
);
self::assertSame(AuthenticationFailedEvent::class, $event->getName());
self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame('device-a', $event->getDeviceFingerprint());
self::assertSame('user-a', $event->getUserId());
self::assertSame('Invalid credentials', $event->getReason());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('identity-a', $event->getIdentityId());
self::assertSame('Test Agent', $event->getUserAgent());
self::assertSame('/login', $event->getRequestPath());
self::assertSame('POST', $event->getRequestMethod());
self::assertSame(SecurityEvent::SEVERITY_WARNING, $event->getSeverity());
self::assertSame(SecurityEventSeverity::WARNING, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SecurityRequestEventInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class AuthenticationSucceededEventTest extends TestCase
{
#[Test]
#[TestDox('Successful-authentication state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new AuthenticationSucceededEvent(
'user-a',
'tenant-a',
);
self::assertSame(AuthenticationSucceededEvent::class, $event->getName());
self::assertSame('user-a', $event->getUserId());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame(['userId' => 'user-a'], $event->getData());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
self::assertNotInstanceOf(SecurityRequestEventInterface::class, $event);
}
#[Test]
#[TestDox('Successful authentication requires a user ID')]
public function rejectsIncompleteAuthenticationContext(): void
{
$this->expectException(\InvalidArgumentException::class);
new AuthenticationSucceededEvent('');
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class BruteForceDetectedEventTest extends TestCase
{
#[Test]
#[TestDox('Brute-force detection state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new BruteForceDetectedEvent(
'203.0.113.10',
5,
300,
'tenant-a',
);
self::assertSame(BruteForceDetectedEvent::class, $event->getName());
self::assertSame('203.0.113.10', $event->getIpAddress());
self::assertSame(5, $event->getFailureCount());
self::assertSame(300, $event->getWindowSeconds());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('5 failed attempts in 300 seconds', $event->getReason());
self::assertSame(
['failureCount' => 5, 'windowSeconds' => 300],
$event->getData(),
);
self::assertSame(SecurityEventSeverity::CRITICAL, $event->getSeverity());
}
#[Test]
#[TestDox('Brute-force detection rejects invalid measurements')]
public function rejectsInvalidMeasurements(): void
{
foreach ([
['', 5, 300],
['203.0.113.10', 0, 300],
['203.0.113.10', 5, 0],
] as $arguments) {
try {
new BruteForceDetectedEvent(...$arguments);
self::fail('Invalid brute-force measurements were accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -167,6 +167,43 @@ final class EventDispatcherTest extends TestCase
$dispatcher->dispatch(new Event('test.event'));
}
#[Test]
#[TestDox('Propagated deferred failures close their execution scope')]
public function recoversFromDeferredFailure(): void
{
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
FailingListener::class,
'fail',
DeliveryMode::Deferred,
failurePolicy: FailurePolicy::Propagate,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([FailingListener::class => new FailingListener()]),
new NullLogger(),
);
$dispatcher->beginExecution('failed');
$dispatcher->dispatch(new Event('test.event'));
try {
$dispatcher->processDeferred('failed');
self::fail('Expected deferred listener failure to propagate.');
} catch (\RuntimeException $error) {
self::assertSame('Listener failed.', $error->getMessage());
}
$dispatcher->beginExecution('next');
$result = $dispatcher->processDeferred('next');
self::assertSame(0, $result->processed);
self::assertSame(0, $result->remaining);
}
#[Test]
#[TestDox('Deferred processing stops at its configured count limit')]
public function boundsDeferredWork(): void
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallPolicyEventTest extends TestCase
{
#[Test]
#[TestDox('IP and device policy events expose typed immutable state')]
public function constructsSubjectPolicyEvents(): void
{
$blocked = new IpBlockedEvent('203.0.113.10', 'Repeated abuse', 'tenant-a');
$allowed = new IpAllowedEvent('203.0.113.11', 'Trusted service', 'tenant-a');
$device = new DeviceBlockedEvent('device-a', 'Compromised device', 'tenant-a');
self::assertSame(IpBlockedEvent::class, $blocked->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->getName());
self::assertSame('203.0.113.11', $allowed->getIpAddress());
self::assertSame(SecurityEventSeverity::INFO, $allowed->getSeverity());
self::assertSame(DeviceBlockedEvent::class, $device->getName());
self::assertSame('device-a', $device->getDeviceFingerprint());
self::assertSame(SecurityEventSeverity::CRITICAL, $device->getSeverity());
}
#[Test]
#[TestDox('Firewall settings events capture the complete configuration transition')]
public function constructsSettingsEvent(): void
{
$event = new FirewallSettingsUpdatedEvent(
'Tighten controls',
['maxAuthFailures' => 5],
['maxAuthFailures' => 3],
'tenant-a',
'operator-a',
);
self::assertSame(FirewallSettingsUpdatedEvent::class, $event->getName());
self::assertSame('Tighten controls', $event->getChangeReason());
self::assertSame(['maxAuthFailures' => 5], $event->getPrevious());
self::assertSame(['maxAuthFailures' => 3], $event->getCurrent());
self::assertSame('tenant-a', $event->getTenantId());
self::assertSame('operator-a', $event->getIdentityId());
self::assertSame('manual', $event->getChangeOrigin());
self::assertSame(SecurityEventSeverity::INFO, $event->getSeverity());
}
#[Test]
#[TestDox('Firewall policy events reject missing subject and ownership context')]
public function rejectsIncompletePolicyContext(): void
{
foreach ([
static fn() => new IpBlockedEvent(''),
static fn() => new IpAllowedEvent(''),
static fn() => new DeviceBlockedEvent(''),
static fn() => new FirewallSettingsUpdatedEvent('', [], [], 'tenant-a'),
static fn() => new FirewallSettingsUpdatedEvent('Reason', [], [], ''),
] as $construction) {
try {
$construction();
self::fail('Incomplete firewall policy context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallRuleEventTest extends TestCase
{
#[Test]
#[TestDox('Firewall rule events capture immutable rule snapshots')]
public function capturesRuleSnapshot(): void
{
$expiresAt = new \DateTimeImmutable('+5 minutes');
$rule = (new FirewallRuleObject())
->setId('rule-a')
->setScope(FirewallRuleObject::SCOPE_TENANT)
->setTenantId('tenant-a')
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setReason('Repeated abuse')
->setCreatedBy('creator-a')
->setExpiresAt($expiresAt)
->setMetadata(['origin' => 'automatic', 'failureCount' => 5]);
$event = FirewallRuleExtendedEvent::fromRule(
$rule,
'operator-a',
['changeReason' => 'Continue monitoring'],
);
$rule->setReason('Mutated after publication');
self::assertSame(FirewallRuleExtendedEvent::class, $event->getName());
self::assertSame('rule-a', $event->getRuleId());
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $event->getRuleScope());
self::assertSame(FirewallRuleObject::TYPE_IP, $event->getRuleType());
self::assertSame(FirewallRuleObject::ACTION_BLOCK, $event->getRuleAction());
self::assertSame('203.0.113.10', $event->getRuleValue());
self::assertSame('Repeated abuse', $event->getReason());
self::assertSame('automatic', $event->getOrigin());
self::assertSame($expiresAt->format(\DateTimeInterface::ATOM), $event->getExpiresAt());
self::assertSame('tenant-a', $event->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());
}
#[Test]
#[TestDox('Each firewall rule lifecycle operation has a dedicated event key')]
public function exposesDedicatedLifecycleKeys(): void
{
$rule = (new FirewallRuleObject())
->setId('rule-a')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10');
foreach ([
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleRemovedEvent::class,
] as $eventClass) {
self::assertSame($eventClass, $eventClass::fromRule($rule)->getName());
}
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class RateLimitExceededEventTest extends TestCase
{
#[Test]
#[TestDox('Rate-limit state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new RateLimitExceededEvent(
'203.0.113.10',
101,
60,
'/login',
'tenant-a',
);
self::assertSame(RateLimitExceededEvent::class, $event->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->getTenantId());
self::assertSame('101 requests in 60 seconds', $event->getReason());
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
}
#[Test]
#[TestDox('Rate-limit detection rejects invalid measurements')]
public function rejectsInvalidMeasurements(): void
{
foreach ([
['', 101, 60, '/login'],
['203.0.113.10', 0, 60, '/login'],
['203.0.113.10', 101, 0, '/login'],
['203.0.113.10', 101, 60, ''],
] as $arguments) {
try {
new RateLimitExceededEvent(...$arguments);
self::fail('Invalid rate-limit measurements were accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\SecurityEventSeverity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class SecurityEventSeverityTest extends TestCase
{
#[Test]
#[TestDox('Security severity levels expose stable numeric values')]
public function exposesStableNumericValues(): void
{
self::assertSame(0, SecurityEventSeverity::DEBUG->value);
self::assertSame(1, SecurityEventSeverity::INFO->value);
self::assertSame(2, SecurityEventSeverity::WARNING->value);
self::assertSame(3, SecurityEventSeverity::ERROR->value);
self::assertSame(4, SecurityEventSeverity::CRITICAL->value);
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class SecurityEventTest extends TestCase
{
#[Test]
#[TestDox('Direct construction applies the event type default severity')]
public function appliesDefaultSeverityDuringConstruction(): void
{
self::assertSame(
SecurityEvent::SEVERITY_WARNING,
(new SecurityEvent(SecurityEvent::AUTH_LOGOUT))->getSeverity(),
);
self::assertSame(
SecurityEvent::SEVERITY_ERROR,
(new SecurityEvent(SecurityEvent::RATE_LIMIT_EXCEEDED))->getSeverity(),
);
self::assertSame(
SecurityEvent::SEVERITY_CRITICAL,
(new SecurityEvent(SecurityEvent::DEVICE_BLOCKED))->getSeverity(),
);
self::assertSame(
SecurityEvent::SEVERITY_INFO,
(new SecurityEvent(SecurityEvent::FIREWALL_RULE_CREATED))->getSeverity(),
);
}
#[Test]
#[TestDox('Construction can override the event type default severity')]
public function allowsSeverityOverride(): void
{
$event = new SecurityEvent(
SecurityEvent::AUTH_LOGOUT,
severity: SecurityEvent::SEVERITY_CRITICAL,
);
self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $event->getSeverity());
}
#[Test]
#[TestDox('Event state exposes no mutation methods')]
public function exposesNoMutationMethods(): void
{
foreach ([
'set',
'setTenantId',
'setIdentityId',
'setIpAddress',
'setDeviceFingerprint',
'setUserAgent',
'setRequestPath',
'setRequestMethod',
'setUserId',
'setReason',
'setSeverity',
] as $method) {
self::assertFalse(method_exists(SecurityEvent::class, $method));
}
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXC\Security\Event\SecurityEventSeverity;
use KTXC\Security\Event\SuspiciousActivityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class SuspiciousActivityEventTest extends TestCase
{
#[Test]
#[TestDox('Suspicious-activity state is typed and complete at construction')]
public function constructsTypedState(): void
{
$event = new SuspiciousActivityEvent(
ipAddress: '203.0.113.20',
detector: 'payload-signature',
detectionData: ['score' => 98],
tenantId: 'tenant-a',
identityId: 'identity-a',
deviceFingerprint: 'device-a',
userAgent: 'Test Agent',
requestPath: '/admin',
requestMethod: 'POST',
userId: 'user-a',
reason: 'Matched a blocked payload signature',
);
self::assertSame(SuspiciousActivityEvent::class, $event->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->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());
self::assertSame('POST', $event->getRequestMethod());
self::assertSame('user-a', $event->getUserId());
self::assertSame('Matched a blocked payload signature', $event->getReason());
self::assertSame(SecurityEventSeverity::ERROR, $event->getSeverity());
}
#[Test]
#[TestDox('Suspicious activity rejects incomplete or conflicting detection context')]
public function rejectsInvalidDetectionContext(): void
{
foreach ([
['', 'payload-signature', []],
['203.0.113.20', '', []],
['203.0.113.20', 'payload-signature', ['detector' => 'replacement']],
] as $arguments) {
try {
new SuspiciousActivityEvent(...$arguments);
self::fail('Invalid suspicious-activity context was accepted.');
} catch (\InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
}
+28 -9
View File
@@ -16,8 +16,18 @@ use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallRuleService;
use KTXC\Event\EventListenerRegistry;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXF\Event\DeliveryMode;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
@@ -42,15 +52,24 @@ final class CoreModuleTest extends TestCase
$registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Immediate)[0]->service,
);
self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred));
$successListeners = $registry->listeners(
AuthenticationSucceededEvent::class,
DeliveryMode::Deferred,
);
self::assertCount(1, $successListeners);
self::assertSame(FirewallService::class, $successListeners[0]->service);
self::assertSame('logAuthenticationSuccess', $successListeners[0]->method);
foreach ([
SecurityEvent::RATE_LIMIT_EXCEEDED,
SecurityEvent::SUSPICIOUS_ACTIVITY,
SecurityEvent::FIREWALL_RULE_CREATED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
AccessDeniedEvent::class,
BruteForceDetectedEvent::class,
RateLimitExceededEvent::class,
SuspiciousActivityEvent::class,
FirewallRuleCreatedEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleDisabledEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleRemovedEvent::class,
FirewallSettingsUpdatedEvent::class,
] as $event) {
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
self::assertCount(1, $listeners);
+45 -1
View File
@@ -20,6 +20,7 @@ use KTXC\Http\Middleware\RequestHandlerInterface;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedResponse;
use KTXC\KernelInterface;
@@ -78,6 +79,31 @@ final class HttpRuntimeTest extends TestCase
self::assertTrue($kernel->outcome?->successful);
}
#[Test]
#[TestDox('HTTP requests are available only for the active runtime execution')]
public function scopesRequestContext(): void
{
$requestContext = new RequestContext();
$observer = new RequestContextMiddleware($requestContext);
$kernel = new RuntimeKernel(
new RuntimeContainer([
RequestContext::class => $requestContext,
TenantMiddleware::class => new PassMiddleware(),
FirewallMiddleware::class => new PassMiddleware(),
AuthenticationMiddleware::class => new PassMiddleware(),
RouterMiddleware::class => $observer,
]),
new TenantContext($this->createStub(TenantService::class)),
new IdentityContext(),
);
$request = Request::create('/auth/verify', 'POST');
(new HttpRuntime($kernel, false))->run($request, send: false);
self::assertSame($request, $observer->observed);
self::assertNull($requestContext->current());
}
#[Test]
#[TestDox('HTTP exceptions render a response and still terminate')]
public function fails(): void
@@ -198,9 +224,12 @@ final class RuntimeKernel implements KernelInterface
final class RuntimeContainer implements ContainerInterface
{
private readonly array $services;
public function __construct(
private readonly array $services,
array $services,
) {
$this->services = [RequestContext::class => new RequestContext(), ...$services];
}
public function get(string $id): mixed
@@ -250,6 +279,21 @@ final class ResponseMiddleware extends PassMiddleware
}
}
final class RequestContextMiddleware extends PassMiddleware
{
public ?Request $observed = null;
public function __construct(
private readonly RequestContext $requestContext,
) {}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$this->observed = $this->requestContext->current();
return new Response('ok');
}
}
final class ThrowingMiddleware extends PassMiddleware
{
public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Security;
use KTXC\Context\TenantContextInterface;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Resource\ProviderManager;
use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\AuthenticationManager;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService;
use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface;
use KTXF\Security\Authentication\AuthenticationSession;
use KTXF\Security\Authentication\ProviderContext;
use KTXF\Security\Authentication\ProviderResult;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AuthenticationManagerTest extends TestCase
{
#[Test]
public function completedAuthenticationPublishesAuthenticationSucceededEvent(): void
{
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('identifier')->willReturn('tenant-a');
$tenant->method('configuration')->willReturn(new TenantConfiguration());
$session = new AuthenticationSession(
id: 'auth-session',
state: AuthenticationSession::STATE_IDENTIFIED,
tenantIdentifier: 'tenant-a',
userIdentifier: 'user-a',
userIdentity: 'person@example.com',
methodsAvailable: ['password'],
createdAt: time(),
expiresAt: time() + 300,
);
$cache = $this->createMock(EphemeralCacheInterface::class);
$cache->expects($this->once())
->method('get')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn($session);
$cache->expects($this->once())->method('set')->willReturn(true);
$cache->expects($this->once())
->method('delete')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn(true);
$provider = new SuccessfulAuthenticationProvider();
$providers = $this->createStub(ProviderManager::class);
$providers->method('resolve')->willReturn($provider);
$users = $this->createMock(UserAccountsService::class);
$users->expects($this->once())
->method('fetchByIdentifier')
->with('user-a')
->willReturn([
'uid' => 'user-a',
'identity' => 'person@example.com',
'label' => 'Person',
'permissions' => [],
]);
$tokens = $this->createStub(TokenService::class);
$tokens->method('createToken')->willReturn('token');
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with(self::callback(static fn($event): bool =>
$event instanceof AuthenticationSucceededEvent
&& $event->getUserId() === 'user-a'
&& $event->getTenantId() === 'tenant-a'
));
$manager = new AuthenticationManager(
$tenant,
$cache,
$providers,
$tokens,
$users,
$events,
);
$response = $manager->handle(AuthenticationRequest::verify(
'auth-session',
'password',
'correct',
));
self::assertTrue($response->isSuccess());
}
#[Test]
public function rejectedCredentialPublishesAuthenticationFailedEvent(): void
{
$tenant = $this->createStub(TenantContextInterface::class);
$tenant->method('configuration')->willReturn(new TenantConfiguration());
$session = new AuthenticationSession(
id: 'auth-session',
state: AuthenticationSession::STATE_IDENTIFIED,
tenantIdentifier: 'tenant-a',
userIdentifier: 'user-a',
userIdentity: 'person@example.com',
methodsAvailable: ['password'],
createdAt: time(),
expiresAt: time() + 300,
);
$cache = $this->createMock(EphemeralCacheInterface::class);
$cache->expects($this->once())
->method('get')
->with('auth-session', CacheScope::Tenant, 'auth')
->willReturn($session);
$cache->expects($this->once())
->method('set')
->willReturn(true);
$provider = new class implements AuthenticationProviderInterface {
public int $verificationCount = 0;
public function type(): string
{
return 'authentication';
}
public function identifier(): string
{
return 'password';
}
public function label(): string
{
return 'Password';
}
public function description(): string
{
return 'Test provider';
}
public function method(): string
{
return self::METHOD_CREDENTIAL;
}
public function icon(): string
{
return '';
}
public function verify(ProviderContext $context, string $secret): ProviderResult
{
$this->verificationCount++;
return ProviderResult::failed(ProviderResult::ERROR_INVALID_FACTOR);
}
public function beginChallenge(ProviderContext $context): ProviderResult
{
return ProviderResult::failed();
}
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
{
return ProviderResult::failed();
}
public function beginRedirect(
ProviderContext $context,
string $callbackUrl,
?string $returnUrl = null,
): ProviderResult {
return ProviderResult::failed();
}
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
{
return ProviderResult::failed();
}
};
$providers = $this->createMock(ProviderManager::class);
$providers->method('resolve')->with('authentication', 'password')->willReturn($provider);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects($this->once())
->method('dispatch')
->with(self::callback(static function ($event): bool {
return $event instanceof AuthenticationFailedEvent
&& $event->getUserId() === 'user-a'
&& $event->getReason() === ProviderResult::ERROR_INVALID_FACTOR
&& $event->getTenantId() === 'tenant-a';
}));
$manager = new AuthenticationManager(
$tenant,
$cache,
$providers,
$this->createStub(TokenService::class),
$this->createStub(UserAccountsService::class),
$events,
);
$response = $manager->handle(AuthenticationRequest::verify(
'auth-session',
'password',
'incorrect',
));
self::assertTrue($response->isFailed());
self::assertSame(1, $provider->verificationCount);
}
}
final class SuccessfulAuthenticationProvider implements AuthenticationProviderInterface
{
public function type(): string
{
return 'authentication';
}
public function identifier(): string
{
return 'password';
}
public function label(): string
{
return 'Password';
}
public function description(): string
{
return 'Successful test provider';
}
public function method(): string
{
return self::METHOD_CREDENTIAL;
}
public function icon(): string
{
return '';
}
public function verify(ProviderContext $context, string $secret): ProviderResult
{
return ProviderResult::success();
}
public function beginChallenge(ProviderContext $context): ProviderResult
{
return ProviderResult::failed();
}
public function verifyChallenge(ProviderContext $context, string $code): ProviderResult
{
return ProviderResult::failed();
}
public function beginRedirect(
ProviderContext $context,
string $callbackUrl,
?string $returnUrl = null,
): ProviderResult {
return ProviderResult::failed();
}
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
{
return ProviderResult::failed();
}
}
@@ -11,7 +11,14 @@ use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallRuleScope;
use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallRuleEnabledEvent;
use KTXC\Security\Event\FirewallRuleExtendedEvent;
use KTXC\Security\Event\FirewallRuleRemovedEvent;
use KTXC\Security\Event\DeviceBlockedEvent;
use KTXC\Security\Event\IpAllowedEvent;
use KTXC\Security\Event\IpBlockedEvent;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
@@ -41,7 +48,10 @@ class FirewallRuleManagerTest extends TestCase
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects($this->exactly(2))
->method('depositRule')
->willReturnArgument(0);
->willReturnCallback(static function (FirewallRuleObject $rule): FirewallRuleObject {
static $sequence = 0;
return $rule->setId('rule-' . ++$sequence);
});
$tenant = $this->manager->blockIp(
FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin-a'
@@ -135,7 +145,11 @@ class FirewallRuleManagerTest extends TestCase
public function testConfirmedCurrentIpBlock(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->expects(self::once())->method('depositRule')->willReturnArgument(0);
$this->store->expects(self::once())
->method('depositRule')
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('rule-confirmed')
);
$rule = $this->manager->createManualRule(
FirewallRuleScope::tenant('tenant-a'),
@@ -249,7 +263,9 @@ class FirewallRuleManagerTest extends TestCase
$cache = new FirewallRuleCache($this->store);
$manager = new FirewallRuleManager($this->store, $cache, $this->events);
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnArgument(0);
$this->store->method('depositRule')->willReturnCallback(
static fn(FirewallRuleObject $rule): FirewallRuleObject => $rule->setId('rule-cache')
);
self::assertSame([], $cache->tenant('tenant-a'));
$manager->blockIp(FirewallRuleScope::tenant('tenant-a'), '203.0.113.10', null, 'admin');
@@ -280,7 +296,7 @@ class FirewallRuleManagerTest extends TestCase
300
);
$audit = $events[SecurityEvent::FIREWALL_RULE_CREATED];
$audit = $events[FirewallRuleCreatedEvent::class];
self::assertSame('rule-123', $audit->get('ruleId'));
self::assertSame(FirewallRuleObject::SCOPE_TENANT, $audit->get('ruleScope'));
self::assertSame(FirewallRuleObject::TYPE_IP, $audit->get('ruleType'));
@@ -288,6 +304,42 @@ class FirewallRuleManagerTest extends TestCase
self::assertSame(FirewallRuleManager::ORIGIN_MANUAL, $audit->get('origin'));
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());
}
#[TestDox('IP allowance and device blocking publish dedicated policy events')]
public function testSubjectPolicyEvents(): void
{
$this->store->method('findExactIpRule')->willReturn(null);
$this->store->method('depositRule')->willReturnCallback(
static function (FirewallRuleObject $rule): FirewallRuleObject {
static $sequence = 0;
return $rule->setId('policy-rule-' . ++$sequence);
}
);
$events = [];
$this->events->expects($this->exactly(4))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (&$events): void {
$events[] = $event;
});
$this->manager->allowIp(
FirewallRuleScope::tenant('tenant-a'),
'203.0.113.11',
'Trusted service',
'admin-a',
);
$this->manager->blockDevice(
FirewallRuleScope::tenant('tenant-a'),
'device-a',
'Compromised device',
'admin-a',
);
self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof IpAllowedEvent));
self::assertCount(1, array_filter($events, static fn($event): bool => $event instanceof DeviceBlockedEvent));
}
#[TestDox('Manual lifecycle changes persist state, reasons, actors, and extension history')]
@@ -329,10 +381,10 @@ class FirewallRuleManagerTest extends TestCase
self::assertGreaterThan($originalExpiry, $extended->getExpiresAt());
self::assertCount(1, $extended->getMetadata()['extensions']);
self::assertSame([
SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_REMOVED,
FirewallRuleDisabledEvent::class,
FirewallRuleEnabledEvent::class,
FirewallRuleExtendedEvent::class,
FirewallRuleRemovedEvent::class,
], array_map(static fn($event): string => $event->getName(), $audits));
self::assertSame(
['Investigation', 'Threat confirmed', 'Continue monitoring', 'Case closed'],
@@ -401,7 +453,7 @@ class FirewallRuleManagerTest extends TestCase
$this->events->expects(self::once())
->method('dispatch')
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
$event instanceof FirewallRuleExtendedEvent
&& $event->get('lastFailureCount') === 8
));
@@ -102,7 +102,9 @@ class FirewallRuleServicesTest extends TestCase
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
$rule->isTenantScoped() && $rule->getTenantId() === 'tenant-a'
))
->willReturnArgument(0);
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('tenant-rule')
);
$this->tenantService()->blockIp('203.0.113.10');
}
@@ -117,7 +119,9 @@ class FirewallRuleServicesTest extends TestCase
->with(self::callback(static fn(FirewallRuleObject $rule): bool =>
$rule->isSystemScoped() && $rule->getTenantId() === null
))
->willReturnArgument(0);
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('system-rule')
);
$this->systemService()->blockIp('203.0.113.10');
}
+142 -53
View File
@@ -5,10 +5,20 @@ declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Context\TenantContextInterface;
use KTXC\Http\Request\Request;
use KTXC\Http\Request\RequestContext;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Security\Event\AccessDeniedEvent;
use KTXC\Security\Event\AuthenticationFailedEvent;
use KTXC\Security\Event\AuthenticationSucceededEvent;
use KTXC\Security\Event\BruteForceDetectedEvent;
use KTXC\Security\Event\FirewallRuleCreatedEvent;
use KTXC\Security\Event\FirewallRuleDisabledEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use KTXC\Security\Event\RateLimitExceededEvent;
use KTXC\Security\Event\SuspiciousActivityEvent;
use KTXC\Service\FirewallService;
use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager;
@@ -25,6 +35,7 @@ class FirewallServiceTest extends TestCase
private FirewallStore&MockObject $store;
private TenantContextInterface&MockObject $tenantContext;
private EventDispatcherInterface&MockObject $events;
private RequestContext $requestContext;
private FirewallService $service;
private ?string $currentTenant;
private ?TenantConfiguration $currentConfiguration;
@@ -34,6 +45,16 @@ class FirewallServiceTest extends TestCase
$this->store = $this->createMock(FirewallStore::class);
$this->tenantContext = $this->createMock(TenantContextInterface::class);
$this->events = $this->createMock(EventDispatcherInterface::class);
$this->requestContext = new RequestContext();
$this->requestContext->initialize(Request::create(
'/login',
'POST',
server: [
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_USER_AGENT' => 'Test Agent',
'HTTP_X_DEVICE_FINGERPRINT' => 'device-a',
],
));
$this->currentTenant = 'tenant-a';
$this->currentConfiguration = null;
$this->tenantContext->method('identifier')->willReturnCallback(
@@ -49,7 +70,8 @@ class FirewallServiceTest extends TestCase
$this->tenantContext,
$this->events,
$manager,
$cache
$cache,
$this->requestContext,
);
}
@@ -71,7 +93,15 @@ class FirewallServiceTest extends TestCase
$this->store->expects($this->once())->method('listSystemRules')->willReturn([$systemBlock]);
$this->store->expects($this->once())->method('listRules')->with('tenant-a')->willReturn([$tenantAllow]);
$this->events->expects($this->once())->method('dispatch');
$this->events->expects($this->once())
->method('dispatch')
->with(self::callback(static function ($event): bool {
return $event instanceof AccessDeniedEvent
&& $event->getIpAddress() === '203.0.113.10'
&& $event->getRuleId() === 'system-block'
&& $event->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM
&& $event->getReason() === 'system-block';
}));
$result = $this->service->analyze('203.0.113.10');
@@ -211,13 +241,12 @@ class FirewallServiceTest extends TestCase
&& $log->getEventType() === FirewallLogObject::EVENT_RULE_MATCH;
}))
->willReturnArgument(0);
$event = \KTXC\Security\Event\SecurityEvent::accessDenied(
'203.0.113.10',
null,
'tenant-rule',
FirewallRuleObject::SCOPE_TENANT,
'Tenant block',
'tenant-a',
$event = new AccessDeniedEvent(
ipAddress: '203.0.113.10',
ruleId: 'tenant-rule',
ruleScope: FirewallRuleObject::SCOPE_TENANT,
reason: 'Tenant block',
tenantId: 'tenant-a',
);
$this->service->logSecurityEvent($event);
@@ -235,12 +264,11 @@ class FirewallServiceTest extends TestCase
&& $log->getRuleScope() === FirewallRuleObject::SCOPE_SYSTEM;
}))
->willReturnArgument(0);
$event = \KTXC\Security\Event\SecurityEvent::accessDenied(
'203.0.113.10',
null,
'system-rule',
FirewallRuleObject::SCOPE_SYSTEM,
'System block'
$event = new AccessDeniedEvent(
ipAddress: '203.0.113.10',
ruleId: 'system-rule',
ruleScope: FirewallRuleObject::SCOPE_SYSTEM,
reason: 'System block',
);
$this->service->logSecurityEvent($event);
@@ -253,7 +281,7 @@ class FirewallServiceTest extends TestCase
$this->store->expects($this->never())->method('createLog');
$this->service->logSecurityEvent(
new AuthenticationFailedEvent('203.0.113.10')
new AuthenticationFailedEvent()
);
}
@@ -272,7 +300,7 @@ class FirewallServiceTest extends TestCase
&& $metadata['windowSeconds'] === 60;
}))
->willReturnArgument(0);
$event = \KTXC\Security\Event\SecurityEvent::rateLimitExceeded(
$event = new RateLimitExceededEvent(
'203.0.113.10',
101,
60,
@@ -283,6 +311,25 @@ class FirewallServiceTest extends TestCase
$this->service->logSecurityEvent($event);
}
#[TestDox('Successful authentication maps to an allowed access log')]
public function testAuthenticationSuccessAudit(): void
{
$this->store->expects($this->once())
->method('createLog')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getEventType() === FirewallLogObject::EVENT_ACCESS_CHECK
&& $log->getResult() === FirewallLogObject::RESULT_ALLOWED
&& $log->getIpAddress() === '203.0.113.10'
&& $log->getIdentityId() === 'user-a';
}))
->willReturnArgument(0);
$this->service->logAuthenticationSuccess(new AuthenticationSucceededEvent(
'user-a',
'tenant-a',
));
}
#[TestDox('Suspicious-activity events retain request and detection metadata')]
public function testSuspiciousActivityAudit(): void
{
@@ -297,11 +344,9 @@ class FirewallServiceTest extends TestCase
&& $log->getMetadata()['detector'] === 'payload-signature';
}))
->willReturnArgument(0);
$event = \KTXC\Security\Event\SecurityEvent::create(
\KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY,
'203.0.113.20',
null,
['detector' => 'payload-signature'],
$event = new SuspiciousActivityEvent(
ipAddress: '203.0.113.20',
detector: 'payload-signature',
tenantId: 'tenant-a',
requestPath: '/admin',
requestMethod: 'POST',
@@ -324,14 +369,15 @@ class FirewallServiceTest extends TestCase
&& $log->getIdentityId() === 'operator';
}))
->willReturnArgument(0);
$event = new \KTXC\Security\Event\SecurityEvent(
\KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_DISABLED,
[
'ruleId' => 'rule-123',
'ruleScope' => FirewallRuleObject::SCOPE_SYSTEM,
'origin' => FirewallRuleManager::ORIGIN_MANUAL,
],
identityId: 'operator',
$event = FirewallRuleDisabledEvent::fromRule(
(new FirewallRuleObject())
->setId('rule-123')
->setScope(FirewallRuleObject::SCOPE_SYSTEM)
->setType(FirewallRuleObject::TYPE_IP)
->setAction(FirewallRuleObject::ACTION_BLOCK)
->setValue('203.0.113.10')
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]),
'operator',
);
$this->service->logSecurityEvent($event);
@@ -350,11 +396,12 @@ class FirewallServiceTest extends TestCase
&& $log->getMetadata()['changeReason'] === 'Tighten controls'
))
->willReturnArgument(0);
$event = new \KTXC\Security\Event\SecurityEvent(
\KTXC\Security\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
['changeReason' => 'Tighten controls'],
$event = new FirewallSettingsUpdatedEvent(
changeReason: 'Tighten controls',
previous: ['maxAuthFailures' => 5],
current: ['maxAuthFailures' => 3],
tenantId: 'tenant-a',
identityId: 'operator',
actorId: 'operator',
);
$this->service->logSecurityEvent($event);
@@ -378,16 +425,53 @@ class FirewallServiceTest extends TestCase
->willReturn(4);
$this->events->expects($this->never())->method('dispatch');
$event = new AuthenticationFailedEvent(
'203.0.113.10',
tenantId: 'tenant-a',
);
$event = new AuthenticationFailedEvent(tenantId: 'tenant-a');
$this->service->handleAuthFailure($event);
self::assertSame(8, $this->currentConfiguration->firewall()->maxAuthFailures());
self::assertSame(7200, $this->currentConfiguration->firewall()->autoBlockDuration());
}
#[TestDox('Authentication failures combine event facts with the current request')]
public function testAuthenticationFailureRequestContext(): void
{
$this->store->expects($this->once())
->method('createLogOnce')
->with(self::callback(static function (FirewallLogObject $log): bool {
return $log->getIpAddress() === '203.0.113.10'
&& $log->getDeviceFingerprint() === 'device-a'
&& $log->getUserAgent() === 'Test Agent'
&& $log->getRequestPath() === '/login'
&& $log->getRequestMethod() === 'POST'
&& $log->getIdentityId() === 'user-a'
&& $log->getMetadata()['reason'] === 'invalid_credentials';
}))
->willReturn(true);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
userId: 'user-a',
reason: 'invalid_credentials',
tenantId: 'tenant-a',
));
}
#[TestDox('Authentication failures outside HTTP request context are ignored')]
public function testAuthenticationFailureWithoutRequestContext(): void
{
$this->requestContext->clear();
$this->store->expects($this->never())->method('createLogOnce');
$this->store->expects($this->never())->method('countRecentFailures');
$this->service->handleAuthFailure(new AuthenticationFailedEvent(
reason: 'invalid_credentials',
tenantId: 'tenant-a',
));
}
#[TestDox('Unsafe numeric firewall settings fall back to safe defaults')]
public function testConfigurationBounds(): void
{
@@ -404,10 +488,7 @@ class FirewallServiceTest extends TestCase
->with('tenant-a', '203.0.113.10', 300)
->willReturn(0);
$event = new AuthenticationFailedEvent(
'203.0.113.10',
tenantId: 'tenant-a',
);
$event = new AuthenticationFailedEvent(tenantId: 'tenant-a');
$this->service->handleAuthFailure($event);
}
@@ -444,29 +525,37 @@ class FirewallServiceTest extends TestCase
&& $metadata['lastFailureCount'] === 5
&& $metadata['blockDurationSeconds'] === 3600;
}))
->willReturnArgument(0);
->willReturnCallback(static fn(FirewallRuleObject $rule): FirewallRuleObject =>
$rule->setId('automatic-rule')
);
$publishedTenants = [];
$lifecycleOrigin = null;
$bruteForceEvent = null;
$this->events->expects($this->exactly(3))
->method('dispatch')
->willReturnCallback(static function (\KTXF\Event\Event $event) use (
&$publishedTenants,
&$lifecycleOrigin
&$lifecycleOrigin,
&$bruteForceEvent,
): void {
$publishedTenants[] = $event->getTenantId();
if ($event->getName() === \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_CREATED) {
if ($event instanceof BruteForceDetectedEvent) {
$bruteForceEvent = $event;
}
if ($event instanceof FirewallRuleCreatedEvent) {
$lifecycleOrigin = $event->get('origin');
}
});
$event = new AuthenticationFailedEvent(
'203.0.113.10',
tenantId: 'tenant-event',
);
$event = new AuthenticationFailedEvent(tenantId: 'tenant-event');
$this->service->handleAuthFailure($event);
self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants);
self::assertInstanceOf(BruteForceDetectedEvent::class, $bruteForceEvent);
self::assertSame('203.0.113.10', $bruteForceEvent->getIpAddress());
self::assertSame(5, $bruteForceEvent->getFailureCount());
self::assertSame(300, $bruteForceEvent->getWindowSeconds());
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
}
@@ -486,7 +575,7 @@ class FirewallServiceTest extends TestCase
$this->events->expects($this->never())->method('dispatch');
$this->service->handleAuthFailure(
new AuthenticationFailedEvent('203.0.113.10')
new AuthenticationFailedEvent()
);
}
@@ -500,7 +589,7 @@ class FirewallServiceTest extends TestCase
->willReturn(0);
$this->service->handleAuthFailure(
new AuthenticationFailedEvent('203.0.113.10')
new AuthenticationFailedEvent()
);
}
@@ -512,7 +601,7 @@ class FirewallServiceTest extends TestCase
$this->store->expects($this->never())->method('depositRule');
$this->service->handleAuthFailure(
new AuthenticationFailedEvent('203.0.113.10')
new AuthenticationFailedEvent()
);
}
@@ -526,7 +615,7 @@ class FirewallServiceTest extends TestCase
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(1);
$event = new AuthenticationFailedEvent('203.0.113.10');
$event = new AuthenticationFailedEvent();
$eventId = $event->getEventId();
$this->service->handleAuthFailure($event);
@@ -9,7 +9,7 @@ use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\TenantService;
use KTXF\Event\EventDispatcherInterface;
use KTXC\Security\Event\SecurityEvent;
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
@@ -37,8 +37,8 @@ final class FirewallSettingsServiceTest extends TestCase
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::once())
->method('dispatch')
->with(self::callback(static fn(SecurityEvent $event): bool =>
$event->getName() === SecurityEvent::FIREWALL_SETTINGS_UPDATED
->with(self::callback(static fn(FirewallSettingsUpdatedEvent $event): bool =>
$event->getName() === FirewallSettingsUpdatedEvent::class
&& $event->getTenantId() === 'tenant-a'
&& $event->getIdentityId() === 'admin-a'
&& $event->get('changeReason') === 'Tighten authentication controls'