Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.98% covered (success)
91.98%
195 / 212
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccessManagerService
91.94% covered (success)
91.94%
194 / 211
64.29% covered (warning)
64.29%
9 / 14
57.64
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
 getModuleMatrix
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 matchesScope
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
13
 updateModuleLevel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 bulkUpdateModuleLevels
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getModuleRules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 createRule
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 deleteRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 recompileAll
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserAccessMatrix
79.55% covered (warning)
79.55%
35 / 44
0.00% covered (danger)
0.00%
0 / 1
7.42
 fetchUserOwnersMap
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 compileUserModuleRow
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 resolveStandardUserModuleRow
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
8.04
 buildScopeDescription
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
10.66
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\Access\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Access\Domain\Model\AccessRule;
12use App\Core\Access\Domain\Model\AccessRuleType;
13use App\Core\Access\Domain\Model\AccessSubjectType;
14use App\Core\Access\Domain\Model\ModuleAccessLevel;
15use App\Core\Access\Domain\Repository\AccessRepositoryInterface;
16use PDO;
17
18/**
19 * Access Management Application Service.
20 *
21 * Coordinates module permission matrix, explicit rule lifecycles, and fast-lookup cache.
22 *
23 * @package App\Core\Access\Application\Service
24 */
25final readonly class AccessManagerService implements AccessManagerServiceInterface
26{
27    private const string DEFAULT_ICON_CLASS = 'bi bi-box';
28
29    /**
30     * AccessManagerService constructor.
31     *
32     * @param AccessRepositoryInterface $accessRepo Access repository instance.
33     * @param PDO                       $pdo        Active database connection.
34     */
35    public function __construct(
36        private AccessRepositoryInterface $accessRepo,
37        private PDO $pdo
38    ) {
39    }
40
41    /**
42     * Builds module permissions matrix with counts and metadata, optionally filtered by scope.
43     *
44     * @param string|null $scope Scope filter ('admin', 'client', or null for all).
45     * @return list<array<string, mixed>> List of module descriptors with access levels.
46     */
47    public function getModuleMatrix(?string $scope = null): array
48    {
49        $levels = $this->accessRepo->getAllModuleLevels();
50
51        $stmt = $this->pdo->query(
52            'SELECT `id`, `name`, `label`, `type`, `icon_class`, `is_active` '
53            . 'FROM `a_core_module_records` ORDER BY `name` ASC'
54        );
55
56        $rulesCounts = $this->accessRepo->countRulesByModule();
57        $matrix = [];
58
59        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
60            $name = (string) $row['name'];
61            if (!$this->matchesScope($name, $scope)) {
62                continue;
63            }
64            $type = (string) $row['type'];
65            $level = $levels[$name] ?? ModuleAccessLevel::PRIVATE;
66
67            $matrix[] = [
68                'id'           => (int) $row['id'],
69                'name'         => $name,
70                'label'        => (string) $row['label'],
71                'type'         => $type,
72                'is_crud'      => $type === 'crud',
73                'icon_class'   => (string) ($row['icon_class'] ?? self::DEFAULT_ICON_CLASS),
74                'access_level' => $level->value,
75                'level_label'  => $level->label(),
76                'rules_count'  => $rulesCounts[$name] ?? 0,
77            ];
78        }
79
80        return $matrix;
81    }
82
83    private function matchesScope(string $moduleName, ?string $scope): bool
84    {
85        if ($scope === null || $scope === '' || $scope === 'all') {
86            return true;
87        }
88
89        $isAdmin = str_starts_with($moduleName, 'system_')
90            || str_starts_with($moduleName, 'logs_')
91            || str_starts_with($moduleName, 'server_')
92            || str_starts_with($moduleName, 'about_')
93            || str_starts_with($moduleName, 'automation_')
94            || str_starts_with($moduleName, 'mail_')
95            || $moduleName === 'user_password_resets'
96            || $moduleName === 'settings_parameters'
97            || $moduleName === 'integrations';
98
99        return $scope === 'admin' ? $isAdmin : !$isAdmin;
100    }
101
102    /**
103     * Updates base access level for a module.
104     *
105     * @param string   $moduleName Target module.
106     * @param string   $levelValue Raw enum value.
107     * @param int|null $userId     Actor user ID.
108     * @return ModuleAccessLevel Resulting access tier.
109     */
110    public function updateModuleLevel(string $moduleName, string $levelValue, ?int $userId = null): ModuleAccessLevel
111    {
112        $level = ModuleAccessLevel::tryFrom($levelValue) ?? ModuleAccessLevel::PRIVATE;
113        $this->accessRepo->setModuleLevel($moduleName, $level, $userId);
114
115        return $level;
116    }
117
118    /**
119     * Updates base access level for multiple modules.
120     *
121     * @param string             $levelValue  Raw enum value.
122     * @param array<int, string> $moduleNames Target module names (empty for all).
123     * @param int|null           $userId      Actor user ID.
124     * @return int Number of updated modules.
125     */
126    public function bulkUpdateModuleLevels(
127        string $levelValue,
128        array $moduleNames = [],
129        ?int $userId = null
130    ): int {
131        $level = ModuleAccessLevel::tryFrom($levelValue) ?? ModuleAccessLevel::PRIVATE;
132        if ($moduleNames === []) {
133            $matrix = $this->getModuleMatrix();
134            $moduleNames = array_map(static fn(array $m): string => (string) $m['name'], $matrix);
135        }
136
137        $count = 0;
138        foreach ($moduleNames as $moduleName) {
139            $this->accessRepo->setModuleLevel($moduleName, $level, $userId);
140            $count++;
141        }
142
143        return $count;
144    }
145
146    /**
147     * Retrieves all defined rules for a module.
148     *
149     * @param string $moduleName Target module.
150     * @return list<array<string, mixed>> Serialized rule array list.
151     */
152    public function getModuleRules(string $moduleName): array
153    {
154        $rules = $this->accessRepo->findRulesByModule($moduleName);
155
156        $result = [];
157        foreach ($rules as $r) {
158            $result[] = $r->toArray();
159        }
160
161        return $result;
162    }
163
164    /**
165     * Creates and compiles a new access rule.
166     *
167     * @param array<string, mixed> $payload Incoming rule definition.
168     * @param int|null             $userId  Author user ID.
169     * @return AccessRule Created rule entity.
170     */
171    public function createRule(array $payload, ?int $userId = null): AccessRule
172    {
173        $ruleType = AccessRuleType::tryFrom((string) ($payload['rule_type'] ?? ''))
174            ?? AccessRuleType::RECORD_SHARING;
175        $subjectType = AccessSubjectType::tryFrom((string) ($payload['subject_type'] ?? ''))
176            ?? AccessSubjectType::USER;
177        $targetType = AccessSubjectType::tryFrom((string) ($payload['target_type'] ?? ''))
178            ?? AccessSubjectType::USER;
179
180        $rule = new AccessRule(
181            id:          null,
182            moduleName:  (string) ($payload['module_name'] ?? ''),
183            ruleType:    $ruleType,
184            subjectType: $subjectType,
185            subjectId:   (int) ($payload['subject_id'] ?? 0),
186            targetType:  $targetType,
187            targetId:    (int) ($payload['target_id'] ?? 0),
188            canCreate:   (bool) ($payload['can_create'] ?? false),
189            canRead:     (bool) ($payload['can_read'] ?? true),
190            canEdit:     (bool) ($payload['can_edit'] ?? false),
191            canDelete:   (bool) ($payload['can_delete'] ?? false),
192            isDeny:      (bool) ($payload['is_deny'] ?? false),
193            description: isset($payload['description']) ? (string) $payload['description'] : null,
194            createdAt:   null,
195            updatedAt:   null,
196            createdBy:   $userId,
197        );
198
199        $newId = $this->accessRepo->saveRule($rule);
200
201        return $this->accessRepo->findRuleById($newId) ?? $rule;
202    }
203
204    /**
205     * Deletes an existing access rule and recompiles cache.
206     *
207     * @param int $ruleId Rule identifier.
208     * @return bool True if deleted.
209     */
210    public function deleteRule(int $ruleId): bool
211    {
212        return $this->accessRepo->deleteRule($ruleId);
213    }
214
215    /**
216     * Forces full recompile of all modules and users.
217     */
218    public function recompileAll(): void
219    {
220        $this->accessRepo->recompileAll();
221    }
222
223    /**
224     * {@inheritdoc}
225     */
226    public function getUserAccessMatrix(int $userId): array
227    {
228        $user = null;
229        foreach (['c_mod_users_records', 'a_mod_users_records'] as $tbl) {
230            try {
231                $userStmt = $this->pdo->prepare(
232                    'SELECT `id`, `username`, `first_name`, `last_name`, `is_superuser`, `profile_id` '
233                    . "FROM `{$tbl}` WHERE `id` = :uid LIMIT 1"
234                );
235                $userStmt->execute([':uid' => $userId]);
236                $user = $userStmt->fetch(PDO::FETCH_ASSOC);
237                if ($user) {
238                    break;
239                }
240            } catch (\Throwable) {
241                // Try next table
242            }
243        }
244
245        if (!$user) {
246            return [
247                'user_id'      => $userId,
248                'user_display' => 'Unknown',
249                'is_superuser' => false,
250                'cache_source' => 'acache',
251                'modules'      => [],
252            ];
253        }
254
255        $isSuperuser = (bool) ($user['is_superuser'] ?? 0);
256        $displayName = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
257        if ($displayName === '') {
258            $displayName = (string) $user['username'];
259        }
260
261        $userOwners = $this->fetchUserOwnersMap($userId);
262        $levels = $this->accessRepo->getAllModuleLevels();
263        $modulesStmt = $this->pdo->query(
264            'SELECT `id`, `name`, `label`, `type`, `icon_class` '
265            . 'FROM `a_core_module_records` WHERE `is_active` = 1 '
266            . 'ORDER BY `type` ASC, `label` ASC'
267        );
268
269        $modules = [];
270        while ($row = $modulesStmt->fetch(PDO::FETCH_ASSOC)) {
271            $name = (string) $row['name'];
272            $level = $levels[$name] ?? ModuleAccessLevel::PRIVATE;
273            $owners = $userOwners[$name] ?? [];
274            $modules[] = $this->compileUserModuleRow($row, $level, $owners, $isSuperuser);
275        }
276
277        return [
278            'user_id'      => $userId,
279            'username'     => (string) $user['username'],
280            'user_display' => $displayName,
281            'is_superuser' => $isSuperuser,
282            'cache_source' => 'acache (materialized permissions cache)',
283            'modules'      => $modules,
284        ];
285    }
286
287    /**
288     * Fetches user precalculated owners map from a_core_access_user_owners.
289     *
290     * @param int $userId Target user ID.
291     * @return array<string, list<array<string, mixed>>> Grouped owners by module.
292     */
293    private function fetchUserOwnersMap(int $userId): array
294    {
295        $stmt = $this->pdo->prepare(
296            'SELECT `module_name`, `owner_type`, `owner_id`, `can_read`, `can_edit`, `can_delete` ' .
297            'FROM `a_core_access_user_owners` WHERE `user_id` = :uid'
298        );
299        $stmt->execute([':uid' => $userId]);
300        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
301
302        $map = [];
303        foreach ($rows as $row) {
304            $mod = (string) $row['module_name'];
305            $map[$mod][] = $row;
306        }
307
308        return $map;
309    }
310
311    /**
312     * Compiles a single module row for a user's access matrix.
313     *
314     * @param array<string, mixed>        $row         Module metadata row.
315     * @param ModuleAccessLevel           $level       Base access level.
316     * @param list<array<string, mixed>>  $owners      Precalculated owner records.
317     * @param bool                        $isSuperuser Superuser flag.
318     * @return array<string, mixed> Compiled row.
319     */
320    private function compileUserModuleRow(
321        array $row,
322        ModuleAccessLevel $level,
323        array $owners,
324        bool $isSuperuser
325    ): array {
326        $name = (string) $row['name'];
327        $type = (string) $row['type'];
328
329        if ($isSuperuser) {
330            return [
331                'module_id'    => (int) $row['id'],
332                'module_name'  => $name,
333                'label'        => (string) $row['label'],
334                'type'         => $type,
335                'is_crud'      => $type === 'crud',
336                'icon_class'   => (string) ($row['icon_class'] ?? self::DEFAULT_ICON_CLASS),
337                'base_level'   => $level->value,
338                'level_label'  => $level->label(),
339                'can_read'     => true,
340                'can_create'   => true,
341                'can_edit'     => true,
342                'can_delete'   => true,
343                'scope_label'  => 'Full Access (Superadministrator)',
344                'has_own'      => true,
345                'has_struct'   => true,
346                'shared_count' => 0,
347            ];
348        }
349
350        return $this->resolveStandardUserModuleRow($row, $level, $owners, $type);
351    }
352
353    /**
354     * Resolves access permissions for a standard (non-superuser) user.
355     *
356     * @param array<string, mixed>       $row    Module row.
357     * @param ModuleAccessLevel          $level  Access level.
358     * @param list<array<string, mixed>> $owners Owner rows.
359     * @param string                     $type   Module type.
360     * @return array<string, mixed> Formatted result.
361     */
362    private function resolveStandardUserModuleRow(
363        array $row,
364        ModuleAccessLevel $level,
365        array $owners,
366        string $type
367    ): array {
368        $canRead = $level->allowsPublicRead();
369        $canEdit = $level->allowsPublicEdit();
370        $canDelete = $level->allowsPublicDelete();
371        $canCreate = $level->isPublic() || !empty($owners);
372        $hasOwn = false;
373        $hasStruct = false;
374        $sharedCount = 0;
375
376        foreach ($owners as $o) {
377            $canRead = $canRead || ((int) ($o['can_read'] ?? 0) === 1);
378            $canEdit = $canEdit || ((int) ($o['can_edit'] ?? 0) === 1);
379            $canDelete = $canDelete || ((int) ($o['can_delete'] ?? 0) === 1);
380
381            $oType = (string) ($o['owner_type'] ?? '');
382            if ($oType === 'user') {
383                $hasOwn = true;
384            } elseif ($oType === 'structure') {
385                $hasStruct = true;
386            } else {
387                $sharedCount++;
388            }
389        }
390
391        $scopeDesc = $this->buildScopeDescription($level, $hasOwn, $hasStruct, $sharedCount);
392
393        return [
394            'module_id'    => (int) $row['id'],
395            'module_name'  => (string) $row['name'],
396            'label'        => (string) $row['label'],
397            'type'         => $type,
398            'is_crud'      => $type === 'crud',
399            'icon_class'   => (string) ($row['icon_class'] ?? self::DEFAULT_ICON_CLASS),
400            'base_level'   => $level->value,
401            'level_label'  => $level->label(),
402            'can_read'     => $canRead,
403            'can_create'   => $canCreate,
404            'can_edit'     => $canEdit,
405            'can_delete'   => $canDelete,
406            'scope_label'  => $scopeDesc,
407            'has_own'      => $hasOwn,
408            'has_struct'   => $hasStruct,
409            'shared_count' => $sharedCount,
410        ];
411    }
412
413    /**
414     * Generates human-readable scope description for matrix row.
415     *
416     * @param ModuleAccessLevel $level       Base module level.
417     * @param bool              $hasOwn      Has self-owned access.
418     * @param bool              $hasStruct   Has structure access.
419     * @param int               $sharedCount Number of shared owners.
420     * @return string Description text.
421     */
422    private function buildScopeDescription(
423        ModuleAccessLevel $level,
424        bool $hasOwn,
425        bool $hasStruct,
426        int $sharedCount
427    ): string {
428        $publicDesc = match ($level) {
429            ModuleAccessLevel::PUBLIC_DELETE => 'Public (Read, Edit, Delete)',
430            ModuleAccessLevel::PUBLIC_EDIT   => 'Public (Read and Edit)',
431            ModuleAccessLevel::PUBLIC_READ   => 'Public (Read Only)',
432            default                          => null,
433        };
434        if ($publicDesc !== null) {
435            return $publicDesc;
436        }
437
438        $parts = [];
439        if ($hasOwn) {
440            $parts[] = 'Own records';
441        }
442        if ($hasStruct) {
443            $parts[] = 'Department / Structure';
444        }
445        if ($sharedCount > 0) {
446            $parts[] = 'Shared (' . $sharedCount . ')';
447        }
448
449        return $parts === [] ? 'No access permissions' : implode(' + ', $parts);
450    }
451}