authentication provider provisioning
This commit is contained in:
@@ -10,7 +10,6 @@ use KTXC\Security\Authentication\AuthenticationRequest;
|
||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||
use KTXC\Service\TokenService;
|
||||
use KTXC\Service\UserService;
|
||||
use KTXC\Service\UserProvisioningService;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
@@ -32,7 +31,6 @@ class AuthenticationManager
|
||||
private readonly ProviderManager $providerManager,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserService $userService,
|
||||
private readonly UserProvisioningService $provisioningService,
|
||||
) {
|
||||
$this->securityCode = $this->tenant->configuration()->security()->code();
|
||||
}
|
||||
@@ -392,19 +390,32 @@ class AuthenticationManager
|
||||
);
|
||||
}
|
||||
|
||||
// Find or provision user from external identity
|
||||
$providerConfig = $this->getProviderConfig($method);
|
||||
$user = $this->findOrProvisionUser($method, $result->identity, $providerConfig);
|
||||
// Provider has already provisioned the user - just get user identifier
|
||||
$userIdentifier = $result->identity['user_identifier'] ?? null;
|
||||
|
||||
if ($user === null) {
|
||||
if (!$userIdentifier) {
|
||||
$this->deleteSession($session->id);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INTERNAL,
|
||||
'User provisioning failed',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
// Load user
|
||||
$userData = $this->userService->fetchByIdentifier($userIdentifier);
|
||||
if (!$userData) {
|
||||
$this->deleteSession($session->id);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_USER_NOT_FOUND,
|
||||
'User not found and auto-provisioning is disabled',
|
||||
'User not found after provisioning',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->populate($userData, 'users');
|
||||
|
||||
// Set user in session
|
||||
$session->userIdentifier = $user->getId();
|
||||
$session->userIdentity = $user->getIdentity();
|
||||
@@ -688,6 +699,7 @@ class AuthenticationManager
|
||||
$attributes['identity'] = $userIdentity;
|
||||
$attributes['external_subject'] = $externalSubject;
|
||||
|
||||
/*
|
||||
// Try to find by external subject first
|
||||
if ($externalSubject) {
|
||||
$user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject);
|
||||
@@ -733,6 +745,7 @@ class AuthenticationManager
|
||||
$providerConfig
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ class UserService
|
||||
) {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// User Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchByIdentity(string $identifier): User | null
|
||||
{
|
||||
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
@@ -34,6 +38,69 @@ class UserService
|
||||
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
|
||||
{
|
||||
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||
@@ -44,4 +111,54 @@ class UserService
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\Collection;
|
||||
use KTXF\Utile\UUID;
|
||||
|
||||
class UserStore
|
||||
{
|
||||
@@ -11,6 +11,10 @@ class UserStore
|
||||
public function __construct(protected DataStore $store)
|
||||
{ }
|
||||
|
||||
// =========================================================================
|
||||
// User Operations (Full User Object)
|
||||
// =========================================================================
|
||||
|
||||
public function fetchByIdentity(string $tenant, string $identity): array | null
|
||||
{
|
||||
|
||||
@@ -63,195 +67,137 @@ class UserStore
|
||||
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
|
||||
public function fetchByProviderSubject(string $tenant, string $provider, string $subject): array | null
|
||||
{
|
||||
$entry = $this->store->selectCollection('users')->findOne([
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'external_subject' => $externalSubject
|
||||
'provider_subject' => $subject
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$entry) { return null; }
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user