Skip to content

Commit 6b59a9c

Browse files
committed
feat(auth): support hierarchical permission wildcards
Add hierarchical wildcard matching for Shield permissions. - Support nested trailing wildcards like forum.posts.* - Support middle-segment wildcards like forum.*.create - Share wildcard matching between user and group permission checks - Document wildcard semantics and direct user wildcard assignment - Cover matcher behavior and public authorization paths Co-authored-by: bgeneto <bgeneto@duck.com> Co-authored-by: christianberkman <christianberkman@users.noreply.github.com> Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 34be62b commit 6b59a9c

8 files changed

Lines changed: 329 additions & 36 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. Each one is defined by a string with dot-separated segments, like `users.create` or `forum.posts.create`.
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 on a dotted scope matches the scope itself and all child permission segments. For example, `forum.posts.*` matches `forum.posts`, `forum.posts.create`, and `forum.posts.comments.delete`.
60+
When `*` appears between segments, it matches exactly one segment. For example, `forum.*.create` matches `forum.posts.create`.
61+
Parent matching applies to dotted scopes like `forum.posts`, not root labels like `forum`. 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: 38 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 consisting of dot-separated segments, like `users.create` or
40+
`forum.posts.create`. 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,43 @@ 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 on a dotted scope, it also grants the parent scope itself and all descendant permissions.
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`, `forum.posts.create`, and `forum.posts.comments.delete`.
87+
Wildcards can also appear between segments: `forum.*.create` matches `forum.posts.create` and
88+
`forum.comments.create`, but does not match `forum.create` or `forum.posts.comments.create`.
89+
Since `$user->can()` expects dot-separated permissions like `scope.action`, parent matching applies to dotted
90+
permission scopes like `forum.posts`, not to root labels like `forum`.
91+
92+
Exact child permissions do not grant their parent permission. For example, `forum.posts.create` does not grant
93+
`forum.posts`.
94+
95+
Wildcard matching is used by `$user->can()` and `$group->can()` for both user-level and group-level permissions.
96+
97+
!!! warning
98+
99+
Wildcard permissions can grant access to the parent scope and to future child permissions added under the
100+
same scope. Use broad wildcards like `admin.*` carefully, and prefer literal permissions for highly sensitive
101+
access.
102+
84103
## Authorizing Users
85104

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

88107
#### can()
89108

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
109+
Allows you to check if a user has one or more permissions. The permission string(s) should be passed as the argument(s). Returns
91110
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.
111+
permissions (**group-level permissions**) to determine if they are allowed. Wildcard permissions are supported for both
112+
user-level and group-level permissions.
93113

94114
```php
95115
if ($user->can('users.create')) {
@@ -172,6 +192,17 @@ is thrown.
172192
$user->addPermission('users.create', 'users.edit');
173193
```
174194

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

177208
Removes one or more **user-level** permissions from a user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException`
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
if (! self::isValid($permission)) {
27+
return false;
28+
}
29+
30+
foreach ($grants as $grant) {
31+
if (! self::isValid($grant)) {
32+
continue;
33+
}
34+
35+
if ($grant === $permission) {
36+
return true;
37+
}
38+
39+
if (str_contains($grant, '*') && self::matchesWildcardGrant($grant, $permission)) {
40+
return true;
41+
}
42+
}
43+
44+
return false;
45+
}
46+
47+
private static function matchesWildcardGrant(string $grant, string $permission): bool
48+
{
49+
$grantSegments = explode('.', $grant);
50+
$permissionSegments = explode('.', $permission);
51+
52+
if (end($grantSegments) === '*') {
53+
array_pop($grantSegments);
54+
55+
// Root labels like `admin` are not permission scopes, so `admin.*` should not grant `admin`.
56+
if (count($grantSegments) === 1 && count($permissionSegments) === 1) {
57+
return false;
58+
}
59+
60+
return count($permissionSegments) >= count($grantSegments)
61+
&& self::segmentsMatch($grantSegments, array_slice($permissionSegments, 0, count($grantSegments)));
62+
}
63+
64+
return self::segmentsMatch($grantSegments, $permissionSegments);
65+
}
66+
67+
/**
68+
* @param list<string> $grantSegments
69+
* @param list<string> $permissionSegments
70+
*/
71+
private static function segmentsMatch(array $grantSegments, array $permissionSegments): bool
72+
{
73+
if (count($grantSegments) !== count($permissionSegments)) {
74+
return false;
75+
}
76+
77+
foreach ($grantSegments as $index => $grantSegment) {
78+
if ($grantSegment !== '*' && $grantSegment !== $permissionSegments[$index]) {
79+
return false;
80+
}
81+
}
82+
83+
return true;
84+
}
85+
86+
private static function isValid(string $permission): bool
87+
{
88+
$segments = explode('.', $permission);
89+
90+
if ($segments === ['*'] || $segments[0] === '*') {
91+
return false;
92+
}
93+
94+
foreach ($segments as $segment) {
95+
if ($segment === '' || ($segment !== '*' && str_contains($segment, '*'))) {
96+
return false;
97+
}
98+
}
99+
100+
return true;
101+
}
102+
}

‎src/Authorization/Traits/Authorizable.php‎

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

1616
use CodeIgniter\I18n\Time;
1717
use CodeIgniter\Shield\Authorization\AuthorizationException;
18+
use CodeIgniter\Shield\Authorization\PermissionMatcher;
1819
use CodeIgniter\Shield\Exceptions\LogicException;
1920
use CodeIgniter\Shield\Models\GroupModel;
2021
use CodeIgniter\Shield\Models\PermissionModel;
@@ -253,10 +254,9 @@ public function hasPermission(string $permission): bool
253254

254255
/**
255256
* Checks user permissions and their group permissions
256-
* to see if the user has a specific permission or group
257-
* of permissions.
257+
* to see if the user has one or more permissions.
258258
*
259-
* @param string $permissions string(s) consisting of a scope and action, like `users.create`
259+
* @param string $permissions Dot-separated permission string(s), like `users.create`
260260
*/
261261
public function can(string ...$permissions): bool
262262
{
@@ -270,34 +270,23 @@ public function can(string ...$permissions): bool
270270
$matrix = setting('AuthGroups.matrix');
271271

272272
foreach ($permissions as $permission) {
273-
// Permission must contain a scope and action
273+
// Permission must contain at least two dot-separated segments.
274274
if (! str_contains($permission, '.')) {
275275
throw new LogicException(
276-
'A permission must be a string consisting of a scope and action, like `users.create`.'
276+
'A permission must be a dot-separated string, like `users.create`.'
277277
. ' Invalid permission: ' . $permission,
278278
);
279279
}
280280

281281
$permission = strtolower($permission);
282282

283283
// Check user's permissions
284-
if (in_array($permission, $this->permissionsCache, true)) {
284+
if (PermissionMatcher::matches($permission, $this->permissionsCache)) {
285285
return true;
286286
}
287287

288-
if (count($this->groupCache) === 0) {
289-
return false;
290-
}
291-
292288
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)) {
289+
if (isset($matrix[$group]) && PermissionMatcher::matches($permission, $matrix[$group])) {
301290
return true;
302291
}
303292
}

‎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)