Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.95% covered (warning)
84.95%
79 / 93
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PermissionContextFactory
84.78% covered (warning)
84.78%
78 / 92
66.67% covered (warning)
66.67%
8 / 12
62.90
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
 createFromRequest
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 resolveProfileIdAndStructures
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 persistProfileIdToSession
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
12.72
 resolveSessionUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveImpersonatorUser
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 resolveFrameworkSessionUser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 resolveNativeSessionUser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 resolveRequestId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 resolveUserStructureIds
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 loadUserStructureIdsFromDb
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
 loadUserProfileIdFromDb
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
13.12
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\Core\Engine\Application\Security;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\ModuleMetadata;
12use App\Core\Engine\Domain\Model\PermissionContext;
13use Psr\Http\Message\ServerRequestInterface;
14use Yiisoft\Session\SessionInterface;
15
16/**
17 * Permission Context Factory.
18 *
19 * Creates a PermissionContext value object from the current Yii3 session
20 * and PSR-7 server request. Reads actor data from SessionInterface or $_SESSION.
21 *
22 * @package App\Core\Engine\Application\Security
23 */
24final readonly class PermissionContextFactory
25{
26    /**
27     * PermissionContextFactory constructor.
28     *
29     * @param SessionInterface|null $session Yii3 session instance.
30     * @param \PDO|null             $pdo     Optional database connection for loading memberships.
31     */
32    public function __construct(
33        private ?SessionInterface $session = null,
34        private ?\PDO             $pdo = null,
35    ) {
36    }
37
38    /**
39     * Creates a PermissionContext from the current session and request.
40     *
41     * Reads the authenticated user data stored in the session under 'user' key.
42     * If no session data exists, returns an anonymous context.
43     *
44     * @param ServerRequestInterface $request PSR-7 incoming server request.
45     * @param ModuleMetadata|null    $module  Optional module for owner scope detection.
46     * @return PermissionContext Hydrated permission context for this request.
47     */
48    public function createFromRequest(
49        ServerRequestInterface $request,
50        ?ModuleMetadata        $module = null
51    ): PermissionContext {
52        $serverParams = $request->getServerParams();
53        $ipAddress    = (string) ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');
54
55        $sessionUser = $this->resolveSessionUser($request);
56        $impersonatorUser = $this->resolveImpersonatorUser($request);
57        $requestId = $this->resolveRequestId($request);
58
59        if (!is_array($sessionUser) || empty($sessionUser['id'])) {
60            return PermissionContext::anonymous($ipAddress, $requestId);
61        }
62
63        $userId = (int)$sessionUser['id'];
64        $isSuperuser = (bool) ($sessionUser['is_superuser'] ?? false);
65
66        if ($isSuperuser) {
67            // Superusers rely solely on root privileges without profile restrictions
68            $structIds = [];
69            $sessionUser['profile_id'] = null;
70        } else {
71            $structIds = $this->resolveProfileIdAndStructures($sessionUser, $userId);
72        }
73        $ownerScoped = $module !== null && $module->hasOwnerScope();
74
75        return PermissionContext::fromSession(
76            $sessionUser,
77            $ipAddress,
78            $ownerScoped,
79            $requestId,
80            $structIds,
81            $impersonatorUser
82        );
83    }
84
85    /**
86     * Resolves structure IDs and ensures profile ID is populated for standard users.
87     *
88     * @param array<string, mixed> $sessionUser User session array (passed by reference).
89     * @param int $userId Authenticated user ID.
90     * @return list<int> Associated structure IDs.
91     */
92    private function resolveProfileIdAndStructures(array &$sessionUser, int $userId): array
93    {
94        $structIds = $this->resolveUserStructureIds($sessionUser, $userId);
95        if ((!isset($sessionUser['profile_id']) || $sessionUser['profile_id'] === null)
96            && $this->pdo !== null && $userId > 0) {
97            $profileId = $this->loadUserProfileIdFromDb($userId);
98            $sessionUser['profile_id'] = $profileId;
99            $this->persistProfileIdToSession($profileId);
100        }
101
102        return $structIds;
103    }
104
105    /**
106     * Persists refreshed profile ID into session stores.
107     *
108     * @param int|null $profileId Loaded profile ID.
109     */
110    private function persistProfileIdToSession(?int $profileId): void
111    {
112        if ($this->session !== null && $this->session->has('user')) {
113            $u = $this->session->get('user');
114            if (is_array($u)) {
115                $u['profile_id'] = $profileId;
116                $this->session->set('user', $u);
117            }
118        }
119        if (isset($_SESSION['user']) && is_array($_SESSION['user'])) {
120            $_SESSION['user']['profile_id'] = $profileId;
121        }
122    }
123
124    /**
125     * Resolves authenticated user dictionary from request attributes, session, or superglobals.
126     *
127     * @param ServerRequestInterface $request Incoming HTTP request.
128     * @return array<string, mixed>|null User array or null if unauthenticated.
129     */
130    private function resolveSessionUser(ServerRequestInterface $request): ?array
131    {
132        $requestUser = $request->getAttribute('user');
133        if (is_array($requestUser) && !empty($requestUser['id'])) {
134            return $requestUser;
135        }
136
137        return $this->resolveFrameworkSessionUser() ?? $this->resolveNativeSessionUser();
138    }
139
140    /**
141     * Resolves original impersonator user dictionary from session or superglobals if impersonating.
142     *
143     * @param ServerRequestInterface $request Incoming HTTP request.
144     * @return array<string, mixed>|null Impersonator user array or null if not impersonating.
145     */
146    private function resolveImpersonatorUser(ServerRequestInterface $request): ?array
147    {
148        $requestImpersonator = $request->getAttribute('impersonator_user');
149        if (is_array($requestImpersonator) && !empty($requestImpersonator['id'])) {
150            return $requestImpersonator;
151        }
152
153        $sessionImp = ($this->session !== null && $this->session->has('impersonator_user'))
154            ? $this->session->get('impersonator_user')
155            : ($_SESSION['impersonator_user'] ?? null);
156
157        return is_array($sessionImp) ? $sessionImp : null;
158    }
159
160    /**
161     * Resolves user dictionary from Yii session interface.
162     *
163     * @return array<string, mixed>|null User array or null.
164     */
165    private function resolveFrameworkSessionUser(): ?array
166    {
167        if ($this->session === null) {
168            return null;
169        }
170
171        if ($this->session->has('user')) {
172            return $this->session->get('user');
173        }
174
175        return $this->session->has('user_id') ? [
176            'id'           => (int)$this->session->get('user_id'),
177            'is_superuser' => (bool)$this->session->get('is_superuser', false),
178        ] : null;
179    }
180
181    /**
182     * Resolves user dictionary from PHP native superglobal $_SESSION.
183     *
184     * @return array<string, mixed>|null User array or null.
185     */
186    private function resolveNativeSessionUser(): ?array
187    {
188        if (isset($_SESSION['user'])) {
189            return $_SESSION['user'];
190        }
191        if (isset($_SESSION['user_id'])) {
192            return [
193                'id'           => (int)$_SESSION['user_id'],
194                'is_superuser' => !empty($_SESSION['is_superuser']),
195            ];
196        }
197
198        return null;
199    }
200
201    /**
202     * Resolves correlation request identifier from headers or attributes.
203     *
204     * @param ServerRequestInterface $request Incoming HTTP request.
205     * @return string|null Request ID or null.
206     */
207    private function resolveRequestId(ServerRequestInterface $request): ?string
208    {
209        $header = $request->getHeaderLine('X-Request-ID');
210        if ($header !== '') {
211            return $header;
212        }
213
214        $attr = $request->getAttribute('request_id');
215
216        return is_string($attr) && $attr !== '' ? $attr : null;
217    }
218
219    /**
220     * Resolves organizational structure node IDs for the user.
221     *
222     * @param array<string, mixed> $sessionUser User session dictionary.
223     * @param int                  $userId      User identifier.
224     * @return array<int> Structure IDs.
225     */
226    private function resolveUserStructureIds(array $sessionUser, int $userId): array
227    {
228        if (isset($sessionUser['structure_ids']) && is_array($sessionUser['structure_ids'])) {
229            return array_map('intval', $sessionUser['structure_ids']);
230        }
231
232        return $this->loadUserStructureIdsFromDb($userId);
233    }
234
235    /**
236     * Loads user structure node IDs from database relation table.
237     *
238     * @param int $userId User identifier.
239     * @return array<int> Structure IDs.
240     */
241    private function loadUserStructureIdsFromDb(int $userId): array
242    {
243        if ($this->pdo === null || $userId <= 0) {
244            return [];
245        }
246
247        foreach (['c_rel_users_structure', 'a_rel_users_structure'] as $tbl) {
248            try {
249                $stmt = $this->pdo->prepare("SELECT `structure_id` FROM `{$tbl}` WHERE `user_id` = :u");
250                $stmt->execute([':u' => $userId]);
251                /** @var array<int, int|string> $col */
252                $col = $stmt->fetchAll(\PDO::FETCH_COLUMN);
253                if ($col !== []) {
254                    return array_map('intval', $col);
255                }
256            } catch (\Throwable) {
257                // Table might not exist, try next
258            }
259        }
260
261        return [];
262    }
263
264    /**
265     * Loads user assigned profile ID from users table.
266     *
267     * @param int $userId User identifier.
268     * @return int|null Assigned profile ID or null.
269     */
270    private function loadUserProfileIdFromDb(int $userId): ?int
271    {
272        if ($this->pdo === null || $userId <= 0) {
273            return null;
274        }
275
276        foreach (['c_mod_users_records', 'a_mod_users_records'] as $tbl) {
277            try {
278                $stmt = $this->pdo->prepare("SELECT `profile_id` FROM `{$tbl}` WHERE `id` = :u LIMIT 1");
279                $stmt->execute([':u' => $userId]);
280                $val = $stmt->fetchColumn();
281                if ($val !== false && $val !== null) {
282                    return (int) $val;
283                }
284            } catch (\Throwable) {
285                // Table might not exist, try next
286            }
287        }
288
289        return null;
290    }
291}