From 7dd60a32c91a786558d6c830100d852f55f54392 Mon Sep 17 00:00:00 2001 From: Martin Dub Date: Wed, 25 Feb 2026 13:53:14 +0100 Subject: [PATCH] Adding wildcard permission support --- src/Auth/Eloquent/User.php | 23 ++++++- src/Auth/File/Role.php | 21 ++++++- src/Auth/File/User.php | 23 ++++++- src/Auth/UserGroup.php | 25 +++++++- tests/Auth/PermissibleContractTests.php | 81 +++++++++++++++++++++++++ tests/Auth/RoleTest.php | 39 ++++++++++++ tests/Auth/UserGroupTest.php | 81 +++++++++++++++++++++++++ 7 files changed, 288 insertions(+), 5 deletions(-) diff --git a/src/Auth/Eloquent/User.php b/src/Auth/Eloquent/User.php index bbd09ee0b60..c3a5b33bd2f 100644 --- a/src/Auth/Eloquent/User.php +++ b/src/Auth/Eloquent/User.php @@ -232,7 +232,15 @@ public function permissions() public function hasPermission($permission) { - return $this->permissions()->contains($permission); + $permissions = $this->permissions(); + + if ($permissions->contains($permission)) { + return true; + } + + return $permissions->contains(function ($userPermission) use ($permission) { + return $this->matchesWildcard($userPermission, $permission); + }); } public function makeSuper() @@ -411,4 +419,17 @@ public function passkeys(): Collection ->map(fn ($model) => app(Passkey::class)->setModel($model)) ->keyBy(fn ($passkey) => $passkey->id()); } + + protected function matchesWildcard(string $wildcardPermission, string $requestedPermission): bool + { + if (! str_contains($wildcardPermission, '*')) { + return false; + } + + $pattern = preg_quote($wildcardPermission, '/'); + $pattern = str_replace('\*', '.*', $pattern); + $pattern = '/^'.$pattern.'$/'; + + return (bool) preg_match($pattern, $requestedPermission); + } } diff --git a/src/Auth/File/Role.php b/src/Auth/File/Role.php index c02279463aa..3b2b86f1a0e 100644 --- a/src/Auth/File/Role.php +++ b/src/Auth/File/Role.php @@ -98,7 +98,13 @@ public function removePermission($permission) public function hasPermission(string $permission): bool { - return $this->permissions->contains($permission); + if ($this->permissions->contains($permission)) { + return true; + } + + return $this->permissions->contains(function ($rolePermission) use ($permission) { + return $this->matchesWildcard($rolePermission, $permission); + }); } public function isSuper(): bool @@ -106,6 +112,19 @@ public function isSuper(): bool return $this->hasPermission('super'); } + protected function matchesWildcard(string $wildcardPermission, string $requestedPermission): bool + { + if (! str_contains($wildcardPermission, '*')) { + return false; + } + + $pattern = preg_quote($wildcardPermission, '/'); + $pattern = str_replace('\*', '.*', $pattern); + $pattern = '/^'.$pattern.'$/'; + + return (bool) preg_match($pattern, $requestedPermission); + } + public function save() { // TODO: Move this logic into \Statamic\Auth\Role.php to be consistent with \Statamic\Auth\UserGroup? diff --git a/src/Auth/File/User.php b/src/Auth/File/User.php index 65165502256..ae2a9a53323 100644 --- a/src/Auth/File/User.php +++ b/src/Auth/File/User.php @@ -292,7 +292,15 @@ public function permissions() public function hasPermission($permission) { - return $this->permissions()->contains($permission); + $permissions = $this->permissions(); + + if ($permissions->contains($permission)) { + return true; + } + + return $permissions->contains(function ($userPermission) use ($permission) { + return $this->matchesWildcard($userPermission, $permission); + }); } public function makeSuper() @@ -381,6 +389,19 @@ public function getCurrentDirtyStateAttributes(): array ], $this->data()->toArray()); } + protected function matchesWildcard(string $wildcardPermission, string $requestedPermission): bool + { + if (! str_contains($wildcardPermission, '*')) { + return false; + } + + $pattern = preg_quote($wildcardPermission, '/'); + $pattern = str_replace('\*', '.*', $pattern); + $pattern = '/^'.$pattern.'$/'; + + return (bool) preg_match($pattern, $requestedPermission); + } + public function passkeys(): Collection { return $this->passkeys; diff --git a/src/Auth/UserGroup.php b/src/Auth/UserGroup.php index 75cc314fabe..56821776c9d 100644 --- a/src/Auth/UserGroup.php +++ b/src/Auth/UserGroup.php @@ -133,9 +133,17 @@ public function hasRole($role): bool public function hasPermission($permission) { - return $this->roles->reduce(function ($carry, $role) { + $permissions = $this->roles->reduce(function ($carry, $role) { return $carry->merge($role->permissions()); - }, collect())->contains($permission); + }, collect()); + + if ($permissions->contains($permission)) { + return true; + } + + return $permissions->contains(function ($groupPermission) use ($permission) { + return $this->matchesWildcard($groupPermission, $permission); + }); } public function isSuper(): bool @@ -201,4 +209,17 @@ public function blueprint() { return Facades\UserGroup::blueprint(); } + + protected function matchesWildcard(string $wildcardPermission, string $requestedPermission): bool + { + if (! str_contains($wildcardPermission, '*')) { + return false; + } + + $pattern = preg_quote($wildcardPermission, '/'); + $pattern = str_replace('\*', '.*', $pattern); + $pattern = '/^'.$pattern.'$/'; + + return (bool) preg_match($pattern, $requestedPermission); + } } diff --git a/tests/Auth/PermissibleContractTests.php b/tests/Auth/PermissibleContractTests.php index 816036a03f7..9dd337b6f52 100644 --- a/tests/Auth/PermissibleContractTests.php +++ b/tests/Auth/PermissibleContractTests.php @@ -363,4 +363,85 @@ public function it_sets_all_the_groups() 'c' => 'c', ], $user->groups()->map->handle()->all()); } + + #[Test] + public function it_checks_wildcard_permission_with_asterisk_at_beginning() + { + $role = RoleAPI::make('test')->addPermission('* blog entries'); + RoleAPI::shouldReceive('find')->with('test')->andReturn($role); + RoleAPI::shouldReceive('all')->andReturn(collect([$role])); + + $user = $this->createPermissible()->assignRole($role); + $user->save(); + + $this->assertTrue($user->hasPermission('view blog entries')); + $this->assertTrue($user->hasPermission('edit blog entries')); + $this->assertTrue($user->hasPermission('delete blog entries')); + $this->assertFalse($user->hasPermission('view news entries')); + $this->assertFalse($user->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_wildcard_permission_with_asterisk_in_middle() + { + $role = RoleAPI::make('test')->addPermission('view * entries'); + RoleAPI::shouldReceive('find')->with('test')->andReturn($role); + RoleAPI::shouldReceive('all')->andReturn(collect([$role])); + + $user = $this->createPermissible()->assignRole($role); + $user->save(); + + $this->assertTrue($user->hasPermission('view blog entries')); + $this->assertTrue($user->hasPermission('view news entries')); + $this->assertTrue($user->hasPermission('view products entries')); + $this->assertFalse($user->hasPermission('edit blog entries')); + $this->assertFalse($user->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_wildcard_permission_through_user_group() + { + $role = RoleAPI::make('test')->addPermission('view * entries'); + $userGroup = (new UserGroup)->handle('testgroup')->assignRole($role); + + RoleAPI::shouldReceive('find')->with('test')->andReturn($role); + RoleAPI::shouldReceive('all')->andReturn(collect([$role])); + UserGroupAPI::shouldReceive('find')->with('testgroup')->andReturn($userGroup); + UserGroupAPI::shouldReceive('all')->andReturn(collect([$userGroup])); + + $user = $this->createPermissible()->addToGroup($userGroup); + $user->save(); + + $this->assertTrue($user->hasPermission('view blog entries')); + $this->assertTrue($user->hasPermission('view news entries')); + $this->assertTrue($user->hasPermission('view products entries')); + $this->assertFalse($user->hasPermission('edit blog entries')); + $this->assertFalse($user->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_multiple_wildcard_permissions_from_different_sources() + { + $directRole = RoleAPI::make('direct')->addPermission('view * entries'); + $groupRole = RoleAPI::make('grouprole')->addPermission('* blog entries'); + $userGroup = (new UserGroup)->handle('testgroup')->assignRole($groupRole); + + RoleAPI::shouldReceive('find')->with('direct')->andReturn($directRole); + RoleAPI::shouldReceive('find')->with('grouprole')->andReturn($groupRole); + RoleAPI::shouldReceive('all')->andReturn(collect([$directRole, $groupRole])); + UserGroupAPI::shouldReceive('find')->with('testgroup')->andReturn($userGroup); + UserGroupAPI::shouldReceive('all')->andReturn(collect([$userGroup])); + + $user = $this->createPermissible() + ->assignRole($directRole) + ->addToGroup($userGroup); + $user->save(); + + $this->assertTrue($user->hasPermission('view blog entries')); + $this->assertTrue($user->hasPermission('view news entries')); + $this->assertTrue($user->hasPermission('edit blog entries')); + $this->assertTrue($user->hasPermission('delete blog entries')); + $this->assertFalse($user->hasPermission('delete news entries')); + $this->assertFalse($user->hasPermission('view blog posts')); + } } diff --git a/tests/Auth/RoleTest.php b/tests/Auth/RoleTest.php index dd98910c6d5..d3b79361bee 100644 --- a/tests/Auth/RoleTest.php +++ b/tests/Auth/RoleTest.php @@ -138,4 +138,43 @@ public function it_is_arrayable() ->each(fn ($value, $key) => $this->assertEquals($value, $role->{$key})) ->each(fn ($value, $key) => $this->assertEquals($value, $role[$key])); } + + #[Test] + public function it_checks_wildcard_permission_with_asterisk_at_beginning() + { + $role = (new Role)->addPermission('* blog entries'); + + $this->assertTrue($role->hasPermission('view blog entries')); + $this->assertTrue($role->hasPermission('edit blog entries')); + $this->assertTrue($role->hasPermission('delete blog entries')); + $this->assertFalse($role->hasPermission('view news entries')); + $this->assertFalse($role->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_wildcard_permission_with_asterisk_in_middle() + { + $role = (new Role)->addPermission('view * entries'); + + $this->assertTrue($role->hasPermission('view blog entries')); + $this->assertTrue($role->hasPermission('view news entries')); + $this->assertTrue($role->hasPermission('view products entries')); + $this->assertFalse($role->hasPermission('edit blog entries')); + $this->assertFalse($role->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_multiple_wildcard_permissions() + { + $role = (new Role) + ->addPermission('view * entries') + ->addPermission('* blog entries'); + + $this->assertTrue($role->hasPermission('view blog entries')); + $this->assertTrue($role->hasPermission('view news entries')); + $this->assertTrue($role->hasPermission('edit blog entries')); + $this->assertTrue($role->hasPermission('delete blog entries')); + $this->assertFalse($role->hasPermission('delete news entries')); + $this->assertFalse($role->hasPermission('view blog posts')); + } } diff --git a/tests/Auth/UserGroupTest.php b/tests/Auth/UserGroupTest.php index 2d48b1bce1f..8bf2b29cfa3 100644 --- a/tests/Auth/UserGroupTest.php +++ b/tests/Auth/UserGroupTest.php @@ -392,4 +392,85 @@ public function it_clones_internal_collections() $this->assertEquals('A', $group->getSupplement('bar')); $this->assertEquals('B', $clone->getSupplement('bar')); } + + #[Test] + public function it_checks_wildcard_permission_with_asterisk_at_beginning() + { + $role = new class extends Role + { + public function permissions($permissions = null) + { + return collect(['* blog entries']); + } + }; + + $group = UserGroup::make()->assignRole($role); + + $this->assertTrue($group->hasPermission('view blog entries')); + $this->assertTrue($group->hasPermission('edit blog entries')); + $this->assertTrue($group->hasPermission('delete blog entries')); + $this->assertFalse($group->hasPermission('view news entries')); + $this->assertFalse($group->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_wildcard_permission_with_asterisk_in_middle() + { + $role = new class extends Role + { + public function permissions($permissions = null) + { + return collect(['view * entries']); + } + }; + + $group = UserGroup::make()->assignRole($role); + + $this->assertTrue($group->hasPermission('view blog entries')); + $this->assertTrue($group->hasPermission('view news entries')); + $this->assertTrue($group->hasPermission('view products entries')); + $this->assertFalse($group->hasPermission('edit blog entries')); + $this->assertFalse($group->hasPermission('view blog posts')); + } + + #[Test] + public function it_checks_multiple_roles_with_wildcard_permissions() + { + $roleOne = new class extends Role + { + public function handle(?string $handle = null) + { + return 'role_one'; + } + + public function permissions($permissions = null) + { + return collect(['view * entries']); + } + }; + + $roleTwo = new class extends Role + { + public function handle(?string $handle = null) + { + return 'role_two'; + } + + public function permissions($permissions = null) + { + return collect(['* blog entries']); + } + }; + + $group = UserGroup::make() + ->assignRole($roleOne) + ->assignRole($roleTwo); + + $this->assertTrue($group->hasPermission('view blog entries')); + $this->assertTrue($group->hasPermission('view news entries')); + $this->assertTrue($group->hasPermission('edit blog entries')); + $this->assertTrue($group->hasPermission('delete blog entries')); + $this->assertFalse($group->hasPermission('delete news entries')); + $this->assertFalse($group->hasPermission('view blog posts')); + } }