Skip to content

Commit 94d66d6

Browse files
authored
feat: add wildcard support (#11)
see readme
1 parent 2d6acf3 commit 94d66d6

File tree

9 files changed

+780
-37
lines changed

9 files changed

+780
-37
lines changed

README.md

Lines changed: 177 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
A unified authorization management system for Laravel that brings together roles, permissions, and feature flags into a
1010
single, type-safe API. Built
11-
on [Spatie Laravel Permission](https://github.com/spatie/laravel-permission), [Laravel Pennant](https://laravel.com/docs/pennant),
11+
on [Spatie Laravel Permission](https://github.com/spatie/laravel-permission). Integrates
12+
with [Laravel Pennant](https://laravel.com/docs/pennant)
1213
and [Laravel Hoist](https://github.com/offload-project/laravel-hoist).
1314

1415
## Features
@@ -30,10 +31,14 @@ and [Laravel Hoist](https://github.com/offload-project/laravel-hoist).
3031

3132
- PHP 8.4+
3233
- Laravel 11+
33-
- Laravel Pennant 1.0+
34-
- Laravel Hoist 1.0+
3534
- Spatie Laravel Permission 6.0+
36-
- Spatie Laravel Data 4.0+
35+
36+
### Works With
37+
38+
Mandate integrates with these packages for optional feature flag support:
39+
40+
- [Laravel Pennant](https://laravel.com/docs/pennant) 1.0+ - Gate permissions/roles behind feature flags
41+
- [Laravel Hoist](https://github.com/offload-project/laravel-hoist) 1.0+ - Enhanced feature flag management
3742

3843
## Installation
3944

@@ -47,14 +52,54 @@ Publish the configuration:
4752

4853
```bash
4954
php artisan vendor:publish --tag=mandate-config
55+
```
56+
57+
Optionally, publish migrations if you want to store `set`, `label`, or `description` columns:
58+
59+
```bash
5060
php artisan vendor:publish --tag=mandate-migrations
5161
```
5262

5363
## Quick Start
5464

55-
### 1. Create Permission Classes
65+
Define roles and permissions directly in config - no classes required:
66+
67+
```php
68+
// config/mandate.php
69+
'role_permissions' => [
70+
'viewer' => [
71+
'users.view',
72+
'posts.view',
73+
],
74+
75+
'editor' => [
76+
'users.view',
77+
'posts.view',
78+
'posts.create',
79+
'posts.update',
80+
],
81+
82+
'admin' => [
83+
'users.*', // Wildcard: all user permissions
84+
'posts.*', // Wildcard: all post permissions
85+
],
86+
],
87+
```
88+
89+
Then sync to database:
90+
91+
```bash
92+
php artisan mandate:sync --seed
93+
```
94+
95+
That's it! For type-safe constants and IDE autocompletion,
96+
see [Defining Classes](#defining-roles-and-permissions-using-classes).
97+
98+
## Defining Roles and Permissions Using Classes
99+
100+
For larger applications, define permissions and roles as classes for type-safety and IDE support.
56101

57-
> These are OPTIONAL but useful for use throughout the codebase)
102+
### Permission Classes
58103

59104
```bash
60105
php artisan mandate:permission UserPermissions --set=users
@@ -70,23 +115,23 @@ use OffloadProject\Mandate\Attributes\PermissionsSet;
70115
final class UserPermissions
71116
{
72117
#[Label('View Users')]
73-
public const string VIEW = 'view users';
118+
public const string VIEW = 'users.view';
74119

75120
#[Label('Create Users')]
76-
public const string CREATE = 'create users';
121+
public const string CREATE = 'users.create';
77122

78123
#[Label('Update Users')]
79-
public const string UPDATE = 'update users';
124+
public const string UPDATE = 'users.update';
80125

81126
#[Label('Delete Users')]
82-
public const string DELETE = 'delete users';
127+
public const string DELETE = 'users.delete';
83128

84129
#[Label('Export Users'), Description('Export user data to CSV')]
85-
public const string EXPORT = 'export users';
130+
public const string EXPORT = 'users.export';
86131
}
87132
```
88133

89-
### 2. Create Role Classes
134+
### Role Classes
90135

91136
```bash
92137
php artisan mandate:role SystemRoles --set=system
@@ -115,7 +160,10 @@ final class SystemRoles
115160
}
116161
```
117162

118-
### 3. Map Roles to Permissions (Config)
163+
### Map Roles to Permissions (Config)
164+
165+
With inheritance defined in role classes, only specify *direct* permissions - inherited permissions resolve
166+
automatically:
119167

120168
```php
121169
// config/mandate.php
@@ -124,28 +172,29 @@ use App\Permissions\PostPermissions;
124172
use App\Roles\SystemRoles;
125173

126174
'role_permissions' => [
127-
SystemRoles::ADMINISTRATOR => [
128-
UserPermissions::class, // All user permissions
129-
PostPermissions::class, // All post permissions
175+
// Viewer gets base permissions
176+
SystemRoles::VIEWER => [
177+
UserPermissions::VIEW,
178+
PostPermissions::VIEW,
130179
],
131180

181+
// Editor inherits Viewer permissions, only add Editor-specific
132182
SystemRoles::EDITOR => [
133-
UserPermissions::VIEW,
134-
PostPermissions::VIEW,
135183
PostPermissions::CREATE,
136184
PostPermissions::UPDATE,
137185
],
138186

139-
SystemRoles::VIEWER => [
140-
UserPermissions::VIEW,
141-
PostPermissions::VIEW,
187+
// Administrator inherits Editor (and transitively Viewer)
188+
SystemRoles::ADMINISTRATOR => [
189+
UserPermissions::class, // All user permissions
190+
PostPermissions::DELETE,
142191
],
143192
],
144193
```
145194

146-
### 4. Define Feature Gates (Optional)
195+
## Feature Gates (Optional)
147196

148-
Features control which permissions/roles are available:
197+
Features control which permissions/roles are available (requires [Pennant or Hoist](#works-with)):
149198

150199
```php
151200
// app/Features/ExportFeature.php
@@ -176,7 +225,7 @@ class ExportFeature
176225
}
177226
```
178227

179-
### 5. Sync to Database
228+
## Sync to Database
180229

181230
```bash
182231
# Initial setup - seeds role permissions from config
@@ -630,6 +679,103 @@ php artisan mandate:sync --seed
630679

631680
This means the database role will have all permissions (direct + inherited) assigned via Spatie.
632681

682+
## Wildcard Permissions
683+
684+
Mandate supports wildcard patterns for permission matching, allowing flexible permission checks and role configuration.
685+
686+
### Wildcard Patterns
687+
688+
The `*` wildcard matches a single segment (does not cross dots):
689+
690+
| Pattern | Matches | Does Not Match |
691+
|----------------|-----------------------------------------|------------------------------------|
692+
| `users.*` | `users.view`, `users.create` | `posts.view`, `users.admin.view` |
693+
| `*.view` | `users.view`, `posts.view` | `users.create`, `admin.users.view` |
694+
| `users.*.view` | `users.admin.view`, `users.public.view` | `users.view`, `posts.admin.view` |
695+
696+
### Using Wildcards in Permission Checks
697+
698+
Check if a user has any permission matching a pattern:
699+
700+
```php
701+
use OffloadProject\Mandate\Facades\Mandate;
702+
703+
// Check if user has any users.* permission
704+
if (Mandate::can($user, 'users.*')) {
705+
// User has at least one permission like users.view, users.create, etc.
706+
}
707+
708+
// Check if user has any *.view permission
709+
if (Mandate::can($user, '*.view')) {
710+
// User has at least one view permission (users.view, posts.view, etc.)
711+
}
712+
```
713+
714+
### Using Wildcards in Config
715+
716+
Assign multiple permissions to a role using wildcards:
717+
718+
```php
719+
// config/mandate.php
720+
'role_permissions' => [
721+
'viewer' => [
722+
'*.view', // All view permissions (users.view, posts.view, etc.)
723+
],
724+
725+
'user-admin' => [
726+
'users.*', // All user permissions
727+
'reports.view', // Plus specific permission
728+
],
729+
730+
'super-admin' => [
731+
UserPermissions::class, // All from class
732+
'*.delete', // Plus all delete permissions
733+
],
734+
],
735+
```
736+
737+
Wildcards are expanded at sync time to the actual matching permissions.
738+
739+
### Using Wildcards in Middleware
740+
741+
Protect routes with wildcard permission patterns:
742+
743+
```php
744+
use OffloadProject\Mandate\Http\Middleware\MandatePermission;
745+
746+
// String-based
747+
Route::get('/users', UserController::class)
748+
->middleware('mandate.permission:users.*');
749+
750+
Route::get('/reports', ReportController::class)
751+
->middleware('mandate.permission:*.view');
752+
753+
// Using the helper
754+
Route::get('/users', UserController::class)
755+
->middleware(MandatePermission::using('users.*'));
756+
```
757+
758+
### Dot-Notation Permissions
759+
760+
For best wildcard support, use dot-notation for permission names:
761+
762+
```php
763+
#[PermissionsSet('users')]
764+
final class UserPermissions
765+
{
766+
public const string VIEW = 'users.view';
767+
public const string CREATE = 'users.create';
768+
public const string UPDATE = 'users.update';
769+
public const string DELETE = 'users.delete';
770+
}
771+
```
772+
773+
This naming convention enables powerful patterns:
774+
775+
- `users.*` - All user permissions
776+
- `*.view` - All view permissions across modules
777+
- `*.delete` - All delete permissions (for admin roles)
778+
633779
## Attributes
634780

635781
### Permission Classes
@@ -643,13 +789,13 @@ This means the database role will have all permissions (direct + inherited) assi
643789

644790
### Role Classes
645791

646-
| Attribute | Target | Description |
647-
|--------------------------------|-------------------|----------------------------------------------|
648-
| `#[RoleSet('name')]` | Class | Groups roles together (required) |
649-
| `#[Label('Human Name')]` | Constant | Human-readable label |
650-
| `#[Description('Details')]` | Constant | Detailed description |
651-
| `#[Guard('web')]` | Class or Constant | Auth guard to use |
652-
| `#[Inherits('parent', ...)]` | Constant | Parent role(s) to inherit permissions from |
792+
| Attribute | Target | Description |
793+
|------------------------------|-------------------|--------------------------------------------|
794+
| `#[RoleSet('name')]` | Class | Groups roles together (required) |
795+
| `#[Label('Human Name')]` | Constant | Human-readable label |
796+
| `#[Description('Details')]` | Constant | Detailed description |
797+
| `#[Guard('web')]` | Class or Constant | Auth guard to use |
798+
| `#[Inherits('parent', ...)]` | Constant | Parent role(s) to inherit permissions from |
653799

654800
## Artisan Commands
655801

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"require": {
2020
"php": "^8.4",
2121
"illuminate/contracts": "^11.0|^12.0",
22-
"illuminate/support": "^11.0|^12.0"
22+
"illuminate/support": "^11.0|^12.0",
23+
"spatie/laravel-data": "^4.18",
24+
"spatie/laravel-permission": "^6.24.0"
2325
},
2426
"require-dev": {
2527
"larastan/larastan": "^3.8.1",
@@ -28,8 +30,6 @@
2830
"orchestra/testbench": "^9.0|^10.8.0",
2931
"pestphp/pest": "^3.0|^4.0",
3032
"pestphp/pest-plugin-laravel": "^3.0|^4.0",
31-
"spatie/laravel-data": "^4.18",
32-
"spatie/laravel-permission": "^6.24.0",
3333
"offload-project/laravel-hoist": "^1.0.0"
3434
},
3535
"autoload": {

src/Services/PermissionRegistry.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use OffloadProject\Mandate\Contracts\FeatureRegistryContract;
1313
use OffloadProject\Mandate\Contracts\PermissionRegistryContract;
1414
use OffloadProject\Mandate\Data\PermissionData;
15+
use OffloadProject\Mandate\Support\WildcardMatcher;
1516
use ReflectionClass;
1617
use ReflectionClassConstant;
1718

@@ -109,9 +110,18 @@ public function available(Model $model): Collection
109110

110111
/**
111112
* Check if a model has a specific permission (considering feature flags).
113+
*
114+
* Supports wildcard patterns:
115+
* - 'users.*' matches any permission starting with 'users.'
116+
* - '*.view' matches any permission ending with '.view'
112117
*/
113118
public function can(Model $model, string $permission): bool
114119
{
120+
// Check if this is a wildcard pattern
121+
if (WildcardMatcher::isWildcard($permission)) {
122+
return $this->canWithWildcard($model, $permission);
123+
}
124+
115125
$permissionData = $this->forModel($model)->firstWhere('name', $permission);
116126

117127
if ($permissionData === null) {
@@ -165,6 +175,23 @@ public function clearCache(): void
165175
{
166176
$this->cachedPermissions = null;
167177
$this->featureMap = null;
178+
WildcardMatcher::clearCache();
179+
}
180+
181+
/**
182+
* Check if a model has any permission matching a wildcard pattern.
183+
*/
184+
private function canWithWildcard(Model $model, string $pattern): bool
185+
{
186+
foreach ($this->forModel($model) as $permissionData) {
187+
if (WildcardMatcher::matches($pattern, $permissionData->name)) {
188+
if ($permissionData->isGranted()) {
189+
return true;
190+
}
191+
}
192+
}
193+
194+
return false;
168195
}
169196

170197
/**

0 commit comments

Comments
 (0)