Skip to content

Commit 0311b46

Browse files
authored
Merge pull request #1327 from memleakd/feat/hierarchical-permission-wildcards
feat: support hierarchical permission wildcards
2 parents 5e9238a + 2294c68 commit 0311b46

8 files changed

Lines changed: 400 additions & 48 deletions

File tree

‎docs/quick_start_guide/using_authorization.md‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ When a user registers on your site, they are assigned the group specified at `Co
2222

2323
### Change Available Permissions
2424

25-
The permissions on the site are stored in the `AuthGroups` config file also. Each one is defined by a string that represents a context and a permission, joined with a decimal point.
25+
The permissions on the site are stored in the `AuthGroups` config file also. Permissions are usually written with dot-separated segments, like `users.create` or `forum.posts.create`, but single-segment permissions are also allowed.
2626

2727
```php
2828
public array $permissions = [
@@ -42,19 +42,24 @@ public array $permissions = [
4242

4343
### Assign Permissions to a Group
4444

45-
Each group can have its own specific set of permissions. These are defined in `Config\AuthGroups::$matrix`. You can specify each permission by it's full name, or using the context and an asterisk (*) to specify all permissions within that context.
45+
Each group can have its own specific set of permissions. These are defined in `Config\AuthGroups::$matrix`. You can specify each permission by its full name, or use `*` as a wildcard segment.
4646

4747
```php
4848
public array $matrix = [
4949
'superadmin' => [
5050
'admin.*',
51+
'forum.posts.*',
5152
'users.*',
5253
'beta.access',
5354
],
5455
//
5556
];
5657
```
5758

59+
A trailing `*` wildcard matches descendant permission segments only. For example, `forum.posts.*` matches `forum.posts.create` and `forum.posts.comments.delete`, but not `forum.posts`.
60+
When `*` appears between segments, it matches exactly one segment. For example, `forum.*.create` matches `forum.posts.create`.
61+
The first segment cannot be `*`, and a standalone `*` permission does not grant all permissions.
62+
5863
## Assign Permissions to a User
5964

6065
Permissions can also be assigned directly to a user, regardless of what groups they belong to. This is done programatically on the `User` Entity.
@@ -65,6 +70,17 @@ $user = auth()->user();
6570
$user->addPermission('users.create', 'beta.access');
6671
```
6772

73+
Wildcard permissions can also be assigned directly to a user, but they must be listed in `Config\AuthGroups::$permissions`
74+
before they can be assigned.
75+
76+
```php
77+
public array $permissions = [
78+
'forum.posts.*' => 'Can manage forum posts',
79+
];
80+
81+
$user->addPermission('forum.posts.*');
82+
```
83+
6884
This will add all new permissions. You can also sync permissions so that the user ONLY has the given permissions directly assigned to them. Any not in the provided list are removed from the user.
6985

7086
```php

‎docs/references/authorization.md‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ public string $defaultGroup = 'user';
3535

3636
## Defining Available Permissions
3737

38-
All permissions must be added to the `AuthGroups` config file, also. A permission is simply a string consisting of
39-
a scope and action, like `users.create`. The scope would be `users` and the action would be `create`. Each permission
40-
can have a description for display within UIs if needed.
38+
Permissions that can be assigned directly to users must be added to the `AuthGroups` config file.
39+
A permission is a string, usually written with dot-separated segments like `users.create` or
40+
`forum.posts.create`. Single-segment permissions are also allowed. Each permission can have a description for display within UIs if needed.
4141

4242
```php
4343
public array $permissions = [
@@ -58,7 +58,7 @@ config file, under the `$matrix` property.
5858

5959
!!! note
6060

61-
This defines **group-level permissons**.
61+
This defines **group-level permissions**.
6262

6363
The matrix is an associative array with the group name as the key,
6464
and an array of permissions that should be applied to that group.
@@ -73,23 +73,41 @@ public array $matrix = [
7373
];
7474
```
7575

76-
You can use a wildcard within a scope to allow all actions within that scope, by using a `*` in place of the action.
76+
You can use `*` as a wildcard segment to allow permissions under a scope. A wildcard matches one full segment.
77+
When the wildcard is trailing, it grants descendant permissions only.
78+
The first segment cannot be `*`, and a standalone `*` permission does not grant all permissions.
7779

7880
```php
7981
public array $matrix = [
8082
'superadmin' => ['admin.*', 'users.*', 'beta.*'],
8183
];
8284
```
8385

86+
For example, `forum.posts.*` matches `forum.posts.create` and `forum.posts.comments.delete`, but not
87+
`forum.posts`.
88+
Wildcards can also appear between segments: `forum.*.create` matches `forum.posts.create` and
89+
`forum.comments.create`, but does not match `forum.create` or `forum.posts.comments.create`.
90+
91+
Exact child permissions do not grant their parent permission. For example, `forum.posts.create` does not grant
92+
`forum.posts`.
93+
94+
Wildcard matching is used by `$user->can()` and `$group->can()` for both user-level and group-level permissions.
95+
96+
!!! warning
97+
98+
Wildcard permissions can grant access to future child permissions added under the same scope. Use broad
99+
wildcards like `admin.*` carefully, and prefer literal permissions for highly sensitive access.
100+
84101
## Authorizing Users
85102

86103
The `Authorizable` trait on the `User` entity provides the following methods to authorize your users.
87104

88105
#### can()
89106

90-
Allows you to check if a user is permitted to do a specific action or group or actions. The permission string(s) should be passed as the argument(s). Returns
107+
Allows you to check if a user has one or more permissions. The permission string(s) should be passed as the argument(s). Returns
91108
boolean `true`/`false`. Will check the user's direct permissions (**user-level permissions**) first, and then check against all of the user's groups
92-
permissions (**group-level permissions**) to determine if they are allowed.
109+
permissions (**group-level permissions**) to determine if they are allowed. Wildcard permissions are supported for both
110+
user-level and group-level permissions.
93111

94112
```php
95113
if ($user->can('users.create')) {
@@ -172,6 +190,17 @@ is thrown.
172190
$user->addPermission('users.create', 'users.edit');
173191
```
174192

193+
Wildcard permissions can also be assigned to a user, but they must be listed in `Config\AuthGroups::$permissions`
194+
before they can be assigned.
195+
196+
```php
197+
public array $permissions = [
198+
'forum.posts.*' => 'Can manage forum posts',
199+
];
200+
201+
$user->addPermission('forum.posts.*');
202+
```
203+
175204
#### removePermission()
176205

177206
Removes one or more **user-level** permissions from a user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException`
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter Shield.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Shield\Authorization;
15+
16+
/**
17+
* Matches permission grants against requested permission names for Shield authorization internals.
18+
*/
19+
final class PermissionMatcher
20+
{
21+
/**
22+
* @param list<string> $grants
23+
*/
24+
public static function matches(string $permission, array $grants): bool
25+
{
26+
$permissionSegments = self::splitIfValid($permission);
27+
28+
if ($permissionSegments === null) {
29+
return false;
30+
}
31+
32+
$permissionSegmentCount = count($permissionSegments);
33+
34+
foreach ($grants as $grant) {
35+
if ($grant === $permission) {
36+
return true;
37+
}
38+
39+
$wildcardPosition = strpos($grant, '*');
40+
41+
if ($wildcardPosition === false) {
42+
continue;
43+
}
44+
45+
if (
46+
$wildcardPosition > 0
47+
&& $wildcardPosition === strlen($grant) - 1
48+
&& $grant[$wildcardPosition - 1] === '.'
49+
) {
50+
$prefix = substr($grant, 0, $wildcardPosition - 1);
51+
52+
if (self::isLiteral($prefix) && str_starts_with($permission, $prefix . '.')) {
53+
return true;
54+
}
55+
56+
continue;
57+
}
58+
59+
$grantSegments = self::splitIfValid($grant);
60+
61+
if ($grantSegments === null) {
62+
continue;
63+
}
64+
65+
if (self::matchesWildcardGrant($grantSegments, $permissionSegments, $permissionSegmentCount)) {
66+
return true;
67+
}
68+
}
69+
70+
return false;
71+
}
72+
73+
/**
74+
* @param list<string> $grantSegments
75+
* @param list<string> $permissionSegments
76+
*/
77+
private static function matchesWildcardGrant(array $grantSegments, array $permissionSegments, int $permissionSegmentCount): bool
78+
{
79+
$grantSegmentCount = count($grantSegments);
80+
81+
if ($grantSegments[$grantSegmentCount - 1] === '*') {
82+
$grantSegmentCount--;
83+
84+
return $permissionSegmentCount > $grantSegmentCount
85+
&& self::segmentsMatch($grantSegments, $permissionSegments, $grantSegmentCount);
86+
}
87+
88+
return $permissionSegmentCount === $grantSegmentCount
89+
&& self::segmentsMatch($grantSegments, $permissionSegments, $grantSegmentCount);
90+
}
91+
92+
/**
93+
* @param list<string> $grantSegments
94+
* @param list<string> $permissionSegments
95+
*/
96+
private static function segmentsMatch(array $grantSegments, array $permissionSegments, int $segmentCount): bool
97+
{
98+
for ($index = 0; $index < $segmentCount; $index++) {
99+
if ($grantSegments[$index] !== '*' && $grantSegments[$index] !== $permissionSegments[$index]) {
100+
return false;
101+
}
102+
}
103+
104+
return true;
105+
}
106+
107+
/**
108+
* @return list<string>|null
109+
*/
110+
private static function splitIfValid(string $permission): ?array
111+
{
112+
if ($permission === '' || str_starts_with($permission, '*')) {
113+
return null;
114+
}
115+
116+
$segments = explode('.', $permission);
117+
118+
foreach ($segments as $segment) {
119+
if ($segment === '' || ($segment !== '*' && str_contains($segment, '*'))) {
120+
return null;
121+
}
122+
}
123+
124+
return $segments;
125+
}
126+
127+
private static function isLiteral(string $permission): bool
128+
{
129+
return $permission !== ''
130+
&& ! str_contains($permission, '*')
131+
&& ! str_contains($permission, '..')
132+
&& ! str_starts_with($permission, '.')
133+
&& ! str_ends_with($permission, '.');
134+
}
135+
}

‎src/Authorization/Traits/Authorizable.php‎

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
use CodeIgniter\I18n\Time;
1717
use CodeIgniter\Shield\Authorization\AuthorizationException;
18-
use CodeIgniter\Shield\Exceptions\LogicException;
18+
use CodeIgniter\Shield\Authorization\PermissionMatcher;
1919
use CodeIgniter\Shield\Models\GroupModel;
2020
use CodeIgniter\Shield\Models\PermissionModel;
2121

@@ -253,10 +253,9 @@ public function hasPermission(string $permission): bool
253253

254254
/**
255255
* Checks user permissions and their group permissions
256-
* to see if the user has a specific permission or group
257-
* of permissions.
256+
* to see if the user has one or more permissions.
258257
*
259-
* @param string $permissions string(s) consisting of a scope and action, like `users.create`
258+
* @param string $permissions Permission string(s), usually dot-separated like `users.create`
260259
*/
261260
public function can(string ...$permissions): bool
262261
{
@@ -270,34 +269,15 @@ public function can(string ...$permissions): bool
270269
$matrix = setting('AuthGroups.matrix');
271270

272271
foreach ($permissions as $permission) {
273-
// Permission must contain a scope and action
274-
if (! str_contains($permission, '.')) {
275-
throw new LogicException(
276-
'A permission must be a string consisting of a scope and action, like `users.create`.'
277-
. ' Invalid permission: ' . $permission,
278-
);
279-
}
280-
281272
$permission = strtolower($permission);
282273

283274
// Check user's permissions
284-
if (in_array($permission, $this->permissionsCache, true)) {
275+
if (PermissionMatcher::matches($permission, $this->permissionsCache)) {
285276
return true;
286277
}
287278

288-
if (count($this->groupCache) === 0) {
289-
return false;
290-
}
291-
292279
foreach ($this->groupCache as $group) {
293-
// Check exact match
294-
if (isset($matrix[$group]) && in_array($permission, $matrix[$group], true)) {
295-
return true;
296-
}
297-
298-
// Check wildcard match
299-
$check = substr($permission, 0, strpos($permission, '.')) . '.*';
300-
if (isset($matrix[$group]) && in_array($check, $matrix[$group], true)) {
280+
if (isset($matrix[$group]) && PermissionMatcher::matches($permission, $matrix[$group])) {
301281
return true;
302282
}
303283
}

‎src/Entities/Group.php‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\Shield\Entities;
1515

1616
use CodeIgniter\Entity\Entity;
17+
use CodeIgniter\Shield\Authorization\PermissionMatcher;
1718

1819
/**
1920
* Represents a single User Group
@@ -79,15 +80,9 @@ public function can(string $permission): bool
7980
{
8081
$this->populatePermissions();
8182

82-
// Check exact match
83-
if ($this->permissions !== null && $this->permissions !== [] && in_array($permission, $this->permissions, true)) {
84-
return true;
85-
}
86-
87-
// Check wildcard match
88-
$check = substr($permission, 0, strpos($permission, '.')) . '.*';
89-
90-
return $this->permissions !== null && $this->permissions !== [] && in_array($check, $this->permissions, true);
83+
return $this->permissions !== null
84+
&& $this->permissions !== []
85+
&& PermissionMatcher::matches($permission, $this->permissions);
9186
}
9287

9388
/**

0 commit comments

Comments
 (0)