Files
server/core/lib/Service/UserProvisioningService.php
2025-12-21 10:09:54 -05:00

279 lines
8.8 KiB
PHP

<?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';
}
}