Initial Version
This commit is contained in:
278
core/lib/Service/UserProvisioningService.php
Normal file
278
core/lib/Service/UserProvisioningService.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user