diff --git a/README.md b/README.md index d5149a9..3e70084 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ -# Grant -

- Grant + Grant: roles and permissions as PHP enums for Laravel

@@ -10,36 +8,9 @@ License

-- [Introduction](#introduction) -- [Installation](#installation) - - [Publishing Resources](#publishing-resources) -- [Defining Permissions and Roles](#defining-permissions-and-roles) - - [Permissions](#permissions) - - [Roles](#roles) - - [Inheriting Permissions](#inheriting-permissions) - - [Renaming and Removing Roles](#renaming-and-removing-roles) -- [Assigning Roles](#assigning-roles) - - [Preparing Your User Model](#preparing-your-user-model) - - [Granting and Revoking Roles](#granting-and-revoking-roles) - - [Scoped Roles](#scoped-roles) - - [Caching](#caching) - - [The Grant Facade](#the-grant-facade) -- [Authorizing Actions](#authorizing-actions) - - [Via the Gate](#via-the-gate) - - [Via Policies](#via-policies) - - [Super Admins](#super-admins) -- [Configuration](#configuration) - - [Customizing the Assignment Model](#customizing-the-assignment-model) - - [UUID and ULID Keys](#uuid-and-ulid-keys) -- [Artisan Commands](#artisan-commands) -- [Managing Roles in Filament](#managing-roles-in-filament) -- [Testing](#testing) -- [Development](#development) -- [Credits](#credits) - ## Introduction -Grant provides minimal, enum-first roles and permissions for Laravel. Permissions are a PHP enum, roles are a PHP enum, assignments are stored per user and may be scoped to any Eloquent model, and every authorization check uses Laravel's native Gate: +Grant gives your Laravel app roles and permissions with almost nothing to learn. Permissions are a PHP enum. Roles are a PHP enum. Only the assignments live in the database, and every check goes through Laravel's native Gate: ```php $user->grant(Role::Editor); @@ -47,46 +18,39 @@ $user->grant(Role::Editor); $user->can(Permission::EditPosts); ``` -Because definitions live in code, they are versioned, refactorable, and type-safe. Only assignments live in the database. Grant ships no check API of its own, so removing the package leaves every `can` call in your application valid. - -Grant requires PHP 8.3+ and Laravel 12 or 13. +Grant has no check API of its own, so if you ever remove it, every `can` call in your app keeps working. ## Installation -You may install Grant via the Composer package manager: +Grant requires PHP 8.3+ and Laravel 12 or 13. ```shell composer require shipfastlabs/grant -``` -Next, run the `grant:install` Artisan command. This command publishes Grant's configuration file and migration, then creates starter `app/Enums/Permission.php` and `app/Enums/Role.php` enums if they do not exist yet: - -```shell php artisan grant:install - php artisan migrate ``` -Finally, add the `Shipfastlabs\Grant\HasRoles` trait to the user model configured in `auth.providers.users.model`. +The install command publishes the config and migration, and creates starter `app/Enums/Permission.php` and `app/Enums/Role.php` enums if you don't have them yet. -### Publishing Resources +Then add the `HasRoles` trait to your user model: -The install command publishes everything you need. If you would like to publish an individual resource, you may use the `vendor:publish` Artisan command with one of Grant's publish tags: +```php +use Shipfastlabs\Grant\HasRoles; -```shell -php artisan vendor:publish --tag=grant-config -php artisan vendor:publish --tag=grant-migrations +class User extends Authenticatable +{ + use HasRoles; +} ``` -## Defining Permissions and Roles +## Quick Start -### Permissions +### Define permissions -Permissions are the abilities your application checks. They are defined as a string-backed enum that implements the `Shipfastlabs\Grant\Ability` interface. The interface requires a `deniedMessage` method, which Grant passes to the Gate's denial response: +A permission is a string-backed enum that implements `Ability`. Each case's value is the ability name registered with the Gate. ```php - 'You need editor access to change posts.', - default => 'You are not allowed to do that.', - }; + return 'You are not allowed to do that.'; } } ``` -Each case's backing value is the ability string registered with the Gate, so `Permission::EditPosts` and `'edit-posts'` refer to the same ability. +### Define roles -### Roles - -Roles group permissions for assignment. They are a string-backed enum that implements the `Shipfastlabs\Grant\Role` interface, which requires a `permissions` method returning the permissions the role holds: +A role is a string-backed enum that implements `Role` and lists the permissions it holds. ```php - [!NOTE] -> Roles are for assigning and permissions are for checking. Grant intentionally provides no `role:` middleware or `@role` Blade directive. If you need to gate on a role, add a permission to it instead. - -### Inheriting Permissions - -Grant has no role hierarchy feature because PHP already provides one. Since `permissions` returns an array, you may spread another role's permissions into a case to build one role on top of another: - -```php -public function permissions(): array -{ - return match ($this) { - self::Admin => Permission::cases(), - self::Editor => [ - Permission::EditPosts, - Permission::DeletePosts, - ...self::Viewer->permissions(), - ], - self::Viewer => [Permission::ViewReports], - }; -} -``` - -The `Editor` role now holds every `Viewer` permission in addition to its own. Grant removes duplicate permissions when resolving a user's abilities, so overlapping lists are harmless. - -### Renaming and Removing Roles - -Only a role's backing value is stored in the database, so renaming a case while keeping its value, or renaming any permission, requires no data changes. Changing a role's value or removing a case leaves stored assignments that no longer match the enum. Grant ignores those rows when resolving roles, so affected users silently lose the role rather than triggering an error. - -After changing the enum, run the `grant:sync` Artisan command. It lists every stored value without a matching case and asks what each one should become: - -```shell -php artisan grant:sync -``` - -``` - Stored role [admin] no longer exists. What should its 12 assignments become? - › administrator - editor - viewer - Delete them -``` +> Roles are for assigning, permissions are for checking. Grant intentionally has no `role:` middleware or `@role` directive. If you need to gate on a role, give it a permission. -Assignments are remapped or deleted immediately. If a user already holds the target role in the same scope, the orphaned row is simply removed. When run with `--no-interaction`, the command only reports orphaned values and exits with a failure status, which makes it a convenient deployment check: - -```shell -php artisan grant:sync --no-interaction -``` - -## Assigning Roles - -### Preparing Your User Model - -Add the `Shipfastlabs\Grant\HasRoles` trait to your `User` model. Grant resolves the user model from your `auth.providers.users.model` configuration, so any class configured there will work: +Since `permissions` returns an array, one role can build on another with a plain spread: ```php - [ + Permission::EditPosts, + ...self::Viewer->permissions(), +], ``` -Assignments are stored in the `role_assignments` table with a `user_id` foreign key that cascades on delete, so removing a user removes their assignments at the database level. The trait also provides a `roleAssignments` relationship for direct access to those rows: +### Assign roles ```php -$user->roleAssignments; // Collection -``` - -> [!NOTE] -> Grant supports a single user model. If your application authenticates several models on different guards, only the model configured as the default users provider may hold roles. - -### Granting and Revoking Roles - -The trait provides a small set of methods for managing a user's roles: - -```php -use App\Enums\Role; - $user->grant(Role::Editor); - $user->revoke(Role::Editor); - $user->syncRoles([Role::Editor, Role::Viewer]); + +$user->hasRole(Role::Admin); +$user->roles(); // Collection +$user->permissions(); // Collection ``` -You may inspect the roles and permissions a user currently holds: +### Check permissions -```php -$user->hasRole(Role::Admin); +Use Laravel's authorization features exactly as you already do: -$user->roles(); // Collection +```php +$user->can(Permission::EditPosts); -$user->permissions(); // Collection +Gate::authorize(Permission::DeletePosts); ``` -Permissions are always derived from the enum definitions at call time, so only assignments are read from the database. Enum values are the ability strings, which makes them convenient to share with your frontend: +```blade +@can(Permission::EditPosts) + +@endcan +``` ```php -'permissions' => $user->permissions()->map->value, +Route::put('/posts/{post}', UpdatePostController::class) + ->can(Permission::EditPosts); ``` -### Scoped Roles +That's it. The rest of this document covers the optional extras. + +## Scoped Roles -A role may be granted globally or scoped to any persisted Eloquent model, such as a team or project. To scope a role, pass the model as the `on` argument: +Pass any Eloquent model as `on` to scope a role to a team, project, or anything else: ```php $user->grant(Role::Admin, on: $team); - $user->revoke(Role::Admin, on: $team); - $user->syncRoles([Role::Admin], on: $team); $user->roles(on: $team); -``` - -When authorizing with a scope, Grant resolves the roles held on that model and falls back to the user's global roles. A scoped check never grants less than an unscoped check would: +$user->permissions(on: $team); -```php $user->can(Permission::EditPosts, $team); - -$user->permissions(on: $team); ``` -### Caching +A scoped check also includes the user's global roles, so it never grants less than an unscoped check. -Grant reads a user's assignments from the database once per request and answers every later `roles`, `hasRole`, `permissions`, and `can` call for that user from memory, whether global or scoped. A page that authorizes fifty models in a loop costs one query, and no eager loading is needed. +## Policies -The memo lives only for the current request. Under Octane it is cleared per request and in queue workers per job, so it never leaks between users or jobs. Granting, revoking, or syncing roles through Grant, and saving or deleting a `RoleAssignment` model, clear the affected user. Only bulk query builder writes bypass it. After one of those, or whenever you want a fresh read, flush explicitly: +Policies keep owning model-level rules like ownership. Grant supplies the capability, your policy adds the context: ```php -use Shipfastlabs\Grant\Facades\Grant; - -Grant::flush($user); // one user - -Grant::flush(); // everyone +public function update(User $user, Post $post): bool +{ + return $user->can(Permission::EditPosts) + && $post->author_id === $user->id; +} ``` -Grant does not cache across requests. Definitions already live in code, so the only thing left to cache would be one indexed query per user per request. - -### The Grant Facade - -The `Grant` facade exposes the same assignment operations for situations where calling a method on the model is not convenient. The facade never performs authorization checks: +To skip the repeated check, add the `Requires` attribute. The Gate denies the action unless the user holds the permission, then runs your method: ```php -use Shipfastlabs\Grant\Facades\Grant; +use Shipfastlabs\Grant\Requires; -Grant::grant($user, Role::Editor); -Grant::revoke($user, Role::Editor); -Grant::syncRoles($user, [Role::Viewer]); -Grant::hasRole($user, Role::Editor); +#[Requires(Permission::EditPosts)] +public function update(User $user, Post $post): bool +{ + return $post->author_id === $user->id; +} ``` -## Authorizing Actions +Gate arguments are forwarded, so `#[Requires]` respects roles scoped to the policy's model. -### Via the Gate +## Super Admins -At boot, Grant defines a Gate ability for every case of your permission enum. There is no package-specific check API. Instead, you use each of Laravel's authorization features as you normally would: +Pick one role in `config/grant.php` to pass every Gate check: ```php -use App\Enums\Permission; -use Illuminate\Support\Facades\Gate; +'super_admin' => App\Enums\Role::Admin, +``` -$user->can(Permission::EditPosts); +Set it to `null` to turn the bypass off. Only a globally granted role bypasses the Gate; the same role granted `on: $team` behaves like any other role. -Gate::allows(Permission::EditPosts); +## Renaming and Removing Roles -Gate::authorize(Permission::DeletePosts); // throws with deniedMessage() +Only a role's backing value is stored. Renaming a case, or any permission, needs no data changes. Changing a role's value or deleting a case leaves orphaned rows, which Grant ignores, so those users quietly lose the role. -Gate::inspect(Permission::EditPosts)->message(); -``` +Run `grant:sync` after changing the enum to remap or delete them: -```blade -@can(Permission::EditPosts) - -@endcan +```shell +php artisan grant:sync ``` -```php -Route::put('/posts/{post}', UpdatePostController::class) - ->can(Permission::EditPosts); - -Route::middleware('can:edit-posts')->group(function () { - // ... -}); ``` - -Form requests and controller authorization work the same way: - -```php -public function authorize(): bool -{ - return $this->user()->can(Permission::EditPosts); -} + Stored role [admin] no longer exists. What should its 12 assignments become? + › administrator + editor + viewer + Delete them ``` -### Via Policies +With `--no-interaction` it only reports orphans and exits with a failure status, which makes it a handy deploy check. -Policies continue to own model-level context such as ownership and status. Grant supplies the capability and your policy adds the context: +## Caching -```php -public function update(User $user, Post $post): bool -{ - return $user->can(Permission::EditPosts) - && $post->author_id === $user->id; -} -``` +Grant loads a user's assignments once per request and answers every later `can`, `hasRole`, `roles`, and `permissions` call from memory. Authorizing fifty models in a loop costs one query. -The `Shipfastlabs\Grant\Requires` attribute removes the repeated capability check. When a policy method carries the attribute, the Gate denies the action unless the user holds the given permission, then runs the policy method as usual: +The memo is cleared per request (and per job in queue workers, and under Octane). Granting, revoking, syncing, and saving or deleting a `RoleAssignment` clear it for you. Only bulk query-builder writes bypass it, so flush manually after those: ```php -use Shipfastlabs\Grant\Requires; +use Shipfastlabs\Grant\Facades\Grant; -#[Requires(Permission::EditPosts)] -public function update(User $user, Post $post): bool -{ - return $post->author_id === $user->id; -} +Grant::flush($user); // one user +Grant::flush(); // everyone ``` -The Gate arguments are forwarded to the permission check, so `#[Requires]` honors roles scoped to the policy's model. When the permission is missing, the denial carries that permission's `deniedMessage`. - -### Super Admins +## The Grant Facade -You may designate one role as a super admin in your configuration file. Users holding that role pass every Gate check, including policies, before any other ability is consulted: +The facade mirrors the assignment methods for places where calling the model is awkward. It never performs checks. ```php -'super_admin' => App\Enums\Role::Admin, +Grant::grant($user, Role::Editor); +Grant::revoke($user, Role::Editor); +Grant::syncRoles($user, [Role::Viewer]); +Grant::hasRole($user, Role::Editor); ``` -Set the option to `null` to disable the bypass entirely. Any other value that is not a case of your role enum throws an `InvalidConfigurationException`. - -Only a globally granted super admin role bypasses the Gate. Granting the role scoped to a model, such as `on: $team`, makes the user an ordinary holder of that role's permissions on that team. - ## Configuration -Grant's configuration file is published to `config/grant.php` and contains four options: +`config/grant.php` has four options: ```php return [ @@ -389,37 +251,21 @@ return [ ]; ``` -### Customizing the Assignment Model - -Each row of the `role_assignments` table is represented by the `Shipfastlabs\Grant\Models\RoleAssignment` Eloquent model, which provides `user` and `scopeable` relationships. To add relationships, query scopes, or casts of your own, extend the model and point the `model` option at your subclass: +To add your own relationships or scopes, extend `Shipfastlabs\Grant\Models\RoleAssignment` and point `model` at your subclass. -```php - App\Models\RoleAssignment::class, +```shell +php artisan vendor:publish --tag=grant-config +php artisan vendor:publish --tag=grant-migrations ``` -Grant uses the configured model for every query it makes, including the `roleAssignments` relationship on your user model. - -### UUID and ULID Keys - -The published migration uses `foreignId` for the user and `nullableMorphs` for the scope. If your users use UUID or ULID primary keys, change the user column to `foreignUuid` or `foreignUlid`. If your scopes do, change the scope columns to `nullableUuidMorphs` or `nullableUlidMorphs`. Make these edits before running the migration. - ## Artisan Commands -Grant provides a handful of Artisan commands for inspecting and extending your definitions: - ```shell # List every permission registered with the Gate... php artisan grant:list @@ -432,17 +278,13 @@ php artisan grant:show 1 --on='App\Models\Team:5' php artisan grant:sync ``` -To add a permission or role, add a case to the enum. After adding a role, remember to map its permissions in `Role::permissions`. +To add a permission or role, add a case to the enum. For a new role, map its permissions in `Role::permissions`. -## Managing Roles in Filament +## Filament -Grant ships no admin panel plugin because there is nothing to manage except which roles a user holds, and Filament handles string-backed enums natively. Add a `Select` to your user resource form that reads from `roles()` and writes through `syncRoles()`: +Grant ships no admin panel plugin. Filament already handles backed enums, so a `Select` on your user resource is enough: ```php -use App\Enums\Role; -use App\Models\User; -use Filament\Forms\Components\Select; - Select::make('roles') ->multiple() ->options(Role::class) @@ -455,11 +297,11 @@ Select::make('roles') )), ``` -Implement Filament's `HasLabel` interface on your role enum to control the option labels. For scoped roles, wrap the same select in a `Repeater` alongside a select for the scope model and call `syncRoles` with the `on` argument for each entry. +Implement Filament's `HasLabel` on your role enum to control the labels. ## Testing -To create users with roles already assigned, add a state to your `UserFactory` that grants the role after creation: +Add a factory state to create users with roles: ```php public function role(Role $role, ?Model $on = null): static @@ -470,11 +312,9 @@ public function role(Role $role, ?Model $on = null): static ```php $editor = User::factory()->role(Role::Editor)->create(); - -$admin = User::factory()->role(Role::Admin, on: $team)->create(); ``` -For fluent authorization assertions, add the `Shipfastlabs\Grant\Testing\InteractsWithGrant` trait to your base test case: +For fluent assertions, use the `InteractsWithGrant` trait in your base test case: ```php use Shipfastlabs\Grant\Testing\InteractsWithGrant; @@ -485,8 +325,6 @@ abstract class TestCase extends BaseTestCase } ``` -The trait provides `assertCan`, `assertCannot`, and `withRole` helpers that operate on the currently authenticated user: - ```php $this->actingAs($user) ->assertCannot(Permission::EditPosts) @@ -494,13 +332,13 @@ $this->actingAs($user) ->assertCan(Permission::EditPosts); ``` -## Development +## Contributing -```bash +```shell composer test ``` -Please review the [contribution guide](CONTRIBUTING.md) before opening a pull request. Security issues should follow the [security policy](SECURITY.md). +Please read the [contribution guide](CONTRIBUTING.md) first. For security issues, see the [security policy](SECURITY.md). ## Credits diff --git a/art/og.png b/art/og.png index fc918b2..db34626 100644 Binary files a/art/og.png and b/art/og.png differ