Initial Version
This commit is contained in:
224
core/lib/Stores/ExternalIdentityStore.php
Normal file
224
core/lib/Stores/ExternalIdentityStore.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
|
||||
/**
|
||||
* External Identity Store
|
||||
* Maps external identity provider subjects to local users
|
||||
*
|
||||
* Collection: external_identities
|
||||
* Schema: {
|
||||
* tid: string, // Tenant identifier
|
||||
* uid: string, // Local user identifier
|
||||
* provider: string, // Provider identifier (e.g., 'oidc', 'saml')
|
||||
* external_subject: string, // External subject identifier (e.g., OIDC sub, SAML NameID)
|
||||
* attributes: object, // Cached attributes from provider
|
||||
* linked_at: int, // Timestamp when identity was linked
|
||||
* last_login: int // Last login via this external identity
|
||||
* }
|
||||
*/
|
||||
class ExternalIdentityStore
|
||||
{
|
||||
protected const COLLECTION_NAME = 'external_identities';
|
||||
|
||||
public function __construct(protected DataStore $store)
|
||||
{ }
|
||||
|
||||
/**
|
||||
* Find external identity by provider and external subject
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @param string $externalSubject External subject identifier
|
||||
* @return array|null External identity record or null
|
||||
*/
|
||||
public function findByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array
|
||||
{
|
||||
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all external identities for a user
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $userId Local user identifier
|
||||
* @return array<array> List of external identity records
|
||||
*/
|
||||
public function findByUser(string $tenant, string $userId): array
|
||||
{
|
||||
$cursor = $this->store->selectCollection(self::COLLECTION_NAME)->find([
|
||||
'tid' => $tenant,
|
||||
'uid' => $userId
|
||||
]);
|
||||
|
||||
$result = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$result[] = (array)$entry;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find external identity for a user from a specific provider
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $userId Local user identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @return array|null External identity record or null
|
||||
*/
|
||||
public function findByUserAndProvider(string $tenant, string $userId, string $provider): ?array
|
||||
{
|
||||
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
|
||||
'tid' => $tenant,
|
||||
'uid' => $userId,
|
||||
'provider' => $provider
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an external identity to a local user
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $userId Local user identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @param string $externalSubject External subject identifier
|
||||
* @param array $attributes Optional attributes from provider
|
||||
* @return bool Whether the operation was successful
|
||||
*/
|
||||
public function linkIdentity(
|
||||
string $tenant,
|
||||
string $userId,
|
||||
string $provider,
|
||||
string $externalSubject,
|
||||
array $attributes = []
|
||||
): bool {
|
||||
$now = time();
|
||||
|
||||
// Use upsert to handle both create and update
|
||||
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
[
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject
|
||||
],
|
||||
[
|
||||
'$set' => [
|
||||
'uid' => $userId,
|
||||
'attributes' => $attributes,
|
||||
'last_login' => $now
|
||||
],
|
||||
'$setOnInsert' => [
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject,
|
||||
'linked_at' => $now
|
||||
]
|
||||
],
|
||||
['upsert' => true]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an external identity from a user
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $userId Local user identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @return bool Whether the operation was successful
|
||||
*/
|
||||
public function unlinkIdentity(string $tenant, string $userId, string $provider): bool
|
||||
{
|
||||
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([
|
||||
'tid' => $tenant,
|
||||
'uid' => $userId,
|
||||
'provider' => $provider
|
||||
]);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last login timestamp for an external identity
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @param string $externalSubject External subject identifier
|
||||
* @return bool Whether the operation was successful
|
||||
*/
|
||||
public function updateLastLogin(string $tenant, string $provider, string $externalSubject): bool
|
||||
{
|
||||
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
[
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject
|
||||
],
|
||||
['$set' => ['last_login' => time()]]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached attributes for an external identity
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @param string $externalSubject External subject identifier
|
||||
* @param array $attributes New attributes to store
|
||||
* @return bool Whether the operation was successful
|
||||
*/
|
||||
public function updateAttributes(string $tenant, string $provider, string $externalSubject, array $attributes): bool
|
||||
{
|
||||
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
[
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject
|
||||
],
|
||||
['$set' => ['attributes' => $attributes]]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all external identities for a user (used when deleting user)
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $userId Local user identifier
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function deleteAllForUser(string $tenant, string $userId): int
|
||||
{
|
||||
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteMany([
|
||||
'tid' => $tenant,
|
||||
'uid' => $userId
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
}
|
||||
309
core/lib/Stores/FirewallStore.php
Normal file
309
core/lib/Stores/FirewallStore.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
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';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) {}
|
||||
|
||||
// ========================================
|
||||
// Rule Operations
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* List all rules for a tenant
|
||||
*/
|
||||
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||
{
|
||||
$filter = ['tenantId' => $tenantId];
|
||||
|
||||
if ($activeOnly) {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$or'] = [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
];
|
||||
}
|
||||
|
||||
$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 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' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
]
|
||||
];
|
||||
|
||||
$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' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
]
|
||||
];
|
||||
|
||||
$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(['_id' => $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): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
||||
'tenantId' => $tenantId,
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'value' => $ipAddress,
|
||||
'action' => $action,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a rule
|
||||
*/
|
||||
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
if ($rule->getId()) {
|
||||
return $this->updateRule($rule);
|
||||
} else {
|
||||
return $this->createRule($rule);
|
||||
}
|
||||
}
|
||||
|
||||
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$data = $rule->jsonSerialize();
|
||||
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 = $rule->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||
['_id' => $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(['_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired rules
|
||||
*/
|
||||
public function cleanupExpiredRules(): int
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
||||
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
|
||||
'expiresAt' => ['$ne' => null]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Log Operations
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Log a firewall event
|
||||
*/
|
||||
public function createLog(FirewallLogObject $log): FirewallLogObject
|
||||
{
|
||||
$data = $log->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
||||
$log->setId((string)$result->getInsertedId());
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' => $since->format(\DateTimeInterface::ATOM)]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' => $since->format(\DateTimeInterface::ATOM)];
|
||||
}
|
||||
|
||||
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' => $cutoff->format(\DateTimeInterface::ATOM)]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
}
|
||||
75
core/lib/Stores/TenantStore.php
Normal file
75
core/lib/Stores/TenantStore.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
|
||||
class TenantStore
|
||||
{
|
||||
|
||||
protected const COLLECTION_NAME = 'tenants';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) { }
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find();
|
||||
$list = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$entry = (new TenantObject())->jsonDeserialize((array)$entry);
|
||||
$list[$entry->getId()] = $entry;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function fetch(string $identifier): ?TenantObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['identifier' => $identifier]);
|
||||
if (!$entry) { return null; }
|
||||
return (new TenantObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
public function fetchByDomain(string $domain): ?TenantObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['domains' => $domain]);
|
||||
if (!$entry) { return null; }
|
||||
$entity = new TenantObject();
|
||||
$entity->jsonDeserialize((array)$entry);
|
||||
return $entity;
|
||||
}
|
||||
|
||||
public function deposit(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
if ($entry->getId()) {
|
||||
return $this->update($entry);
|
||||
} else {
|
||||
return $this->create($entry);
|
||||
}
|
||||
}
|
||||
|
||||
private function create(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
|
||||
$entry->setId((string)$result->getInsertedId());
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function update(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function destroy(TenantObject $entry): void
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
}
|
||||
257
core/lib/Stores/UserStore.php
Normal file
257
core/lib/Stores/UserStore.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\Collection;
|
||||
|
||||
class UserStore
|
||||
{
|
||||
|
||||
public function __construct(protected DataStore $store)
|
||||
{ }
|
||||
|
||||
public function fetchByIdentity(string $tenant, string $identity): array | null
|
||||
{
|
||||
|
||||
$pipeline = [
|
||||
[
|
||||
'$match' => [
|
||||
'tid' => $tenant,
|
||||
'identity' => $identity
|
||||
]
|
||||
],
|
||||
[
|
||||
'$lookup' => [
|
||||
'from' => 'user_roles',
|
||||
'localField' => 'roles', // Array field in `users`
|
||||
'foreignField' => 'rid', // Scalar field in `user_roles`
|
||||
'as' => 'role_details'
|
||||
]
|
||||
],
|
||||
// Add flattened, deduplicated permissions while preserving all original user fields
|
||||
[
|
||||
'$addFields' => [
|
||||
'permissions' => [
|
||||
'$reduce' => [
|
||||
'input' => [
|
||||
'$map' => [
|
||||
'input' => '$role_details',
|
||||
'as' => 'r',
|
||||
'in' => [ '$ifNull' => ['$$r.permissions', []] ]
|
||||
]
|
||||
],
|
||||
'initialValue' => [],
|
||||
'in' => [ '$setUnion' => ['$$value', '$$this'] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
// Optionally remove expanded role documents from output
|
||||
[ '$unset' => 'role_details' ]
|
||||
];
|
||||
|
||||
$entry = $this->store->selectCollection('users')->aggregate($pipeline)->toArray()[0] ?? null;
|
||||
if (!$entry) { return null; }
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
public function fetchByIdentifier(string $tenant, string $identifier): array | null
|
||||
{
|
||||
$entry = $this->store->selectCollection('users')->findOne(['tid' => $tenant, 'uid' => $identifier]);
|
||||
if (!$entry) { return null; }
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user settings from the embedded settings field in the user document
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $identifier User identifier
|
||||
* @param array $settings Optional array of specific setting keys to retrieve
|
||||
* @return array|null Settings array or null if user not found
|
||||
*/
|
||||
public function fetchSettings(string $tenant, string $identifier, array $keys = []): array | null
|
||||
{
|
||||
$entry = $this->store->selectCollection('users')->findOne(
|
||||
['tid' => $tenant, 'uid' => $identifier],
|
||||
['projection' => ['settings' => 1]]
|
||||
);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$settings = (array)($entry['settings'] ?? []);
|
||||
|
||||
if (empty($keys)) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
// Filter to only requested keys
|
||||
return array_filter(
|
||||
$settings,
|
||||
fn($key) => in_array($key, $keys),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store/update user settings in the embedded settings field
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $identifier User identifier
|
||||
* @param array $settings Key-value pairs to set/update
|
||||
* @return bool Whether the update was acknowledged
|
||||
*/
|
||||
public function storeSettings(string $tenant, string $identifier, array $settings): bool
|
||||
{
|
||||
// Build dot-notation update for each setting key
|
||||
$setFields = [];
|
||||
foreach ($settings as $key => $value) {
|
||||
$setFields["settings.$key"] = $value;
|
||||
}
|
||||
|
||||
$result = $this->store->selectCollection('users')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $identifier],
|
||||
['$set' => $setFields]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific settings from a user
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $identifier User identifier
|
||||
* @param array $keys Setting keys to remove
|
||||
* @return bool Whether the update was acknowledged
|
||||
*/
|
||||
public function removeSettings(string $tenant, string $identifier, array $keys): bool
|
||||
{
|
||||
$unsetFields = [];
|
||||
foreach ($keys as $key) {
|
||||
$unsetFields["settings.$key"] = "";
|
||||
}
|
||||
|
||||
$result = $this->store->selectCollection('users')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $identifier],
|
||||
['$unset' => $unsetFields]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*
|
||||
* @param array $data User data including tid, uid, identity, label, provider, etc.
|
||||
* @return string|null The created user's UID or null on failure
|
||||
*/
|
||||
public function create(array $data): ?string
|
||||
{
|
||||
// Ensure required fields
|
||||
if (empty($data['tid']) || empty($data['uid']) || empty($data['identity'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
$data['enabled'] = $data['enabled'] ?? true;
|
||||
$data['roles'] = $data['roles'] ?? [];
|
||||
$data['profile'] = $data['profile'] ?? [];
|
||||
$data['settings'] = $data['settings'] ?? [];
|
||||
$data['initial_login'] = $data['initial_login'] ?? time();
|
||||
$data['recent_login'] = $data['recent_login'] ?? time();
|
||||
|
||||
$result = $this->store->selectCollection('users')->insertOne($data);
|
||||
|
||||
if ($result->isAcknowledged()) {
|
||||
return $data['uid'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile fields
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $identifier User identifier
|
||||
* @param array $profile Profile data to update
|
||||
* @return bool Whether the update was acknowledged
|
||||
*/
|
||||
public function updateProfile(string $tenant, string $identifier, array $profile): bool
|
||||
{
|
||||
$setFields = [];
|
||||
foreach ($profile as $key => $value) {
|
||||
$setFields["profile.$key"] = $value;
|
||||
}
|
||||
|
||||
$result = $this->store->selectCollection('users')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $identifier],
|
||||
['$set' => $setFields]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's last login timestamp
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $identifier User identifier
|
||||
* @return bool Whether the update was acknowledged
|
||||
*/
|
||||
public function updateLastLogin(string $tenant, string $identifier): bool
|
||||
{
|
||||
$result = $this->store->selectCollection('users')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $identifier],
|
||||
['$set' => ['recent_login' => time()]]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's label
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $identifier User identifier
|
||||
* @param string $label New label
|
||||
* @return bool Whether the update was acknowledged
|
||||
*/
|
||||
public function updateLabel(string $tenant, string $identifier, string $label): bool
|
||||
{
|
||||
$result = $this->store->selectCollection('users')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $identifier],
|
||||
['$set' => ['label' => $label]]
|
||||
);
|
||||
|
||||
return $result->isAcknowledged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by external subject (for external identity providers)
|
||||
*
|
||||
* @param string $tenant Tenant identifier
|
||||
* @param string $provider Provider identifier
|
||||
* @param string $externalSubject External subject identifier
|
||||
* @return array|null User data or null if not found
|
||||
*/
|
||||
public function fetchByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array
|
||||
{
|
||||
$entry = $this->store->selectCollection('users')->findOne([
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user