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
+19 -3
View File
@@ -90,9 +90,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
}
if (array_key_exists('timestamp', $data)) {
$this->timestamp = $data['timestamp'] !== null
? new \DateTimeImmutable($data['timestamp'])
: null;
$this->timestamp = self::deserializeDate($data['timestamp']);
}
if (array_key_exists('metadata', $data)) {
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
@@ -122,6 +120,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
// Getters and setters
public function getId(): ?string
@@ -69,14 +69,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
}
if (array_key_exists('createdAt', $data)) {
$this->createdAt = $data['createdAt'] !== null
? new \DateTimeImmutable($data['createdAt'])
: null;
$this->createdAt = self::deserializeDate($data['createdAt']);
}
if (array_key_exists('expiresAt', $data)) {
$this->expiresAt = $data['expiresAt'] !== null
? new \DateTimeImmutable($data['expiresAt'])
: null;
$this->expiresAt = self::deserializeDate($data['expiresAt']);
}
if (array_key_exists('enabled', $data)) {
$this->enabled = (bool)$data['enabled'];
@@ -106,6 +102,24 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
/**
* Check if this rule has expired
*/
+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);
}
}