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 1/5] 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], + ]; + } +} From b0150be7ea09b173fb416d54555b8736588187fa Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Fri, 29 May 2026 16:48:10 +0200 Subject: [PATCH 2/5] refactor(auth): make wildcard grants descendants-only Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- docs/quick_start_guide/using_authorization.md | 4 ++-- docs/references/authorization.md | 12 +++++------- src/Authorization/PermissionMatcher.php | 7 +------ tests/Authorization/AuthorizableTest.php | 6 +++--- tests/Authorization/GroupTest.php | 2 +- tests/Authorization/PermissionMatcherTest.php | 4 ++-- 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/docs/quick_start_guide/using_authorization.md b/docs/quick_start_guide/using_authorization.md index 0526a622e..816238892 100644 --- a/docs/quick_start_guide/using_authorization.md +++ b/docs/quick_start_guide/using_authorization.md @@ -56,9 +56,9 @@ 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`. +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`. 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. +The first segment cannot be `*`, and a standalone `*` permission does not grant all permissions. ## Assign Permissions to a User diff --git a/docs/references/authorization.md b/docs/references/authorization.md index 017628849..59c299bcd 100644 --- a/docs/references/authorization.md +++ b/docs/references/authorization.md @@ -74,7 +74,7 @@ public array $matrix = [ ``` 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. +When the wildcard is trailing, it grants descendant permissions only. The first segment cannot be `*`, and a standalone `*` permission does not grant all permissions. ```php @@ -83,11 +83,10 @@ public array $matrix = [ ]; ``` -For example, `forum.posts.*` matches `forum.posts`, `forum.posts.create`, and `forum.posts.comments.delete`. +For example, `forum.posts.*` matches `forum.posts.create` and `forum.posts.comments.delete`, but not +`forum.posts`. 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`. @@ -96,9 +95,8 @@ Wildcard matching is used by `$user->can()` and `$group->can()` for both user-le !!! 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. + Wildcard permissions can grant access 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 diff --git a/src/Authorization/PermissionMatcher.php b/src/Authorization/PermissionMatcher.php index 3c27295be..33076cfdd 100644 --- a/src/Authorization/PermissionMatcher.php +++ b/src/Authorization/PermissionMatcher.php @@ -52,12 +52,7 @@ private static function matchesWildcardGrant(string $grant, string $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) + return count($permissionSegments) > count($grantSegments) && self::segmentsMatch($grantSegments, array_slice($permissionSegments, 0, count($grantSegments))); } diff --git a/tests/Authorization/AuthorizableTest.php b/tests/Authorization/AuthorizableTest.php index d65dd4d03..c0d00bdf7 100644 --- a/tests/Authorization/AuthorizableTest.php +++ b/tests/Authorization/AuthorizableTest.php @@ -317,9 +317,9 @@ public function testCanCascadesToGroupsWithHierarchicalWildcards(): void $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('forum.posts')); + $this->assertFalse($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')); @@ -335,7 +335,7 @@ public function testCanChecksUserLevelHierarchicalWildcards(): void $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.posts')); $this->assertFalse($this->user->can('forum.users.create')); } diff --git a/tests/Authorization/GroupTest.php b/tests/Authorization/GroupTest.php index c149d129b..4434d071e 100644 --- a/tests/Authorization/GroupTest.php +++ b/tests/Authorization/GroupTest.php @@ -98,8 +98,8 @@ public function testCanWithHierarchicalWildcards(): void $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('forum.posts')); $this->assertFalse($group->can('admin.create')); $this->assertFalse($group->can('admin.users.roles.create')); $this->assertFalse($group->can('admin.users.delete')); diff --git a/tests/Authorization/PermissionMatcherTest.php b/tests/Authorization/PermissionMatcherTest.php index 2a91ead29..ce942d8f2 100644 --- a/tests/Authorization/PermissionMatcherTest.php +++ b/tests/Authorization/PermissionMatcherTest.php @@ -44,7 +44,7 @@ public static function provideMatches(): iterable '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], + 'trailing wildcard does not match parent' => ['admin.users', ['admin.users.*'], false], '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], @@ -54,7 +54,7 @@ public static function provideMatches(): iterable '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 do not match parent' => ['admin.users.create', ['admin.*.create.*'], false], '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], From ed1ba7a34275f012dc837dd2f2370dcc38f9da2b Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 31 May 2026 12:22:44 +0200 Subject: [PATCH 3/5] refactor: optimize permission wildcard matching Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- src/Authorization/PermissionMatcher.php | 92 +++++++++++++++++-------- 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/src/Authorization/PermissionMatcher.php b/src/Authorization/PermissionMatcher.php index 33076cfdd..096b85b0a 100644 --- a/src/Authorization/PermissionMatcher.php +++ b/src/Authorization/PermissionMatcher.php @@ -23,20 +23,46 @@ final class PermissionMatcher */ public static function matches(string $permission, array $grants): bool { - if (! self::isValid($permission)) { + $permissionSegments = self::splitIfValid($permission); + + if ($permissionSegments === null) { return false; } + $permissionSegmentCount = count($permissionSegments); + foreach ($grants as $grant) { - if (! self::isValid($grant)) { + if ($grant === $permission) { + return true; + } + + $wildcardPosition = strpos($grant, '*'); + + if ($wildcardPosition === false) { continue; } - if ($grant === $permission) { - return true; + if ( + $wildcardPosition > 0 + && $wildcardPosition === strlen($grant) - 1 + && $grant[$wildcardPosition - 1] === '.' + ) { + $prefix = substr($grant, 0, $wildcardPosition - 1); + + if (self::isLiteral($prefix) && str_starts_with($permission, $prefix . '.')) { + return true; + } + + continue; } - if (str_contains($grant, '*') && self::matchesWildcardGrant($grant, $permission)) { + $grantSegments = self::splitIfValid($grant); + + if ($grantSegments === null) { + continue; + } + + if (self::matchesWildcardGrant($grantSegments, $permissionSegments, $permissionSegmentCount)) { return true; } } @@ -44,33 +70,33 @@ public static function matches(string $permission, array $grants): bool return false; } - private static function matchesWildcardGrant(string $grant, string $permission): bool + /** + * @param list $grantSegments + * @param list $permissionSegments + */ + private static function matchesWildcardGrant(array $grantSegments, array $permissionSegments, int $permissionSegmentCount): bool { - $grantSegments = explode('.', $grant); - $permissionSegments = explode('.', $permission); + $grantSegmentCount = count($grantSegments); - if (end($grantSegments) === '*') { - array_pop($grantSegments); + if ($grantSegments[$grantSegmentCount - 1] === '*') { + $grantSegmentCount--; - return count($permissionSegments) > count($grantSegments) - && self::segmentsMatch($grantSegments, array_slice($permissionSegments, 0, count($grantSegments))); + return $permissionSegmentCount > $grantSegmentCount + && self::segmentsMatch($grantSegments, $permissionSegments, $grantSegmentCount); } - return self::segmentsMatch($grantSegments, $permissionSegments); + return $permissionSegmentCount === $grantSegmentCount + && self::segmentsMatch($grantSegments, $permissionSegments, $grantSegmentCount); } /** * @param list $grantSegments * @param list $permissionSegments */ - private static function segmentsMatch(array $grantSegments, array $permissionSegments): bool + private static function segmentsMatch(array $grantSegments, array $permissionSegments, int $segmentCount): bool { - if (count($grantSegments) !== count($permissionSegments)) { - return false; - } - - foreach ($grantSegments as $index => $grantSegment) { - if ($grantSegment !== '*' && $grantSegment !== $permissionSegments[$index]) { + for ($index = 0; $index < $segmentCount; $index++) { + if ($grantSegments[$index] !== '*' && $grantSegments[$index] !== $permissionSegments[$index]) { return false; } } @@ -78,20 +104,32 @@ private static function segmentsMatch(array $grantSegments, array $permissionSeg return true; } - private static function isValid(string $permission): bool + /** + * @return list|null + */ + private static function splitIfValid(string $permission): ?array { - $segments = explode('.', $permission); - - if ($segments === ['*'] || $segments[0] === '*') { - return false; + if ($permission === '' || str_starts_with($permission, '*')) { + return null; } + $segments = explode('.', $permission); + foreach ($segments as $segment) { if ($segment === '' || ($segment !== '*' && str_contains($segment, '*'))) { - return false; + return null; } } - return true; + return $segments; + } + + private static function isLiteral(string $permission): bool + { + return $permission !== '' + && ! str_contains($permission, '*') + && ! str_contains($permission, '..') + && ! str_starts_with($permission, '.') + && ! str_ends_with($permission, '.'); } } From 498b35d6e5c18f16ed5d7b6f747d1436b7aa9846 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 31 May 2026 21:21:31 +0200 Subject: [PATCH 4/5] test: cover permission matcher edge cases Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- tests/Authorization/PermissionMatcherTest.php | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/tests/Authorization/PermissionMatcherTest.php b/tests/Authorization/PermissionMatcherTest.php index ce942d8f2..bf3b8fcd7 100644 --- a/tests/Authorization/PermissionMatcherTest.php +++ b/tests/Authorization/PermissionMatcherTest.php @@ -37,23 +37,32 @@ public function testMatches(string $permission, array $grants, bool $expected): 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 does not match parent' => ['admin.users', ['admin.users.*'], false], - '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], + // Exact matches + '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], + 'exact child permission does not match parent' => ['admin.users', ['admin.users.create'], false], + + // Trailing wildcard matches + '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 does not match parent' => ['admin.users', ['admin.users.*'], false], + 'broad wildcard does not match root permission' => ['admin', ['admin.*'], false], + + // Invalid leading and standalone wildcards + '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], + + // Middle wildcard matches + '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], + + // Combined wildcard matches 'middle and trailing wildcards do not match parent' => ['admin.users.create', ['admin.*.create.*'], false], '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], @@ -61,13 +70,32 @@ public static function provideMatches(): iterable '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], + + // Fast-path and string manipulation edge cases + 'grant starting with dot does not match' => ['admin.users.create', ['.admin.*'], false], + 'grant with consecutive dots does not match' => ['admin.users.create', ['admin..users.*'], false], + 'grant being just a dot-star does not match' => ['admin.users', ['.*'], false], + 'trailing wildcard does not match partial prefix' => ['admin.usersExtra.create', ['admin.users.*'], false], + 'trailing wildcard does not match sibling prefix' => ['admin.user.create', ['admin.users.*'], false], + 'valid grant after invalid fast-path grant matches' => ['admin.users.create', ['.admin.*', 'admin.users.*'], true], + + // Whitespace sensitivity and malformed segments + 'permission with trailing space does not match' => ['admin.users.create ', ['admin.users.create'], false], + 'grant with space does not match exact' => ['admin.users.create', ['admin. users.*'], false], + '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], + + // Empty states handling + 'empty grants array returns false' => ['admin.users.create', [], false], + + // Single segment logic validation + 'exact single segment matches' => ['admin', ['admin'], true], + 'single segment does not match child grant' => ['admin', ['admin.users'], false], ]; } } From 2294c68b55e1b907bf929529b75dcd275f74a68a Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:22:41 +0200 Subject: [PATCH 5/5] fix: allow single-segment user permissions Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- docs/quick_start_guide/using_authorization.md | 2 +- docs/references/authorization.md | 4 ++-- src/Authorization/Traits/Authorizable.php | 11 +---------- tests/Authorization/AuthorizableTest.php | 19 ++++++++++++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/quick_start_guide/using_authorization.md b/docs/quick_start_guide/using_authorization.md index 816238892..007748271 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 with dot-separated segments, like `users.create` or `forum.posts.create`. +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. ```php public array $permissions = [ diff --git a/docs/references/authorization.md b/docs/references/authorization.md index 59c299bcd..4826e381f 100644 --- a/docs/references/authorization.md +++ b/docs/references/authorization.md @@ -36,8 +36,8 @@ public string $defaultGroup = 'user'; ## Defining Available Permissions 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. +A permission is a string, usually written with dot-separated segments like `users.create` or +`forum.posts.create`. Single-segment permissions are also allowed. Each permission can have a description for display within UIs if needed. ```php public array $permissions = [ diff --git a/src/Authorization/Traits/Authorizable.php b/src/Authorization/Traits/Authorizable.php index 2f0a088ea..ac3228082 100644 --- a/src/Authorization/Traits/Authorizable.php +++ b/src/Authorization/Traits/Authorizable.php @@ -16,7 +16,6 @@ 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; @@ -256,7 +255,7 @@ public function hasPermission(string $permission): bool * Checks user permissions and their group permissions * to see if the user has one or more permissions. * - * @param string $permissions Dot-separated permission string(s), like `users.create` + * @param string $permissions Permission string(s), usually dot-separated like `users.create` */ public function can(string ...$permissions): bool { @@ -270,14 +269,6 @@ public function can(string ...$permissions): bool $matrix = setting('AuthGroups.matrix'); foreach ($permissions as $permission) { - // Permission must contain at least two dot-separated segments. - if (! str_contains($permission, '.')) { - throw new LogicException( - 'A permission must be a dot-separated string, like `users.create`.' - . ' Invalid permission: ' . $permission, - ); - } - $permission = strtolower($permission); // Check user's permissions diff --git a/tests/Authorization/AuthorizableTest.php b/tests/Authorization/AuthorizableTest.php index c0d00bdf7..692c6acd6 100644 --- a/tests/Authorization/AuthorizableTest.php +++ b/tests/Authorization/AuthorizableTest.php @@ -15,7 +15,6 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authorization\AuthorizationException; -use CodeIgniter\Shield\Exceptions\LogicException; use CodeIgniter\Shield\Models\UserModel; use Locale; use Tests\Support\DatabaseTestCase; @@ -353,12 +352,22 @@ public function testCanChecksLaterPermissionsWithoutGroups(): void $this->assertTrue($this->user->can('beta.access', 'admin.access')); } - public function testCanGetsInvalidPermission(): void + public function testCanChecksSingleSegmentPermission(): void { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Invalid permission: developer'); + $this->addConfigPermissions([ + 'developer' => 'Can access developer tools', + ]); - $this->user->addGroup('superadmin'); + $this->user->addPermission('developer'); + + $this->assertTrue($this->user->can('developer')); + } + + public function testCanChecksSingleSegmentGroupPermission(): void + { + $this->setGroupPermissions('developer', ['developer']); + + $this->user->addGroup('developer'); $this->assertTrue($this->user->can('developer')); }