Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.75% covered (success)
92.75%
64 / 69
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserImpersonationService
92.65% covered (success)
92.65%
63 / 68
64.29% covered (warning)
64.29%
9 / 14
45.81
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
 canImpersonate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
8
 getSwitchableUsersFor
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 hasAnyImpersonationRights
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getGrantsForUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActorsForTargetUser
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAllGrantsMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllActiveUsers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 searchUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findUserById
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 findUsersByIds
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 saveGrants
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 resolveGrantsRepo
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 resolveUserRepo
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
12use App\Modules\User\Domain\Repository\UserImpersonationRepositoryInterface;
13use 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 */
22final 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}