Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.07% covered (success)
92.07%
151 / 164
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccessRuleCompiler
92.02% covered (success)
92.02%
150 / 163
58.33% covered (warning)
58.33%
7 / 12
49.17
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
 recompileModule
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 resolveUserTableForModule
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 fetchActiveUserIds
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 fetchUserStructures
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 resolveRules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 compileUserDirectOwnership
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 compileSharingRule
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
11
 recompileAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 recompileUser
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 buildInsertSql
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
2.33
 loadRulesForModule
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
7
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\Compiler;
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 PDO;
15use PDOStatement;
16
17/**
18 * Compiles access rules into materialized ownership lookup table.
19 *
20 * Denormalizes hierarchical and direct access permissions for ultra-fast query filtering.
21 *
22 * @package App\Core\Access\Infrastructure\Compiler
23 */
24final readonly class AccessRuleCompiler
25{
26    private const string TABLE_USER_OWNERS = 'a_core_access_user_owners';
27    private const string TABLE_RULES = 'a_core_access_rules';
28    private const string TABLE_USERS_STRUCTURE = 'c_rel_users_structure';
29    private const string SQL_SELECT = 'SELECT ';
30    private const string SQL_FROM = ' FROM `';
31    private const string SQL_INSERT_INTO = 'INSERT INTO `';
32
33    /**
34     * AccessRuleCompiler constructor.
35     *
36     * @param PDO $pdo Database connection.
37     */
38    public function __construct(private PDO $pdo)
39    {
40    }
41
42    /**
43     * Materializes access rules for a specific module into fast-lookup table.
44     *
45     * @param string                $moduleName    Module to recompile.
46     * @param list<AccessRule>|null $rules         Preloaded rules, or null to load from DB.
47     * @param callable|null         $rulesResolver Optional callable (string $mod): list<AccessRule>.
48     */
49    public function recompileModule(
50        string $moduleName,
51        ?array $rules = null,
52        ?callable $rulesResolver = null
53    ): void {
54        $delStmt = $this->pdo->prepare('DELETE FROM `' . self::TABLE_USER_OWNERS . '` WHERE `module_name` = :mod');
55        $delStmt->execute([':mod' => $moduleName]);
56
57        $tableUsers = $this->resolveUserTableForModule($moduleName);
58        $userIds = $this->fetchActiveUserIds($tableUsers);
59        if (empty($userIds)) {
60            return;
61        }
62
63        $userStructures = $this->fetchUserStructures('c_rel_users_structure');
64        $resolvedRules = $this->resolveRules($moduleName, $rules, $rulesResolver);
65        $insertStmt = $this->pdo->prepare($this->buildInsertSql());
66
67        foreach ($userIds as $uidRaw) {
68            $uId = (int) $uidRaw;
69            $sIds = $userStructures[$uId] ?? [];
70
71            $this->compileUserDirectOwnership($insertStmt, $uId, $moduleName, $sIds);
72
73            foreach ($resolvedRules as $rule) {
74                $this->compileSharingRule($insertStmt, $rule, $uId, $moduleName, $sIds);
75            }
76        }
77    }
78
79    private function resolveUserTableForModule(string $moduleName): string
80    {
81        try {
82            $modStmt = $this->pdo->prepare(
83                'SELECT `table_name` FROM `a_core_module_records` WHERE `name` = :m LIMIT 1'
84            );
85            $modStmt->execute([':m' => $moduleName]);
86            $modTable = (string) $modStmt->fetchColumn();
87
88            return str_starts_with($modTable, 'c_') ? 'c_mod_users_records' : 'a_mod_users_records';
89        } catch (\Throwable) {
90            return 'a_mod_users_records';
91        }
92    }
93
94    /**
95     * @return list<int|string>
96     */
97    private function fetchActiveUserIds(string $tableUsers): array
98    {
99        foreach (['status = \'active\'', 'record_status = 1'] as $cond) {
100            try {
101                $usersStmt = $this->pdo->query(
102                    self::SQL_SELECT . '`id`' . self::SQL_FROM . $tableUsers . '` WHERE ' . $cond
103                );
104                if ($usersStmt) {
105                    return $usersStmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
106                }
107            } catch (\Throwable) {
108                // Try next condition
109            }
110        }
111
112        return [];
113    }
114
115    /**
116     * @return array<int, list<int>>
117     */
118    private function fetchUserStructures(string $tableUsersStructure): array
119    {
120        $userStructures = [];
121        foreach ([$tableUsersStructure, 'a_rel_users_structure'] as $strTbl) {
122            try {
123                $structStmt = $this->pdo->query(
124                    self::SQL_SELECT . '`user_id`, `structure_id`' . self::SQL_FROM . $strTbl . '`'
125                );
126                if ($structStmt) {
127                    while ($row = $structStmt->fetch(PDO::FETCH_ASSOC)) {
128                        $uId = (int) $row['user_id'];
129                        $sId = (int) $row['structure_id'];
130                        $userStructures[$uId][] = $sId;
131                    }
132                    break;
133                }
134            } catch (\Throwable) {
135                // Table might not exist, try next
136            }
137        }
138
139        return $userStructures;
140    }
141
142    /**
143     * @param list<AccessRule>|null $rules
144     * @return list<AccessRule>
145     */
146    private function resolveRules(string $moduleName, ?array $rules, ?callable $rulesResolver): array
147    {
148        if ($rules !== null) {
149            return $rules;
150        }
151
152        if ($rulesResolver !== null) {
153            /** @var list<AccessRule> */
154            return $rulesResolver($moduleName);
155        }
156
157        return $this->loadRulesForModule($moduleName);
158    }
159
160    /**
161     * Compiles direct self and structure ownership records for a user.
162     *
163     * @param PDOStatement $insertStmt Prepared insert statement.
164     * @param int          $uId        User ID.
165     * @param string       $moduleName Module name.
166     * @param list<int>    $sIds       User structure IDs.
167     */
168    public function compileUserDirectOwnership(
169        PDOStatement $insertStmt,
170        int $uId,
171        string $moduleName,
172        array $sIds
173    ): void {
174        $insertStmt->execute([
175            ':uid'   => $uId,
176            ':mod'   => $moduleName,
177            ':otype' => 'user',
178            ':oid'   => $uId,
179            ':cr'    => 1,
180            ':ce'    => 1,
181            ':cd'    => 1,
182        ]);
183
184        foreach ($sIds as $sId) {
185            $insertStmt->execute([
186                ':uid'   => $uId,
187                ':mod'   => $moduleName,
188                ':otype' => 'structure',
189                ':oid'   => $sId,
190                ':cr'    => 1,
191                ':ce'    => 1,
192                ':cd'    => 1,
193            ]);
194        }
195    }
196
197    /**
198     * Compiles a single sharing rule assignment if applicable to the user.
199     *
200     * @param PDOStatement $insertStmt Prepared insert statement.
201     * @param AccessRule   $rule       Sharing rule.
202     * @param int          $uId        User ID.
203     * @param string       $moduleName Module name.
204     * @param list<int>    $sIds       User structure IDs.
205     */
206    public function compileSharingRule(
207        PDOStatement $insertStmt,
208        AccessRule $rule,
209        int $uId,
210        string $moduleName,
211        array $sIds
212    ): void {
213        if ($rule->ruleType !== AccessRuleType::RECORD_SHARING || $rule->isDeny) {
214            return;
215        }
216
217        $appliesToUser = ($rule->subjectType === AccessSubjectType::ALL)
218            || ($rule->subjectType === AccessSubjectType::USER && $rule->subjectId === $uId)
219            || ($rule->subjectType === AccessSubjectType::STRUCTURE && in_array($rule->subjectId, $sIds, true));
220
221        if (!$appliesToUser) {
222            return;
223        }
224
225        if ($rule->targetType === AccessSubjectType::USER) {
226            $insertStmt->execute([
227                ':uid'   => $uId,
228                ':mod'   => $moduleName,
229                ':otype' => 'user',
230                ':oid'   => $rule->targetId,
231                ':cr'    => (int) $rule->canRead,
232                ':ce'    => (int) $rule->canEdit,
233                ':cd'    => (int) $rule->canDelete,
234            ]);
235        } elseif ($rule->targetType === AccessSubjectType::STRUCTURE) {
236            $insertStmt->execute([
237                ':uid'   => $uId,
238                ':mod'   => $moduleName,
239                ':otype' => 'structure',
240                ':oid'   => $rule->targetId,
241                ':cr'    => (int) $rule->canRead,
242                ':ce'    => (int) $rule->canEdit,
243                ':cd'    => (int) $rule->canDelete,
244            ]);
245
246            $subUsersStmt = $this->pdo->prepare(
247                'SELECT `user_id` FROM `' . self::TABLE_USERS_STRUCTURE . '` WHERE `structure_id` = :sid'
248            );
249            $subUsersStmt->execute([':sid' => $rule->targetId]);
250            while ($subUid = $subUsersStmt->fetchColumn()) {
251                $insertStmt->execute([
252                    ':uid'   => $uId,
253                    ':mod'   => $moduleName,
254                    ':otype' => 'user',
255                    ':oid'   => (int) $subUid,
256                    ':cr'    => (int) $rule->canRead,
257                    ':ce'    => (int) $rule->canEdit,
258                    ':cd'    => (int) $rule->canDelete,
259                ]);
260            }
261        }
262    }
263
264    /**
265     * Recompiles materialized rules for all active modules.
266     *
267     * @param callable|null $rulesResolver Optional resolver for module rules.
268     */
269    public function recompileAll(?callable $rulesResolver = null): void
270    {
271        $modulesStmt = $this->pdo->query('SELECT `name` FROM `a_core_module_records` WHERE `is_active` = 1');
272        while ($modName = $modulesStmt->fetchColumn()) {
273            if (is_string($modName)) {
274                $this->recompileModule($modName, null, $rulesResolver);
275            }
276        }
277    }
278
279    /**
280     * Recompiles materialized rules for a specific user across all modules.
281     *
282     * @param int           $userId        User ID.
283     * @param callable|null $rulesResolver Optional resolver for module rules.
284     */
285    public function recompileUser(int $userId, ?callable $rulesResolver = null): void
286    {
287        if ($userId <= 0) {
288            return;
289        }
290        $this->recompileAll($rulesResolver);
291    }
292
293    /**
294     * Builds database dialect-specific INSERT ON DUPLICATE/CONFLICT SQL query.
295     *
296     * @return string SQL statement string.
297     */
298    private function buildInsertSql(): string
299    {
300        $isSqlite = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
301        if ($isSqlite) {
302            return self::SQL_INSERT_INTO . self::TABLE_USER_OWNERS . '` '
303                . '(`user_id`, `module_name`, `owner_type`, `owner_id`, `can_read`, `can_edit`, `can_delete`) '
304                . 'VALUES (:uid, :mod, :otype, :oid, :cr, :ce, :cd) '
305                . 'ON CONFLICT(`user_id`, `module_name`, `owner_type`, `owner_id`) DO UPDATE SET '
306                . '`can_read` = MAX(`can_read`, excluded.`can_read`), '
307                . '`can_edit` = MAX(`can_edit`, excluded.`can_edit`), '
308                . '`can_delete` = MAX(`can_delete`, excluded.`can_delete`)';
309        }
310
311        return self::SQL_INSERT_INTO . self::TABLE_USER_OWNERS . '` '
312            . '(`user_id`, `module_name`, `owner_type`, `owner_id`, `can_read`, `can_edit`, `can_delete`) '
313            . 'VALUES (:uid, :mod, :otype, :oid, :cr, :ce, :cd) '
314            . 'ON DUPLICATE KEY UPDATE '
315            . '`can_read` = GREATEST(`can_read`, VALUES(`can_read`)), '
316            . '`can_edit` = GREATEST(`can_edit`, VALUES(`can_edit`)), '
317            . '`can_delete` = GREATEST(`can_delete`, VALUES(`can_delete`))';
318    }
319
320    /**
321     * Fallback to load rules directly from database when not provided.
322     *
323     * @param string $moduleName Module name.
324     * @return list<AccessRule> Loaded rules.
325     */
326    private function loadRulesForModule(string $moduleName): array
327    {
328        $sql = 'SELECT `id`, `module_name`, `rule_type`, `subject_type`, `subject_id`, '
329            . '`target_type`, `target_id`, `can_create`, `can_read`, `can_edit`, `can_delete`, `is_deny`, '
330            . '`description`, `created_at`, `updated_at`, `created_by` '
331            . self::SQL_FROM . self::TABLE_RULES . '` WHERE `module_name` = :mod ORDER BY `id` ASC';
332
333        $stmt = $this->pdo->prepare($sql);
334        $stmt->execute([':mod' => $moduleName]);
335
336        $rules = [];
337        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
338            $rules[] = new AccessRule(
339                id:          isset($row['id']) ? (int) $row['id'] : null,
340                moduleName:  (string) $row['module_name'],
341                ruleType:    AccessRuleType::tryFrom((string) ($row['rule_type'] ?? ''))
342                    ?? AccessRuleType::RECORD_SHARING,
343                subjectType: AccessSubjectType::tryFrom((string) ($row['subject_type'] ?? ''))
344                    ?? AccessSubjectType::USER,
345                subjectId:   (int) ($row['subject_id'] ?? 0),
346                targetType:  AccessSubjectType::tryFrom((string) ($row['target_type'] ?? ''))
347                    ?? AccessSubjectType::USER,
348                targetId:    (int) ($row['target_id'] ?? 0),
349                canCreate:   (bool) ($row['can_create'] ?? false),
350                canRead:     (bool) ($row['can_read'] ?? true),
351                canEdit:     (bool) ($row['can_edit'] ?? false),
352                canDelete:   (bool) ($row['can_delete'] ?? false),
353                isDeny:      (bool) ($row['is_deny'] ?? false),
354                description: isset($row['description']) ? (string) $row['description'] : null,
355                createdAt:   isset($row['created_at']) ? (string) $row['created_at'] : null,
356                updatedAt:   isset($row['updated_at']) ? (string) $row['updated_at'] : null,
357                createdBy:   isset($row['created_by']) ? (int) $row['created_by'] : null,
358            );
359        }
360
361        return $rules;
362    }
363}