feat(firewall): add audited configuration management
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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\Service\FirewallRuleCache;
|
||||
use KTXC\Service\FirewallRuleManager;
|
||||
use KTXC\Service\FirewallSettingsService;
|
||||
use KTXC\Service\FirewallStatusService;
|
||||
use KTXC\Service\FirewallLogService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
@@ -18,6 +19,7 @@ use KTXC\Service\SystemFirewallStatusService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Service\TenantFirewallStatusService;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
@@ -43,13 +45,17 @@ final class FirewallControllerTest extends TestCase
|
||||
new FirewallRuleCache($this->store),
|
||||
$this->createStub(EventDispatcherInterface::class)
|
||||
);
|
||||
$settings = new FirewallSettingsService(
|
||||
$this->createStub(TenantService::class),
|
||||
$this->createStub(EventDispatcherInterface::class)
|
||||
);
|
||||
$this->controller = new FirewallController(
|
||||
new TenantFirewallRuleService($manager, $tenant, $identity),
|
||||
new SystemFirewallRuleService($manager, $identity),
|
||||
new TenantFirewallLogService(new FirewallLogService($this->store), $tenant, $identity),
|
||||
new SystemFirewallLogService(new FirewallLogService($this->store), $identity),
|
||||
new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity),
|
||||
new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity)
|
||||
new TenantFirewallStatusService(new FirewallStatusService($this->store), $tenant, $identity, $settings),
|
||||
new SystemFirewallStatusService(new FirewallStatusService($this->store), $identity, $settings)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +182,8 @@ final class FirewallControllerTest extends TestCase
|
||||
'tenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_READ,
|
||||
'systemMetrics' => SystemFirewallLogService::PERMISSION_READ,
|
||||
'maintenanceStatus' => SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ,
|
||||
'updateTenantConfiguration' => TenantFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
|
||||
'updateSystemTenantConfiguration' => SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE,
|
||||
'createTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
||||
'createSystemRule' => SystemFirewallRuleService::PERMISSION_MANAGE,
|
||||
'updateTenantRule' => TenantFirewallRuleService::PERMISSION_MANAGE,
|
||||
|
||||
@@ -34,7 +34,7 @@ final class CoreModuleTest extends TestCase
|
||||
$module->boot();
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
self::assertCount(11, $definitions);
|
||||
self::assertCount(12, $definitions);
|
||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||
self::assertSame(
|
||||
FirewallService::class,
|
||||
@@ -49,6 +49,7 @@ final class CoreModuleTest extends TestCase
|
||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
||||
] as $event) {
|
||||
$listeners = $registry->listeners($event, DeliveryMode::Deferred);
|
||||
self::assertCount(1, $listeners);
|
||||
@@ -82,6 +83,8 @@ final class CoreModuleTest extends TestCase
|
||||
self::assertArrayHasKey(TenantFirewallLogService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(SystemFirewallLogService::PERMISSION_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_SETTINGS_MANAGE, $permissions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,28 @@ class FirewallServiceTest extends TestCase
|
||||
$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')]
|
||||
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\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Service\FirewallStatusService;
|
||||
use KTXC\Service\FirewallSettingsService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallStatusService;
|
||||
use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallStatusService;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -37,7 +40,9 @@ final class FirewallStatusServicesTest extends TestCase
|
||||
->method('countBlockedRequests')
|
||||
->with('tenant-a', self::isInstanceOf(\DateTimeImmutable::class))
|
||||
->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::assertFalse($service->configuration()['enabled']);
|
||||
@@ -55,7 +60,9 @@ final class FirewallStatusServicesTest extends TestCase
|
||||
->with('tenant-a', null)
|
||||
->willReturn(12);
|
||||
$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('success', $service->maintenanceStatus()['status']);
|
||||
@@ -80,14 +87,18 @@ final class FirewallStatusServicesTest extends TestCase
|
||||
$store->expects(self::never())->method('maintenanceStatus');
|
||||
|
||||
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.');
|
||||
} catch (\RuntimeException $error) {
|
||||
self::assertStringContainsString(TenantFirewallLogService::PERMISSION_READ, $error->getMessage());
|
||||
}
|
||||
|
||||
$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')]
|
||||
@@ -99,4 +110,12 @@ final class FirewallStatusServicesTest extends TestCase
|
||||
|
||||
(new FirewallStatusService($store))->systemMetrics(null, 'not-a-date');
|
||||
}
|
||||
|
||||
private function settings(): FirewallSettingsService
|
||||
{
|
||||
return new FirewallSettingsService(
|
||||
$this->createStub(TenantService::class),
|
||||
$this->createStub(EventDispatcherInterface::class)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user