Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
318 / 318
100.00% covered (success)
100.00%
50 / 50
CRAP
100.00% covered (success)
100.00%
1 / 1
Acl
100.00% covered (success)
100.00%
318 / 318
100.00% covered (success)
100.00%
50 / 50
197
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 getRole
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRole
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addRole
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 addRoles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeRole
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 getResource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasResource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addResource
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getResources
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasResources
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addResources
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeResource
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 setStrict
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isStrict
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMultiStrict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isMultiStrict
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setParentStrict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isParentStrict
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeAllowRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deny
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRule
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
11
 removeDenyRule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeRule
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
14
 isAllowed
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
25
 isAllowedMulti
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 isAllowedMultiStrict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isDenied
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveDenied
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
17
 isDeniedMulti
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 isDeniedMultiStrict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedPermissions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDeniedPermissions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEffectivePermissions
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 createAssertion
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 deleteAssertion
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 deleteRuleAssertions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 hasAssertionKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAssertionKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 addPolicy
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 hasPolicies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 evaluatePolicies
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
19
 evaluatePolicy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 verifyRole
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 verifyResource
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 generateAssertionKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 traverseChildren
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Acl;
16
17use Pop\Acl\Assertion\AssertionInterface;
18use InvalidArgumentException;
19
20/**
21 * ACL class
22 *
23 * @category   Pop
24 * @package    Pop\Acl
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    5.0.0
29 */
30class Acl
31{
32
33    /**
34     * Array of roles
35     * @var array
36     */
37    protected array $roles = [];
38
39    /**
40     * Array of resources
41     * @var array
42     */
43    protected array $resources = [];
44
45    /**
46     * Array of allowed roles, resources and permissions
47     * @var array
48     */
49    protected array $allowed = [];
50
51    /**
52     * Array of denied roles, resources and permissions
53     * @var array
54     */
55    protected array $denied = [];
56
57    /**
58     * Array of assertions
59     * @var array
60     */
61    protected array $assertions = [
62        'allowed' => [],
63        'denied'  => []
64    ];
65
66    /**
67     * Array of policies
68     * @var array
69     */
70    protected array $policies = [];
71
72    /**
73     * Strict flag
74     * @var bool
75     */
76    protected bool $strict = false;
77
78    /**
79     * Multi strict flag
80     * @var bool
81     */
82    protected bool $multiStrict = false;
83
84    /**
85     * Parent strict flag
86     * @var bool
87     */
88    protected bool $parentStrict = false;
89
90    /**
91     * Constructor
92     *
93     * Instantiate the ACL object
94     */
95    public function __construct()
96    {
97        $args = func_get_args();
98
99        foreach ($args as $arg) {
100            if (is_array($arg)) {
101                foreach ($arg as $a) {
102                    if ($a instanceof AclRole) {
103                        $this->addRole($a);
104                    } else if ($a instanceof AclResource) {
105                        $this->addResource($a);
106                    }
107                }
108            } else if ($arg instanceof AclRole) {
109                $this->addRole($arg);
110            } else if ($arg instanceof AclResource) {
111                $this->addResource($arg);
112            }
113        }
114    }
115
116    /**
117     * Get a role
118     *
119     * @param  string $role
120     * @return AclRole|null
121     */
122    public function getRole(string $role): AclRole|null
123    {
124        return $this->roles[$role] ?? null;
125    }
126
127    /**
128     * Get roles
129     *
130     * @return array
131     */
132    public function getRoles(): array
133    {
134        return $this->roles;
135    }
136
137    /**
138     * Has roles
139     *
140     * @return bool
141     */
142    public function hasRoles(): bool
143    {
144        return !empty($this->roles);
145    }
146
147    /**
148     * See if a role has been added
149     *
150     * @param  string $role
151     * @return bool
152     */
153    public function hasRole(string $role): bool
154    {
155        return (isset($this->roles[$role]));
156    }
157
158    /**
159     * Add a role
160     *
161     * @param  AclRole $role
162     * @return Acl
163     */
164    public function addRole(AclRole $role): Acl
165    {
166        if (!isset($this->roles[$role->getName()])) {
167            $this->roles[$role->getName()] = $role;
168
169            // Traverse up if role has parents
170            while ($role->hasParent()) {
171                $role = $role->getParent();
172                $this->roles[$role->getName()] = $role;
173            }
174
175            // Traverse down if the role has children
176            if ($role->hasChildren()) {
177                $this->traverseChildren($role->getChildren());
178            }
179        }
180        return $this;
181    }
182
183    /**
184     * Add roles
185     *
186     * @param  array $roles
187     * @return Acl
188     */
189    public function addRoles(array $roles): Acl
190    {
191        foreach ($roles as $role) {
192            $this->addRole($role);
193        }
194
195        return $this;
196    }
197
198    /**
199     * Remove a role
200     *
201     * Children are reparented onto the removed role's own parent (or become
202     * root roles if it had none). All allow/deny rules, assertions and
203     * policies referencing this role are purged.
204     *
205     * @param  mixed $role
206     * @throws Exception
207     * @return Acl
208     */
209    public function removeRole(mixed $role): Acl
210    {
211        $this->verifyRole($role);
212
213        $roleObj  = $this->roles[(string)$role];
214        $roleName = (string)$roleObj;
215        $parent   = $roleObj->getParent();
216
217        foreach ($roleObj->getChildren() as $child) {
218            if ($parent !== null) {
219                $child->setParent($parent);
220            } else {
221                $child->clearParent();
222            }
223        }
224
225        if ($parent !== null) {
226            $parent->removeChild($roleObj);
227        }
228
229        foreach (['allowed', 'denied'] as $type) {
230            if (isset($this->{$type}[$roleName])) {
231                foreach ($this->{$type}[$roleName] as $resourceName => $permissions) {
232                    $this->deleteRuleAssertions($type, $roleName, $resourceName, $permissions);
233                }
234                unset($this->{$type}[$roleName]);
235            }
236        }
237
238        $this->policies = array_values(array_filter(
239            $this->policies,
240            fn($policy) => (string)$policy['role'] !== $roleName
241        ));
242
243        unset($this->roles[$roleName]);
244
245        return $this;
246    }
247
248    /**
249     * Get a resource
250     *
251     * @param  string $resource
252     * @return AclResource|null
253     */
254    public function getResource(string $resource): AclResource|null
255    {
256        return $this->resources[$resource] ?? null;
257    }
258
259    /**
260     * See if a resource has been added
261     *
262     * @param  string $resource
263     * @return bool
264     */
265    public function hasResource(string $resource): bool
266    {
267        return (isset($this->resources[$resource]));
268    }
269
270    /**
271     * Add a resource
272     *
273     * @param AclResource $resource
274     * @return Acl
275     */
276    public function addResource(AclResource $resource): Acl
277    {
278        $this->resources[$resource->getName()] = $resource;
279        return $this;
280    }
281
282    /**
283     * Get resources
284     *
285     * @return array
286     */
287    public function getResources(): array
288    {
289        return $this->resources;
290    }
291
292    /**
293     * Has resources
294     *
295     * @return bool
296     */
297    public function hasResources(): bool
298    {
299        return !empty($this->resources);
300    }
301
302    /**
303     * Add resources
304     *
305     * @param  array $resources
306     * @return Acl
307     */
308    public function addResources(array $resources): Acl
309    {
310        foreach ($resources as $resource) {
311            $this->addResource($resource);
312        }
313
314        return $this;
315    }
316
317    /**
318     * Remove a resource
319     *
320     * All allow/deny rules, assertions and policies referencing this
321     * resource (across every role) are purged.
322     *
323     * @param  mixed $resource
324     * @throws Exception
325     * @return Acl
326     */
327    public function removeResource(mixed $resource): Acl
328    {
329        $this->verifyResource($resource);
330
331        $resourceObj  = $this->resources[(string)$resource];
332        $resourceName = (string)$resourceObj;
333
334        foreach (['allowed', 'denied'] as $type) {
335            foreach ($this->{$type} as $roleName => $resources) {
336                if (isset($resources[$resourceName])) {
337                    $this->deleteRuleAssertions($type, $roleName, $resourceName, $resources[$resourceName]);
338                    unset($this->{$type}[$roleName][$resourceName]);
339                    // Remove the role entry if it has no more resources
340                    if (count($this->{$type}[$roleName]) === 0) {
341                        unset($this->{$type}[$roleName]);
342                    }
343                }
344            }
345        }
346
347        $this->policies = array_values(array_filter(
348            $this->policies,
349            fn($policy) => ($policy['resource'] === null) || ((string)$policy['resource'] !== $resourceName)
350        ));
351
352        unset($this->resources[$resourceName]);
353
354        return $this;
355    }
356
357    /**
358     * Set strict
359     *
360     * @param  bool      $strict
361     * @param  bool|null $multiStrict
362     * @return Acl
363     */
364    public function setStrict(bool $strict = true, bool|null $multiStrict = null): Acl
365    {
366        $this->strict = $strict;
367        if ($multiStrict !== null) {
368            $this->multiStrict = $multiStrict;
369        }
370        return $this;
371    }
372
373    /**
374     * See if ACL object is set to strict
375     *
376     * @return bool
377     */
378    public function isStrict(): bool
379    {
380        return $this->strict;
381    }
382
383    /**
384     * Set multi strict
385     *
386     * @param  bool $multiStrict
387     * @return Acl
388     */
389    public function setMultiStrict(bool $multiStrict = true): Acl
390    {
391        $this->multiStrict = $multiStrict;
392        return $this;
393    }
394
395    /**
396     * See if ACL object is set to multi strict
397     *
398     * @return bool
399     */
400    public function isMultiStrict(): bool
401    {
402        return $this->multiStrict;
403    }
404
405    /**
406     * Set parent strict
407     *
408     * @param  bool $parentStrict
409     * @return Acl
410     */
411    public function setParentStrict(bool $parentStrict = true): Acl
412    {
413        $this->parentStrict = $parentStrict;
414        return $this;
415    }
416
417    /**
418     * See if ACL object is set to parent strict
419     *
420     * @return bool
421     */
422    public function isParentStrict(): bool
423    {
424        return $this->parentStrict;
425    }
426
427    /**
428     * Allow a role permission to a resource or resources
429     *
430     * @param  mixed               $role
431     * @param  mixed               $resource
432     * @param  mixed               $permission
433     * @param  ?AssertionInterface $assertion
434     * @throws Exception
435     * @return Acl
436     */
437    public function allow(
438        mixed $role, mixed $resource = null, mixed $permission = null, ?AssertionInterface $assertion = null
439    ): Acl
440    {
441        return $this->setRule('allowed', $role, $resource, $permission, $assertion);
442    }
443
444    /**
445     * Remove an allow rule
446     *
447     * @param  mixed $role
448     * @param  mixed $resource
449     * @param  mixed $permission
450     * @throws Exception
451     * @return Acl
452     */
453    public function removeAllowRule(mixed $role, mixed $resource = null, mixed $permission = null): Acl
454    {
455        return $this->removeRule('allowed', $role, $resource, $permission);
456    }
457
458    /**
459     * Deny a role permission to a resource or resources
460     *
461     * @param  mixed               $role
462     * @param  mixed               $resource
463     * @param  mixed               $permission
464     * @param  ?AssertionInterface $assertion
465     * @throws Exception
466     * @return Acl
467     */
468    public function deny(
469        mixed $role, mixed $resource = null, mixed $permission = null, ?AssertionInterface $assertion = null
470    ): Acl
471    {
472        return $this->setRule('denied', $role, $resource, $permission, $assertion);
473    }
474
475    /**
476     * Shared implementation for allow()/deny()
477     *
478     * @param  string              $type
479     * @param  mixed               $role
480     * @param  mixed               $resource
481     * @param  mixed               $permission
482     * @param  ?AssertionInterface $assertion
483     * @throws Exception
484     * @return Acl
485     */
486    protected function setRule(
487        string $type, mixed $role, mixed $resource = null, mixed $permission = null, ?AssertionInterface $assertion = null
488    ): Acl
489    {
490        if ($this->verifyRole($role)) {
491            $role = $this->roles[(string)$role];
492
493            if (!isset($this->{$type}[(string)$role])) {
494                $this->{$type}[(string)$role] = [];
495            }
496
497            if (($resource !== null) && ($this->verifyResource($resource))) {
498                $resource = $this->resources[(string)$resource];
499
500                if (!isset($this->{$type}[(string)$role][(string)$resource])) {
501                    $this->{$type}[(string)$role][(string)$resource] = [];
502                }
503                if ($permission !== null) {
504                    if (!is_array($permission)) {
505                        $permission = [$permission];
506                    }
507                    foreach ($permission as $perm) {
508                        $this->{$type}[(string)$role][(string)$resource][] = $perm;
509                        if ($assertion !== null) {
510                            $this->createAssertion($assertion, $type, $role, $resource, $perm);
511                        }
512                    }
513                } else {
514                    if ($assertion !== null) {
515                        $this->createAssertion($assertion, $type, $role, $resource);
516                    }
517                }
518            }
519        }
520
521        return $this;
522    }
523
524    /**
525     * Remove a deny rule
526     *
527     * @param  mixed $role
528     * @param  mixed $resource
529     * @param  mixed $permission
530     * @throws Exception
531     * @return Acl
532     */
533    public function removeDenyRule(mixed $role, mixed $resource = null, mixed $permission = null): Acl
534    {
535        return $this->removeRule('denied', $role, $resource, $permission);
536    }
537
538    /**
539     * Shared implementation for removeAllowRule()/removeDenyRule()
540     *
541     * @param  string $type
542     * @param  mixed  $role
543     * @param  mixed  $resource
544     * @param  mixed  $permission
545     * @throws Exception
546     * @return Acl
547     */
548    protected function removeRule(string $type, mixed $role, mixed $resource = null, mixed $permission = null): Acl
549    {
550        if (($this->verifyRole($role)) && isset($this->{$type}[(string)$role])) {
551            // If only role passed
552            if (($resource === null) && ($permission === null)) {
553                unset($this->{$type}[(string)$role]);
554                $this->deleteAssertion($type, $role);
555            // If role & resource passed
556            } else if (($resource !== null) && ($permission === null) && ($this->verifyResource($resource)) &&
557                isset($this->{$type}[(string)$role][(string)$resource])) {
558                unset($this->{$type}[(string)$role][(string)$resource]);
559                $this->deleteAssertion($type, $role, $resource);
560            // If role, resource & permission passed
561            } else {
562                if (!is_array($permission)) {
563                    $permission = [$permission];
564                }
565                foreach ($permission as $perm) {
566                    if (($this->verifyResource($resource)) && isset($this->{$type}[(string)$role][(string)$resource]) &&
567                        in_array($perm, $this->{$type}[(string)$role][(string)$resource])) {
568                        $key = array_search($perm, $this->{$type}[(string)$role][(string)$resource]);
569                        unset($this->{$type}[(string)$role][(string)$resource][$key]);
570                    }
571                    $this->deleteAssertion($type, $role, $resource, $perm);
572                }
573            }
574        }
575
576        return $this;
577    }
578
579    /**
580     * Determine if the role is allowed
581     *
582     * @param  mixed $role
583     * @param  mixed $resource
584     * @param  mixed $permission
585     * @throws Exception
586     * @return bool
587     */
588    public function isAllowed(mixed $role, mixed $resource = null, mixed $permission = null): bool
589    {
590        $result   = false;
591        $isParent = false;
592
593        // Evaluated once and shared with the internal deny check below,
594        // instead of letting isDenied() re-run evaluatePolicies() itself.
595        $policyResult = $this->hasPolicies() ? $this->evaluatePolicies($role, $resource, $permission) : null;
596
597        if ($this->verifyRole($role)) {
598            if ($resource !== null) {
599                $this->verifyResource($resource);
600            }
601
602            // If is not denied
603            if (!$this->resolveDenied($role, $resource, $permission, $policyResult)) {
604                // If not strict, pass
605                if ((!$this->strict) && (!$this->multiStrict)) {
606                    $result = true;
607                // If strict, check for explicit allow rule
608                } else {
609                    $roleToCheck = $this->roles[(string)$role];
610                    while ($roleToCheck !== null) {
611                        if (isset($this->allowed[(string)$roleToCheck])) {
612                            // No explicit resources or permissions
613                            if (count($this->allowed[(string)$roleToCheck]) == 0) {
614                                $result = true;
615                            // Resource set, but no explicit permissions
616                            } else if (($resource !== null) && isset($this->allowed[(string)$roleToCheck][(string)$resource]) &&
617                                (count($this->allowed[(string)$roleToCheck][(string)$resource]) == 0)) {
618                                $result = true;
619                            // Else, has resource and permissions set
620                            } else if (($resource !== null) && ($permission !== null) &&
621                                isset($this->allowed[(string)$roleToCheck][(string)$resource]) &&
622                                (count($this->allowed[(string)$roleToCheck][(string)$resource]) > 0)) {
623                                $permissionsToCheck = (!is_array($permission)) ? [$permission] : $permission;
624                                $allowedPermissions = $this->allowed[(string)$roleToCheck][(string)$resource];
625                                $permissions        = array_intersect($permissionsToCheck, $allowedPermissions);
626
627                                $result = ((($isParent) && (!$this->parentStrict)) ||
628                                    (count($permissions) == count($permissionsToCheck)) ||
629                                    in_array('*', $allowedPermissions, true));
630                            }
631                        }
632
633                        // Traverse up through the parent roles
634                        $roleToCheck = $roleToCheck->getParent();
635                        $isParent    = true;
636                    }
637                }
638            }
639        }
640
641        // Check for assertions
642        if ($result) {
643            $assertionKey = $this->getAssertionKey('allowed', $role, $resource, $permission);
644            if ($assertionKey !== null) {
645                $assertionRole     = $this->roles[(string)$role];
646                $assertionResource = ($resource !== null) ?  $this->resources[(string)$resource] : null;
647                $result            =
648                    $this->assertions['allowed'][$assertionKey]->assert($this, $assertionRole, $assertionResource, $permission);
649            }
650        }
651
652        // Check for policies
653        if ($policyResult !== null) {
654            $result = $policyResult;
655        }
656
657        return $result;
658    }
659
660    /**
661     * Determine if multiple roles are allowed
662     *
663     * @param  array $roles
664     * @param  mixed $resource
665     * @param  mixed $permission
666     * @throws Exception
667     * @return bool
668     */
669    public function isAllowedMulti(array $roles, mixed $resource = null, mixed $permission = null): bool
670    {
671        // If strict, all roles must be allowed
672        if ($this->multiStrict) {
673            $result = true;
674            foreach ($roles as $role) {
675                if (!$this->isAllowed($role, $resource, $permission)) {
676                    $result = false;
677                    break;
678                }
679            }
680        // Else, evaluate loosely
681        } else {
682            // Check for any explicitly set denied rules for any of the roles
683            foreach ($roles as $role) {
684                if ($this->isDenied($role, $resource, $permission)) {
685                    return false;
686                }
687            }
688
689            // Else, evaluate if any of the roles are allowed
690            $result = false;
691            foreach ($roles as $role) {
692                if ($this->isAllowed($role, $resource, $permission)) {
693                    $result = true;
694                    break;
695                }
696            }
697        }
698
699        return $result;
700    }
701
702    /**
703     * Determine if multiple roles are allowed using the strict parameter
704     * All of the roles must be allowed to return true, otherwise it will return false
705     *
706     * @param  array $roles
707     * @param  mixed $resource
708     * @param  mixed $permission
709     * @throws Exception
710     * @return bool
711     */
712    public function isAllowedMultiStrict(array $roles, mixed $resource = null, mixed $permission = null): bool
713    {
714        $this->multiStrict = true;
715        return $this->isAllowedMulti($roles, $resource, $permission);
716    }
717
718    /**
719     * Determine if a role is denied
720     *
721     * @param  mixed $role
722     * @param  mixed $resource
723     * @param  mixed $permission
724     * @throws Exception
725     * @return bool
726     */
727    public function isDenied(mixed $role, mixed $resource = null, mixed $permission = null): bool
728    {
729        $policyResult = $this->hasPolicies() ? $this->evaluatePolicies($role, $resource, $permission) : null;
730
731        return $this->resolveDenied($role, $resource, $permission, $policyResult);
732    }
733
734    /**
735     * Shared deny evaluation used by isDenied() and, internally, isAllowed()
736     * (which passes in an already-evaluated policy result to avoid evaluating
737     * policies twice per isAllowed() call).
738     *
739     * @param  mixed     $role
740     * @param  mixed     $resource
741     * @param  mixed     $permission
742     * @param  bool|null $policyResult
743     * @throws Exception
744     * @return bool
745     */
746    protected function resolveDenied(mixed $role, mixed $resource, mixed $permission, bool|null $policyResult): bool
747    {
748        $result = false;
749
750        if ($this->verifyRole($role)) {
751            if ($resource !== null) {
752                $this->verifyResource($resource);
753            }
754
755            // Check if the user, resource and/or permission is denied
756            $roleToCheck = $this->roles[(string)$role];
757            while ($roleToCheck !== null) {
758                if (isset($this->denied[(string)$roleToCheck])) {
759                    if (count($this->denied[(string)$roleToCheck]) > 0) {
760                        if (($resource !== null) && array_key_exists((string)$resource, $this->denied[(string)$roleToCheck])) {
761                            if (count($this->denied[(string)$roleToCheck][(string)$resource]) > 0) {
762                                if ($permission !== null) {
763                                    $deniedPermissions = $this->denied[(string)$roleToCheck][(string)$resource];
764                                    if (in_array('*', $deniedPermissions, true)) {
765                                        $result = true;
766                                    } else {
767                                        $permissions = (!is_array($permission)) ? [$permission] : $permission;
768                                        foreach ($permissions as $p) {
769                                            if (in_array($p, $deniedPermissions)) {
770                                                $result = true;
771                                            }
772                                        }
773                                    }
774                                }
775                            } else {
776                                $result = true;
777                            }
778                        }
779                    } else {
780                        $result = true;
781                    }
782                }
783                $roleToCheck = $roleToCheck->getParent();
784            }
785        }
786
787        // Check for assertions
788        $assertionKey = $this->getAssertionKey('denied', $role, $resource, $permission);
789        if ($assertionKey !== null) {
790            $assertionRole     = $this->roles[(string)$role];
791            $assertionResource = ($resource !== null) ?  $this->resources[(string)$resource] : null;
792            $result            =
793                $this->assertions['denied'][$assertionKey]->assert($this, $assertionRole, $assertionResource, $permission);
794        }
795
796        // Check for policies
797        if ($policyResult !== null) {
798            $result = !$policyResult;
799        }
800
801        return $result;
802    }
803
804    /**
805     * Determine if multiple roles are denied
806     *
807     * @param  array $roles
808     * @param  mixed $resource
809     * @param  mixed $permission
810     * @throws Exception
811     * @return bool
812     */
813    public function isDeniedMulti(array $roles, mixed $resource = null, mixed $permission = null): bool
814    {
815        // If strict, all roles must be denied
816        if ($this->multiStrict) {
817            $result = true;
818            foreach ($roles as $role) {
819                if (!$this->isDenied($role, $resource, $permission)) {
820                    $result = false;
821                    break;
822                }
823            }
824        // Else, evaluate loosely
825        } else {
826            // Else, evaluate if any of the roles are denied
827            $result = false;
828            foreach ($roles as $role) {
829                if ($this->isDenied($role, $resource, $permission)) {
830                    $result = true;
831                    break;
832                }
833            }
834        }
835
836        return $result;
837    }
838
839    /**
840     * Determine if multiple roles are denied using the strict parameter
841     *  All of the roles must be denied to return true, otherwise it will return false
842     *
843     * @param  array $roles
844     * @param  mixed $resource
845     * @param  mixed $permission
846     * @return bool
847     *@throws Exception
848     */
849    public function isDeniedMultiStrict(array $roles, mixed $resource = null, mixed $permission = null): bool
850    {
851        $this->multiStrict = true;
852        return $this->isDeniedMulti($roles, $resource, $permission);
853    }
854
855    /**
856     * Get the effective allowed permissions for a role on a resource,
857     * merged with inheritance. Returns ['*'] if access is unrestricted
858     * (either via an explicit '*' permission or an empty permission list
859     * at any level of the role's parent chain), or [] if no rule exists.
860     *
861     * @param  mixed $role
862     * @param  mixed $resource
863     * @throws Exception
864     * @return array
865     */
866    public function getAllowedPermissions(mixed $role, mixed $resource): array
867    {
868        return $this->getEffectivePermissions('allowed', $role, $resource);
869    }
870
871    /**
872     * Get the effective denied permissions for a role on a resource,
873     * merged with inheritance. Returns ['*'] if denial is unrestricted
874     * (either via an explicit '*' permission or an empty permission list
875     * at any level of the role's parent chain), or [] if no rule exists.
876     *
877     * @param  mixed $role
878     * @param  mixed $resource
879     * @throws Exception
880     * @return array
881     */
882    public function getDeniedPermissions(mixed $role, mixed $resource): array
883    {
884        return $this->getEffectivePermissions('denied', $role, $resource);
885    }
886
887    /**
888     * Shared walk for getAllowedPermissions()/getDeniedPermissions()
889     *
890     * @param  string $type
891     * @param  mixed  $role
892     * @param  mixed  $resource
893     * @throws Exception
894     * @return array
895     */
896    protected function getEffectivePermissions(string $type, mixed $role, mixed $resource): array
897    {
898        $this->verifyRole($role);
899        $this->verifyResource($resource);
900
901        $resourceName = (string)$this->resources[(string)$resource];
902        $roleToCheck  = $this->roles[(string)$role];
903        $permissions  = [];
904
905        while ($roleToCheck !== null) {
906            $roleRules = $this->{$type}[(string)$roleToCheck] ?? null;
907            if ($roleRules !== null) {
908                if (array_key_exists($resourceName, $roleRules)) {
909                    if (count($roleRules[$resourceName]) === 0) {
910                        return ['*'];
911                    }
912                    $permissions = array_merge($permissions, $roleRules[$resourceName]);
913                } else if (count($roleRules) === 0) {
914                    return ['*'];
915                }
916            }
917            $roleToCheck = $roleToCheck->getParent();
918        }
919
920        if (in_array('*', $permissions, true)) {
921            return ['*'];
922        }
923
924        return array_values(array_unique($permissions));
925    }
926
927    /**
928     * Create assertion
929     *
930     * @param  AssertionInterface $assertion
931     * @param  string             $type
932     * @param  mixed              $role
933     * @param  mixed              $resource
934     * @param  ?string            $permission
935     * @throws InvalidArgumentException
936     * @return void
937     */
938    public function createAssertion(
939        AssertionInterface $assertion, string $type, mixed $role, mixed $resource = null, ?string $permission = null
940    ): void
941    {
942        $key = $this->generateAssertionKey($role, $resource, $permission);
943
944        if (($type != 'allowed') && ($type != 'denied')) {
945            throw new InvalidArgumentException("Error: The assertion type must be either 'allowed' or 'denied'.");
946        }
947        $this->assertions[$type][$key] = $assertion;
948    }
949
950    /**
951     * Delete assertion
952     *
953     * @param  string  $type
954     * @param  mixed   $role
955     * @param  mixed   $resource
956     * @param  ?string $permission
957     * @return void
958     */
959    public function deleteAssertion(string $type, mixed $role, mixed $resource = null, ?string $permission = null): void
960    {
961        $key = $this->generateAssertionKey($role, $resource, $permission);
962
963        if (isset($this->assertions[$type][$key])) {
964            unset($this->assertions[$type][$key]);
965        }
966    }
967
968    /**
969     * Delete the assertions for a role/resource's full permission set,
970     * shared by removeRole() and removeResource()
971     *
972     * @param  string $type
973     * @param  string $roleName
974     * @param  string $resourceName
975     * @param  array  $permissions
976     * @return void
977     */
978    protected function deleteRuleAssertions(string $type, string $roleName, string $resourceName, array $permissions): void
979    {
980        if (count($permissions) === 0) {
981            $this->deleteAssertion($type, $roleName, $resourceName);
982        } else {
983            foreach ($permissions as $perm) {
984                $this->deleteAssertion($type, $roleName, $resourceName, $perm);
985            }
986        }
987    }
988
989    /**
990     * Has assertion key
991     *
992     * @param  string $type
993     * @param  mixed  $role
994     * @param  mixed  $resource
995     * @param  mixed  $permission
996     * @throws InvalidArgumentException
997     * @return bool
998     */
999    public function hasAssertionKey(string $type, mixed $role, mixed $resource = null, mixed $permission = null): bool
1000    {
1001        return ($this->getAssertionKey($type, $role, $resource, $permission) !== null);
1002    }
1003
1004    /**
1005     * Get assertion key
1006     *
1007     * @param  string $type
1008     * @param  mixed  $role
1009     * @param  mixed  $resource
1010     * @param  mixed  $permission
1011     * @throws InvalidArgumentException
1012     * @return string|null
1013     */
1014    public function getAssertionKey(string $type, mixed $role, mixed $resource = null, mixed $permission = null): string|null
1015    {
1016        $key = $this->generateAssertionKey($role, $resource, $permission);
1017
1018        if (($type != 'allowed') && ($type != 'denied')) {
1019            throw new InvalidArgumentException("Error: The assertion type must be either 'allowed' or 'denied'.");
1020        }
1021
1022        return (isset($this->assertions[$type][$key])) ? $key : null;
1023    }
1024
1025    /**
1026     * Add policy
1027     *
1028     * @param  string $method
1029     * @param  mixed  $role
1030     * @param  mixed  $resource
1031     * @return Acl
1032     */
1033    public function addPolicy(string $method, mixed $role, mixed $resource = null): Acl
1034    {
1035        $this->policies[] = [
1036            'method'   => $method,
1037            'role'     => $role,
1038            'resource' => $resource
1039        ];
1040
1041        return $this;
1042    }
1043
1044    /**
1045     * Has policies
1046     *
1047     * @return bool
1048     */
1049    public function hasPolicies(): bool
1050    {
1051        return (count($this->policies) > 0);
1052    }
1053
1054    /**
1055     * Evaluate policies
1056     *
1057     * @param  mixed $role
1058     * @param  mixed $resource
1059     * @param  mixed $permission
1060     * @throws Exception
1061     * @throws \Pop\Acl\Policy\Exception if a policy method isn't callable on the role
1062     * @return bool|null
1063     */
1064    public function evaluatePolicies(mixed $role = null, mixed $resource = null, mixed $permission = null): bool|null
1065    {
1066        $result = null;
1067
1068        if (($role === null) && ($resource === null) && ($permission === null)) {
1069            foreach ($this->policies as $policy) {
1070                $result = $this->evaluatePolicy($policy['method'], $policy['role'], $policy['resource']);
1071                if ($result === false) {
1072                    return false;
1073                }
1074            }
1075        } else {
1076            $policyRole     = null;
1077            $policyResource = null;
1078            $policyMethod   = ($permission !== null) ? $permission : null;
1079
1080            if ($role !== null) {
1081                $this->verifyRole($role);
1082                $policyRole = ($role instanceof AclRole) ? $role->getName() : $role;
1083            }
1084            if ($resource !== null) {
1085                $this->verifyResource($resource);
1086                $policyResource = ($resource instanceof AclResource) ? $resource->getName() : $resource;
1087            }
1088
1089            foreach ($this->policies as $policy) {
1090                if ((($policyRole === null) || ($policyRole == $policy['role'])) &&
1091                    (($policyResource === null) || ($policyResource == $policy['resource'])) &&
1092                    (($policyMethod === null) || ($policyMethod == $policy['method']))) {
1093                    $result = $this->evaluatePolicy($policy['method'], $policy['role'], $policy['resource']);
1094                    if ($result === false) {
1095                        return false;
1096                    }
1097                }
1098            }
1099        }
1100
1101        return $result;
1102    }
1103
1104    /**
1105     * Evaluate policy
1106     *
1107     * @param  string $method
1108     * @param  mixed  $role
1109     * @param  mixed  $resource
1110     * @throws Exception
1111     * @throws \Pop\Acl\Policy\Exception if a policy method isn't callable on the role
1112     * @return bool|null
1113     */
1114    public function evaluatePolicy(string $method, mixed $role, mixed $resource = null): bool|null
1115    {
1116        if (is_string($role) && ($this->verifyRole($role))) {
1117            $role = $this->roles[(string)$role];
1118        }
1119
1120        if (!in_array('Pop\Acl\Policy\PolicyTrait', class_uses($role))) {
1121            throw new Exception('Error: The role must use Pop\Acl\Policy\PolicyTrait.');
1122        }
1123
1124        if ($resource !== null) {
1125            $this->verifyResource($resource);
1126            $resource = $this->resources[(string)$resource];
1127        }
1128
1129        return $role->can($method, $resource);
1130    }
1131
1132    /**
1133     * Verify role
1134     *
1135     * @param  mixed $role
1136     * @throws Exception
1137     * @return bool
1138     */
1139    protected function verifyRole(mixed $role): bool
1140    {
1141        if (!is_string($role) && !($role instanceof AclRole)) {
1142            throw new \InvalidArgumentException('Error: The role must be a string or an instance of Role.');
1143        }
1144        if (!isset($this->roles[(string)$role])) {
1145            throw new Exception("Error: The role '" . (string)$role . "' has not been added.");
1146        }
1147
1148        return true;
1149    }
1150
1151    /**
1152     * Verify resource
1153     *
1154     * @param  mixed $resource
1155     * @throws Exception
1156     * @return bool
1157     */
1158    protected function verifyResource(mixed $resource): bool
1159    {
1160        if (!is_string($resource) && !($resource instanceof AclResource)) {
1161            throw new \InvalidArgumentException('Error: The resource must be a string or an instance of Resource.');
1162        }
1163        if (!isset($this->resources[(string)$resource])) {
1164            throw new Exception("Error: The resource '" . (string)$resource . "' has not been added.");
1165        }
1166
1167        return true;
1168    }
1169
1170    /**
1171     * Generate assertion key
1172     *
1173     * @param  mixed $role
1174     * @param  mixed $resource
1175     * @param  mixed $permission
1176     * @return string
1177     */
1178    protected function generateAssertionKey(mixed $role, mixed $resource = null, mixed $permission = null): string
1179    {
1180        $key = (string)$role;
1181
1182        if ($resource !== null) {
1183            $key .= '-' . (string)$resource;
1184        }
1185        if ($permission !== null) {
1186            $key .= '-' . (is_array($permission) ? implode(',', $permission) : (string)$permission);
1187        }
1188
1189        return $key;
1190    }
1191
1192    /**
1193     * Traverse child roles to add them to the ACL object
1194     *
1195     * @param  array $childRoles
1196     * @return void
1197     */
1198    protected function traverseChildren(array $childRoles): void
1199    {
1200        foreach ($childRoles as $childRole) {
1201            $this->addRole($childRole);
1202            if ($childRole->hasChildren()) {
1203                $this->traverseChildren($childRole->getChildren());
1204            }
1205        }
1206    }
1207
1208}