Failed Conditions
Push — master ( 7aeedc...f7e5fc )
by Adrien
05:38 queued 02:24
created

Acl::show()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 31
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 8

Importance

Changes 0
Metric Value
eloc 17
c 0
b 0
f 0
dl 0
loc 31
ccs 19
cts 19
cp 1
rs 8.4444
cc 8
nc 6
nop 2
crap 8
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Ecodev\Felix\Acl;
6
7
use Doctrine\Common\Util\ClassUtils;
8
use Ecodev\Felix\Model\CurrentUser;
9
use Ecodev\Felix\Model\Model;
10
use Exception;
11
use Laminas\Permissions\Acl\Assertion\AssertionInterface;
12
use Laminas\Permissions\Acl\Resource\ResourceInterface;
13
use Laminas\Permissions\Acl\Role\RoleInterface;
14
15
class Acl extends \Laminas\Permissions\Acl\Acl
16
{
17
    /**
18
     * The message explaining the last denial.
19
     */
20
    private ?string $message = null;
21
22
    /**
23
     * @var string[]
24
     */
25
    private array $reasons = [];
26
27
    private DebugAcl $debugAcl;
28
29
    /**
30
     * @var array<string, string>
31
     */
32
    private array $resourceTranslations = [];
33
34
    /**
35
     * @var array<string, string>
36
     */
37
    private array $privilegeTranslations = [];
38
39 8
    public function __construct()
40
    {
41 8
        $this->debugAcl = new DebugAcl();
42
    }
43
44 8
    public function addRole($role, $parents = null)
45
    {
46 8
        $this->debugAcl->addRole($role, $parents);
47
48 8
        return parent::addRole($role, $parents);
49
    }
50
51 8
    public function addResource($resource, $parent = null)
52
    {
53 8
        $this->debugAcl->addResource($resource, $parent);
54
55 8
        return parent::addResource($resource, $parent);
56
    }
57
58 8
    public function allow($roles = null, $resources = null, $privileges = null, ?AssertionInterface $assert = null)
59
    {
60 8
        $this->debugAcl->allow($roles, $resources, $privileges, $assert);
61
62 8
        return parent::allow($roles, $resources, $privileges, $assert);
63
    }
64
65
    public function deny($roles = null, $resources = null, $privileges = null, ?AssertionInterface $assert = null)
66
    {
67
        $this->debugAcl->deny($roles, $resources, $privileges, $assert);
68
69
        return parent::deny($roles, $resources, $privileges, $assert);
70
    }
71
72 4
    protected function createModelResource(string $class): ModelResource
73
    {
74 4
        $resource = new ModelResource($class);
75 4
        $this->addResource($resource);
76
77 4
        return $resource;
78
    }
79
80
    /**
81
     * Override parent to provide compatibility with MultipleRoles.
82
     *
83
     * @param null|RoleInterface|string $role
84
     * @param null|ResourceInterface|string $resource
85
     * @param null|string $privilege
86
     */
87 5
    public function isAllowed($role = null, $resource = null, $privilege = null): bool
88
    {
89
        // Normalize roles
90 5
        if ($role instanceof MultipleRoles) {
91 1
            $roles = $role->getRoles();
92
        } else {
93 4
            $roles = [$role];
94
        }
95
96
        // If at least one role is allowed, then return early
97 5
        foreach ($roles as $role) {
98 5
            if (parent::isAllowed($role, $resource, $privilege)) {
99 1
                return true;
100
            }
101
        }
102
103 5
        return false;
104
    }
105
106
    /**
107
     * Return whether the current user is allowed to do something.
108
     *
109
     * This should be the main method to do all ACL checks.
110
     */
111 5
    public function isCurrentUserAllowed(Model|string $modelOrResource, string $privilege): bool
112
    {
113 5
        $resource = is_string($modelOrResource) ? $modelOrResource : new ModelResource($this->getClass($modelOrResource), $modelOrResource);
114 5
        $role = $this->getCurrentRole();
115 5
        $this->reasons = [];
116
117 5
        $isAllowed = $this->isAllowed($role, $resource, $privilege);
118
119 5
        $this->message = $this->buildMessage($resource, $privilege, $role, $isAllowed);
120
121 5
        return $isAllowed;
122
    }
123
124
    /**
125
     * Set the reason for rejection that will be shown to end-user.
126
     *
127
     * This method always return false for usage convenience and should be used by all assertions,
128
     * instead of only return false themselves.
129
     *
130
     * @return false
131
     */
132 4
    public function reject(string $reason): bool
133
    {
134 4
        $this->reasons[] = $reason;
135
136 4
        return false;
137
    }
138
139 4
    private function getClass(Model $resource): string
140
    {
141 4
        return ClassUtils::getRealClass($resource::class);
142
    }
143
144 5
    private function getCurrentRole(): MultipleRoles|string
145
    {
146 5
        $user = CurrentUser::get();
147 5
        if (!$user) {
148 2
            return 'anonymous';
149
        }
150
151 4
        return $user->getRole();
152
    }
153
154 5
    private function buildMessage(ModelResource|string $resource, ?string $privilege, MultipleRoles|string $role, bool $isAllowed): ?string
155
    {
156 5
        if ($isAllowed) {
157 1
            return null;
158
        }
159
160 5
        if ($resource instanceof ModelResource) {
161 4
            $resource = $resource->getName();
162
        }
163
164 5
        $user = CurrentUser::get();
165 5
        $userName = $user ? 'User "' . $user->getLogin() . '"' : 'Non-logged user';
166 5
        $privilege ??= 'NULL';
167
168 5
        $message = "$userName with role $role is not allowed on resource \"$resource\" with privilege \"$privilege\"";
169
170 5
        $this->reasons = array_unique($this->reasons);
171 5
        $count = count($this->reasons);
172 5
        if ($count === 1) {
173 3
            $message .= ' because ' . $this->reasons[0];
174 3
        } elseif ($count) {
175 1
            $list = array_map(fn ($reason) => '- ' . $reason, $this->reasons);
176 1
            $message .= ' because:' . PHP_EOL . PHP_EOL . implode(PHP_EOL, $list);
177
        }
178
179 5
        return $message;
180
    }
181
182
    /**
183
     * Returns the message explaining the last denial, if any.
184
     */
185 5
    public function getLastDenialMessage(): ?string
186
    {
187 5
        return $this->message;
188
    }
189
190
    /**
191
     * Show the ACL configuration for the given role in a structured array.
192
     *
193
     * @return array<array{resource:string, privileges: array<int, array{privilege:null|string, allowed: bool, allowIf: string[], denyIf: string[]}>}>
194
     */
195 2
    public function show(MultipleRoles|string $role, bool $useTranslations = true): array
196
    {
197 2
        $result = [];
198
        /** @var string[] $resources */
199 2
        $resources = $this->getResources();
200 2
        sort($resources);
201
202 2
        foreach ($resources as $resource) {
203 2
            $privileges = [];
204 2
            foreach ($this->debugAcl->getPrivileges() as $privilege) {
205 2
                $privileges[] = $this->debugAcl->show($role, $resource, $privilege);
206
            }
207
208 2
            $result[] = [
209 2
                'resource' => $resource,
210 2
                'privileges' => $privileges,
211 2
            ];
212
        }
213
214 2
        ksort($result);
215
216 2
        if ($useTranslations && ($this->resourceTranslations || $this->privilegeTranslations)) {
217 2
            foreach ($result as &$resource) {
218 2
                $resource['resource'] = $this->translate($this->resourceTranslations, $resource['resource']);
219 2
                foreach ($resource['privileges'] as &$privilege) {
220 2
                    $privilege['privilege'] = $this->translate($this->privilegeTranslations, $privilege['privilege'] ?? '');
221
                }
222
            }
223
        }
224
225 1
        return $result;
226
    }
227
228
    /**
229
     * Configure the translations for all resources and all privileges for the `show()` method.
230
     *
231
     * If this is set but one translation is missing, then `show()` will throw an exception when called.
232
     *
233
     * To disable translation altogether you can pass two empty lists.
234
     *
235
     * @param array<string, string> $resourceTranslations
236
     * @param array<string, string> $privilegeTranslations
237
     */
238 2
    public function setTranslations(array $resourceTranslations, array $privilegeTranslations): void
239
    {
240 2
        $this->resourceTranslations = $resourceTranslations;
241 2
        $this->privilegeTranslations = $privilegeTranslations;
242
    }
243
244
    /**
245
     * @param array<string, string> $translations
246
     */
247 2
    private function translate(array $translations, string $message): string
248
    {
249 2
        return $translations[$message] ?? throw new Exception('Was not marked as translatable: ' . $message);
250
    }
251
252
    /**
253
     * Returns all non-null privileges indexed by all non-null resources.
254
     *
255
     * @return array<string, array<string>>
256
     */
257 1
    public function getPrivilegesByResource(): array
258
    {
259 1
        return $this->debugAcl->getPrivilegesByResource();
260
    }
261
}
262