diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index 4df88ff..f873c5a 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -5,7 +5,7 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Module\ModuleManager; use KTXC\Security\Authorization\PermissionChecker; -use KTXC\Service\UserService; +use KTXC\Service\UserAccountsService; use KTXC\SessionIdentity; use KTXF\Controller\ControllerAbstract; use KTXC\SessionTenant; @@ -17,7 +17,7 @@ class InitController extends ControllerAbstract private readonly SessionTenant $tenant, private readonly SessionIdentity $userIdentity, private readonly ModuleManager $moduleManager, - private readonly UserService $userService, + private readonly UserAccountsService $userService, private readonly PermissionChecker $permissionChecker, ) {} diff --git a/core/lib/Controllers/UserAccountsController.php b/core/lib/Controllers/UserAccountsController.php new file mode 100644 index 0000000..57bac4e --- /dev/null +++ b/core/lib/Controllers/UserAccountsController.php @@ -0,0 +1,251 @@ +userIdentity->hasPermission('user.admin')) { + return new JsonResponse([ + 'status' => 'error', + 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] + ], JsonResponse::HTTP_FORBIDDEN); + } + + $result = $this->process($operation, $data); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result, + ], JsonResponse::HTTP_OK); + + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => 400, 'message' => $e->getMessage()] + ], JsonResponse::HTTP_BAD_REQUEST); + + } catch (\Throwable $e) { + $this->logger->error('User manager operation failed', [ + 'operation' => $operation, + 'error' => $e->getMessage() + ]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => $e->getCode(), 'message' => $e->getMessage()] + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Process operation + */ + private function process(string $operation, array $data): mixed + { + return match ($operation) { + 'user.list' => $this->userList($data), + 'user.fetch' => $this->userFetch($data), + 'user.create' => $this->userCreate($data), + 'user.update' => $this->userUpdate($data), + 'user.delete' => $this->userDelete($data), + 'user.provider.unlink' => $this->userProviderUnlink($data), + default => throw new \InvalidArgumentException("Invalid operation: {$operation}"), + }; + } + + // ========================================================================= + // User Operations + // ========================================================================= + + /** + * List all users for tenant + */ + private function userList(array $data): array + { + return $this->userService->listUsers($data); + } + + /** + * Fetch single user by UID + */ + private function userFetch(array $data): array + { + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + $user = $this->userService->fetchByIdentifier($uid); + if (!$user) { + throw new \InvalidArgumentException('User not found'); + } + + // Get editable fields for profile + $editableFields = $this->userService->getEditableFields($uid); + $user['profile_editable'] = $editableFields; + + return $user; + } + + /** + * Create new user + */ + private function userCreate(array $data): array + { + if (!$this->userIdentity->hasPermission('user.create')) { + throw new \InvalidArgumentException('Insufficient permissions to create users'); + } + + $userData = [ + 'identity' => $data['identity'] ?? throw new \InvalidArgumentException('Identity required'), + 'label' => $data['label'] ?? $data['identity'], + 'enabled' => $data['enabled'] ?? true, + 'roles' => $data['roles'] ?? [], + 'profile' => $data['profile'] ?? [], + 'settings' => [], + 'provider' => null, + 'provider_subject' => null, + 'provider_managed_fields' => [] + ]; + + $this->logger->info('Creating user', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'identity' => $userData['identity'], + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->createUser($userData); + } + + /** + * Update existing user + */ + private function userUpdate(array $data): bool + { + if (!$this->userIdentity->hasPermission('user.update')) { + throw new \InvalidArgumentException('Insufficient permissions to update users'); + } + + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + // Build updates (exclude sensitive fields) + $updates = []; + $allowedFields = ['label', 'enabled', 'roles', 'profile']; + + foreach ($allowedFields as $field) { + if (isset($data[$field])) { + $updates[$field] = $data[$field]; + } + } + + if (empty($updates)) { + throw new \InvalidArgumentException('No valid fields to update'); + } + + // Special handling for profile updates (respect managed fields) + if (isset($updates['profile'])) { + $user = $this->userService->fetchByIdentifier($uid); + $managedFields = $user['provider_managed_fields'] ?? []; + + foreach ($managedFields as $field) { + unset($updates['profile'][$field]); + } + } + + $this->logger->info('Updating user', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'uid' => $uid, + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->updateUser($uid, $updates); + } + + /** + * Delete user + */ + private function userDelete(array $data): bool + { + if (!$this->userIdentity->hasPermission('user.delete')) { + throw new \InvalidArgumentException('Insufficient permissions to delete users'); + } + + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + // Prevent self-deletion + if ($uid === $this->userIdentity->identifier()) { + throw new \InvalidArgumentException('Cannot delete your own account'); + } + + $this->logger->info('Deleting user', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'uid' => $uid, + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->deleteUser($uid); + } + + // ========================================================================= + // Security Operations + // ========================================================================= + + /** + * Unlink external provider + */ + private function userProviderUnlink(array $data): bool + { + if (!$this->userIdentity->hasPermission('user.admin')) { + throw new \InvalidArgumentException('Insufficient permissions'); + } + + $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); + + $updates = [ + 'provider' => null, + 'provider_subject' => null, + 'provider_managed_fields' => [] + ]; + + $this->logger->info('Unlinking provider', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'uid' => $uid, + 'actor' => $this->userIdentity->identifier() + ]); + + return $this->userService->updateUser($uid, $updates); + } +} diff --git a/core/lib/Controllers/UserProfileController.php b/core/lib/Controllers/UserProfileController.php index cff9a00..fbeedad 100644 --- a/core/lib/Controllers/UserProfileController.php +++ b/core/lib/Controllers/UserProfileController.php @@ -3,7 +3,7 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; -use KTXC\Service\UserService; +use KTXC\Service\UserAccountsService; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; @@ -14,7 +14,7 @@ class UserProfileController extends ControllerAbstract public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, - private readonly UserService $userService + private readonly UserAccountsService $userService ) {} /** diff --git a/core/lib/Controllers/UserRolesController.php b/core/lib/Controllers/UserRolesController.php new file mode 100644 index 0000000..630c58c --- /dev/null +++ b/core/lib/Controllers/UserRolesController.php @@ -0,0 +1,201 @@ +userIdentity->hasPermission('role.admin')) { + return new JsonResponse([ + 'status' => 'error', + 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] + ], JsonResponse::HTTP_FORBIDDEN); + } + + $result = $this->process($operation, $data); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result, + ], JsonResponse::HTTP_OK); + + } catch (\InvalidArgumentException $e) { + $this->logger->error('Role manager validation error', [ + 'operation' => $operation, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => 400, 'message' => $e->getMessage()] + ], JsonResponse::HTTP_BAD_REQUEST); + + } catch (\Throwable $e) { + $this->logger->error('Role manager operation failed', [ + 'operation' => $operation, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'data' => ['code' => $e->getCode(), 'message' => $e->getMessage()] + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Process operation + */ + private function process(string $operation, array $data): mixed + { + return match ($operation) { + 'role.list' => $this->roleList($data), + 'role.fetch' => $this->roleFetch($data), + 'role.create' => $this->roleCreate($data), + 'role.update' => $this->roleUpdate($data), + 'role.delete' => $this->roleDelete($data), + 'permissions.list' => $this->permissionsList($data), + default => throw new \InvalidArgumentException("Invalid operation: {$operation}"), + }; + } + + // ========================================================================= + // Role Operations + // ========================================================================= + + /** + * List all roles + */ + private function roleList(array $data): array + { + $roles = $this->roleService->listRoles(); + + // Add user count to each role + foreach ($roles as &$role) { + $role['user_count'] = $this->roleService->getRoleUserCount($role['rid']); + } + + return $roles; + } + + /** + * Fetch single role + */ + private function roleFetch(array $data): array + { + $rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required'); + + $role = $this->roleService->getRole($rid); + if (!$role) { + throw new \InvalidArgumentException('Role not found'); + } + + $role['user_count'] = $this->roleService->getRoleUserCount($rid); + + return $role; + } + + /** + * Create new role + */ + private function roleCreate(array $data): array + { + if (!$this->userIdentity->hasPermission('role.manage')) { + throw new \InvalidArgumentException('Insufficient permissions to create roles'); + } + + $roleData = [ + 'label' => $data['label'] ?? throw new \InvalidArgumentException('Role label required'), + 'description' => $data['description'] ?? '', + 'permissions' => $data['permissions'] ?? [] + ]; + + return $this->roleService->createRole($roleData); + } + + /** + * Update existing role + */ + private function roleUpdate(array $data): bool + { + if (!$this->userIdentity->hasPermission('role.manage')) { + throw new \InvalidArgumentException('Insufficient permissions to update roles'); + } + + $rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required'); + + $updates = []; + $allowedFields = ['label', 'description', 'permissions']; + + foreach ($allowedFields as $field) { + if (isset($data[$field])) { + $updates[$field] = $data[$field]; + } + } + + if (empty($updates)) { + throw new \InvalidArgumentException('No valid fields to update'); + } + + return $this->roleService->updateRole($rid, $updates); + } + + /** + * Delete role + */ + private function roleDelete(array $data): bool + { + if (!$this->userIdentity->hasPermission('role.manage')) { + throw new \InvalidArgumentException('Insufficient permissions to delete roles'); + } + + $rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required'); + + return $this->roleService->deleteRole($rid); + } + + /** + * Get available permissions + */ + private function permissionsList(array $data): array + { + return $this->roleService->availablePermissions(); + } +} diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php index 349dd8b..ce0ef04 100644 --- a/core/lib/Controllers/UserSettingsController.php +++ b/core/lib/Controllers/UserSettingsController.php @@ -3,7 +3,7 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; -use KTXC\Service\UserService; +use KTXC\Service\UserAccountsService; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; @@ -14,7 +14,7 @@ class UserSettingsController extends ControllerAbstract public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, - private readonly UserService $userService + private readonly UserAccountsService $userService ) {} /** diff --git a/core/lib/Security/AuthenticationManager.php b/core/lib/Security/AuthenticationManager.php index 67b2549..89dbb16 100644 --- a/core/lib/Security/AuthenticationManager.php +++ b/core/lib/Security/AuthenticationManager.php @@ -9,7 +9,7 @@ use KTXC\Resource\ProviderManager; use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Service\TokenService; -use KTXC\Service\UserService; +use KTXC\Service\UserAccountsService; use KTXC\SessionTenant; use KTXF\Cache\CacheScope; use KTXF\Cache\EphemeralCacheInterface; @@ -30,7 +30,7 @@ class AuthenticationManager private readonly EphemeralCacheInterface $cache, private readonly ProviderManager $providerManager, private readonly TokenService $tokenService, - private readonly UserService $userService, + private readonly UserAccountsService $userService, ) { $this->securityCode = $this->tenant->configuration()->security()->code(); } diff --git a/core/lib/Service/SecurityService.php b/core/lib/Service/SecurityService.php index 107b721..7291a21 100644 --- a/core/lib/Service/SecurityService.php +++ b/core/lib/Service/SecurityService.php @@ -22,7 +22,7 @@ class SecurityService public function __construct( private readonly TokenService $tokenService, - private readonly UserService $userService, + private readonly UserAccountsService $userService, private readonly SessionTenant $sessionTenant ) { $this->securityCode = $this->sessionTenant->configuration()->security()->code(); diff --git a/core/lib/Service/UserService.php b/core/lib/Service/UserAccountsService.php similarity index 91% rename from core/lib/Service/UserService.php rename to core/lib/Service/UserAccountsService.php index 35d18a9..70ec62d 100644 --- a/core/lib/Service/UserService.php +++ b/core/lib/Service/UserAccountsService.php @@ -5,15 +5,15 @@ namespace KTXC\Service; use KTXC\Models\Identity\User; use KTXC\SessionIdentity; use KTXC\SessionTenant; -use KTXC\Stores\UserStore; +use KTXC\Stores\UserAccountsStore; -class UserService +class UserAccountsService { public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, - private readonly UserStore $userStore + private readonly UserAccountsStore $userStore ) { } @@ -21,6 +21,21 @@ class UserService // User Operations // ========================================================================= + /** + * List all users with optional filters + */ + public function listUsers(array $filters = []): array + { + $users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters); + + // Remove sensitive data + foreach ($users as &$user) { + unset($user['settings']); + } + + return $users; + } + public function fetchByIdentity(string $identifier): User | null { $data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); diff --git a/core/lib/Service/UserRolesService.php b/core/lib/Service/UserRolesService.php new file mode 100644 index 0000000..7383f93 --- /dev/null +++ b/core/lib/Service/UserRolesService.php @@ -0,0 +1,143 @@ +roleStore->listRoles($this->tenantIdentity->identifier()); + } + + /** + * Get role by ID + */ + public function getRole(string $rid): ?array + { + return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid); + } + + /** + * Create a new role + */ + public function createRole(array $roleData): array + { + $this->validateRoleData($roleData); + + $this->logger->info('Creating role', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'label' => $roleData['label'] ?? 'Unnamed' + ]); + + return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData); + } + + /** + * Update existing role + */ + public function updateRole(string $rid, array $updates): bool + { + // Verify role exists and is not system role + $role = $this->getRole($rid); + if (!$role) { + throw new \InvalidArgumentException('Role not found'); + } + + if ($role['system'] ?? false) { + throw new \InvalidArgumentException('Cannot modify system roles'); + } + + $this->validateRoleData($updates, false); + + $this->logger->info('Updating role', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'rid' => $rid + ]); + + return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates); + } + + /** + * Delete a role + */ + public function deleteRole(string $rid): bool + { + // Verify role exists and is not system role + $role = $this->getRole($rid); + if (!$role) { + throw new \InvalidArgumentException('Role not found'); + } + + if ($role['system'] ?? false) { + throw new \InvalidArgumentException('Cannot delete system roles'); + } + + // Check if role is assigned to users + $userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + if ($userCount > 0) { + throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)"); + } + + $this->logger->info('Deleting role', [ + 'tenant' => $this->tenantIdentity->identifier(), + 'rid' => $rid + ]); + + return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid); + } + + /** + * Get user count for a role + */ + public function getRoleUserCount(string $rid): int + { + return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + } + + /** + * Get all available permissions from modules + * Grouped by category with metadata + */ + public function availablePermissions(): array + { + return $this->roleStore->availablePermissions(); + } + + // ========================================================================= + // Validation + // ========================================================================= + + /** + * Validate role data + */ + private function validateRoleData(array $data, bool $isCreate = true): void + { + if ($isCreate && empty($data['label'])) { + throw new \InvalidArgumentException('Role label is required'); + } + + if (isset($data['permissions']) && !is_array($data['permissions'])) { + throw new \InvalidArgumentException('Permissions must be an array'); + } + } +} diff --git a/core/lib/Stores/UserStore.php b/core/lib/Stores/UserAccountsStore.php similarity index 72% rename from core/lib/Stores/UserStore.php rename to core/lib/Stores/UserAccountsStore.php index aeff880..bda6f8f 100644 --- a/core/lib/Stores/UserStore.php +++ b/core/lib/Stores/UserAccountsStore.php @@ -5,7 +5,7 @@ namespace KTXC\Stores; use KTXC\Db\DataStore; use KTXF\Utile\UUID; -class UserStore +class UserAccountsStore { public function __construct(protected DataStore $store) @@ -15,6 +15,64 @@ class UserStore // User Operations (Full User Object) // ========================================================================= + /** + * List all users for a tenant with optional filters + */ + public function listUsers(string $tenant, array $filters = []): array + { + // Build filter + $filter = ['tid' => $tenant]; + + if (isset($filters['enabled'])) { + $filter['enabled'] = (bool)$filters['enabled']; + } + + if (isset($filters['role'])) { + $filter['roles'] = $filters['role']; + } + + // Fetch users with aggregated role data + $pipeline = [ + ['$match' => $filter], + [ + '$lookup' => [ + 'from' => 'user_roles', + 'localField' => 'roles', + 'foreignField' => 'rid', + 'as' => 'role_details' + ] + ], + [ + '$addFields' => [ + 'permissions' => [ + '$reduce' => [ + 'input' => [ + '$map' => [ + 'input' => '$role_details', + 'as' => 'r', + 'in' => ['$ifNull' => ['$$r.permissions', []]] + ] + ], + 'initialValue' => [], + 'in' => ['$setUnion' => ['$$value', '$$this']] + ] + ] + ] + ], + ['$unset' => 'role_details'], + ['$sort' => ['label' => 1]] + ]; + + $cursor = $this->store->selectCollection('user_accounts')->aggregate($pipeline); + + $users = []; + foreach ($cursor as $entry) { + $users[] = (array)$entry; + } + + return $users; + } + public function fetchByIdentity(string $tenant, string $identity): array | null { @@ -55,7 +113,7 @@ class UserStore [ '$unset' => 'role_details' ] ]; - $entry = $this->store->selectCollection('users')->aggregate($pipeline)->toArray()[0] ?? null; + $entry = $this->store->selectCollection('user_accounts')->aggregate($pipeline)->toArray()[0] ?? null; if (!$entry) { return null; } return (array)$entry; } @@ -97,14 +155,14 @@ class UserStore [ '$unset' => 'role_details' ] ]; - $entry = $this->store->selectCollection('users')->aggregate($pipeline)->toArray()[0] ?? null; + $entry = $this->store->selectCollection('user_accounts')->aggregate($pipeline)->toArray()[0] ?? null; if (!$entry) { return null; } return (array)$entry; } public function fetchByProviderSubject(string $tenant, string $provider, string $subject): array | null { - $entry = $this->store->selectCollection('users')->findOne([ + $entry = $this->store->selectCollection('user_accounts')->findOne([ 'tid' => $tenant, 'provider' => $provider, 'provider_subject' => $subject @@ -122,14 +180,14 @@ class UserStore $userData['profile'] = $userData['profile'] ?? []; $userData['settings'] = $userData['settings'] ?? []; - $this->store->selectCollection('users')->insertOne($userData); + $this->store->selectCollection('user_accounts')->insertOne($userData); return $this->fetchByIdentifier($tenant, $userData['uid']); } public function updateUser(string $tenant, string $uid, array $updates): bool { - $result = $this->store->selectCollection('users')->updateOne( + $result = $this->store->selectCollection('user_accounts')->updateOne( ['tid' => $tenant, 'uid' => $uid], ['$set' => $updates] ); @@ -139,7 +197,7 @@ class UserStore public function deleteUser(string $tenant, string $uid): bool { - $result = $this->store->selectCollection('users')->deleteOne([ + $result = $this->store->selectCollection('user_accounts')->deleteOne([ 'tid' => $tenant, 'uid' => $uid ]); @@ -153,7 +211,7 @@ class UserStore public function fetchProfile(string $tenant, string $uid): ?array { - $user = $this->store->selectCollection('users')->findOne( + $user = $this->store->selectCollection('user_accounts')->findOne( ['tid' => $tenant, 'uid' => $uid], ['projection' => ['profile' => 1, 'provider_managed_fields' => 1]] ); @@ -179,7 +237,7 @@ class UserStore $updates["profile.{$key}"] = $value; } - $result = $this->store->selectCollection('users')->updateOne( + $result = $this->store->selectCollection('user_accounts')->updateOne( ['tid' => $tenant, 'uid' => $uid], ['$set' => $updates] ); @@ -194,7 +252,7 @@ class UserStore public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array { // Only fetch the settings field from the database - $user = $this->store->selectCollection('users')->findOne( + $user = $this->store->selectCollection('user_accounts')->findOne( ['tid' => $tenant, 'uid' => $uid], ['projection' => ['settings' => 1]] ); @@ -227,7 +285,7 @@ class UserStore $updates["settings.{$key}"] = $value; } - $result = $this->store->selectCollection('users')->updateOne( + $result = $this->store->selectCollection('user_accounts')->updateOne( ['tid' => $tenant, 'uid' => $uid], ['$set' => $updates] ); diff --git a/core/lib/Stores/UserRolesStore.php b/core/lib/Stores/UserRolesStore.php new file mode 100644 index 0000000..fa90f17 --- /dev/null +++ b/core/lib/Stores/UserRolesStore.php @@ -0,0 +1,142 @@ +store->selectCollection(self::COLLECTION_NAME)->find( + ['tid' => $tenant], + ['sort' => ['label' => 1]] + ); + + $roles = []; + foreach ($cursor as $entry) { + $role = (array)$entry; + // Ensure permissions is an array + if (isset($role['permissions'])) { + $role['permissions'] = (array)$role['permissions']; + } + $roles[] = $role; + } + + return $roles; + } + + /** + * Fetch role by tenant and role ID + */ + public function fetchByRid(string $tenant, string $rid): ?array + { + $entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([ + 'tid' => $tenant, + 'rid' => $rid + ]); + + if (!$entry) { + return null; + } + + return (array)$entry; + } + + /** + * Create a new role + */ + public function createRole(string $tenant, array $roleData): array + { + $roleData['tid'] = $tenant; + $roleData['rid'] = $roleData['rid'] ?? UUID::v4(); + $roleData['label'] = $roleData['label'] ?? 'Unnamed Role'; + $roleData['description'] = $roleData['description'] ?? ''; + $roleData['permissions'] = $roleData['permissions'] ?? []; + $roleData['system'] = $roleData['system'] ?? false; + + $this->store->selectCollection(self::COLLECTION_NAME)->insertOne($roleData); + + return $this->fetchByRid($tenant, $roleData['rid']); + } + + /** + * Update an existing role + */ + public function updateRole(string $tenant, string $rid, array $updates): bool + { + // Prevent updating system flag + unset($updates['tid'], $updates['rid'], $updates['system']); + + if (empty($updates)) { + return false; + } + + $result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne( + ['tid' => $tenant, 'rid' => $rid], + ['$set' => $updates] + ); + + return $result->getModifiedCount() > 0; + } + + /** + * Delete a role + */ + public function deleteRole(string $tenant, string $rid): bool + { + // Check if role is system role + $role = $this->fetchByRid($tenant, $rid); + if (!$role || ($role['system'] ?? false)) { + return false; // Cannot delete system roles + } + + $result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([ + 'tid' => $tenant, + 'rid' => $rid + ]); + + return $result->getDeletedCount() > 0; + } + + /** + * Count users assigned to a role + */ + public function countUsersInRole(string $tenant, string $rid): int + { + $count = $this->store->selectCollection('user_accounts')->countDocuments([ + 'tid' => $tenant, + 'roles' => $rid + ]); + + return (int)$count; + } + + /** + * Get all available permissions from modules + * Grouped by category with metadata + */ + public function availablePermissions(): array + { + return $this->moduleManager->availablePermissions(); + } +}