From 6b59a9c491c8550ed813af8d87ad0b0fa2b5960f Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 23 May 2026 16:48:11 +0200 Subject: [PATCH] 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 Co-authored-by: christianberkman Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- docs/quick_start_guide/using_authorization.md | 20 +++- docs/references/authorization.md | 45 ++++++-- src/Authorization/PermissionMatcher.php | 102 ++++++++++++++++++ src/Authorization/Traits/Authorizable.php | 25 ++--- src/Entities/Group.php | 13 +-- tests/Authorization/AuthorizableTest.php | 69 ++++++++++++ tests/Authorization/GroupTest.php | 18 ++++ tests/Authorization/PermissionMatcherTest.php | 73 +++++++++++++ 8 files changed, 329 insertions(+), 36 deletions(-) create mode 100644 src/Authorization/PermissionMatcher.php create mode 100644 tests/Authorization/PermissionMatcherTest.php diff --git a/docs/quick_start_guide/using_authorization.md b/docs/quick_start_guide/using_authorization.md index ec28c48cc..0526a622e 100644 --- a/docs/quick_start_guide/using_authorization.md +++ b/docs/quick_start_guide/using_authorization.md @@ -22,7 +22,7 @@ When a user registers on your site, they are assigned the group specified at `Co ### Change Available Permissions -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. +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`. ```php public array $permissions = [ @@ -42,12 +42,13 @@ public array $permissions = [ ### Assign Permissions to a Group -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. +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. ```php public array $matrix = [ 'superadmin' => [ 'admin.*', + 'forum.posts.*', 'users.*', 'beta.access', ], @@ -55,6 +56,10 @@ public array $matrix = [ ]; ``` +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`. +When `*` appears between segments, it matches exactly one segment. For example, `forum.*.create` matches `forum.posts.create`. +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. + ## Assign Permissions to a User 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(); $user->addPermission('users.create', 'beta.access'); ``` +Wildcard permissions can also be assigned directly to a user, but they must be listed in `Config\AuthGroups::$permissions` +before they can be assigned. + +```php +public array $permissions = [ + 'forum.posts.*' => 'Can manage forum posts', +]; + +$user->addPermission('forum.posts.*'); +``` + 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. ```php diff --git a/docs/references/authorization.md b/docs/references/authorization.md index 81c1e6a14..017628849 100644 --- a/docs/references/authorization.md +++ b/docs/references/authorization.md @@ -35,9 +35,9 @@ public string $defaultGroup = 'user'; ## Defining Available Permissions -All permissions must be added to the `AuthGroups` config file, also. A permission is simply a string consisting of -a scope and action, like `users.create`. The scope would be `users` and the action would be `create`. Each permission -can have a description for display within UIs if needed. +Permissions that can be assigned directly to users must be added to the `AuthGroups` config file. +A permission is a string consisting of dot-separated segments, like `users.create` or +`forum.posts.create`. Each permission can have a description for display within UIs if needed. ```php public array $permissions = [ @@ -58,7 +58,7 @@ config file, under the `$matrix` property. !!! note - This defines **group-level permissons**. + This defines **group-level permissions**. The matrix is an associative array with the group name as the key, and an array of permissions that should be applied to that group. @@ -73,7 +73,9 @@ public array $matrix = [ ]; ``` -You can use a wildcard within a scope to allow all actions within that scope, by using a `*` in place of the action. +You can use `*` as a wildcard segment to allow permissions under a scope. A wildcard matches one full segment. +When the wildcard is trailing on a dotted scope, it also grants the parent scope itself and all descendant permissions. +The first segment cannot be `*`, and a standalone `*` permission does not grant all permissions. ```php public array $matrix = [ @@ -81,15 +83,33 @@ public array $matrix = [ ]; ``` +For example, `forum.posts.*` matches `forum.posts`, `forum.posts.create`, and `forum.posts.comments.delete`. +Wildcards can also appear between segments: `forum.*.create` matches `forum.posts.create` and +`forum.comments.create`, but does not match `forum.create` or `forum.posts.comments.create`. +Since `$user->can()` expects dot-separated permissions like `scope.action`, parent matching applies to dotted +permission scopes like `forum.posts`, not to root labels like `forum`. + +Exact child permissions do not grant their parent permission. For example, `forum.posts.create` does not grant +`forum.posts`. + +Wildcard matching is used by `$user->can()` and `$group->can()` for both user-level and group-level permissions. + +!!! warning + + Wildcard permissions can grant access to the parent scope and to future child permissions added under the + same scope. Use broad wildcards like `admin.*` carefully, and prefer literal permissions for highly sensitive + access. + ## Authorizing Users The `Authorizable` trait on the `User` entity provides the following methods to authorize your users. #### can() -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 +Allows you to check if a user has one or more permissions. The permission string(s) should be passed as the argument(s). Returns boolean `true`/`false`. Will check the user's direct permissions (**user-level permissions**) first, and then check against all of the user's groups -permissions (**group-level permissions**) to determine if they are allowed. +permissions (**group-level permissions**) to determine if they are allowed. Wildcard permissions are supported for both +user-level and group-level permissions. ```php if ($user->can('users.create')) { @@ -172,6 +192,17 @@ is thrown. $user->addPermission('users.create', 'users.edit'); ``` +Wildcard permissions can also be assigned to a user, but they must be listed in `Config\AuthGroups::$permissions` +before they can be assigned. + +```php +public array $permissions = [ + 'forum.posts.*' => 'Can manage forum posts', +]; + +$user->addPermission('forum.posts.*'); +``` + #### removePermission() Removes one or more **user-level** permissions from a user. If a permission doesn't exist, a `CodeIgniter\Shield\Authorization\AuthorizationException` diff --git a/src/Authorization/PermissionMatcher.php b/src/Authorization/PermissionMatcher.php new file mode 100644 index 000000000..3c27295be --- /dev/null +++ b/src/Authorization/PermissionMatcher.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Authorization; + +/** + * Matches permission grants against requested permission names for Shield authorization internals. + */ +final class PermissionMatcher +{ + /** + * @param list $grants + */ + public static function matches(string $permission, array $grants): bool + { + if (! self::isValid($permission)) { + return false; + } + + foreach ($grants as $grant) { + if (! self::isValid($grant)) { + continue; + } + + if ($grant === $permission) { + return true; + } + + if (str_contains($grant, '*') && self::matchesWildcardGrant($grant, $permission)) { + return true; + } + } + + return false; + } + + private static function matchesWildcardGrant(string $grant, string $permission): bool + { + $grantSegments = explode('.', $grant); + $permissionSegments = explode('.', $permission); + + if (end($grantSegments) === '*') { + array_pop($grantSegments); + + // Root labels like `admin` are not permission scopes, so `admin.*` should not grant `admin`. + if (count($grantSegments) === 1 && count($permissionSegments) === 1) { + return false; + } + + return count($permissionSegments) >= count($grantSegments) + && self::segmentsMatch($grantSegments, array_slice($permissionSegments, 0, count($grantSegments))); + } + + return self::segmentsMatch($grantSegments, $permissionSegments); + } + + /** + * @param list $grantSegments + * @param list $permissionSegments + */ + private static function segmentsMatch(array $grantSegments, array $permissionSegments): bool + { + if (count($grantSegments) !== count($permissionSegments)) { + return false; + } + + foreach ($grantSegments as $index => $grantSegment) { + if ($grantSegment !== '*' && $grantSegment !== $permissionSegments[$index]) { + return false; + } + } + + return true; + } + + private static function isValid(string $permission): bool + { + $segments = explode('.', $permission); + + if ($segments === ['*'] || $segments[0] === '*') { + return false; + } + + foreach ($segments as $segment) { + if ($segment === '' || ($segment !== '*' && str_contains($segment, '*'))) { + return false; + } + } + + return true; + } +} diff --git a/src/Authorization/Traits/Authorizable.php b/src/Authorization/Traits/Authorizable.php index ee50addf6..2f0a088ea 100644 --- a/src/Authorization/Traits/Authorizable.php +++ b/src/Authorization/Traits/Authorizable.php @@ -15,6 +15,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authorization\AuthorizationException; +use CodeIgniter\Shield\Authorization\PermissionMatcher; use CodeIgniter\Shield\Exceptions\LogicException; use CodeIgniter\Shield\Models\GroupModel; use CodeIgniter\Shield\Models\PermissionModel; @@ -253,10 +254,9 @@ public function hasPermission(string $permission): bool /** * Checks user permissions and their group permissions - * to see if the user has a specific permission or group - * of permissions. + * to see if the user has one or more permissions. * - * @param string $permissions string(s) consisting of a scope and action, like `users.create` + * @param string $permissions Dot-separated permission string(s), like `users.create` */ public function can(string ...$permissions): bool { @@ -270,10 +270,10 @@ public function can(string ...$permissions): bool $matrix = setting('AuthGroups.matrix'); foreach ($permissions as $permission) { - // Permission must contain a scope and action + // Permission must contain at least two dot-separated segments. if (! str_contains($permission, '.')) { throw new LogicException( - 'A permission must be a string consisting of a scope and action, like `users.create`.' + 'A permission must be a dot-separated string, like `users.create`.' . ' Invalid permission: ' . $permission, ); } @@ -281,23 +281,12 @@ public function can(string ...$permissions): bool $permission = strtolower($permission); // Check user's permissions - if (in_array($permission, $this->permissionsCache, true)) { + if (PermissionMatcher::matches($permission, $this->permissionsCache)) { return true; } - if (count($this->groupCache) === 0) { - return false; - } - foreach ($this->groupCache as $group) { - // Check exact match - if (isset($matrix[$group]) && in_array($permission, $matrix[$group], true)) { - return true; - } - - // Check wildcard match - $check = substr($permission, 0, strpos($permission, '.')) . '.*'; - if (isset($matrix[$group]) && in_array($check, $matrix[$group], true)) { + if (isset($matrix[$group]) && PermissionMatcher::matches($permission, $matrix[$group])) { return true; } } diff --git a/src/Entities/Group.php b/src/Entities/Group.php index b63707929..bd397ade3 100644 --- a/src/Entities/Group.php +++ b/src/Entities/Group.php @@ -14,6 +14,7 @@ namespace CodeIgniter\Shield\Entities; use CodeIgniter\Entity\Entity; +use CodeIgniter\Shield\Authorization\PermissionMatcher; /** * Represents a single User Group @@ -79,15 +80,9 @@ public function can(string $permission): bool { $this->populatePermissions(); - // Check exact match - if ($this->permissions !== null && $this->permissions !== [] && in_array($permission, $this->permissions, true)) { - return true; - } - - // Check wildcard match - $check = substr($permission, 0, strpos($permission, '.')) . '.*'; - - return $this->permissions !== null && $this->permissions !== [] && in_array($check, $this->permissions, true); + return $this->permissions !== null + && $this->permissions !== [] + && PermissionMatcher::matches($permission, $this->permissions); } /** diff --git a/tests/Authorization/AuthorizableTest.php b/tests/Authorization/AuthorizableTest.php index 0728a02cf..d65dd4d03 100644 --- a/tests/Authorization/AuthorizableTest.php +++ b/tests/Authorization/AuthorizableTest.php @@ -303,6 +303,56 @@ public function testCanCascadesToGroupsWithWildcards(): void $this->assertTrue($this->user->can('admin.access')); } + public function testCanCascadesToGroupsWithHierarchicalWildcards(): void + { + $this->setGroupPermissions('admin', [ + 'forum.posts.*', + 'admin.*.create', + 'reports.daily.*', + ]); + + $this->user->addGroup('admin'); + + $this->assertTrue($this->user->can('forum.posts.create')); + $this->assertTrue($this->user->can('forum.posts.comments.delete')); + $this->assertTrue($this->user->can('admin.users.create')); + $this->assertTrue($this->user->can('reports.daily.view')); + $this->assertTrue($this->user->can('forum.posts')); + $this->assertTrue($this->user->can('reports.daily')); + + $this->assertFalse($this->user->can('admin.create')); + $this->assertFalse($this->user->can('admin.users.roles.create')); + $this->assertFalse($this->user->can('admin.users.delete')); + } + + public function testCanChecksUserLevelHierarchicalWildcards(): void + { + $this->addConfigPermissions([ + 'forum.posts.*' => 'Can manage forum posts', + ]); + + $this->user->addPermission('forum.posts.*'); + + $this->assertTrue($this->user->can('forum.posts.create')); + $this->assertTrue($this->user->can('forum.posts.comments.delete')); + $this->assertTrue($this->user->can('forum.posts')); + $this->assertFalse($this->user->can('forum.users.create')); + } + + public function testAddPermissionRejectsUnlistedWildcardPermission(): void + { + $this->expectException(AuthorizationException::class); + + $this->user->addPermission('forum.posts.*'); + } + + public function testCanChecksLaterPermissionsWithoutGroups(): void + { + $this->user->addPermission('admin.access'); + + $this->assertTrue($this->user->can('beta.access', 'admin.access')); + } + public function testCanGetsInvalidPermission(): void { $this->expectException(LogicException::class); @@ -385,4 +435,23 @@ public function testGetBanMessage(): void $this->assertSame('You are banned', $this->user->getBanMessage()); } + + /** + * @param list $permissions + */ + private function setGroupPermissions(string $group, array $permissions): void + { + $matrix = setting('AuthGroups.matrix'); + $matrix[$group] = $permissions; + + setting('AuthGroups.matrix', $matrix); + } + + /** + * @param array $permissions + */ + private function addConfigPermissions(array $permissions): void + { + setting('AuthGroups.permissions', array_merge(setting('AuthGroups.permissions'), $permissions)); + } } diff --git a/tests/Authorization/GroupTest.php b/tests/Authorization/GroupTest.php index 68c190be8..c149d129b 100644 --- a/tests/Authorization/GroupTest.php +++ b/tests/Authorization/GroupTest.php @@ -85,6 +85,24 @@ public function testCan(): void $this->assertTrue($group1->can('users.*')); $this->assertTrue($group2->can('users.edit')); + $this->assertFalse($group2->can('Users.Edit')); $this->assertFalse($group2->can('foo.bar')); } + + public function testCanWithHierarchicalWildcards(): void + { + $group = $this->groups->info('user'); + $group->addPermission('forum.posts.*'); + $group->addPermission('admin.*.create'); + + $this->assertTrue($group->can('forum.posts.create')); + $this->assertTrue($group->can('forum.posts.comments.delete')); + $this->assertTrue($group->can('admin.users.create')); + $this->assertTrue($group->can('forum.posts')); + + $this->assertFalse($group->can('admin.create')); + $this->assertFalse($group->can('admin.users.roles.create')); + $this->assertFalse($group->can('admin.users.delete')); + $this->assertFalse($group->can('forum.users.create')); + } } diff --git a/tests/Authorization/PermissionMatcherTest.php b/tests/Authorization/PermissionMatcherTest.php new file mode 100644 index 000000000..2a91ead29 --- /dev/null +++ b/tests/Authorization/PermissionMatcherTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Authorization; + +use CodeIgniter\Shield\Authorization\PermissionMatcher; +use PHPUnit\Framework\Attributes\DataProvider; +use Tests\Support\TestCase; + +/** + * @internal + */ +final class PermissionMatcherTest extends TestCase +{ + /** + * @param list $grants + */ + #[DataProvider('provideMatches')] + public function testMatches(string $permission, array $grants, bool $expected): void + { + $this->assertSame($expected, PermissionMatcher::matches($permission, $grants)); + } + + /** + * @return iterable, bool}> + */ + public static function provideMatches(): iterable + { + return [ + 'exact permission' => ['admin.users.create', ['admin.users.create'], true], + 'uppercase permission does not match lowercase grant' => ['Admin.Users.Create', ['admin.users.create'], false], + 'uppercase grant does not match lowercase permission' => ['admin.users.create', ['Admin.Users.Create'], false], + 'different permission' => ['admin.users.create', ['admin.users.delete'], false], + 'trailing wildcard matches child' => ['admin.users.create', ['admin.users.*'], true], + 'trailing wildcard matches deeper child' => ['admin.users.roles.create', ['admin.users.*'], true], + 'broad trailing wildcard matches child' => ['admin.users.create', ['admin.*'], true], + 'trailing wildcard matches parent' => ['admin.users', ['admin.users.*'], true], + 'broad wildcard does not match root permission' => ['admin', ['admin.*'], false], + 'standalone wildcard does not match child' => ['admin.users.create', ['*'], false], + 'leading wildcard does not match child' => ['admin.users.create', ['*.users.create'], false], + 'leading trailing wildcard does not match globally' => ['admin.users.create', ['*.*'], false], + 'exact child permission does not match parent' => ['admin.users', ['admin.users.create'], false], + 'middle wildcard matches one segment' => ['admin.users.create', ['admin.*.create'], true], + 'middle wildcard does not match multiple segments' => ['admin.users.roles.create', ['admin.*.create'], false], + 'middle wildcard does not match no segment' => ['admin.create', ['admin.*.create'], false], + 'middle wildcard does not match sibling' => ['admin.users.delete', ['admin.*.create'], false], + 'middle and trailing wildcards match parent' => ['admin.users.create', ['admin.*.create.*'], true], + 'middle and trailing wildcards match child' => ['admin.users.create.view', ['admin.*.create.*'], true], + 'middle and trailing wildcards do not match multiple segments' => ['admin.users.roles.create', ['admin.*.create.*'], false], + 'middle and trailing wildcards require segment' => ['admin.create', ['admin.*.create.*'], false], + 'multiple wildcards match' => ['admin.users.roles.create', ['admin.*.*.create'], true], + 'multiple wildcards require one segment each' => ['admin.users.create', ['admin.*.*.create'], false], + 'wildcard check can match exact wildcard grant' => ['admin.users.*', ['admin.users.*'], true], + 'empty permission segment does not match' => ['admin..create', ['admin.*.create'], false], + 'empty grant segment does not match' => ['admin.users.create', ['admin..*'], false], + 'empty segments do not match exactly' => ['admin..create', ['admin..create'], false], + 'partial wildcard grant segment does not match' => ['admin.users.create', ['admin.user*.create'], false], + 'partial wildcard permission segment does not match exactly' => ['admin.user*.create', ['admin.user*.create'], false], + 'standalone wildcard does not match exactly' => ['*', ['*'], false], + 'leading wildcard does not match exactly' => ['*.create', ['*.create'], false], + ]; + } +}