diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php index 8204b4b..3053a62 100644 --- a/core/lib/Models/Firewall/FirewallLogObject.php +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -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 diff --git a/core/lib/Models/Firewall/FirewallRuleObject.php b/core/lib/Models/Firewall/FirewallRuleObject.php index c7a41e4..3835a1b 100644 --- a/core/lib/Models/Firewall/FirewallRuleObject.php +++ b/core/lib/Models/Firewall/FirewallRuleObject.php @@ -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 */ diff --git a/core/lib/Stores/FirewallStore.php b/core/lib/Stores/FirewallStore.php index 3e4cdec..6a1ca68 100644 --- a/core/lib/Stores/FirewallStore.php +++ b/core/lib/Stores/FirewallStore.php @@ -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); + } } diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 986760f..7f220c3 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -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,