Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/quick_start_guide/using_authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -42,19 +42,24 @@ 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',
],
//
];
```

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.
Expand All @@ -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
Expand Down
45 changes: 38 additions & 7 deletions docs/references/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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.
Expand All @@ -73,23 +73,43 @@ 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 = [
'superadmin' => ['admin.*', 'users.*', 'beta.*'],
];
```

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')) {
Expand Down Expand Up @@ -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`
Expand Down
102 changes: 102 additions & 0 deletions src/Authorization/PermissionMatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter Shield.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string> $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<string> $grantSegments
* @param list<string> $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;
}
}
25 changes: 7 additions & 18 deletions src/Authorization/Traits/Authorizable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand All @@ -270,34 +270,23 @@ 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,
);
}

$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;
}
}
Expand Down
13 changes: 4 additions & 9 deletions src/Entities/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace CodeIgniter\Shield\Entities;

use CodeIgniter\Entity\Entity;
use CodeIgniter\Shield\Authorization\PermissionMatcher;

/**
* Represents a single User Group
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading