authentication provider provisioning

This commit is contained in:
root
2025-12-22 17:06:40 -05:00
parent 658a319ded
commit 81822498c8
6 changed files with 268 additions and 831 deletions

View File

@@ -10,7 +10,6 @@ use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Service\TokenService; use KTXC\Service\TokenService;
use KTXC\Service\UserService; use KTXC\Service\UserService;
use KTXC\Service\UserProvisioningService;
use KTXC\SessionTenant; use KTXC\SessionTenant;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
@@ -32,7 +31,6 @@ class AuthenticationManager
private readonly ProviderManager $providerManager, private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService, private readonly TokenService $tokenService,
private readonly UserService $userService, private readonly UserService $userService,
private readonly UserProvisioningService $provisioningService,
) { ) {
$this->securityCode = $this->tenant->configuration()->security()->code(); $this->securityCode = $this->tenant->configuration()->security()->code();
} }
@@ -392,19 +390,32 @@ class AuthenticationManager
); );
} }
// Find or provision user from external identity // Provider has already provisioned the user - just get user identifier
$providerConfig = $this->getProviderConfig($method); $userIdentifier = $result->identity['user_identifier'] ?? null;
$user = $this->findOrProvisionUser($method, $result->identity, $providerConfig);
if (!$userIdentifier) {
$this->deleteSession($session->id);
return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_INTERNAL,
'User provisioning failed',
500
);
}
if ($user === null) { // Load user
$userData = $this->userService->fetchByIdentifier($userIdentifier);
if (!$userData) {
$this->deleteSession($session->id); $this->deleteSession($session->id);
return AuthenticationResponse::failed( return AuthenticationResponse::failed(
AuthenticationResponse::ERROR_USER_NOT_FOUND, AuthenticationResponse::ERROR_USER_NOT_FOUND,
'User not found and auto-provisioning is disabled', 'User not found after provisioning',
401 401
); );
} }
$user = new User();
$user->populate($userData, 'users');
// Set user in session // Set user in session
$session->userIdentifier = $user->getId(); $session->userIdentifier = $user->getId();
$session->userIdentity = $user->getIdentity(); $session->userIdentity = $user->getIdentity();
@@ -688,6 +699,7 @@ class AuthenticationManager
$attributes['identity'] = $userIdentity; $attributes['identity'] = $userIdentity;
$attributes['external_subject'] = $externalSubject; $attributes['external_subject'] = $externalSubject;
/*
// Try to find by external subject first // Try to find by external subject first
if ($externalSubject) { if ($externalSubject) {
$user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject); $user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject);
@@ -733,6 +745,7 @@ class AuthenticationManager
$providerConfig $providerConfig
); );
} }
*/
return null; return null;
} }

View File

@@ -1,137 +0,0 @@
<?php
namespace KTXC\Service;
use KTXC\Identity\Provider\DefaultIdentityProvider;
use KTXC\Models\Identity\User;
use Psr\Container\ContainerInterface;
use KTXC\SessionTenant;
/**
* User Manager Service
* Manages authentication providers and user operations across domains
*/
class UserManagerService
{
private array $availableIdentityProviders = [];
private array $cachedIdentityProviders = [];
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserService $userService,
private readonly ContainerInterface $container
) {
// Register the default identity provider
$this->providerRegister('default', DefaultIdentityProvider::class);
}
/**
* Register an authentication provider
*/
public function providerRegister(string $identifier, string $class): void
{
$this->availableIdentityProviders[$identifier] = $class;
}
public function providerList(?array $filter = null): array
{
$requestedProviders = $filter ? $filter : array_keys($this->availableIdentityProviders);
$result = [];
foreach ($requestedProviders as $identifier) {
// Check if provider is available
if (!isset($this->availableIdentityProviders[$identifier])) {
continue;
}
// Check cache first
if (isset($this->cachedIdentityProviders[$identifier])) {
$result[$identifier] = $this->cachedIdentityProviders[$identifier];
} else {
// Instantiate the provider and cache it
$providerClass = $this->availableIdentityProviders[$identifier];
try {
$providerInstance = $this->container->get($providerClass);
// Cache the instance
$this->cachedIdentityProviders[$identifier] = $providerInstance;
$result[$identifier] = $providerInstance;
} catch (\Exception $e) {
// Skip providers that can't be resolved
error_log("Failed to resolve identity provider {$providerClass}: " . $e->getMessage());
continue;
}
}
}
return $result;
}
/**
* Authenticate user against enabled providers
*/
public function authenticate(string $identity, string $credential): User | null
{
// validate identity and credential
if (empty($identity) || empty($credential)) {
return null;
}
// retrieve user by identity
$user = $this->userService->fetchByIdentity($identity);
// determine if user has logged in before
if (!$user) {
return null;
}
// determine if user has a identity provider assigned
if ($user->getProvider() === null) {
return null;
}
$authenticated = $this->authenticateExtant($user->getProvider(), $identity, $credential);
if ($authenticated) {
return $user;
}
return null;
}
public function authenticateExtant(string $provider, string $identity, string $credential): bool {
// determine if provider is enabled
$providers = $this->providerList([$provider]);
if (empty($providers)) {
return false;
}
// Get the first (and should be only) provider
$provider = reset($providers);
// authenticate user against provider
$user = $provider->authenticate($identity, $credential);
return $user;
}
public function validate(string $identifier): Bool
{
$data = $this->userService->fetchByIdentifier($identifier);
if (!$data) {
return false;
}
if ($data['enabled'] !== true) {
return false;
}
if ($data['tid'] !== $this->tenant->identifier()) {
return false;
}
return true;
}
}

View File

@@ -1,278 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionTenant;
use KTXC\Stores\UserStore;
use KTXC\Stores\ExternalIdentityStore;
use KTXF\Utile\UUID;
/**
* User Provisioning Service
* Handles JIT (Just-In-Time) user provisioning from external identity providers
* and profile synchronization on login
*/
class UserProvisioningService
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserStore $userStore,
private readonly ExternalIdentityStore $externalIdentityStore
) { }
/**
* Provision a new user from external provider attributes
*
* @param string $providerId Provider identifier
* @param array $attributes User attributes from provider
* @param array $providerConfig Provider configuration including attribute_map and default_roles
* @return User|null The provisioned user or null on failure
*/
public function provisionUser(string $providerId, array $attributes, array $providerConfig): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Map attributes to user fields
$mappedData = $this->mapAttributes($attributes, $providerConfig['attribute_map'] ?? []);
// Validate required fields
$identity = $mappedData['identity'] ?? $attributes['identity'] ?? $attributes['email'] ?? null;
if (!$identity) {
return null;
}
// Generate user ID
$userId = UUID::v4();
// Build user data
$userData = [
'tid' => $tenantId,
'uid' => $userId,
'identity' => $identity,
'label' => $mappedData['label'] ?? $attributes['label'] ?? $attributes['name'] ?? $identity,
'enabled' => true,
'provider' => $providerId,
'external_subject' => $attributes['external_subject'] ?? null,
'roles' => $providerConfig['default_roles'] ?? [],
'profile' => $mappedData['profile'] ?? [],
'settings' => [],
'initial_login' => time(),
'recent_login' => time(),
];
// Create the user
$createdUserId = $this->userStore->create($userData);
if (!$createdUserId) {
return null;
}
// Link external identity if we have an external subject
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
// Build and return User object
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Synchronize user profile with attributes from provider
* Called on each login to keep profile data up to date
*
* @param User $user The existing user
* @param array $attributes Attributes from provider
* @param array $attributeMap Attribute mapping configuration
* @return bool Whether sync was successful
*/
public function syncProfile(User $user, array $attributes, array $attributeMap = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
// Map attributes
$mappedData = $this->mapAttributes($attributes, $attributeMap);
// Update profile fields if we have mapped profile data
if (!empty($mappedData['profile'])) {
$this->userStore->updateProfile($tenantId, $userId, $mappedData['profile']);
}
// Update label if provided and different
if (!empty($mappedData['label']) && $mappedData['label'] !== $user->getLabel()) {
$this->userStore->updateLabel($tenantId, $userId, $mappedData['label']);
}
// Always update last login
$this->userStore->updateLastLogin($tenantId, $userId);
// Update external identity attributes if applicable
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->updateLastLogin(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject']
);
$this->externalIdentityStore->updateAttributes(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
return true;
}
/**
* Link an external identity to an existing user
*
* @param User $user The user to link
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes Optional attributes from provider
* @return bool Whether linking was successful
*/
public function linkExternalIdentity(User $user, string $providerId, string $externalSubject, array $attributes = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
return $this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$externalSubject,
$attributes
);
}
/**
* Find user by external identity
*
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @return User|null The user or null if not found
*/
public function findByExternalIdentity(string $providerId, string $externalSubject): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Look up in external identities table
$externalIdentity = $this->externalIdentityStore->findByExternalSubject(
$tenantId,
$providerId,
$externalSubject
);
if (!$externalIdentity) {
return null;
}
// Fetch the linked user
$userData = $this->userStore->fetchByIdentifier($tenantId, $externalIdentity['uid']);
if (!$userData) {
return null;
}
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Map provider attributes to user fields using attribute map
*
* @param array $attributes Raw attributes from provider
* @param array $attributeMap Mapping configuration {source_attr: target_field}
* @return array Mapped data with 'identity', 'label', 'profile' keys
*/
protected function mapAttributes(array $attributes, array $attributeMap): array
{
$result = [
'identity' => null,
'label' => null,
'profile' => [],
];
foreach ($attributeMap as $sourceAttr => $targetField) {
// Get source value (supports nested attributes with dot notation)
$value = $this->getNestedValue($attributes, $sourceAttr);
if ($value === null) {
continue;
}
// Set target value (supports nested targets with dot notation)
if ($targetField === 'identity') {
$result['identity'] = $value;
} elseif ($targetField === 'label') {
$result['label'] = $value;
} elseif (str_starts_with($targetField, 'profile.')) {
$profileField = substr($targetField, 8);
$result['profile'][$profileField] = $value;
}
}
return $result;
}
/**
* Get nested value from array using dot notation
*
* @param array $array Source array
* @param string $key Key with optional dot notation (e.g., 'user.email')
* @return mixed|null Value or null if not found
*/
protected function getNestedValue(array $array, string $key): mixed
{
$keys = explode('.', $key);
$value = $array;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return null;
}
$value = $value[$k];
}
return $value;
}
/**
* Check if auto-provisioning is enabled for a provider
*
* @param string $providerId Provider identifier
* @return bool
*/
public function isAutoProvisioningEnabled(string $providerId): bool
{
$config = $this->tenant->identityProviderConfig($providerId);
return ($config['provisioning'] ?? 'manual') === 'auto';
}
}

View File

@@ -17,6 +17,10 @@ class UserService
) { ) {
} }
// =========================================================================
// User Operations
// =========================================================================
public function fetchByIdentity(string $identifier): User | null public function fetchByIdentity(string $identifier): User | null
{ {
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); $data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
@@ -34,6 +38,69 @@ class UserService
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier); return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
} }
public function fetchByIdentityRaw(string $identifier): array | null
{
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
}
public function fetchByProviderSubject(string $provider, string $subject): ?array
{
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
}
public function createUser(array $userData): array
{
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
}
public function updateUser(string $uid, array $updates): bool
{
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
}
public function deleteUser(string $uid): bool
{
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
}
// =========================================================================
// Profile Operations
// =========================================================================
public function fetchProfile(string $uid): ?array
{
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
}
public function storeProfile(string $uid, array $profileFields): bool
{
// Get managed fields to filter out read-only fields
$user = $this->fetchByIdentifier($uid);
if (!$user) {
return false;
}
$managedFields = $user['provider_managed_fields'] ?? [];
$editableFields = [];
// Only include fields that are not managed by provider
foreach ($profileFields as $field => $value) {
if (!in_array($field, $managedFields)) {
$editableFields[$field] = $value;
}
}
if (empty($editableFields)) {
return false;
}
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
}
// =========================================================================
// Settings Operations
// =========================================================================
public function fetchSettings(array $settings = []): array | null public function fetchSettings(array $settings = []): array | null
{ {
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
@@ -42,6 +109,56 @@ class UserService
public function storeSettings(array $settings): bool public function storeSettings(array $settings): bool
{ {
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
} }
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Check if a profile field is editable by the user
*
* @param string $uid User identifier
* @param string $field Profile field name
* @return bool True if field is editable, false if managed by provider
*/
public function isFieldEditable(string $uid, string $field): bool
{
$user = $this->fetchByIdentifier($uid);
if (!$user) {
return false;
}
$managedFields = $user['provider_managed_fields'] ?? [];
return !in_array($field, $managedFields);
}
/**
* Get editable fields for a user
*
* @param string $uid User identifier
* @return array Array with field => ['value' => ..., 'editable' => bool, 'provider' => ...]
*/
public function getEditableFields(string $uid): array
{
$user = $this->fetchByIdentifier($uid);
if (!$user || !isset($user['profile'])) {
return [];
}
$managedFields = $user['provider_managed_fields'] ?? [];
$provider = $user['provider'] ?? null;
$editable = [];
foreach ($user['profile'] as $field => $value) {
$editable[$field] = [
'value' => $value,
'editable' => !in_array($field, $managedFields),
'provider' => in_array($field, $managedFields) ? $provider : null,
];
}
return $editable;
}
} }

View File

@@ -1,224 +0,0 @@
<?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();
}
}

View File

@@ -3,7 +3,7 @@
namespace KTXC\Stores; namespace KTXC\Stores;
use KTXC\Db\DataStore; use KTXC\Db\DataStore;
use KTXC\Db\Collection; use KTXF\Utile\UUID;
class UserStore class UserStore
{ {
@@ -11,6 +11,10 @@ class UserStore
public function __construct(protected DataStore $store) public function __construct(protected DataStore $store)
{ } { }
// =========================================================================
// User Operations (Full User Object)
// =========================================================================
public function fetchByIdentity(string $tenant, string $identity): array | null public function fetchByIdentity(string $tenant, string $identity): array | null
{ {
@@ -63,195 +67,137 @@ class UserStore
return (array)$entry; return (array)$entry;
} }
/** public function fetchByProviderSubject(string $tenant, string $provider, string $subject): array | null
* 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([ $entry = $this->store->selectCollection('users')->findOne([
'tid' => $tenant, 'tid' => $tenant,
'provider' => $provider, 'provider' => $provider,
'external_subject' => $externalSubject 'provider_subject' => $subject
]); ]);
if (!$entry) { return null; }
if (!$entry) {
return null;
}
return (array)$entry; return (array)$entry;
} }
public function createUser(string $tenant, array $userData): array
{
$userData['tid'] = $tenant;
$userData['uid'] = $userData['uid'] ?? UUID::v4();
$userData['enabled'] = $userData['enabled'] ?? true;
$userData['roles'] = $userData['roles'] ?? [];
$userData['profile'] = $userData['profile'] ?? [];
$userData['settings'] = $userData['settings'] ?? [];
$this->store->selectCollection('users')->insertOne($userData);
return $this->fetchByIdentifier($tenant, $userData['uid']);
}
public function updateUser(string $tenant, string $uid, array $updates): bool
{
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
return $result->getModifiedCount() > 0;
}
public function deleteUser(string $tenant, string $uid): bool
{
$result = $this->store->selectCollection('users')->deleteOne([
'tid' => $tenant,
'uid' => $uid
]);
return $result->getDeletedCount() > 0;
}
// =========================================================================
// Profile Operations
// =========================================================================
public function fetchProfile(string $tenant, string $uid): ?array
{
$user = $this->store->selectCollection('users')->findOne(
['tid' => $tenant, 'uid' => $uid],
['projection' => ['profile' => 1, 'provider_managed_fields' => 1]]
);
if (!$user) {
return null;
}
return [
'profile' => $user['profile'] ?? [],
'provider_managed_fields' => $user['provider_managed_fields'] ?? [],
];
}
public function storeProfile(string $tenant, string $uid, array $profileFields): bool
{
if (empty($profileFields)) {
return false;
}
$updates = [];
foreach ($profileFields as $key => $value) {
$updates["profile.{$key}"] = $value;
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
return $result->getModifiedCount() > 0;
}
// =========================================================================
// Settings Operations
// =========================================================================
public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array
{
// Only fetch the settings field from the database
$user = $this->store->selectCollection('users')->findOne(
['tid' => $tenant, 'uid' => $uid],
['projection' => ['settings' => 1]]
);
if (!$user) {
return null;
}
$userSettings = $user['settings'] ?? [];
if (empty($settings)) {
return $userSettings;
}
$result = [];
foreach ($settings as $key) {
$result[$key] = $userSettings[$key] ?? null;
}
return $result;
}
public function storeSettings(string $tenant, string $uid, array $settings): bool
{
if (empty($settings)) {
return false;
}
$updates = [];
foreach ($settings as $key => $value) {
$updates["settings.{$key}"] = $value;
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $uid],
['$set' => $updates]
);
return $result->getModifiedCount() > 0;
}
} }