feat(firewall): add audited configuration management

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-03 22:44:34 -04:00
parent d5ca89e160
commit fb39aa57fd
15 changed files with 410 additions and 8 deletions
@@ -314,6 +314,43 @@ final class FirewallController extends ControllerAbstract
return new JsonResponse($this->tenantStatus->configuration()); return new JsonResponse($this->tenantStatus->configuration());
} }
#[AuthenticatedRoute(
'/firewall/configuration',
name: 'firewall.tenant.configuration.update',
methods: ['PUT'],
permissions: [TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateTenantConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->tenantStatus->updateConfiguration(
$enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute(
'/firewall/system/tenants/{tenantId}/configuration',
name: 'firewall.system.tenant.configuration.update',
methods: ['PUT'],
permissions: [SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE],
)]
public function updateSystemTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): JsonResponse {
return $this->settingsResponse(fn() => $this->systemStatus->updateTenantConfiguration(
$tenantId, $enabled, $maxAuthFailures, $authFailureWindow, $autoBlockDuration, $reason
));
}
#[AuthenticatedRoute( #[AuthenticatedRoute(
'/firewall/system/metrics', '/firewall/system/metrics',
name: 'firewall.system.metrics.read', name: 'firewall.system.metrics.read',
@@ -405,4 +442,24 @@ final class FirewallController extends ControllerAbstract
]], JsonResponse::HTTP_BAD_REQUEST); ]], JsonResponse::HTTP_BAD_REQUEST);
} }
} }
private function settingsResponse(callable $mutation): JsonResponse
{
try {
$configuration = $mutation();
if ($configuration === null) {
return new JsonResponse(['error' => [
'code' => 'tenant_not_found',
'message' => 'Tenant not found.',
]], JsonResponse::HTTP_NOT_FOUND);
}
return new JsonResponse(['configuration' => $configuration]);
} catch (\InvalidArgumentException $error) {
return new JsonResponse(['error' => [
'code' => 'invalid_firewall_configuration',
'message' => $error->getMessage(),
]], JsonResponse::HTTP_BAD_REQUEST);
}
}
} }
@@ -26,6 +26,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
public const EVENT_RULE_ENABLED = 'rule_enabled'; public const EVENT_RULE_ENABLED = 'rule_enabled';
public const EVENT_RULE_DISABLED = 'rule_disabled'; public const EVENT_RULE_DISABLED = 'rule_disabled';
public const EVENT_RULE_REMOVED = 'rule_removed'; public const EVENT_RULE_REMOVED = 'rule_removed';
public const EVENT_SETTINGS_UPDATED = 'settings_updated';
private ?string $id = null; private ?string $id = null;
private ?string $eventId = null; private ?string $eventId = null;
+11
View File
@@ -51,6 +51,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
SecurityEvent::FIREWALL_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED, SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) { ] as $event) {
$this->events->listen( $this->events->listen(
'core', 'core',
@@ -150,6 +151,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'description' => 'View effective firewall settings for the current tenant', 'description' => 'View effective firewall settings for the current tenant',
'group' => 'Firewall Management' 'group' => 'Firewall Management'
], ],
TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
'label' => 'Manage Tenant Firewall Settings',
'description' => 'Update firewall settings for the current tenant',
'group' => 'Firewall Management'
],
SystemFirewallRuleService::PERMISSION_READ => [ SystemFirewallRuleService::PERMISSION_READ => [
'label' => 'View System Firewall Rules', 'label' => 'View System Firewall Rules',
'description' => 'View firewall rules that apply to every tenant', 'description' => 'View firewall rules that apply to every tenant',
@@ -170,6 +176,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
'description' => 'View the last firewall cleanup result and operational status', 'description' => 'View the last firewall cleanup result and operational status',
'group' => 'System Administration' 'group' => 'System Administration'
], ],
SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE => [
'label' => 'Manage Tenant Firewall Settings System-Wide',
'description' => 'Update firewall settings for any tenant',
'group' => 'System Administration'
],
'system.admin' => [ 'system.admin' => [
'label' => 'System Administrator', 'label' => 'System Administrator',
'description' => 'Full system access (superuser)', 'description' => 'Full system access (superuser)',
+1
View File
@@ -24,6 +24,7 @@ final class FirewallLogService
FirewallLogObject::EVENT_RULE_ENABLED, FirewallLogObject::EVENT_RULE_ENABLED,
FirewallLogObject::EVENT_RULE_DISABLED, FirewallLogObject::EVENT_RULE_DISABLED,
FirewallLogObject::EVENT_RULE_REMOVED, FirewallLogObject::EVENT_RULE_REMOVED,
FirewallLogObject::EVENT_SETTINGS_UPDATED,
]; ];
public function __construct(private readonly FirewallStore $store) public function __construct(private readonly FirewallStore $store)
+3 -1
View File
@@ -272,6 +272,7 @@ class FirewallService
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED, SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
default => FirewallLogObject::EVENT_ACCESS_CHECK, default => FirewallLogObject::EVENT_ACCESS_CHECK,
}; };
} }
@@ -288,7 +289,8 @@ class FirewallService
SecurityEvent::FIREWALL_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_EXTENDED,
SecurityEvent::FIREWALL_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED, SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
default => FirewallLogObject::RESULT_BLOCKED, default => FirewallLogObject::RESULT_BLOCKED,
}; };
} }
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
final class FirewallSettingsService
{
public function __construct(
private readonly TenantService $tenants,
private readonly EventDispatcherInterface $events,
) {
}
public function update(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason,
?string $actorId
): ?array {
$reason = trim($reason);
if ($reason === '' || strlen($reason) > 1000) {
throw new \InvalidArgumentException('A change reason containing 1-1000 bytes is required.');
}
self::bounded($maxAuthFailures, 1, 1000, 'Maximum authentication failures');
self::bounded($authFailureWindow, 1, 86400, 'Authentication failure window');
self::bounded($autoBlockDuration, 1, 31536000, 'Automatic block duration');
$tenant = $this->tenants->fetchById($tenantId);
if ($tenant === null) {
return null;
}
$previous = $tenant->getConfiguration()->firewall()->jsonSerialize();
$current = [
'enabled' => $enabled,
'maxAuthFailures' => $maxAuthFailures,
'authFailureWindow' => $authFailureWindow,
'autoBlockDuration' => $autoBlockDuration,
];
$configuration = (new TenantConfiguration())->jsonDeserialize([
...$tenant->getConfiguration()->jsonSerialize(),
'firewall' => $current,
]);
$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->setTenantId($tenantId)->setIdentityId($actorId);
$this->events->dispatch($event);
return $current;
}
private static function bounded(int $value, int $minimum, int $maximum, string $label): void
{
if ($value < $minimum || $value > $maximum) {
throw new \InvalidArgumentException(
"{$label} must be between {$minimum} and {$maximum}."
);
}
}
}
@@ -9,10 +9,12 @@ use KTXC\Context\IdentityContextInterface;
final class SystemFirewallStatusService final class SystemFirewallStatusService
{ {
public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read'; public const PERMISSION_MAINTENANCE_READ = 'firewall.system.maintenance.read';
public const PERMISSION_SETTINGS_MANAGE = 'firewall.system.settings.manage';
public function __construct( public function __construct(
private readonly FirewallStatusService $status, private readonly FirewallStatusService $status,
private readonly IdentityContextInterface $identity, private readonly IdentityContextInterface $identity,
private readonly FirewallSettingsService $settings,
) { ) {
} }
@@ -28,6 +30,26 @@ final class SystemFirewallStatusService
return $this->status->maintenanceStatus(); return $this->status->maintenanceStatus();
} }
public function updateTenantConfiguration(
string $tenantId,
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): ?array {
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
return $this->settings->update(
$tenantId,
$enabled,
$maxAuthFailures,
$authFailureWindow,
$autoBlockDuration,
$reason,
$this->identity->identifier()
);
}
private function requirePermission(string $permission): void private function requirePermission(string $permission): void
{ {
if (!$this->identity->hasPermission($permission)) { if (!$this->identity->hasPermission($permission)) {
@@ -10,11 +10,13 @@ use KTXC\Context\TenantContextInterface;
final class TenantFirewallStatusService final class TenantFirewallStatusService
{ {
public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read'; public const PERMISSION_SETTINGS_READ = 'firewall.tenant.settings.read';
public const PERMISSION_SETTINGS_MANAGE = 'firewall.tenant.settings.manage';
public function __construct( public function __construct(
private readonly FirewallStatusService $status, private readonly FirewallStatusService $status,
private readonly TenantContextInterface $tenant, private readonly TenantContextInterface $tenant,
private readonly IdentityContextInterface $identity, private readonly IdentityContextInterface $identity,
private readonly FirewallSettingsService $settings,
) { ) {
} }
@@ -36,6 +38,25 @@ final class TenantFirewallStatusService
]; ];
} }
public function updateConfiguration(
bool $enabled,
int $maxAuthFailures,
int $authFailureWindow,
int $autoBlockDuration,
string $reason
): ?array {
$this->requirePermission(self::PERMISSION_SETTINGS_MANAGE);
return $this->settings->update(
$this->tenant->requireIdentifier(),
$enabled,
$maxAuthFailures,
$authFailureWindow,
$autoBlockDuration,
$reason,
$this->identity->identifier()
);
}
private function requirePermission(string $permission): void private function requirePermission(string $permission): void
{ {
if (!$this->identity->hasPermission($permission)) { if (!$this->identity->hasPermission($permission)) {
+1
View File
@@ -31,6 +31,7 @@ class SecurityEvent extends Event
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled'; public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled'; public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed'; public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
private ?string $ipAddress = null; private ?string $ipAddress = null;
private ?string $deviceFingerprint = null; private ?string $deviceFingerprint = null;
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace KTXT\Integration\Service;
use KTXC\Db\DataStore;
use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\TenantService;
use KTXC\Stores\TenantStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallSettingsServiceTest extends TestCase
{
private DataStore $dataStore;
private bool $databaseAvailable = false;
protected function setUp(): void
{
$system = require dirname(__DIR__, 4).'/config/system.php';
$database = $system['database'];
$database['database'] = sprintf('ktrix_firewall_settings_test_%d', getmypid());
$this->dataStore = new DataStore($database);
try {
$this->dataStore->getDatabase()->drop();
$this->databaseAvailable = true;
} catch (\MongoDB\Driver\Exception\Exception $error) {
self::markTestSkipped('MongoDB is unavailable: '.$error->getMessage());
}
}
protected function tearDown(): void
{
if ($this->databaseAvailable) {
$this->dataStore->getDatabase()->drop();
}
}
#[TestDox('Firewall settings updates persist without replacing unrelated tenant configuration')]
public function testPersistence(): void
{
$tenants = new TenantService(new TenantStore($this->dataStore));
$tenants->deposit((new TenantObject())
->setIdentifier('tenant-a')
->setEnabled(true)
->setLabel('Tenant A')
->setDomains(new DomainCollection(['tenant-a.example.test']))
->setConfiguration((new TenantConfiguration())->jsonDeserialize([
'security' => ['code' => 'keep-me'],
])));
$settings = new FirewallSettingsService(
$tenants,
$this->createStub(EventDispatcherInterface::class)
);
$settings->update(
'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a'
);
$stored = $tenants->fetchById('tenant-a')->getConfiguration();
self::assertFalse($stored->firewall()->enabled());
self::assertSame(8, $stored->firewall()->maxAuthFailures());
self::assertSame(600, $stored->firewall()->authFailureWindow());
self::assertSame(7200, $stored->firewall()->autoBlockDuration());
self::assertSame('keep-me', $stored->security()->code());
}
}
@@ -10,6 +10,7 @@ use KTXC\Controllers\FirewallController;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleCache;
use KTXC\Service\FirewallRuleManager; use KTXC\Service\FirewallRuleManager;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\FirewallStatusService; use KTXC\Service\FirewallStatusService;
use KTXC\Service\FirewallLogService; use KTXC\Service\FirewallLogService;
use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallLogService;
@@ -18,6 +19,7 @@ use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallRuleService; use KTXC\Service\TenantFirewallRuleService;
use KTXC\Service\TenantFirewallStatusService; use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\TenantService;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface; use KTXF\Event\EventDispatcherInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
@@ -43,13 +45,17 @@ final class FirewallControllerTest extends TestCase
new FirewallRuleCache($this->store), new FirewallRuleCache($this->store),
$this->createStub(EventDispatcherInterface::class) $this->createStub(EventDispatcherInterface::class)
); );
$settings = new FirewallSettingsService(
$this->createStub(TenantService::class),
$this->createStub(EventDispatcherInterface::class)
);
$this->controller = new FirewallController( $this->controller = new FirewallController(
new TenantFirewallRuleService($manager, $tenant, $identity), new TenantFirewallRuleService($manager, $tenant, $identity),
new SystemFirewallRuleService($manager, $identity), new SystemFirewallRuleService($manager, $identity),
new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity), new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity),
new SystemFirewallLogService(new FirewallLogService($this->store), $identity), new SystemFirewallLogService(new FirewallLogService($this->store), $identity),
new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity), new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity, $settings),
new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity) new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity, $settings)
); );
} }
@@ -176,6 +182,8 @@ final class FirewallControllerTest extends TestCase
'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ, 'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ,
'systemMetrics' => SystemFirewallLogService::PERMISSION_READ, 'systemMetrics' => SystemFirewallLogService::PERMISSION_READ,
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, 'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
'updateTenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
'updateSystemTenantConfiguration' => SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE, 'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE, 'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE, 'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
+4 -1
View File
@@ -34,7 +34,7 @@ final class CoreModuleTest extends TestCase
$module->boot(); $module->boot();
$definitions = $registry->definitions(); $definitions = $registry->definitions();
self::assertCount(11, $definitions); self::assertCount(12, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module')))); self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame( self::assertSame(
FirewallService::class, FirewallService::class,
@@ -49,6 +49,7 @@ final class CoreModuleTest extends TestCase
SecurityEvent::FIREWALL_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_DISABLED,
SecurityEvent::FIREWALL_RULE_ENABLED, SecurityEvent::FIREWALL_RULE_ENABLED,
SecurityEvent::FIREWALL_RULE_REMOVED, SecurityEvent::FIREWALL_RULE_REMOVED,
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
] as $event) { ] as $event) {
$listeners = $registry->listeners($event, DeliveryMode::Deferred); $listeners = $registry->listeners($event, DeliveryMode::Deferred);
self::assertCount(1, $listeners); self::assertCount(1, $listeners);
@@ -82,6 +83,8 @@ final class CoreModuleTest extends TestCase
self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions); self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions); self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_READ, $permissions);
self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_READ, $permissions); self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_READ, $permissions);
self::assertArrayHasKey(TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions); self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions);
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
} }
} }
@@ -336,6 +336,28 @@ class FirewallServiceTest extends TestCase
$this->service->logSecurityEvent($event); $this->service->logSecurityEvent($event);
} }
#[TestDox('Settings changes map to recorded tenant audit entries')]
public function testSettingsAudit(): void
{
$this->store->expects(self::once())
->method('createLog')
->with(self::callback(static fn(FirewallLogObject $log): bool =>
$log->getEventType() === FirewallLogObject::EVENT_SETTINGS_UPDATED
&& $log->getResult() === FirewallLogObject::RESULT_RECORDED
&& $log->getTenantId() === 'tenant-a'
&& $log->getIdentityId() === 'operator'
&& $log->getMetadata()['changeReason'] === 'Tighten controls'
))
->willReturnArgument(0);
$event = new \KTXF\Event\SecurityEvent(
\KTXF\Event\SecurityEvent::FIREWALL_SETTINGS_UPDATED,
['changeReason' => 'Tighten controls']
);
$event->setTenantId('tenant-a')->setIdentityId('operator');
$this->service->logSecurityEvent($event);
}
#[TestDox('Typed tenant firewall settings drive brute-force thresholds')] #[TestDox('Typed tenant firewall settings drive brute-force thresholds')]
public function testFirewallConfiguration(): void public function testFirewallConfiguration(): void
{ {
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Service;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\TenantService;
use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class FirewallSettingsServiceTest extends TestCase
{
#[TestDox('Settings updates preserve other tenant configuration and emit complete audit context')]
public function testUpdate(): void
{
$tenant = (new TenantObject())
->setId('507f1f77bcf86cd799439011')
->setIdentifier('tenant-a')
->setConfiguration((new TenantConfiguration())->jsonDeserialize([
'firewall' => ['enabled' => true, 'maxAuthFailures' => 5],
'security' => ['code' => 'preserved'],
]));
$tenants = $this->createMock(TenantService::class);
$tenants->method('fetchById')->with('tenant-a')->willReturn($tenant);
$tenants->expects(self::once())
->method('deposit')
->with(self::callback(static fn(TenantObject $updated): bool =>
$updated->getConfiguration()->firewall()->maxAuthFailures() === 8
&& $updated->getConfiguration()->security()->jsonSerialize()['code'] === 'preserved'
))
->willReturnArgument(0);
$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
&& $event->getTenantId() === 'tenant-a'
&& $event->getIdentityId() === 'admin-a'
&& $event->get('changeReason') === 'Tighten authentication controls'
&& $event->get('previous')['maxAuthFailures'] === 5
&& $event->get('current')['maxAuthFailures'] === 8
));
$result = (new FirewallSettingsService($tenants, $events))->update(
'tenant-a', false, 8, 600, 7200, 'Tighten authentication controls', 'admin-a'
);
self::assertSame([
'enabled' => false,
'maxAuthFailures' => 8,
'authFailureWindow' => 600,
'autoBlockDuration' => 7200,
], $result);
}
#[TestDox('Invalid settings are rejected before tenant reads or persistence')]
public function testValidation(): void
{
$tenants = $this->createMock(TenantService::class);
$tenants->expects(self::never())->method('fetchById');
$tenants->expects(self::never())->method('deposit');
$this->expectException(\InvalidArgumentException::class);
(new FirewallSettingsService(
$tenants,
$this->createStub(EventDispatcherInterface::class)
))->update('tenant-a', true, 0, 300, 3600, 'Invalid threshold', 'admin-a');
}
#[TestDox('Unknown tenants return no configuration without emitting audit events')]
public function testMissingTenant(): void
{
$tenants = $this->createStub(TenantService::class);
$events = $this->createMock(EventDispatcherInterface::class);
$events->expects(self::never())->method('dispatch');
self::assertNull((new FirewallSettingsService($tenants, $events))->update(
'missing', true, 5, 300, 3600, 'Apply defaults', 'admin-a'
));
}
}
@@ -8,11 +8,14 @@ use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface; use KTXC\Context\TenantContextInterface;
use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Service\FirewallStatusService; use KTXC\Service\FirewallStatusService;
use KTXC\Service\FirewallSettingsService;
use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallLogService;
use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\SystemFirewallStatusService;
use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallLogService;
use KTXC\Service\TenantFirewallStatusService; use KTXC\Service\TenantFirewallStatusService;
use KTXC\Service\TenantService;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXF\Event\EventDispatcherInterface;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -37,7 +40,9 @@ final class FirewallStatusServicesTest extends TestCase
->method('countBlockedRequests') ->method('countBlockedRequests')
->with('tenant-a', self::isInstanceOf(\DateTimeImmutable::class)) ->with('tenant-a', self::isInstanceOf(\DateTimeImmutable::class))
->willReturn(7); ->willReturn(7);
$service = new TenantFirewallStatusService(new FirewallStatusService($store), $tenant, $identity); $service = new TenantFirewallStatusService(
new FirewallStatusService($store), $tenant, $identity, $this->settings()
);
self::assertSame(7, $service->metrics('2026-08-01T00:00:00+00:00')['blockedRequests']); self::assertSame(7, $service->metrics('2026-08-01T00:00:00+00:00')['blockedRequests']);
self::assertFalse($service->configuration()['enabled']); self::assertFalse($service->configuration()['enabled']);
@@ -55,7 +60,9 @@ final class FirewallStatusServicesTest extends TestCase
->with('tenant-a', null) ->with('tenant-a', null)
->willReturn(12); ->willReturn(12);
$store->method('maintenanceStatus')->willReturn(['status' => 'success']); $store->method('maintenanceStatus')->willReturn(['status' => 'success']);
$service = new SystemFirewallStatusService(new FirewallStatusService($store), $identity); $service = new SystemFirewallStatusService(
new FirewallStatusService($store), $identity, $this->settings()
);
self::assertSame(12, $service->metrics('tenant-a')['blockedRequests']); self::assertSame(12, $service->metrics('tenant-a')['blockedRequests']);
self::assertSame('success', $service->maintenanceStatus()['status']); self::assertSame('success', $service->maintenanceStatus()['status']);
@@ -80,14 +87,18 @@ final class FirewallStatusServicesTest extends TestCase
$store->expects(self::never())->method('maintenanceStatus'); $store->expects(self::never())->method('maintenanceStatus');
try { try {
(new TenantFirewallStatusService(new FirewallStatusService($store), $tenant, $identity))->metrics(); (new TenantFirewallStatusService(
new FirewallStatusService($store), $tenant, $identity, $this->settings()
))->metrics();
self::fail('Tenant metrics should be rejected.'); self::fail('Tenant metrics should be rejected.');
} catch (\RuntimeException $error) { } catch (\RuntimeException $error) {
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage()); self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
} }
$this->expectExceptionMessage(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ); $this->expectExceptionMessage(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ);
(new SystemFirewallStatusService(new FirewallStatusService($store), $identity))->maintenanceStatus(); (new SystemFirewallStatusService(
new FirewallStatusService($store), $identity, $this->settings()
))->maintenanceStatus();
} }
#[TestDox('Metrics reject invalid dates before querying storage')] #[TestDox('Metrics reject invalid dates before querying storage')]
@@ -99,4 +110,12 @@ final class FirewallStatusServicesTest extends TestCase
(new FirewallStatusService($store))->systemMetrics(null, 'not-a-date'); (new FirewallStatusService($store))->systemMetrics(null, 'not-a-date');
} }
private function settings(): FirewallSettingsService
{
return new FirewallSettingsService(
$this->createStub(TenantService::class),
$this->createStub(EventDispatcherInterface::class)
);
}
} }