Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.07% covered (success)
93.07%
215 / 231
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImpersonationWebController
93.04% covered (success)
93.04%
214 / 230
53.33% covered (warning)
53.33%
8 / 15
77.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 impersonate
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
9.04
 validateImpersonationPreconditions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 checkSessionAndImpersonating
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 checkPrivilegesAndAudit
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 switchSessionIdentity
87.50% covered (warning)
87.50%
35 / 40
0.00% covered (danger)
0.00%
0 / 1
6.07
 revert
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
7
 checkAdminAccess
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 handleInstanceContextSwitch
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 panel
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
9.04
 searchUsers
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
9.05
 saveGrants
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
10.10
 resolveCurrentSessionUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 createRedirect
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createForbidden
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\User\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Application\Service\SecurityAuditLogger;
12use App\Core\Audit\Domain\Model\SecurityEventSeverity;
13use App\Core\Audit\Domain\Model\SecurityEventType;
14use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
15use App\Core\Audit\Application\Service\SecurityAuditLoggerInterface;
16use App\Modules\User\Application\Service\UserImpersonationService;
17use App\Modules\User\Domain\Repository\UserRepositoryInterface;
18use Psr\Http\Message\ResponseFactoryInterface;
19use Psr\Http\Message\ResponseInterface;
20use Psr\Http\Message\ServerRequestInterface;
21use Twig\Environment as TwigEnvironment;
22use Yiisoft\Session\SessionInterface;
23use 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 */
32final 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}