Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.31% |
172 / 197 |
|
72.22% |
13 / 18 |
CRAP | |
0.00% |
0 / 1 |
| SqlAccessRepository | |
87.24% |
171 / 196 |
|
72.22% |
13 / 18 |
55.19 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getModuleLevel | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| getAllModuleLevels | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| setModuleLevel | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
3.10 | |||
| findRulesByModule | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| countRulesByModule | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| findRuleById | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| saveRule | |
100.00% |
48 / 48 |
|
100.00% |
1 / 1 |
3 | |||
| deleteRule | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| findMatchingRules | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
5 | |||
| canAccessNonCrudModule | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| canAccessPublicNonCrudModule | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
8.12 | |||
| canAccessPrivateNonCrudModule | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
| recompileModule | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| recompileUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| recompileAll | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasSharedOwnerAccess | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
| hydrateRule | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Access\Infrastructure\Repository; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Access\Domain\Model\AccessRule; |
| 12 | use App\Core\Access\Domain\Model\AccessRuleType; |
| 13 | use App\Core\Access\Domain\Model\AccessSubjectType; |
| 14 | use App\Core\Access\Domain\Model\ModuleAccessLevel; |
| 15 | use App\Core\Access\Domain\Repository\AccessRepositoryInterface; |
| 16 | use App\Core\Access\Infrastructure\Compiler\AccessRuleCompiler; |
| 17 | use 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 | */ |
| 26 | final 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 | } |