279 lines
8.8 KiB
PHP
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';
|
|
}
|
|
}
|