feat(firewall): complete operational reliability phase

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-30 23:39:37 -04:00
parent 90d847ffae
commit 4ee91a7918
15 changed files with 574 additions and 22 deletions
@@ -304,6 +304,154 @@ class FirewallStoreTest extends TestCase
self::assertSame(0, $claimIndex['expireAfterSeconds']);
}
#[TestDox('Automatic block extensions persist policy and expiration history')]
public function testAutomaticBlockExtensionPersistence(): void
{
$events = $this->createMock(EventDispatcherInterface::class);
$manager = new FirewallRuleManager($this->store, new FirewallRuleCache($this->store), $events);
$scope = FirewallRuleScope::tenant('tenant-a');
$rule = $manager->blockIp(
$scope,
'203.0.113.10',
'Initial attack',
null,
60,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => 5,
'failureWindowSeconds' => 300,
'lastFailureCount' => 5,
'blockDurationSeconds' => 60,
]
);
$originalExpiry = $rule->getExpiresAt();
$extended = $manager->blockIp(
$scope,
'203.0.113.10',
'Continued attack',
null,
3600,
FirewallRuleManager::ORIGIN_AUTOMATIC,
[
'failureThreshold' => 5,
'failureWindowSeconds' => 300,
'lastFailureCount' => 9,
'blockDurationSeconds' => 3600,
]
);
$persisted = $this->store->fetchRule($rule->getId());
self::assertSame($rule->getId(), $extended->getId());
self::assertNotNull($persisted);
self::assertGreaterThan($originalExpiry, $persisted->getExpiresAt());
self::assertSame(9, $persisted->getMetadata()['lastFailureCount']);
self::assertCount(1, $persisted->getMetadata()['extensions']);
}
#[TestDox('Cleanup removes only expired rules, old logs, and expired claims')]
public function testCleanupBoundaries(): void
{
$this->store->depositRule(
$this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
);
$this->store->depositRule(
$this->rule('active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('+1 hour'))
);
foreach (['-31 days', '-29 days'] as $age) {
$this->store->createLog(
(new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
->setResult(FirewallLogObject::RESULT_ALLOWED)
->setTimestamp(new \DateTimeImmutable($age))
);
}
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
['_id' => $claimId],
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
);
self::assertSame(1, $this->store->cleanupExpiredRules());
self::assertSame(1, $this->store->cleanupOldLogs());
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
self::assertCount(1, $this->store->listRules('tenant-a'));
self::assertCount(1, $this->store->listLogs('tenant-a'));
}
#[TestDox('Maintenance outcomes are persisted with BSON timestamps')]
public function testMaintenanceStatusPersistence(): void
{
$this->store->recordMaintenanceStatus(
new \DateTimeImmutable('-1 second'),
new \DateTimeImmutable(),
'success',
['expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4]
);
$status = $this->store->maintenanceStatus();
$raw = $this->dataStore->selectCollection('firewall_maintenance')
->getMongoCollection()
->findOne(['_id' => 'cleanup']);
self::assertSame('success', $status['status']);
self::assertSame(3, $status['result']['oldLogs']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['startedAt']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['completedAt']);
}
#[TestDox('Firewall query shapes select their intended indexes')]
public function testIndexedQueryShapes(): void
{
$this->store->ensureIndexes();
$this->store->depositRule($this->rule('indexed', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
$this->store->createLog(
(new FirewallLogObject())
->setTenantId('tenant-a')
->setIpAddress('203.0.113.10')
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
->setResult(FirewallLogObject::RESULT_BLOCKED)
->setTimestamp(new \DateTimeImmutable())
);
$database = $this->dataStore->getDatabase()->getMongoDatabase();
$now = new \MongoDB\BSON\UTCDateTime();
$rulePlan = $database->command([
'explain' => [
'find' => 'firewall_rules',
'filter' => [
'scope' => FirewallRuleObject::SCOPE_TENANT,
'tenantId' => 'tenant-a',
'type' => FirewallRuleObject::TYPE_IP,
'value' => '203.0.113.10',
'action' => FirewallRuleObject::ACTION_BLOCK,
'enabled' => true,
'$or' => [['expiresAt' => null], ['expiresAt' => ['$gt' => $now]]],
],
],
'verbosity' => 'queryPlanner',
])->toArray()[0];
$failurePlan = $database->command([
'explain' => [
'find' => 'firewall_logs',
'filter' => [
'tenantId' => 'tenant-a',
'ipAddress' => '203.0.113.10',
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => new \MongoDB\BSON\UTCDateTime(0)],
],
],
'verbosity' => 'queryPlanner',
])->toArray()[0];
self::assertContains('rules_exact_lookup', self::indexNames($rulePlan));
self::assertContains('logs_auth_failures', self::indexNames($failurePlan));
}
private function rule(
string $reason,
string $scope,
@@ -319,4 +467,21 @@ class FirewallStoreTest extends TestCase
->setCreatedAt(new \DateTimeImmutable())
->setEnabled(true);
}
private static function indexNames(mixed $value): array
{
if (is_object($value)) {
$value = (array)$value;
}
if (!is_array($value)) {
return [];
}
$names = isset($value['indexName']) ? [(string)$value['indexName']] : [];
foreach ($value as $child) {
$names = [...$names, ...self::indexNames($child)];
}
return array_values(array_unique($names));
}
}