perf(firewall): use BSON dates and add query indexes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace KTXT\Integration\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Service\FirewallRuleCache;
|
||||
@@ -254,17 +255,55 @@ class FirewallStoreTest extends TestCase
|
||||
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
|
||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
||||
['_id' => $claimId],
|
||||
['$set' => ['expiresAt' => (new \DateTimeImmutable('-1 minute'))->format(\DateTimeInterface::ATOM)]]
|
||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
||||
);
|
||||
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
||||
['_id' => $claimId],
|
||||
['$set' => ['expiresAt' => (new \DateTimeImmutable('-1 minute'))->format(\DateTimeInterface::ATOM)]]
|
||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
||||
);
|
||||
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
|
||||
}
|
||||
|
||||
#[TestDox('Firewall dates use BSON date storage and indexes are installed idempotently')]
|
||||
public function testBsonDatesAndIndexes(): void
|
||||
{
|
||||
$rule = $this->rule('bson-date', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('+1 hour'));
|
||||
$this->store->depositRule($rule);
|
||||
|
||||
$log = (new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
|
||||
->setResult(FirewallLogObject::RESULT_ALLOWED)
|
||||
->setTimestamp(new \DateTimeImmutable());
|
||||
$this->store->createLog($log);
|
||||
|
||||
$storedRule = $this->dataStore->selectCollection('firewall_rules')
|
||||
->getMongoCollection()
|
||||
->findOne(['_id' => new \MongoDB\BSON\ObjectId($rule->getId())]);
|
||||
$storedLog = $this->dataStore->selectCollection('firewall_logs')
|
||||
->getMongoCollection()
|
||||
->findOne(['_id' => new \MongoDB\BSON\ObjectId($log->getId())]);
|
||||
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['createdAt']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['expiresAt']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedLog['timestamp']);
|
||||
|
||||
$expected = $this->store->ensureIndexes();
|
||||
self::assertSame($expected, $this->store->ensureIndexes());
|
||||
|
||||
$claimIndex = null;
|
||||
foreach ($this->dataStore->selectCollection('firewall_brute_force_claims')->getMongoCollection()->listIndexes() as $index) {
|
||||
if ($index->getName() === 'claims_expiry') {
|
||||
$claimIndex = $index;
|
||||
}
|
||||
}
|
||||
self::assertNotNull($claimIndex);
|
||||
self::assertSame(0, $claimIndex['expireAfterSeconds']);
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $reason,
|
||||
string $scope,
|
||||
|
||||
Reference in New Issue
Block a user