Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.31% covered (warning)
87.31%
172 / 197
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlAccessRepository
87.24% covered (warning)
87.24%
171 / 196
72.22% covered (warning)
72.22%
13 / 18
55.19
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
 getModuleLevel
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getAllModuleLevels
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 setModuleLevel
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
3.10
 findRulesByModule
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 countRulesByModule
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 findRuleById
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 saveRule
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
3
 deleteRule
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 findMatchingRules
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
5
 canAccessNonCrudModule
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 canAccessPublicNonCrudModule
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
8.12
 canAccessPrivateNonCrudModule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 recompileModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 recompileUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 recompileAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSharedOwnerAccess
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 hydrateRule
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
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\Infrastructure\Repository;
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 App\Core\Access\Infrastructure\Compiler\AccessRuleCompiler;
17use PDO;
18
19/**
20 * SQL Implementation of Access Control Repository.
21 *
22 * Manages database persistence for module levels, rules, and materialized fast-lookup cache.
23 *
24 * @package App\Core\Access\Infrastructure\Repository
25 */
26final readonly class SqlAccessRepository implements AccessRepositoryInterface
27{
28    private const string TABLE_LEVELS = 'a_core_access_module_levels';
29    private const string TABLE_RULES = 'a_core_access_rules';
30    private const string TABLE_USER_OWNERS = 'a_core_access_user_owners';
31    private const string SQL_SELECT = 'SELECT ';
32    private const string SQL_FROM = ' FROM `';
33    private const string SQL_INSERT_INTO = 'INSERT INTO `';
34    private const string RULE_COLUMNS = '`id`, `module_name`, `rule_type`, `subject_type`, `subject_id`, '
35        . '`target_type`, `target_id`, `can_create`, `can_read`, `can_edit`, `can_delete`, `is_deny`, '
36        . '`description`, `created_at`, `updated_at`, `created_by`';
37
38    private AccessRuleCompiler $compiler;
39
40    /**
41     * SqlAccessRepository constructor.
42     *
43     * @param PDO                     $pdo      Active database connection.
44     * @param AccessRuleCompiler|null $compiler Access rule compilation engine.
45     */
46    public function __construct(
47        private PDO $pdo,
48        ?AccessRuleCompiler $compiler = null
49    ) {
50        $this->compiler = $compiler ?? new AccessRuleCompiler($this->pdo);
51    }
52
53    /**
54     * {@inheritdoc}
55     */
56    public function getModuleLevel(string $moduleName): ModuleAccessLevel
57    {
58        $stmt = $this->pdo->prepare(
59            self::SQL_SELECT . '`access_level`' . self::SQL_FROM . self::TABLE_LEVELS
60            . '` WHERE `module_name` = :mod LIMIT 1'
61        );
62        $stmt->execute([':mod' => $moduleName]);
63        $val = $stmt->fetchColumn();
64
65        if (!is_string($val)) {
66            return ModuleAccessLevel::PRIVATE;
67        }
68
69        return ModuleAccessLevel::tryFrom($val) ?? ModuleAccessLevel::PRIVATE;
70    }
71
72    /**
73     * {@inheritdoc}
74     */
75    public function getAllModuleLevels(): array
76    {
77        $stmt = $this->pdo->query(
78            self::SQL_SELECT . '`module_name`, `access_level`' . self::SQL_FROM . self::TABLE_LEVELS . '`'
79        );
80        $levels = [];
81        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
82            $name = (string) $row['module_name'];
83            $val = (string) $row['access_level'];
84            $levels[$name] = ModuleAccessLevel::tryFrom($val) ?? ModuleAccessLevel::PRIVATE;
85        }
86
87        return $levels;
88    }
89
90    /**
91     * {@inheritdoc}
92     */
93    public function setModuleLevel(string $moduleName, ModuleAccessLevel $level, ?int $updatedBy = null): void
94    {
95        $isSqlite = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
96        if ($isSqlite) {
97            $sql = self::SQL_INSERT_INTO . self::TABLE_LEVELS . '` (`module_name`, `access_level`, `updated_by`) '
98                . 'VALUES (:mod, :lvl, :by) '
99                . 'ON CONFLICT(`module_name`) DO UPDATE SET '
100                . '`access_level` = excluded.`access_level`, `updated_by` = excluded.`updated_by`';
101        } else {
102            $sql = self::SQL_INSERT_INTO . self::TABLE_LEVELS . '` (`module_name`, `access_level`, `updated_by`) '
103                . 'VALUES (:mod, :lvl, :by) '
104                . 'ON DUPLICATE KEY UPDATE `access_level` = VALUES(`access_level`), '
105                . '`updated_by` = VALUES(`updated_by`)';
106        }
107        $stmt = $this->pdo->prepare($sql);
108        $stmt->execute([
109            ':mod' => $moduleName,
110            ':lvl' => $level->value,
111            ':by'  => $updatedBy,
112        ]);
113
114        if ($level === ModuleAccessLevel::PRIVATE) {
115            $this->recompileModule($moduleName);
116        }
117    }
118
119    /**
120     * {@inheritdoc}
121     */
122    public function findRulesByModule(string $moduleName): array
123    {
124        $stmt = $this->pdo->prepare(
125            self::SQL_SELECT . self::RULE_COLUMNS . self::SQL_FROM . self::TABLE_RULES
126            . '` WHERE `module_name` = :mod ORDER BY `id` ASC'
127        );
128        $stmt->execute([':mod' => $moduleName]);
129
130        $rules = [];
131        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
132            $rules[] = $this->hydrateRule($row);
133        }
134
135        return $rules;
136    }
137
138    /**
139     * {@inheritdoc}
140     */
141    public function countRulesByModule(): array
142    {
143        $stmt = $this->pdo->query(
144            'SELECT `module_name`, COUNT(`id`) AS `cnt` FROM `' . self::TABLE_RULES
145            . '` GROUP BY `module_name`'
146        );
147
148        $counts = [];
149        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
150            $counts[(string) $row['module_name']] = (int) $row['cnt'];
151        }
152
153        return $counts;
154    }
155
156    /**
157     * {@inheritdoc}
158     */
159    public function findRuleById(int $id): ?AccessRule
160    {
161        $stmt = $this->pdo->prepare(
162            self::SQL_SELECT . self::RULE_COLUMNS . self::SQL_FROM . self::TABLE_RULES . '` WHERE `id` = :id LIMIT 1'
163        );
164        $stmt->execute([':id' => $id]);
165        $row = $stmt->fetch(PDO::FETCH_ASSOC);
166
167        return $row !== false ? $this->hydrateRule($row) : null;
168    }
169
170    /**
171     * {@inheritdoc}
172     */
173    public function saveRule(AccessRule $rule): int
174    {
175        if ($rule->id !== null && $rule->id > 0) {
176            $stmt = $this->pdo->prepare(
177                'UPDATE `' . self::TABLE_RULES . '` SET '
178                . '`module_name` = :mod, `rule_type` = :rtype, `subject_type` = :stype, `subject_id` = :sid, '
179                . '`target_type` = :ttype, `target_id` = :tid, `can_create` = :cc, `can_read` = :cr, '
180                . '`can_edit` = :ce, `can_delete` = :cd, `is_deny` = :deny, `description` = :desc '
181                . 'WHERE `id` = :id'
182            );
183            $stmt->execute([
184                ':id'    => $rule->id,
185                ':mod'   => $rule->moduleName,
186                ':rtype' => $rule->ruleType->value,
187                ':stype' => $rule->subjectType->value,
188                ':sid'   => $rule->subjectId,
189                ':ttype' => $rule->targetType->value,
190                ':tid'   => $rule->targetId,
191                ':cc'    => (int) $rule->canCreate,
192                ':cr'    => (int) $rule->canRead,
193                ':ce'    => (int) $rule->canEdit,
194                ':cd'    => (int) $rule->canDelete,
195                ':deny'  => (int) $rule->isDeny,
196                ':desc'  => $rule->description,
197            ]);
198            $id = $rule->id;
199        } else {
200            $stmt = $this->pdo->prepare(
201                self::SQL_INSERT_INTO . self::TABLE_RULES . '` '
202                . '(`module_name`, `rule_type`, `subject_type`, `subject_id`, `target_type`, `target_id`, '
203                . '`can_create`, `can_read`, `can_edit`, `can_delete`, `is_deny`, `description`, `created_by`) '
204                . 'VALUES (:mod, :rtype, :stype, :sid, :ttype, :tid, :cc, :cr, :ce, :cd, :deny, :desc, :by)'
205            );
206            $stmt->execute([
207                ':mod'   => $rule->moduleName,
208                ':rtype' => $rule->ruleType->value,
209                ':stype' => $rule->subjectType->value,
210                ':sid'   => $rule->subjectId,
211                ':ttype' => $rule->targetType->value,
212                ':tid'   => $rule->targetId,
213                ':cc'    => (int) $rule->canCreate,
214                ':cr'    => (int) $rule->canRead,
215                ':ce'    => (int) $rule->canEdit,
216                ':cd'    => (int) $rule->canDelete,
217                ':deny'  => (int) $rule->isDeny,
218                ':desc'  => $rule->description,
219                ':by'    => $rule->createdBy,
220            ]);
221            $id = (int) $this->pdo->lastInsertId();
222        }
223
224        $this->recompileModule($rule->moduleName);
225
226        return $id;
227    }
228
229    /**
230     * {@inheritdoc}
231     */
232    public function deleteRule(int $id): bool
233    {
234        $rule = $this->findRuleById($id);
235        if ($rule === null) {
236            return false;
237        }
238
239        $stmt = $this->pdo->prepare('DELETE FROM `' . self::TABLE_RULES . '` WHERE `id` = :id');
240        $stmt->execute([':id' => $id]);
241
242        $this->recompileModule($rule->moduleName);
243
244        return true;
245    }
246
247    /**
248     * {@inheritdoc}
249     */
250    public function findMatchingRules(string $moduleName, int $actorUserId, array $actorStructureIds = []): array
251    {
252        if ($actorUserId <= 0) {
253            return [];
254        }
255
256        $params = [
257            ':mod' => $moduleName,
258            ':uid' => $actorUserId,
259        ];
260
261        if (!empty($actorStructureIds)) {
262            $placeholders = [];
263            foreach ($actorStructureIds as $idx => $sId) {
264                $p = ':sid_' . $idx;
265                $placeholders[] = $p;
266                $params[$p] = $sId;
267            }
268            $structClause = ' OR (`subject_type` = \'structure\' AND `subject_id` IN ('
269                . implode(', ', $placeholders) . '))';
270        } else {
271            $structClause = '';
272        }
273
274        $sql = self::SQL_SELECT . self::RULE_COLUMNS . self::SQL_FROM . self::TABLE_RULES
275            . '` WHERE `module_name` = :mod AND ('
276            . '`subject_type` = \'all\''
277            . ' OR (`subject_type` = \'user\' AND `subject_id` = :uid)'
278            . $structClause
279            . ') ORDER BY `is_deny` DESC, `id` ASC';
280
281        $stmt = $this->pdo->prepare($sql);
282        $stmt->execute($params);
283
284        $rules = [];
285        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
286            $rules[] = $this->hydrateRule($row);
287        }
288
289        return $rules;
290    }
291
292    /**
293     * {@inheritdoc}
294     */
295    public function canAccessNonCrudModule(string $moduleName, int $actorUserId, array $actorStructureIds = []): bool
296    {
297        $level = $this->getModuleLevel($moduleName);
298        $rules = $this->findMatchingRules($moduleName, $actorUserId, $actorStructureIds);
299
300        if ($level->isPublic()) {
301            return $this->canAccessPublicNonCrudModule($rules);
302        }
303
304        return $this->canAccessPrivateNonCrudModule($rules);
305    }
306
307    /**
308     * Checks access to public non-CRUD module based on deny rules.
309     *
310     * @param list<AccessRule> $rules Matching rules.
311     * @return bool True if accessible.
312     */
313    private function canAccessPublicNonCrudModule(array $rules): bool
314    {
315        foreach ($rules as $rule) {
316            if ($rule->isDeny && ($rule->canRead || !$rule->canCreate)) {
317                return false;
318            }
319        }
320
321        return true;
322    }
323
324    /**
325     * Checks access to private non-CRUD module based on explicit grants.
326     *
327     * @param list<AccessRule> $rules Matching rules.
328     * @return bool True if accessible.
329     */
330    private function canAccessPrivateNonCrudModule(array $rules): bool
331    {
332        $canRead = false;
333        foreach ($rules as $rule) {
334            if ($rule->isDeny && $rule->canRead) {
335                return false;
336            }
337            if (!$rule->isDeny && $rule->canRead) {
338                $canRead = true;
339            }
340        }
341
342        return $canRead;
343    }
344
345    /**
346     * {@inheritdoc}
347     */
348    public function recompileModule(string $moduleName): void
349    {
350        $this->compiler->recompileModule($moduleName, $this->findRulesByModule($moduleName));
351    }
352
353    /**
354     * {@inheritdoc}
355     */
356    public function recompileUser(int $userId): void
357    {
358        $this->compiler->recompileUser($userId, fn (string $m) => $this->findRulesByModule($m));
359    }
360
361    /**
362     * {@inheritdoc}
363     */
364    public function recompileAll(): void
365    {
366        $this->compiler->recompileAll(fn (string $m) => $this->findRulesByModule($m));
367    }
368
369    /**
370     * {@inheritdoc}
371     */
372    public function hasSharedOwnerAccess(
373        string $moduleName,
374        int    $actorUserId,
375        string $ownerType,
376        int    $ownerId,
377        string $action = 'read'
378    ): bool {
379        $col = match ($action) {
380            'edit', 'update' => 'can_edit',
381            'delete'         => 'can_delete',
382            default          => 'can_read',
383        };
384
385        $stmt = $this->pdo->prepare(
386            self::SQL_SELECT . '1' . self::SQL_FROM . self::TABLE_USER_OWNERS . '` '
387            . 'WHERE `user_id` = :uid AND `module_name` = :mod '
388            . 'AND `owner_type` = :ot AND `owner_id` = :oid AND `' . $col . '` = 1 LIMIT 1'
389        );
390        $stmt->execute([
391            ':uid' => $actorUserId,
392            ':mod' => $moduleName,
393            ':ot'  => $ownerType,
394            ':oid' => $ownerId,
395        ]);
396
397        return $stmt->fetchColumn() !== false;
398    }
399
400    /**
401     * Hydrates database associative row into typed AccessRule domain model.
402     *
403     * @param array<string, mixed> $row Database row.
404     * @return AccessRule Hydrated model.
405     */
406    private function hydrateRule(array $row): AccessRule
407    {
408        return new AccessRule(
409            id:          isset($row['id']) ? (int) $row['id'] : null,
410            moduleName:  (string) $row['module_name'],
411            ruleType:    AccessRuleType::tryFrom((string) ($row['rule_type'] ?? '')) ?? AccessRuleType::RECORD_SHARING,
412            subjectType: AccessSubjectType::tryFrom((string) ($row['subject_type'] ?? '')) ?? AccessSubjectType::USER,
413            subjectId:   (int) ($row['subject_id'] ?? 0),
414            targetType:  AccessSubjectType::tryFrom((string) ($row['target_type'] ?? '')) ?? AccessSubjectType::USER,
415            targetId:    (int) ($row['target_id'] ?? 0),
416            canCreate:   (bool) ($row['can_create'] ?? false),
417            canRead:     (bool) ($row['can_read'] ?? true),
418            canEdit:     (bool) ($row['can_edit'] ?? false),
419            canDelete:   (bool) ($row['can_delete'] ?? false),
420            isDeny:      (bool) ($row['is_deny'] ?? false),
421            description: isset($row['description']) ? (string) $row['description'] : null,
422            createdAt:   isset($row['created_at']) ? (string) $row['created_at'] : null,
423            updatedAt:   isset($row['updated_at']) ? (string) $row['updated_at'] : null,
424            createdBy:   isset($row['created_by']) ? (int) $row['created_by'] : null,
425        );
426    }
427}