Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.93% covered (warning)
75.93%
82 / 108
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PermissionGuard
76.64% covered (warning)
76.64%
82 / 107
36.36% covered (danger)
36.36%
4 / 11
88.83
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
 assertReadAccess
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
11.31
 assertWriteAccess
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
8.22
 isPublicLevelPermitted
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 isRecordStatusPermitted
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 assertRecordOwnershipOrSharedAccess
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 hasRecordOwnership
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 assertDeleteAccess
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 hasSharedRecordAccess
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 shouldApplyOwnerScope
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 canPerformModuleAction
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
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\Access\Domain\Model\ModuleAccessLevel;
12use App\Core\Access\Domain\Repository\AccessRepositoryInterface;
13use App\Core\Engine\Domain\Exception\PermissionDeniedException;
14use App\Core\Engine\Domain\Model\ModuleMetadata;
15use App\Core\Engine\Domain\Model\PermissionContext;
16use App\Core\Engine\Domain\Model\RecordStatus;
17use App\Modules\Profiles\Domain\Repository\ProfilePermissionRepositoryInterface;
18
19/**
20 * Permission Guard.
21 *
22 * Central access control point for all engine operations.
23 * Enforces dynamic multi-tier permission matrix and explicit sharing rules for CRUD and non-CRUD modules.
24 *
25 * @package App\Core\Engine\Application\Security
26 */
27final readonly class PermissionGuard
28{
29    /**
30     * PermissionGuard constructor.
31     *
32     * @param AccessRepositoryInterface|null            $accessRepo  Optional access control repository.
33     * @param ProfilePermissionRepositoryInterface|null $profileRepo Optional profile permission repository.
34     */
35    public function __construct(
36        private ?AccessRepositoryInterface            $accessRepo = null,
37        private ?ProfilePermissionRepositoryInterface $profileRepo = null,
38    ) {
39    }
40
41    /**
42     * Asserts that the actor has access to perform a list/read operation.
43     *
44     * @param ModuleMetadata    $module  The module being accessed.
45     * @param PermissionContext $context The actor permission context.
46     * @throws PermissionDeniedException If actor lacks required access.
47     */
48    public function assertReadAccess(ModuleMetadata $module, PermissionContext $context): void
49    {
50        if (!$context->isAuthenticated()) {
51            throw PermissionDeniedException::superuserRequired($module->name);
52        }
53
54        if ($context->isSuperuser) {
55            return;
56        }
57
58        if (
59            $this->profileRepo !== null
60            && $context->actorProfileId !== null
61            && !$this->profileRepo->canViewModule($context->actorProfileId, $module->name)
62        ) {
63            throw PermissionDeniedException::superuserRequired($module->name);
64        }
65
66        if ($this->accessRepo !== null) {
67            $level = $this->accessRepo->getModuleLevel($module->name);
68            if ($level->isPublic()) {
69                return;
70            }
71
72            if (!$module->hasOwnerScope()) {
73                $canAccess = $this->accessRepo->canAccessNonCrudModule(
74                    $module->name,
75                    $context->actorUserId,
76                    $context->actorStructureIds
77                );
78                if (!$canAccess) {
79                    throw PermissionDeniedException::superuserRequired($module->name);
80                }
81            }
82        } elseif ($module->requiresSuperuserAccess()) {
83            throw PermissionDeniedException::superuserRequired($module->name);
84        }
85    }
86
87    /**
88     * Asserts that the actor has access to perform a write operation (create/update/delete).
89     *
90     * @param ModuleMetadata    $module       The module being accessed.
91     * @param PermissionContext $context      The actor permission context.
92     * @param int|null          $ownerId      The owner_id of the existing record (for update/delete).
93     * @param array<int, int>   $coOwners     Array of co-owner IDs (users or structures).
94     * @param string            $ownerType    Owner entity type ('user' or 'structure').
95     * @param string            $action       Target action ('create', 'update', 'delete').
96     * @param int|null          $recordStatus Record special access level (0..4).
97     * @throws PermissionDeniedException If actor lacks required write access.
98     */
99    public function assertWriteAccess(
100        ModuleMetadata    $module,
101        PermissionContext $context,
102        ?int              $ownerId = null,
103        array|string|null $coOwners = [],
104        string            $ownerType = 'user',
105        string            $action = 'update',
106        ?int              $recordStatus = null
107    ): void {
108        $this->assertReadAccess($module, $context);
109
110        if (!$module->isWritable()) {
111            throw PermissionDeniedException::superuserRequired($module->name);
112        }
113
114        if ($context->isSuperuser) {
115            return;
116        }
117
118        if (
119            $this->profileRepo !== null
120            && $context->actorProfileId !== null
121            && !$this->canPerformModuleAction($context->actorProfileId, $module->name, $action)
122        ) {
123            throw PermissionDeniedException::superuserRequired($module->name);
124        }
125
126        if ($this->isPublicLevelPermitted($module->name, $action, $recordStatus)
127            || $this->isRecordStatusPermitted($action, $recordStatus)
128        ) {
129            return;
130        }
131
132        $this->assertRecordOwnershipOrSharedAccess(
133            $module,
134            $context,
135            $ownerId,
136            $ownerType,
137            $coOwners,
138            $action
139        );
140    }
141
142    private function isPublicLevelPermitted(string $moduleName, string $action, ?int $recordStatus): bool
143    {
144        if ($recordStatus === RecordStatus::HIDDEN) {
145            return false;
146        }
147        $level = $this->accessRepo?->getModuleLevel($moduleName) ?? ModuleAccessLevel::PRIVATE;
148        return ($level === ModuleAccessLevel::PUBLIC_DELETE)
149            || ($level === ModuleAccessLevel::PUBLIC_EDIT && $action !== 'delete');
150    }
151
152    private function isRecordStatusPermitted(string $action, ?int $recordStatus): bool
153    {
154        if ($recordStatus === null) {
155            return false;
156        }
157        return ($action === 'delete')
158            ? $recordStatus === RecordStatus::ALL_DELETE
159            : ($recordStatus === RecordStatus::ALL_WRITE || $recordStatus === RecordStatus::ALL_DELETE);
160    }
161
162    private function assertRecordOwnershipOrSharedAccess(
163        ModuleMetadata $module,
164        PermissionContext $context,
165        ?int $ownerId,
166        string $ownerType,
167        array|string|null $coOwners,
168        string $action
169    ): void {
170        if ($ownerId === null || !$module->hasOwnerScope()) {
171            return;
172        }
173        $coOwnersList = is_array($coOwners) ? $coOwners : (array) json_decode((string) $coOwners, true);
174        $coOwnerIds   = array_map('intval', $coOwnersList);
175
176        if ($this->hasRecordOwnership($context, $ownerId, $ownerType, $coOwnerIds)) {
177            return;
178        }
179
180        $requiredAction = ($action === 'delete') ? 'delete' : 'edit';
181        if ($this->accessRepo === null
182            || !$this->accessRepo->hasSharedOwnerAccess(
183                $module->name,
184                $context->actorUserId,
185                $ownerType,
186                $ownerId,
187                $requiredAction
188            )
189        ) {
190            throw PermissionDeniedException::ownerRequired($module->name, $ownerId);
191        }
192    }
193
194    /**
195     * Determines if the actor has direct ownership or co-ownership of a record.
196     *
197     * @param PermissionContext $context    Actor context.
198     * @param int               $ownerId    Owner identifier.
199     * @param string            $ownerType  Owner type ('user' or 'structure').
200     * @param list<int>         $coOwnerIds Co-owner identifiers.
201     * @return bool True if actor owns or co-owns the record.
202     */
203    private function hasRecordOwnership(
204        PermissionContext $context,
205        int               $ownerId,
206        string            $ownerType,
207        array             $coOwnerIds
208    ): bool {
209        $hasDirect = ($ownerType === 'structure')
210            ? in_array($ownerId, $context->actorStructureIds, true)
211            : ($ownerId === $context->actorUserId);
212
213        if ($hasDirect || in_array($context->actorUserId, $coOwnerIds, true)) {
214            return true;
215        }
216
217        return !empty($context->actorStructureIds)
218            && !empty(array_intersect($context->actorStructureIds, $coOwnerIds));
219    }
220
221    /**
222     * Asserts that the actor possesses deletion access to the specified module.
223     *
224     * @param ModuleMetadata    $module       Target module metadata.
225     * @param PermissionContext $context      Security context.
226     * @param int|null          $ownerId      Owner identifier.
227     * @param array|string      $coOwners     Co-owner identifiers.
228     * @param string            $ownerType    Owner type ('user' or 'structure').
229     * @param int|null          $recordStatus Special access level.
230     * @throws PermissionDeniedException If actor lacks delete permissions.
231     */
232    public function assertDeleteAccess(
233        ModuleMetadata    $module,
234        PermissionContext $context,
235        ?int              $ownerId = null,
236        array|string|null $coOwners = [],
237        string            $ownerType = 'user',
238        ?int              $recordStatus = null
239    ): void {
240        $this->assertWriteAccess(
241            $module,
242            $context,
243            $ownerId,
244            $coOwners,
245            $ownerType,
246            'delete',
247            $recordStatus
248        );
249    }
250
251    /**
252     * Checks if the actor has shared record access granted via sharing rules.
253     *
254     * @param string            $moduleName Target module name.
255     * @param PermissionContext $context    Actor security context.
256     * @param string            $ownerType  Owner type ('user' or 'structure').
257     * @param int               $ownerId    Owner identifier.
258     * @param string            $action     Action being evaluated ('read', 'edit', 'delete').
259     * @return bool True if actor has compiled shared access.
260     */
261    public function hasSharedRecordAccess(
262        string            $moduleName,
263        PermissionContext $context,
264        string            $ownerType,
265        int               $ownerId,
266        string            $action = 'read'
267    ): bool {
268        if ($context->isSuperuser) {
269            return true;
270        }
271
272        if ($this->accessRepo === null) {
273            return false;
274        }
275
276        return $this->accessRepo->hasSharedOwnerAccess(
277            $moduleName,
278            $context->actorUserId,
279            $ownerType,
280            $ownerId,
281            $action
282        );
283    }
284
285    /**
286     * Determines whether owner scope filtering should be applied to list queries.
287     *
288     * Returns true when the module uses owner scoping, actor is not superuser, and module is PRIVATE.
289     *
290     * @param ModuleMetadata    $module  The module being queried.
291     * @param PermissionContext $context The actor permission context.
292     * @return bool True if owner scope filtering should be applied.
293     */
294    public function shouldApplyOwnerScope(ModuleMetadata $module, PermissionContext $context): bool
295    {
296        if (!$module->hasOwnerScope() || $context->isSuperuser) {
297            return false;
298        }
299
300        if ($this->accessRepo !== null) {
301            $level = $this->accessRepo->getModuleLevel($module->name);
302            return $level === ModuleAccessLevel::PRIVATE;
303        }
304
305        return true;
306    }
307
308    /**
309     * Checks if profile permissions permit action in target module.
310     *
311     * @param int    $profileId  Profile ID.
312     * @param string $moduleName Module name.
313     * @param string $action     Action type.
314     * @return bool True if permitted.
315     */
316    private function canPerformModuleAction(int $profileId, string $moduleName, string $action): bool
317    {
318        return match ($action) {
319            'create' => $this->profileRepo?->canCreateInModule($profileId, $moduleName) ?? false,
320            'delete' => $this->profileRepo?->canDeleteInModule($profileId, $moduleName) ?? false,
321            default  => $this->profileRepo?->canEditInModule($profileId, $moduleName) ?? false,
322        };
323    }
324}