Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.07% |
215 / 231 |
|
53.33% |
8 / 15 |
CRAP | |
0.00% |
0 / 1 |
| ImpersonationWebController | |
93.04% |
214 / 230 |
|
53.33% |
8 / 15 |
77.94 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| impersonate | |
92.31% |
24 / 26 |
|
0.00% |
0 / 1 |
9.04 | |||
| validateImpersonationPreconditions | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| checkSessionAndImpersonating | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| checkPrivilegesAndAudit | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| switchSessionIdentity | |
87.50% |
35 / 40 |
|
0.00% |
0 / 1 |
6.07 | |||
| revert | |
96.43% |
27 / 28 |
|
0.00% |
0 / 1 |
7 | |||
| checkAdminAccess | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| handleInstanceContextSwitch | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
8 | |||
| panel | |
92.11% |
35 / 38 |
|
0.00% |
0 / 1 |
9.04 | |||
| searchUsers | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
9.05 | |||
| saveGrants | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
10.10 | |||
| resolveCurrentSessionUser | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| createRedirect | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| createForbidden | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| 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\Presentation\Web; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Audit\Application\Service\SecurityAuditLogger; |
| 12 | use App\Core\Audit\Domain\Model\SecurityEventSeverity; |
| 13 | use App\Core\Audit\Domain\Model\SecurityEventType; |
| 14 | use App\Core\Instance\Application\Service\InstanceContextManagerInterface; |
| 15 | use App\Core\Audit\Application\Service\SecurityAuditLoggerInterface; |
| 16 | use App\Modules\User\Application\Service\UserImpersonationService; |
| 17 | use App\Modules\User\Domain\Repository\UserRepositoryInterface; |
| 18 | use Psr\Http\Message\ResponseFactoryInterface; |
| 19 | use Psr\Http\Message\ResponseInterface; |
| 20 | use Psr\Http\Message\ServerRequestInterface; |
| 21 | use Twig\Environment as TwigEnvironment; |
| 22 | use Yiisoft\Session\SessionInterface; |
| 23 | use Yiisoft\User\CurrentUser; |
| 24 | |
| 25 | /** |
| 26 | * Controller for user impersonation, identity context switching, and grant configuration. |
| 27 | * |
| 28 | * Dedicated strictly to App-Admin with support for local admin and remote client context. |
| 29 | * |
| 30 | * @package App\Modules\User\Presentation\Web |
| 31 | */ |
| 32 | final readonly class ImpersonationWebController |
| 33 | { |
| 34 | private const string SESSION_IMPERSONATOR = 'impersonator_user'; |
| 35 | private const string SESSION_IMPERSONATOR_ID = 'impersonator_user_id'; |
| 36 | private const string SESSION_CLIENT_USER = 'impersonated_client_user'; |
| 37 | private const string SESSION_STARTED_AT = 'impersonation_started_at'; |
| 38 | private const string SESSION_USER = 'user'; |
| 39 | private const string SESSION_USER_ID = 'user_id'; |
| 40 | private const string FORBIDDEN_SUPERUSER = 'Forbidden: Superuser access required.'; |
| 41 | private const string CONTENT_TYPE_PLAIN = 'text/plain; charset=utf-8'; |
| 42 | private const string ROUTE_LOGIN = '/login'; |
| 43 | private const string ROUTE_DASHBOARD = '/dashboard'; |
| 44 | private const string ROUTE_IMPERSONATION = '/system/impersonation'; |
| 45 | |
| 46 | /** |
| 47 | * ImpersonationWebController constructor. |
| 48 | * |
| 49 | * @param UserImpersonationService $impersonationService Impersonation rules service. |
| 50 | * @param UserRepositoryInterface $userRepository User repository contract. |
| 51 | * @param SessionInterface $session Server-side session service. |
| 52 | * @param CurrentUser $currentUser Yii3 user service. |
| 53 | * @param ResponseFactoryInterface $responseFactory PSR-17 response factory. |
| 54 | * @param TwigEnvironment|null $twig Twig template renderer. |
| 55 | * @param UserRepositoryInterface|null $clientUserRepository Client user repository. |
| 56 | * @param InstanceContextManagerInterface|null $instanceContextManager Context manager. |
| 57 | * @param string $appProfile Current application deployment profile ('admin' or 'client'). |
| 58 | * @param SecurityAuditLoggerInterface|null $securityAuditLogger Security audit logger instance. |
| 59 | */ |
| 60 | public function __construct( |
| 61 | private UserImpersonationService $impersonationService, |
| 62 | private UserRepositoryInterface $userRepository, |
| 63 | private SessionInterface $session, |
| 64 | private CurrentUser $currentUser, |
| 65 | private ResponseFactoryInterface $responseFactory, |
| 66 | private ?TwigEnvironment $twig = null, |
| 67 | private ?UserRepositoryInterface $clientUserRepository = null, |
| 68 | private ?InstanceContextManagerInterface $instanceContextManager = null, |
| 69 | private string $appProfile = 'admin', |
| 70 | private ?SecurityAuditLoggerInterface $securityAuditLogger = null |
| 71 | ) { |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Switches the active session to the target user identity. |
| 76 | * |
| 77 | * Enforces anti-looping rules: nested impersonation is strictly rejected. |
| 78 | * |
| 79 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 80 | * @param int $id Target user identifier to impersonate. |
| 81 | * @return ResponseInterface Redirect response. |
| 82 | */ |
| 83 | /** |
| 84 | * Switches the active session to the target user identity. |
| 85 | * |
| 86 | * Enforces anti-looping rules: nested impersonation is strictly rejected. |
| 87 | * |
| 88 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 89 | * @param int $id Target user identifier to impersonate. |
| 90 | * @return ResponseInterface Redirect response. |
| 91 | */ |
| 92 | public function impersonate(ServerRequestInterface $request, int $id): ResponseInterface |
| 93 | { |
| 94 | $currentUserSession = $this->resolveCurrentSessionUser(); |
| 95 | $body = (array) $request->getParsedBody(); |
| 96 | $params = $request->getQueryParams(); |
| 97 | $targetContext = (string) ($body['context'] ?? $params['context'] ?? ''); |
| 98 | if ($targetContext === '') { |
| 99 | $targetContext = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote()) |
| 100 | ? 'client' |
| 101 | : 'local'; |
| 102 | } |
| 103 | |
| 104 | $preconditionError = $this->validateImpersonationPreconditions( |
| 105 | $currentUserSession, |
| 106 | $id, |
| 107 | $targetContext, |
| 108 | $request |
| 109 | ); |
| 110 | if ($preconditionError !== null) { |
| 111 | return $preconditionError; |
| 112 | } |
| 113 | |
| 114 | $userRepo = ($targetContext === 'client' && $this->clientUserRepository !== null) |
| 115 | ? $this->clientUserRepository |
| 116 | : $this->userRepository; |
| 117 | |
| 118 | $targetUser = $userRepo->findById($id); |
| 119 | if ($targetUser === null || !$targetUser->isActive()) { |
| 120 | $response = $this->responseFactory->createResponse(404); |
| 121 | $response->getBody()->write('Target user not found or inactive.'); |
| 122 | return $response->withHeader('Content-Type', self::CONTENT_TYPE_PLAIN); |
| 123 | } |
| 124 | |
| 125 | $this->switchSessionIdentity($currentUserSession, $targetUser, $targetContext, $request); |
| 126 | |
| 127 | return $this->createRedirect(self::ROUTE_DASHBOARD); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Validates permission and anti-looping preconditions for impersonation. |
| 132 | * |
| 133 | * @param array<string, mixed>|null $currentUserSession Active user session. |
| 134 | * @param int $id Target user identifier. |
| 135 | * @param string $targetContext Target user context. |
| 136 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 137 | * @return ResponseInterface|null Null if valid, or error response. |
| 138 | */ |
| 139 | private function validateImpersonationPreconditions( |
| 140 | ?array $currentUserSession, |
| 141 | int $id, |
| 142 | string $targetContext, |
| 143 | ServerRequestInterface $request |
| 144 | ): ?ResponseInterface { |
| 145 | $initialCheck = $this->checkSessionAndImpersonating($currentUserSession); |
| 146 | if ($initialCheck !== null) { |
| 147 | return $initialCheck; |
| 148 | } |
| 149 | |
| 150 | $actorUserId = (int) ($currentUserSession['id'] ?? 0); |
| 151 | |
| 152 | return $this->checkPrivilegesAndAudit($actorUserId, $id, $targetContext, $request); |
| 153 | } |
| 154 | |
| 155 | private function checkSessionAndImpersonating(?array $currentUserSession): ?ResponseInterface |
| 156 | { |
| 157 | if ($currentUserSession === null) { |
| 158 | return $this->createRedirect(self::ROUTE_LOGIN); |
| 159 | } |
| 160 | |
| 161 | if ($this->session->has(self::SESSION_IMPERSONATOR)) { |
| 162 | $response = $this->responseFactory->createResponse(400); |
| 163 | $response->getBody()->write('Cannot switch user while already impersonating. Revert first.'); |
| 164 | |
| 165 | return $response->withHeader('Content-Type', self::CONTENT_TYPE_PLAIN); |
| 166 | } |
| 167 | |
| 168 | return null; |
| 169 | } |
| 170 | |
| 171 | private function checkPrivilegesAndAudit( |
| 172 | int $actorUserId, |
| 173 | int $id, |
| 174 | string $targetContext, |
| 175 | ServerRequestInterface $request |
| 176 | ): ?ResponseInterface { |
| 177 | if (!$this->impersonationService->canImpersonate($actorUserId, $id, $targetContext)) { |
| 178 | $this->securityAuditLogger?->log( |
| 179 | SecurityEventType::PERMISSION_DENIED, |
| 180 | SecurityEventSeverity::HIGH, |
| 181 | "Unauthorized impersonation attempt by user ID {$actorUserId} to target ID {$id}", |
| 182 | ['actor_user_id' => $actorUserId, 'target_user_id' => $id, 'context' => $targetContext], |
| 183 | $request, |
| 184 | $actorUserId |
| 185 | ); |
| 186 | |
| 187 | return $this->createForbidden('Forbidden: Insufficient privileges to impersonate this user.'); |
| 188 | } |
| 189 | |
| 190 | return null; |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Switches session identity and logs audit event upon impersonation. |
| 195 | * |
| 196 | * @param array<string, mixed> $currentUserSession Original session data. |
| 197 | * @param object $targetUser Target User model. |
| 198 | * @param string $targetContext Target context (client or local). |
| 199 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 200 | */ |
| 201 | private function switchSessionIdentity( |
| 202 | array $currentUserSession, |
| 203 | object $targetUser, |
| 204 | string $targetContext, |
| 205 | ServerRequestInterface $request |
| 206 | ): void { |
| 207 | try { |
| 208 | $this->session->regenerateId(); |
| 209 | } catch (\Throwable) { |
| 210 | // @codeCoverageIgnoreStart |
| 211 | if (session_status() === PHP_SESSION_ACTIVE) { |
| 212 | @session_regenerate_id(true); |
| 213 | } |
| 214 | // @codeCoverageIgnoreEnd |
| 215 | } |
| 216 | |
| 217 | $actorUserId = (int) ($currentUserSession['id'] ?? 0); |
| 218 | $this->session->set(self::SESSION_IMPERSONATOR, $currentUserSession); |
| 219 | $this->session->set(self::SESSION_IMPERSONATOR_ID, $actorUserId); |
| 220 | $this->session->set(self::SESSION_STARTED_AT, time()); |
| 221 | |
| 222 | $targetSessionData = [ |
| 223 | 'id' => $targetUser->getId(), |
| 224 | 'username' => $targetUser->getUsername(), |
| 225 | 'email' => $targetUser->getEmail(), |
| 226 | 'is_superuser' => $targetUser->isSuperuser(), |
| 227 | 'status' => $targetUser->getStatus(), |
| 228 | 'special_access' => $targetUser->getSpecialAccess(), |
| 229 | 'is_active' => $targetUser->isActive(), |
| 230 | 'locale' => $targetUser->getLocale(), |
| 231 | ]; |
| 232 | |
| 233 | if ($targetContext === 'client') { |
| 234 | $targetSessionData['is_client_user'] = true; |
| 235 | $this->session->set(self::SESSION_CLIENT_USER, $targetSessionData); |
| 236 | if ($this->instanceContextManager !== null && !$this->instanceContextManager->isRemote()) { |
| 237 | $this->instanceContextManager->switchToInstance(1); |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | $this->session->set(self::SESSION_USER, $targetSessionData); |
| 242 | $this->session->set(self::SESSION_USER_ID, $targetUser->getId()); |
| 243 | $this->session->set('app_locale', $targetUser->getLocale()); |
| 244 | $this->session->set('last_activity', time()); |
| 245 | |
| 246 | $this->currentUser->login(new Identity($targetUser->getId(), $targetUser->getLocale())); |
| 247 | |
| 248 | $this->securityAuditLogger?->log( |
| 249 | SecurityEventType::USER_IMPERSONATION_START, |
| 250 | SecurityEventSeverity::HIGH, |
| 251 | "User ID {$actorUserId} started impersonating user ID {$targetUser->getId()}" |
| 252 | . " ({$targetUser->getUsername()})", |
| 253 | [ |
| 254 | 'actor_user_id' => $actorUserId, |
| 255 | 'target_user_id' => $targetUser->getId(), |
| 256 | 'target_user' => $targetUser->getUsername(), |
| 257 | 'context' => $targetContext, |
| 258 | ], |
| 259 | $request, |
| 260 | $actorUserId |
| 261 | ); |
| 262 | } |
| 263 | |
| 264 | public function revert(ServerRequestInterface $request): ResponseInterface |
| 265 | { |
| 266 | if (!$this->session->has(self::SESSION_IMPERSONATOR)) { |
| 267 | return $this->createRedirect(self::ROUTE_DASHBOARD); |
| 268 | } |
| 269 | |
| 270 | $impersonator = $this->session->get(self::SESSION_IMPERSONATOR); |
| 271 | $actorUserId = 0; |
| 272 | |
| 273 | if (is_array($impersonator) && !empty($impersonator['id'])) { |
| 274 | $actorUserId = (int) $impersonator['id']; |
| 275 | |
| 276 | // OWASP ASVS 3.1.1: Regenerate session ID upon reverting identity |
| 277 | try { |
| 278 | $this->session->regenerateId(); |
| 279 | } catch (\Throwable) { |
| 280 | // @codeCoverageIgnoreStart |
| 281 | if (session_status() === PHP_SESSION_ACTIVE) { |
| 282 | @session_regenerate_id(true); |
| 283 | } |
| 284 | // @codeCoverageIgnoreEnd |
| 285 | } |
| 286 | |
| 287 | $this->session->set(self::SESSION_USER, $impersonator); |
| 288 | $this->session->set(self::SESSION_USER_ID, $actorUserId); |
| 289 | |
| 290 | $origLocale = (string) ($impersonator['locale'] ?? 'en'); |
| 291 | $this->session->set('app_locale', $origLocale); |
| 292 | $this->session->set('last_activity', time()); |
| 293 | |
| 294 | $this->currentUser->login(new Identity($actorUserId, $origLocale)); |
| 295 | } |
| 296 | |
| 297 | $this->session->remove(self::SESSION_IMPERSONATOR); |
| 298 | $this->session->remove(self::SESSION_IMPERSONATOR_ID); |
| 299 | $this->session->remove(self::SESSION_CLIENT_USER); |
| 300 | $this->session->remove(self::SESSION_STARTED_AT); |
| 301 | |
| 302 | if ($actorUserId > 0) { |
| 303 | $this->securityAuditLogger?->log( |
| 304 | SecurityEventType::USER_IMPERSONATION_STOP, |
| 305 | SecurityEventSeverity::LOW, |
| 306 | "User ID {$actorUserId} reverted impersonation back to primary identity", |
| 307 | ['actor_user_id' => $actorUserId], |
| 308 | $request, |
| 309 | $actorUserId |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | return $this->createRedirect(self::ROUTE_DASHBOARD); |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Renders dedicated impersonation grants configuration panel. |
| 318 | * |
| 319 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 320 | * @return ResponseInterface HTML response. |
| 321 | */ |
| 322 | /** |
| 323 | * Verifies that the current user has superuser access in an App-Admin console environment. |
| 324 | * |
| 325 | * @param array<string, mixed>|null $currentUserSession User session data. |
| 326 | * @param bool $returnRedirectToLogin Whether to redirect to login if session missing. |
| 327 | * @return ResponseInterface|null Error response or null if access granted. |
| 328 | */ |
| 329 | private function checkAdminAccess( |
| 330 | ?array $currentUserSession, |
| 331 | bool $returnRedirectToLogin = true |
| 332 | ): ?ResponseInterface { |
| 333 | if ($this->appProfile === 'client') { |
| 334 | return $this->createForbidden('Impersonation is only available from App-Admin console.'); |
| 335 | } |
| 336 | |
| 337 | if ($currentUserSession === null) { |
| 338 | return $returnRedirectToLogin |
| 339 | ? $this->createRedirect(self::ROUTE_LOGIN) |
| 340 | : $this->createForbidden('Unauthorized: Active session required.'); |
| 341 | } |
| 342 | |
| 343 | return !((bool) ($currentUserSession['is_superuser'] ?? false)) |
| 344 | ? $this->createForbidden(self::FORBIDDEN_SUPERUSER) |
| 345 | : null; |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * Handles switching the instance context if provided in query parameters. |
| 350 | * |
| 351 | * @param array<string, mixed> $params Query parameters. |
| 352 | * @return ResponseInterface|null Redirect response if context was switched, null otherwise. |
| 353 | */ |
| 354 | private function handleInstanceContextSwitch(array $params): ?ResponseInterface |
| 355 | { |
| 356 | if (!isset($params['context'])) { |
| 357 | return null; |
| 358 | } |
| 359 | |
| 360 | $reqContext = (string) $params['context']; |
| 361 | if ($this->instanceContextManager !== null) { |
| 362 | if ($reqContext === 'client' && !$this->instanceContextManager->isRemote()) { |
| 363 | $this->instanceContextManager->switchToInstance(1); |
| 364 | } elseif ($reqContext === 'local' && $this->instanceContextManager->isRemote()) { |
| 365 | $this->instanceContextManager->switchToLocal(); |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | $cleanParams = $params; |
| 370 | unset($cleanParams['context']); |
| 371 | $queryString = !empty($cleanParams) ? '?' . http_build_query($cleanParams) : ''; |
| 372 | |
| 373 | return $this->createRedirect(self::ROUTE_IMPERSONATION . $queryString); |
| 374 | } |
| 375 | |
| 376 | /** |
| 377 | * Renders dedicated impersonation grants configuration panel. |
| 378 | * |
| 379 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 380 | * @return ResponseInterface HTML response. |
| 381 | */ |
| 382 | public function panel(ServerRequestInterface $request): ResponseInterface |
| 383 | { |
| 384 | $currentUserSession = $this->resolveCurrentSessionUser(); |
| 385 | $accessError = $this->checkAdminAccess($currentUserSession, true); |
| 386 | if ($accessError !== null) { |
| 387 | return $accessError; |
| 388 | } |
| 389 | |
| 390 | $params = $request->getQueryParams(); |
| 391 | $switchRedirect = $this->handleInstanceContextSwitch($params); |
| 392 | if ($switchRedirect !== null) { |
| 393 | return $switchRedirect; |
| 394 | } |
| 395 | |
| 396 | $activeContext = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote()) |
| 397 | ? 'client' |
| 398 | : 'local'; |
| 399 | |
| 400 | $selectedUserId = isset($params['user_id']) ? (int) $params['user_id'] : 0; |
| 401 | $activeUser = null; |
| 402 | $grantedIds = []; |
| 403 | $grantedUsers = []; |
| 404 | |
| 405 | if ($selectedUserId > 0) { |
| 406 | $activeUser = $this->impersonationService->findUserById($selectedUserId, $activeContext); |
| 407 | if ($activeUser !== null) { |
| 408 | $grantedIds = $this->impersonationService->getGrantsForUser($selectedUserId, $activeContext); |
| 409 | $grantedUsers = !empty($grantedIds) |
| 410 | ? $this->impersonationService->findUsersByIds($grantedIds, $activeContext) |
| 411 | : []; |
| 412 | } else { |
| 413 | $selectedUserId = 0; |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | $allGrantsMap = $this->impersonationService->getAllGrantsMap($activeContext); |
| 418 | |
| 419 | $html = $this->twig?->render('users/impersonation_config.twig', [ |
| 420 | 'selected_user_id' => $selectedUserId, |
| 421 | 'active_user' => $activeUser, |
| 422 | 'granted_ids' => $grantedIds, |
| 423 | 'granted_users' => $grantedUsers, |
| 424 | 'all_grants_map' => $allGrantsMap, |
| 425 | 'active_context' => $activeContext, |
| 426 | 'active_instance' => $this->instanceContextManager?->getActiveInstance(), |
| 427 | 'all_instances' => $this->instanceContextManager?->getAllActiveInstances() ?? [], |
| 428 | 'request' => $request, |
| 429 | ]) ?? 'Template renderer unavailable.'; |
| 430 | |
| 431 | $response = $this->responseFactory->createResponse(200); |
| 432 | $response->getBody()->write($html); |
| 433 | |
| 434 | return $response->withHeader('Content-Type', 'text/html; charset=utf-8'); |
| 435 | } |
| 436 | |
| 437 | /** |
| 438 | * Searches active users for AJAX autocomplete in impersonation management. |
| 439 | * |
| 440 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 441 | * @return ResponseInterface JSON response with matching users. |
| 442 | */ |
| 443 | public function searchUsers(ServerRequestInterface $request): ResponseInterface |
| 444 | { |
| 445 | $currentUserSession = $this->resolveCurrentSessionUser(); |
| 446 | $accessError = $this->checkAdminAccess($currentUserSession, false); |
| 447 | if ($accessError !== null) { |
| 448 | return $accessError; |
| 449 | } |
| 450 | |
| 451 | $params = $request->getQueryParams(); |
| 452 | $query = trim((string) ($params['q'] ?? '')); |
| 453 | $limit = isset($params['limit']) ? max(1, min(50, (int) $params['limit'])) : 20; |
| 454 | $excludeId = isset($params['exclude_id']) ? (int) $params['exclude_id'] : 0; |
| 455 | |
| 456 | $activeContext = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote()) |
| 457 | ? 'client' |
| 458 | : 'local'; |
| 459 | if (isset($params['context']) && in_array($params['context'], ['local', 'client'], true)) { |
| 460 | $activeContext = (string) $params['context']; |
| 461 | } |
| 462 | |
| 463 | $users = $this->impersonationService->searchUsers($query, $limit, $activeContext); |
| 464 | if ($excludeId > 0) { |
| 465 | $users = array_values(array_filter( |
| 466 | $users, |
| 467 | static fn(array $u): bool => (int) $u['id'] !== $excludeId |
| 468 | )); |
| 469 | } |
| 470 | |
| 471 | $response = $this->responseFactory->createResponse(200); |
| 472 | $payload = json_encode(['success' => true, 'users' => $users], JSON_THROW_ON_ERROR); |
| 473 | $response->getBody()->write($payload); |
| 474 | |
| 475 | return $response->withHeader('Content-Type', 'application/json; charset=utf-8'); |
| 476 | } |
| 477 | |
| 478 | /** |
| 479 | * Saves updated impersonation grants for a specific user in the active context. |
| 480 | * |
| 481 | * @param ServerRequestInterface $request Incoming HTTP request. |
| 482 | * @return ResponseInterface Redirect response. |
| 483 | */ |
| 484 | public function saveGrants(ServerRequestInterface $request): ResponseInterface |
| 485 | { |
| 486 | $currentUserSession = $this->resolveCurrentSessionUser(); |
| 487 | $accessError = $this->checkAdminAccess($currentUserSession, true); |
| 488 | if ($accessError !== null) { |
| 489 | return $accessError; |
| 490 | } |
| 491 | |
| 492 | $body = (array) $request->getParsedBody(); |
| 493 | $params = $request->getQueryParams(); |
| 494 | $targetContext = (string) ($body['context'] ?? $params['context'] ?? ''); |
| 495 | if ($targetContext === '') { |
| 496 | $targetContext = ($this->instanceContextManager !== null && $this->instanceContextManager->isRemote()) |
| 497 | ? 'client' |
| 498 | : 'local'; |
| 499 | } |
| 500 | |
| 501 | $userId = isset($body['user_id']) ? (int) $body['user_id'] : 0; |
| 502 | $targetIds = isset($body['target_user_ids']) && is_array($body['target_user_ids']) |
| 503 | ? array_map('intval', $body['target_user_ids']) |
| 504 | : []; |
| 505 | |
| 506 | $operatorId = (int) ($currentUserSession['id'] ?? 1); |
| 507 | if ($userId > 0) { |
| 508 | $this->impersonationService->saveGrants($userId, $targetIds, $operatorId, $targetContext); |
| 509 | } |
| 510 | |
| 511 | $redirectUrl = $userId > 0 ? self::ROUTE_IMPERSONATION . '?user_id=' . $userId : self::ROUTE_IMPERSONATION; |
| 512 | return $this->createRedirect($redirectUrl); |
| 513 | } |
| 514 | |
| 515 | private function resolveCurrentSessionUser(): ?array |
| 516 | { |
| 517 | if ($this->session->has(self::SESSION_USER)) { |
| 518 | $user = $this->session->get(self::SESSION_USER); |
| 519 | if (is_array($user)) { |
| 520 | return $user; |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | return null; |
| 525 | } |
| 526 | |
| 527 | /** |
| 528 | * Creates a PSR-7 redirect response to the target URL. |
| 529 | * |
| 530 | * @param string $url Target redirect URL. |
| 531 | * @return ResponseInterface Redirect response. |
| 532 | */ |
| 533 | private function createRedirect(string $url): ResponseInterface |
| 534 | { |
| 535 | $response = $this->responseFactory->createResponse(302); |
| 536 | return $response->withHeader('Location', $url); |
| 537 | } |
| 538 | |
| 539 | /** |
| 540 | * Creates a standardized 403 Forbidden PSR-7 response. |
| 541 | * |
| 542 | * @param string $message Error message. |
| 543 | * @return ResponseInterface Forbidden response. |
| 544 | */ |
| 545 | private function createForbidden(string $message): ResponseInterface |
| 546 | { |
| 547 | $response = $this->responseFactory->createResponse(403); |
| 548 | $response->getBody()->write($message); |
| 549 | return $response->withHeader('Content-Type', self::CONTENT_TYPE_PLAIN); |
| 550 | } |
| 551 | } |