6700ff145d
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
678 lines
21 KiB
PHP
678 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXC\Stores;
|
|
|
|
use KTXC\Db\DataStore;
|
|
use KTXC\Db\ObjectId;
|
|
use KTXC\Db\UTCDateTime;
|
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
|
use KTXC\Models\Firewall\FirewallLogObject;
|
|
|
|
/**
|
|
* Store for firewall rules and access logs
|
|
*/
|
|
class FirewallStore
|
|
{
|
|
protected const RULES_COLLECTION = 'firewall_rules';
|
|
protected const LOGS_COLLECTION = 'firewall_logs';
|
|
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
|
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
|
|
|
|
public function __construct(
|
|
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']
|
|
),
|
|
$rules->createIndex(
|
|
['scope' => 1, 'tenantId' => 1, 'createdAt' => -1],
|
|
['name' => 'rules_browse']
|
|
),
|
|
$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']
|
|
),
|
|
$logs->createIndex(
|
|
['tenantId' => 1, 'ruleId' => 1, 'timestamp' => -1],
|
|
['name' => 'logs_rule']
|
|
),
|
|
$logs->createIndex(
|
|
['timestamp' => -1],
|
|
['name' => 'logs_global_timeline']
|
|
),
|
|
$claims->createIndex(
|
|
['expiresAt' => 1],
|
|
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
|
|
),
|
|
];
|
|
}
|
|
|
|
// ========================================
|
|
// Rule Operations
|
|
// ========================================
|
|
|
|
/**
|
|
* Query rules within one ownership scope.
|
|
*
|
|
* @return array{items: FirewallRuleObject[], total: int, limit: int, offset: int}
|
|
*/
|
|
public function queryRules(
|
|
string $scope,
|
|
?string $tenantId,
|
|
string $status,
|
|
?string $type,
|
|
?string $action,
|
|
int $limit,
|
|
int $offset
|
|
): array {
|
|
$filter = [
|
|
'scope' => $scope,
|
|
'tenantId' => $scope === FirewallRuleObject::SCOPE_SYSTEM ? null : $tenantId,
|
|
];
|
|
$now = self::bsonDate(new \DateTimeImmutable());
|
|
if ($status === 'active') {
|
|
$filter['enabled'] = true;
|
|
$filter['$or'] = [
|
|
['expiresAt' => null],
|
|
['expiresAt' => ['$gt' => $now]],
|
|
];
|
|
} elseif ($status === 'disabled') {
|
|
$filter['enabled'] = false;
|
|
} elseif ($status === 'expired') {
|
|
$filter['expiresAt'] = ['$ne' => null, '$lte' => $now];
|
|
}
|
|
if ($type !== null) {
|
|
$filter['type'] = $type;
|
|
}
|
|
if ($action !== null) {
|
|
$filter['action'] = $action;
|
|
}
|
|
|
|
$collection = $this->dataStore->selectCollection(self::RULES_COLLECTION);
|
|
$items = [];
|
|
foreach ($collection->find($filter, [
|
|
'sort' => ['createdAt' => -1, '_id' => -1],
|
|
'limit' => $limit,
|
|
'skip' => $offset,
|
|
]) as $entry) {
|
|
$items[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
}
|
|
|
|
return [
|
|
'items' => $items,
|
|
'total' => $collection->countDocuments($filter),
|
|
'limit' => $limit,
|
|
'offset' => $offset,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* List all rules for a tenant
|
|
*/
|
|
public function listRules(string $tenantId, bool $activeOnly = true): array
|
|
{
|
|
$filter = [
|
|
'tenantId' => $tenantId,
|
|
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
|
];
|
|
|
|
if ($activeOnly) {
|
|
$filter['enabled'] = true;
|
|
$filter['$and'] = [[
|
|
'$or' => [
|
|
['expiresAt' => null],
|
|
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
|
],
|
|
]];
|
|
}
|
|
|
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
|
$list = [];
|
|
|
|
foreach ($cursor as $entry) {
|
|
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
$list[] = $rule;
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
public function listSystemRules(bool $activeOnly = true): array
|
|
{
|
|
$filter = ['scope' => FirewallRuleObject::SCOPE_SYSTEM, 'tenantId' => null];
|
|
if ($activeOnly) {
|
|
$filter['enabled'] = true;
|
|
$filter['$or'] = [
|
|
['expiresAt' => null],
|
|
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
|
];
|
|
}
|
|
|
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
|
$list = [];
|
|
foreach ($cursor as $entry) {
|
|
$list[] = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
/**
|
|
* Find rules by IP address
|
|
*/
|
|
public function findRulesByIp(string $tenantId, string $ipAddress): array
|
|
{
|
|
$filter = [
|
|
'tenantId' => $tenantId,
|
|
'type' => ['$in' => [FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE]],
|
|
'enabled' => true,
|
|
'$or' => [
|
|
['expiresAt' => null],
|
|
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
|
]
|
|
];
|
|
|
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
|
$list = [];
|
|
|
|
foreach ($cursor as $entry) {
|
|
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
$list[] = $rule;
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
/**
|
|
* Find rules by device fingerprint
|
|
*/
|
|
public function findRulesByDevice(string $tenantId, string $deviceFingerprint): array
|
|
{
|
|
$filter = [
|
|
'tenantId' => $tenantId,
|
|
'type' => FirewallRuleObject::TYPE_DEVICE,
|
|
'value' => $deviceFingerprint,
|
|
'enabled' => true,
|
|
'$or' => [
|
|
['expiresAt' => null],
|
|
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
|
|
]
|
|
];
|
|
|
|
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
|
$list = [];
|
|
|
|
foreach ($cursor as $entry) {
|
|
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
$list[] = $rule;
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
/**
|
|
* Fetch a specific rule by ID
|
|
*/
|
|
public function fetchRule(string $id): ?FirewallRuleObject
|
|
{
|
|
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id));
|
|
if (!$entry) {
|
|
return null;
|
|
}
|
|
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
}
|
|
|
|
/**
|
|
* Check if exact IP rule exists
|
|
*/
|
|
public function findExactIpRule(
|
|
?string $tenantId,
|
|
string $ipAddress,
|
|
string $action,
|
|
string $scope = FirewallRuleObject::SCOPE_TENANT
|
|
): ?FirewallRuleObject
|
|
{
|
|
$filter = [
|
|
'type' => FirewallRuleObject::TYPE_IP,
|
|
'value' => $ipAddress,
|
|
'action' => $action,
|
|
'enabled' => true,
|
|
'$or' => [
|
|
['expiresAt' => null],
|
|
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
|
|
],
|
|
];
|
|
|
|
if ($scope === FirewallRuleObject::SCOPE_SYSTEM) {
|
|
$filter['scope'] = FirewallRuleObject::SCOPE_SYSTEM;
|
|
$filter['tenantId'] = null;
|
|
} else {
|
|
$filter['tenantId'] = $tenantId;
|
|
$filter['scope'] = FirewallRuleObject::SCOPE_TENANT;
|
|
}
|
|
|
|
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne($filter);
|
|
|
|
if (!$entry) {
|
|
return null;
|
|
}
|
|
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
|
}
|
|
|
|
/**
|
|
* Create or update a rule
|
|
*/
|
|
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
|
{
|
|
$rule->assertValidScopeOwnership();
|
|
|
|
if ($rule->getId()) {
|
|
return $this->updateRule($rule);
|
|
} else {
|
|
return $this->createRule($rule);
|
|
}
|
|
}
|
|
|
|
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
|
{
|
|
$data = self::ruleDocument($rule);
|
|
unset($data['id']); // Remove id for insert
|
|
|
|
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
|
$rule->setId((string)$result->getInsertedId());
|
|
return $rule;
|
|
}
|
|
|
|
private function updateRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
|
{
|
|
$id = $rule->getId();
|
|
if (!$id) {
|
|
return null;
|
|
}
|
|
|
|
$data = self::ruleDocument($rule);
|
|
unset($data['id']);
|
|
|
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
|
self::ruleIdFilter($id),
|
|
['$set' => $data]
|
|
);
|
|
return $rule;
|
|
}
|
|
|
|
/**
|
|
* Delete a rule
|
|
*/
|
|
public function destroyRule(FirewallRuleObject $rule): void
|
|
{
|
|
$id = $rule->getId();
|
|
if (!$id) {
|
|
return;
|
|
}
|
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id));
|
|
}
|
|
|
|
/**
|
|
* Delete expired rules
|
|
*/
|
|
public function cleanupExpiredRules(): int
|
|
{
|
|
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
|
'expiresAt' => [
|
|
'$lt' => self::bsonDate(new \DateTimeImmutable()),
|
|
'$ne' => null,
|
|
],
|
|
]);
|
|
|
|
return $result->getDeletedCount();
|
|
}
|
|
|
|
// ========================================
|
|
// Log Operations
|
|
// ========================================
|
|
|
|
public function queryTenantLogs(
|
|
string $tenantId,
|
|
array $filters,
|
|
int $limit,
|
|
int $offset
|
|
): array {
|
|
return $this->queryLogs(['tenantId' => $tenantId], $filters, $limit, $offset);
|
|
}
|
|
|
|
public function querySystemLogs(
|
|
?string $tenantId,
|
|
array $filters,
|
|
int $limit,
|
|
int $offset
|
|
): array {
|
|
return $this->queryLogs($tenantId === null ? [] : ['tenantId' => $tenantId], $filters, $limit, $offset);
|
|
}
|
|
|
|
/** @return array{items: FirewallLogObject[], total: int, limit: int, offset: int} */
|
|
private function queryLogs(array $scopeFilter, array $filters, int $limit, int $offset): array
|
|
{
|
|
$filter = $scopeFilter;
|
|
foreach (['ipAddress', 'eventType', 'result', 'ruleId', 'ruleScope'] as $field) {
|
|
if (($filters[$field] ?? null) !== null) {
|
|
$filter[$field] = $filters[$field];
|
|
}
|
|
}
|
|
$timestamp = [];
|
|
if (($filters['from'] ?? null) instanceof \DateTimeInterface) {
|
|
$timestamp['$gte'] = self::bsonDate($filters['from']);
|
|
}
|
|
if (($filters['to'] ?? null) instanceof \DateTimeInterface) {
|
|
$timestamp['$lte'] = self::bsonDate($filters['to']);
|
|
}
|
|
if ($timestamp !== []) {
|
|
$filter['timestamp'] = $timestamp;
|
|
}
|
|
|
|
$collection = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
|
|
$items = [];
|
|
foreach ($collection->find($filter, [
|
|
'sort' => ['timestamp' => -1, '_id' => -1],
|
|
'limit' => $limit,
|
|
'skip' => $offset,
|
|
]) as $entry) {
|
|
$items[] = (new FirewallLogObject())->jsonDeserialize((array)$entry);
|
|
}
|
|
|
|
return [
|
|
'items' => $items,
|
|
'total' => $collection->countDocuments($filter),
|
|
'limit' => $limit,
|
|
'offset' => $offset,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Log a firewall event
|
|
*/
|
|
public function createLog(FirewallLogObject $log): FirewallLogObject
|
|
{
|
|
$data = self::logDocument($log);
|
|
unset($data['id']);
|
|
|
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
|
$log->setId((string)$result->getInsertedId());
|
|
return $log;
|
|
}
|
|
|
|
/**
|
|
* Insert an event-backed log once, using the event ID as MongoDB's unique key.
|
|
*/
|
|
public function createLogOnce(FirewallLogObject $log): bool
|
|
{
|
|
$eventId = $log->getEventId();
|
|
if ($eventId === null || $eventId === '') {
|
|
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
|
|
}
|
|
|
|
$data = self::logDocument($log);
|
|
unset($data['id']);
|
|
$data['_id'] = $eventId;
|
|
|
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->updateOne(
|
|
['_id' => $eventId],
|
|
['$setOnInsert' => $data],
|
|
['upsert' => true]
|
|
);
|
|
$log->setId($eventId);
|
|
|
|
return $result->getUpsertedCount() === 1;
|
|
}
|
|
|
|
/**
|
|
* Get logs for a tenant with optional filters
|
|
*/
|
|
public function listLogs(
|
|
string $tenantId,
|
|
?string $ipAddress = null,
|
|
?string $eventType = null,
|
|
?string $result = null,
|
|
int $limit = 100,
|
|
int $offset = 0
|
|
): array {
|
|
$filter = ['tenantId' => $tenantId];
|
|
|
|
if ($ipAddress !== null) {
|
|
$filter['ipAddress'] = $ipAddress;
|
|
}
|
|
if ($eventType !== null) {
|
|
$filter['eventType'] = $eventType;
|
|
}
|
|
if ($result !== null) {
|
|
$filter['result'] = $result;
|
|
}
|
|
|
|
$cursor = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->find(
|
|
$filter,
|
|
[
|
|
'sort' => ['timestamp' => -1],
|
|
'limit' => $limit,
|
|
'skip' => $offset
|
|
]
|
|
);
|
|
|
|
$list = [];
|
|
foreach ($cursor as $entry) {
|
|
$log = (new FirewallLogObject())->jsonDeserialize((array)$entry);
|
|
$list[] = $log;
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
/**
|
|
* Count recent failures from an IP within a time window
|
|
*/
|
|
public function countRecentFailures(
|
|
string $tenantId,
|
|
string $ipAddress,
|
|
int $windowSeconds = 300
|
|
): int {
|
|
$since = (new \DateTimeImmutable())->modify("-{$windowSeconds} seconds");
|
|
|
|
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments([
|
|
'tenantId' => $tenantId,
|
|
'ipAddress' => $ipAddress,
|
|
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
|
'timestamp' => ['$gte' => self::bsonDate($since)]
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
|
|
*/
|
|
public function claimBruteForce(
|
|
string $tenantId,
|
|
string $ipAddress,
|
|
int $claimDurationSeconds
|
|
): bool {
|
|
if ($claimDurationSeconds < 1) {
|
|
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
|
|
}
|
|
|
|
$now = new \DateTimeImmutable();
|
|
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
|
|
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
|
|
|
|
$collection->deleteOne([
|
|
'_id' => $claimId,
|
|
'expiresAt' => ['$lte' => self::bsonDate($now)],
|
|
]);
|
|
|
|
try {
|
|
$collection->insertOne([
|
|
'_id' => $claimId,
|
|
'tenantId' => $tenantId,
|
|
'ipAddress' => $ipAddress,
|
|
'createdAt' => self::bsonDate($now),
|
|
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
|
|
]);
|
|
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
|
|
if ($error->getCode() === 11000) {
|
|
return false;
|
|
}
|
|
|
|
throw $error;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get blocked requests count for dashboard
|
|
*/
|
|
public function countBlockedRequests(
|
|
string $tenantId,
|
|
?\DateTimeImmutable $since = null
|
|
): int {
|
|
$filter = [
|
|
'tenantId' => $tenantId,
|
|
'result' => FirewallLogObject::RESULT_BLOCKED
|
|
];
|
|
|
|
if ($since !== null) {
|
|
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
|
|
}
|
|
|
|
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
|
}
|
|
|
|
public function countSystemBlockedRequests(
|
|
?string $tenantId = null,
|
|
?\DateTimeImmutable $since = null
|
|
): int {
|
|
$filter = ['result' => FirewallLogObject::RESULT_BLOCKED];
|
|
if ($tenantId !== null) {
|
|
$filter['tenantId'] = $tenantId;
|
|
}
|
|
if ($since !== null) {
|
|
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
|
|
}
|
|
|
|
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
|
}
|
|
|
|
/**
|
|
* Clean up old logs
|
|
*/
|
|
public function cleanupOldLogs(int $daysToKeep = 30): int
|
|
{
|
|
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
|
|
|
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
|
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
|
|
]);
|
|
|
|
return $result->getDeletedCount();
|
|
}
|
|
|
|
public function cleanupExpiredBruteForceClaims(): int
|
|
{
|
|
$result = $this->dataStore
|
|
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
|
|
->deleteMany([
|
|
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
|
|
]);
|
|
|
|
return $result->getDeletedCount();
|
|
}
|
|
|
|
public function recordMaintenanceStatus(
|
|
\DateTimeImmutable $startedAt,
|
|
\DateTimeImmutable $completedAt,
|
|
string $status,
|
|
array $result,
|
|
?string $error = null
|
|
): void {
|
|
$this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne(
|
|
['_id' => 'cleanup'],
|
|
['$set' => [
|
|
'startedAt' => self::bsonDate($startedAt),
|
|
'completedAt' => self::bsonDate($completedAt),
|
|
'status' => $status,
|
|
'result' => $result,
|
|
'error' => $error,
|
|
]],
|
|
['upsert' => true]
|
|
);
|
|
}
|
|
|
|
public function maintenanceStatus(): ?array
|
|
{
|
|
return $this->dataStore
|
|
->selectCollection(self::MAINTENANCE_COLLECTION)
|
|
->findOne(['_id' => 'cleanup']);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private static function ruleIdFilter(string $id): array
|
|
{
|
|
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
|
|
}
|
|
}
|