perf(firewall): use BSON dates and add query indexes

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-30 23:13:37 -04:00
parent ad6443bff3
commit 90d847ffae
4 changed files with 173 additions and 31 deletions
+93 -20
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\UTCDateTime;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
@@ -21,6 +22,51 @@ class FirewallStore
protected readonly DataStore $dataStore
) {}
/**
* Install the indexes used by firewall enforcement, audit queries, and expiry.
*
* MongoDB createIndex is idempotent when the name and specification match.
*
* @return string[]
*/
public function ensureIndexes(): array
{
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
return [
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_by_scope_tenant_active']
),
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_exact_lookup']
),
$logs->createIndex(
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_auth_failures']
),
$logs->createIndex(
['tenantId' => 1, 'timestamp' => -1],
['name' => 'logs_tenant_timeline']
),
$logs->createIndex(
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
['name' => 'logs_blocked_counts']
),
$logs->createIndex(
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_event_type']
),
$claims->createIndex(
['expiresAt' => 1],
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
),
];
}
// ========================================
// Rule Operations
// ========================================
@@ -40,7 +86,7 @@ class FirewallStore
$filter['$and'] = [[
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
],
]];
}
@@ -63,7 +109,7 @@ class FirewallStore
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
];
}
@@ -87,7 +133,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
]
];
@@ -114,7 +160,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
]
];
@@ -158,7 +204,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
],
];
@@ -194,7 +240,7 @@ class FirewallStore
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$data = $rule->jsonSerialize();
$data = self::ruleDocument($rule);
unset($data['id']); // Remove id for insert
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
@@ -209,7 +255,7 @@ class FirewallStore
return null;
}
$data = $rule->jsonSerialize();
$data = self::ruleDocument($rule);
unset($data['id']);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
@@ -237,8 +283,10 @@ class FirewallStore
public function cleanupExpiredRules(): int
{
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$ne' => null]
'expiresAt' => [
'$lt' => self::bsonDate(new \DateTimeImmutable()),
'$ne' => null,
],
]);
return $result->getDeletedCount();
@@ -253,7 +301,7 @@ class FirewallStore
*/
public function createLog(FirewallLogObject $log): FirewallLogObject
{
$data = $log->jsonSerialize();
$data = self::logDocument($log);
unset($data['id']);
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
@@ -271,7 +319,7 @@ class FirewallStore
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
}
$data = $log->jsonSerialize();
$data = self::logDocument($log);
unset($data['id']);
$data['_id'] = $eventId;
@@ -340,7 +388,7 @@ class FirewallStore
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
'timestamp' => ['$gte' => self::bsonDate($since)]
]);
}
@@ -362,7 +410,7 @@ class FirewallStore
$collection->deleteOne([
'_id' => $claimId,
'expiresAt' => ['$lte' => $now->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$lte' => self::bsonDate($now)],
]);
try {
@@ -370,10 +418,8 @@ class FirewallStore
'_id' => $claimId,
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'createdAt' => $now->format(\DateTimeInterface::ATOM),
'expiresAt' => $now
->modify("+{$claimDurationSeconds} seconds")
->format(\DateTimeInterface::ATOM),
'createdAt' => self::bsonDate($now),
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
]);
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
if ($error->getCode() === 11000) {
@@ -399,7 +445,7 @@ class FirewallStore
];
if ($since !== null) {
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
}
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
@@ -413,7 +459,7 @@ class FirewallStore
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
]);
return $result->getDeletedCount();
@@ -424,9 +470,36 @@ class FirewallStore
$result = $this->dataStore
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
->deleteMany([
'expiresAt' => ['$lte' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
]);
return $result->getDeletedCount();
}
private static function ruleDocument(FirewallRuleObject $rule): array
{
$data = $rule->jsonSerialize();
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
return $data;
}
private static function logDocument(FirewallLogObject $log): array
{
$data = $log->jsonSerialize();
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
return $data;
}
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
{
return $date === null ? null : self::bsonDate($date);
}
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
{
return UTCDateTime::fromDateTime($date);
}
}