Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.75% |
64 / 69 |
|
64.29% |
9 / 14 |
CRAP | |
0.00% |
0 / 1 |
| UserImpersonationService | |
92.65% |
63 / 68 |
|
64.29% |
9 / 14 |
45.81 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| canImpersonate | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
8 | |||
| getSwitchableUsersFor | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
7.02 | |||
| hasAnyImpersonationRights | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| getGrantsForUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getActorsForTargetUser | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| getAllGrantsMap | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getAllActiveUsers | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| searchUsers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| findUserById | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
| findUsersByIds | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| saveGrants | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| resolveGrantsRepo | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
6.17 | |||
| resolveUserRepo | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
6.17 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\User\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Instance\Application\Service\InstanceContextManagerInterface; |
| 12 | use App\Modules\User\Domain\Repository\UserImpersonationRepositoryInterface; |
| 13 | use App\Modules\User\Domain\Repository\UserRepositoryInterface; |
| 14 | |
| 15 | /** |
| 16 | * Service for managing user impersonation rules, access checks, and grant configuration. |
| 17 | * |
| 18 | * Supports multi-context operations for local App-Admin and remote client instances. |
| 19 | * |
| 20 | * @package App\Modules\User\Application\Service |
| 21 | */ |
| 22 | final readonly class UserImpersonationService |
| 23 | { |
| 24 | /** |
| 25 | * UserImpersonationService constructor. |
| 26 | * |
| 27 | * @param UserImpersonationRepositoryInterface $grantsRepository Impersonation grants storage. |
| 28 | * @param UserRepositoryInterface $userRepository User repository contract. |
| 29 | * @param UserImpersonationRepositoryInterface|null $clientGrantsRepository Client grants repository. |
| 30 | * @param UserRepositoryInterface|null $clientUserRepository Client user repository contract. |
| 31 | * @param InstanceContextManagerInterface|null $instanceContextManager Instance context manager. |
| 32 | */ |
| 33 | public function __construct( |
| 34 | private UserImpersonationRepositoryInterface $grantsRepository, |
| 35 | private UserRepositoryInterface $userRepository, |
| 36 | private ?UserImpersonationRepositoryInterface $clientGrantsRepository = null, |
| 37 | private ?UserRepositoryInterface $clientUserRepository = null, |
| 38 | private ?InstanceContextManagerInterface $instanceContextManager = null |
| 39 | ) { |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Determines whether an actor user is permitted to impersonate a target user. |
| 44 | * |
| 45 | * Rules: |
| 46 | * - Disallow self-impersonation (actor === target) in local context. |
| 47 | * - Requires positive user IDs. |
| 48 | * - Verifies authorization grants in the database. |
| 49 | * |
| 50 | * @param int $actorUserId Actor user identifier. |
| 51 | * @param int $targetUserId Target user identifier to impersonate. |
| 52 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 53 | * @return bool True if authorized. |
| 54 | */ |
| 55 | public function canImpersonate( |
| 56 | int $actorUserId, |
| 57 | int $targetUserId, |
| 58 | ?string $context = null |
| 59 | ): bool { |
| 60 | $isClient = ($context === 'client') |
| 61 | || ($context === null && $this->instanceContextManager !== null |
| 62 | && $this->instanceContextManager->isRemote()); |
| 63 | |
| 64 | if ($actorUserId <= 0 || $targetUserId <= 0 || (!$isClient && $actorUserId === $targetUserId)) { |
| 65 | return false; |
| 66 | } |
| 67 | |
| 68 | return $this->resolveGrantsRepo($context)->canImpersonate($actorUserId, $targetUserId); |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Retrieves the list of active users that the actor user is allowed to switch to. |
| 73 | * |
| 74 | * @param int $actorUserId Actor user identifier. |
| 75 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 76 | * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}> |
| 77 | */ |
| 78 | public function getSwitchableUsersFor(int $actorUserId, ?string $context = null): array |
| 79 | { |
| 80 | $allActive = $this->resolveUserRepo($context)->getActiveUsers(500); |
| 81 | |
| 82 | $isClient = ($context === 'client') |
| 83 | || ($context === null && $this->instanceContextManager !== null |
| 84 | && $this->instanceContextManager->isRemote()); |
| 85 | |
| 86 | $grantedIds = $this->resolveGrantsRepo($context)->getGrantedTargetUserIds($actorUserId); |
| 87 | if (empty($grantedIds)) { |
| 88 | return []; |
| 89 | } |
| 90 | |
| 91 | $grantedMap = array_flip($grantedIds); |
| 92 | |
| 93 | return array_values(array_filter( |
| 94 | $allActive, |
| 95 | static fn(array $u): bool => ($isClient || (int)$u['id'] !== $actorUserId) |
| 96 | && isset($grantedMap[(int)$u['id']]) |
| 97 | )); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Checks whether an actor user has permission to impersonate at least one user. |
| 102 | * |
| 103 | * @param int $actorUserId Actor user identifier. |
| 104 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 105 | * @return bool True if switcher should be displayed. |
| 106 | */ |
| 107 | public function hasAnyImpersonationRights(int $actorUserId, ?string $context = null): bool |
| 108 | { |
| 109 | if ($actorUserId <= 0) { |
| 110 | return false; |
| 111 | } |
| 112 | |
| 113 | $granted = $this->resolveGrantsRepo($context)->getGrantedTargetUserIds($actorUserId); |
| 114 | return !empty($granted); |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Returns granted target user IDs for a given actor user. |
| 119 | * |
| 120 | * @param int $userId Actor user identifier. |
| 121 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 122 | * @return array<int, int> Granted target user IDs. |
| 123 | */ |
| 124 | public function getGrantsForUser(int $userId, ?string $context = null): array |
| 125 | { |
| 126 | return $this->resolveGrantsRepo($context)->getGrantedTargetUserIds($userId); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Returns list of users who are authorized to impersonate the specified target user. |
| 131 | * |
| 132 | * @param int $targetUserId Target user identifier. |
| 133 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 134 | * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}> |
| 135 | */ |
| 136 | public function getActorsForTargetUser(int $targetUserId, ?string $context = null): array |
| 137 | { |
| 138 | if ($targetUserId <= 0) { |
| 139 | return []; |
| 140 | } |
| 141 | |
| 142 | $actorIds = $this->resolveGrantsRepo($context)->getGrantedActorUserIds($targetUserId); |
| 143 | if (empty($actorIds)) { |
| 144 | return []; |
| 145 | } |
| 146 | |
| 147 | return $this->resolveUserRepo($context)->findUsersByIds($actorIds); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Returns a full map of impersonation grants (actor_id => array of target_ids). |
| 152 | * |
| 153 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 154 | * @return array<int, array<int, int>> Map of actor user ID to list of target user IDs. |
| 155 | */ |
| 156 | public function getAllGrantsMap(?string $context = null): array |
| 157 | { |
| 158 | return $this->resolveGrantsRepo($context)->getAllGrantsMap(); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Returns all active users in the system for the specified context. |
| 163 | * |
| 164 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 165 | * @param int $limit Maximum users. |
| 166 | * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}> |
| 167 | */ |
| 168 | public function getAllActiveUsers(?string $context = null, int $limit = 500): array |
| 169 | { |
| 170 | return $this->resolveUserRepo($context)->getActiveUsers($limit); |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * Searches active users matching query for autocomplete suggestions. |
| 175 | * |
| 176 | * @param string $query Partial username or email query. |
| 177 | * @param int $limit Maximum users to retrieve. |
| 178 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 179 | * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}> |
| 180 | */ |
| 181 | public function searchUsers(string $query, int $limit = 20, ?string $context = null): array |
| 182 | { |
| 183 | return $this->resolveUserRepo($context)->searchAutocomplete($query, $limit); |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Finds a single user by primary ID for impersonation configuration. |
| 188 | * |
| 189 | * @param int $userId Primary user identifier. |
| 190 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 191 | * @return array{id: int, username: string, email: string, is_superuser: bool, label: string}|null |
| 192 | */ |
| 193 | public function findUserById(int $userId, ?string $context = null): ?array |
| 194 | { |
| 195 | if ($userId <= 0) { |
| 196 | return null; |
| 197 | } |
| 198 | |
| 199 | $user = $this->resolveUserRepo($context)->findById($userId); |
| 200 | if ($user === null || !$user->isActive()) { |
| 201 | return null; |
| 202 | } |
| 203 | |
| 204 | $username = $user->getUsername(); |
| 205 | $email = $user->getEmail(); |
| 206 | |
| 207 | return [ |
| 208 | 'id' => (int) $user->getId(), |
| 209 | 'username' => $username, |
| 210 | 'email' => $email, |
| 211 | 'is_superuser' => $user->isSuperuser(), |
| 212 | 'label' => sprintf('%s (%s)', $username, $email), |
| 213 | ]; |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Finds multiple users by their IDs for displaying granted impersonation targets. |
| 218 | * |
| 219 | * @param array<int, int> $userIds Array of user identifiers. |
| 220 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 221 | * @return array<int, array{id: int, username: string, email: string, is_superuser: bool, label: string}> |
| 222 | */ |
| 223 | public function findUsersByIds(array $userIds, ?string $context = null): array |
| 224 | { |
| 225 | if (empty($userIds)) { |
| 226 | return []; |
| 227 | } |
| 228 | |
| 229 | return $this->resolveUserRepo($context)->findUsersByIds($userIds); |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Saves impersonation grants for a specific user in the specified context. |
| 234 | * |
| 235 | * @param int $userId Actor user identifier. |
| 236 | * @param array<int, int> $targetUserIds Granted target user IDs. |
| 237 | * @param int $operatorId Identity of administrator performing the change. |
| 238 | * @param string|null $context Explicit context ('local', 'client', or null for active). |
| 239 | */ |
| 240 | public function saveGrants( |
| 241 | int $userId, |
| 242 | array $targetUserIds, |
| 243 | int $operatorId, |
| 244 | ?string $context = null |
| 245 | ): void { |
| 246 | $filteredTargets = array_values(array_filter( |
| 247 | $targetUserIds, |
| 248 | static fn(int $targetId): bool => $targetId > 0 && $targetId !== $userId |
| 249 | )); |
| 250 | $this->resolveGrantsRepo($context)->saveGrants($userId, $filteredTargets, $operatorId); |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * Resolves the appropriate grants repository for the requested context. |
| 255 | * |
| 256 | * @param string|null $context Explicit context. |
| 257 | * @return UserImpersonationRepositoryInterface Selected repository. |
| 258 | */ |
| 259 | private function resolveGrantsRepo(?string $context = null): UserImpersonationRepositoryInterface |
| 260 | { |
| 261 | $isClient = $context === 'client' |
| 262 | || ($context === null && $this->instanceContextManager !== null |
| 263 | && $this->instanceContextManager->isRemote()); |
| 264 | |
| 265 | if ($isClient && $this->clientGrantsRepository !== null) { |
| 266 | return $this->clientGrantsRepository; |
| 267 | } |
| 268 | |
| 269 | return $this->grantsRepository; |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Resolves the appropriate user repository for the requested context. |
| 274 | * |
| 275 | * @param string|null $context Explicit context. |
| 276 | * @return UserRepositoryInterface Selected user repository. |
| 277 | */ |
| 278 | private function resolveUserRepo(?string $context = null): UserRepositoryInterface |
| 279 | { |
| 280 | $isClient = $context === 'client' |
| 281 | || ($context === null && $this->instanceContextManager !== null |
| 282 | && $this->instanceContextManager->isRemote()); |
| 283 | |
| 284 | if ($isClient && $this->clientUserRepository !== null) { |
| 285 | return $this->clientUserRepository; |
| 286 | } |
| 287 | |
| 288 | return $this->userRepository; |
| 289 | } |
| 290 | } |