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

@@ -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;
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;
}
}