diff --git a/_archive/docs/permission.md b/_archive/docs/permission.md new file mode 100644 index 000000000..75224ddfa --- /dev/null +++ b/_archive/docs/permission.md @@ -0,0 +1,691 @@ +# Permission + +- [Introduction](#introduction) +- [Installation](#installation) + - [Publishing Files](#publishing-files) + - [Running Migrations](#running-migrations) +- [Configuration](#configuration) + - [Models](#models) + - [Database Connection](#database-connection) + - [Table and Column Names](#table-and-column-names) + - [Cache](#cache) +- [Model Setup](#model-setup) +- [Creating Roles and Permissions](#creating-roles-and-permissions) + - [Creating Permissions](#creating-permissions) + - [Creating Roles](#creating-roles) + - [Assigning Permissions to Roles](#assigning-permissions-to-roles) +- [Working With Roles](#working-with-roles) + - [Assigning Roles](#assigning-roles) + - [Checking Roles](#checking-roles) + - [Removing Roles](#removing-roles) +- [Working With Permissions](#working-with-permissions) + - [Assigning Permissions](#assigning-permissions) + - [Checking Permissions](#checking-permissions) + - [Forbidden Permissions](#forbidden-permissions) + - [Revoking Permissions](#revoking-permissions) + - [Retrieving Permissions](#retrieving-permissions) +- [Using Enums](#using-enums) +- [Middleware](#middleware) + - [Permission Middleware](#permission-middleware) + - [Role Middleware](#role-middleware) +- [Console Commands](#console-commands) +- [Polymorphic Owners](#polymorphic-owners) +- [Custom Models](#custom-models) +- [Caching](#caching) +- [Performance](#performance) + + +## Introduction + +Hypervel's permission package provides role-based access control for Eloquent models. You may create roles and permissions, assign them to users or other models, and check access by role, direct permission, or permission inherited through a role. + +The package is based on Spatie's `laravel-permission` package and adapted for Hypervel. It also supports forbidden permissions, which explicitly deny an ability even when the model receives the same permission directly or through a role. + + +## Installation + +You may install the package using Composer: + +```shell +composer require hypervel/permission +``` + +The package service provider is discovered automatically. + + +### Publishing Files + +You may publish the configuration file and migration using the `vendor:publish` command: + +```shell +php artisan vendor:publish --provider="Hypervel\Permission\PermissionServiceProvider" +``` + +You may also publish the files separately using their tags: + +```shell +php artisan vendor:publish --tag=permission-config + +php artisan vendor:publish --tag=permission-migrations +``` + + +### Running Migrations + +After publishing the migration, run your database migrations: + +```shell +php artisan migrate +``` + +The published migration creates the following tables: + +- `roles` +- `permissions` +- `role_has_permissions` +- `owner_has_permissions` +- `owner_has_roles` + +The `role_has_permissions` and `owner_has_permissions` tables include an `is_forbidden` column used by forbidden permissions. + +> [!WARNING] +> If you customize the table or column names in the permission configuration file, update the published migration before running it. + +> [!NOTE] +> The default migration makes role names and permission names unique. If you need to reuse the same name across multiple guards, update those indexes in the published migration before running it. + + +## Configuration + + +### Models + +You may customize the models used for roles and permissions: + +```php +'models' => [ + 'role' => App\Models\Role::class, + 'permission' => App\Models\Permission::class, +], +``` + +Custom role models must implement the `Hypervel\Permission\Contracts\Role` contract. Custom permission models must implement the `Hypervel\Permission\Contracts\Permission` contract. The easiest way to satisfy these contracts is to extend the package's base models. + + +### Database Connection + +You may store the permission tables on a specific database connection: + +```php +'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + ], +], +``` + +The published migration reads this value when choosing its migration connection. + + +### Table and Column Names + +You may customize the table names used by the relationships: + +```php +'table_names' => [ + 'roles' => 'roles', + 'permissions' => 'permissions', + 'role_has_permissions' => 'role_has_permissions', + 'owner_has_permissions' => 'owner_has_permissions', + 'owner_has_roles' => 'owner_has_roles', +], +``` + +You may also customize the pivot and morph column names: + +```php +'column_names' => [ + 'role_pivot_key' => 'role_id', + 'permission_pivot_key' => 'permission_id', + 'owner_morph_key' => 'owner_id', + 'owner_name' => 'owner', +], +``` + + +### Cache + +The package caches role and permission data to reduce database queries during permission checks: + +```php +'cache' => [ + 'expiration_seconds' => 86400, + 'keys' => [ + 'roles' => 'hypervel.permission.cache.roles', + 'owner_roles' => 'hypervel.permission.cache.owner.roles', + 'owner_permissions' => 'hypervel.permission.cache.owner.permissions', + ], + 'store' => env('PERMISSION_CACHE_STORE', 'default'), +], +``` + +When `store` is `default`, the application's default cache store is used. If an unknown cache store is configured, the permission manager falls back to the `array` cache store. + + +## Model Setup + +To assign roles and permissions to a model, add the `Hypervel\Permission\Traits\HasRole` trait: + +```php + +## Creating Roles and Permissions + + +### Creating Permissions + +You may create permissions using the package's `Permission` model: + +```php +use Hypervel\Permission\Models\Permission; + +$editArticles = Permission::create([ + 'name' => 'edit articles', + 'guard_name' => 'web', +]); + +$deleteArticles = Permission::create([ + 'name' => 'delete articles', + 'guard_name' => 'web', +]); +``` + + +### Creating Roles + +You may create roles using the package's `Role` model: + +```php +use Hypervel\Permission\Models\Role; + +$writer = Role::create([ + 'name' => 'writer', + 'guard_name' => 'web', +]); + +$editor = Role::create([ + 'name' => 'editor', + 'guard_name' => 'web', +]); +``` + + +### Assigning Permissions to Roles + +Roles use the same permission methods as other models: + +```php +use Hypervel\Permission\Models\Role; + +$role = Role::where('name', 'writer')->firstOrFail(); + +$role->givePermissionTo('edit articles'); + +$role->givePermissionTo('delete articles', 'publish articles'); + +$role->syncPermissions(['edit articles', 'publish articles']); +``` + +To replace a role's allowed and forbidden permissions at the same time, pass forbidden permissions as the second argument to `syncPermissions`: + +```php +$role->syncPermissions( + ['edit articles'], + ['delete articles'], +); +``` + + +## Working With Roles + + +### Assigning Roles + +You may assign roles by name, ID, enum, array, or variadic arguments: + +```php +$user->assignRole('writer'); + +$user->assignRole('writer', 'editor'); + +$user->assignRole(['writer', 'editor']); + +$user->assignRole($writer->id); +``` + +To replace all of a model's roles, use `syncRoles`: + +```php +$user->syncRoles('writer', 'editor'); +``` + + +### Checking Roles + +You may check a model's assigned roles: + +```php +if ($user->hasRole('writer')) { + // ... +} + +if ($user->hasAnyRoles(['writer', 'editor'])) { + // ... +} + +if ($user->hasAllRoles(['writer', 'editor'])) { + // ... +} +``` + +You may retrieve only the roles that match a given list: + +```php +$matchingRoles = $user->onlyRoles(['writer', 'admin']); +``` + + +### Removing Roles + +You may remove one or more roles from a model: + +```php +$user->removeRole('writer'); + +$user->removeRole('writer', 'editor'); +``` + + +## Working With Permissions + + +### Assigning Permissions + +You may assign permissions directly to a model: + +```php +$user->givePermissionTo('edit articles'); + +$user->givePermissionTo('edit articles', 'delete articles'); + +$user->givePermissionTo(['edit articles', 'delete articles']); +``` + +To replace all of a model's direct permissions, use `syncPermissions`: + +```php +$user->syncPermissions(['edit articles', 'publish articles']); +``` + + +### Checking Permissions + +The `hasPermission` method checks direct permissions and permissions inherited through roles: + +```php +if ($user->hasPermission('edit articles')) { + // ... +} +``` + +You may also check direct permissions or role permissions separately: + +```php +if ($user->hasDirectPermission('edit articles')) { + // ... +} + +if ($user->hasPermissionViaRoles('edit articles')) { + // ... +} +``` + +You may check whether a model has any or all of a given set of permissions: + +```php +if ($user->hasAnyPermissions(['edit articles', 'delete articles'])) { + // ... +} + +if ($user->hasAllPermissions(['edit articles', 'delete articles'])) { + // ... +} +``` + +To check only direct permissions, use `hasAnyDirectPermissions` or `hasAllDirectPermissions`: + +```php +if ($user->hasAnyDirectPermissions(['edit articles', 'delete articles'])) { + // ... +} + +if ($user->hasAllDirectPermissions(['edit articles', 'delete articles'])) { + // ... +} +``` + + +### Forbidden Permissions + +Forbidden permissions explicitly deny access. A forbidden permission overrides an allowed permission, including permissions inherited through roles: + +```php +$user->givePermissionTo('delete articles'); + +$user->giveForbiddenTo('delete articles'); + +$user->hasPermission('delete articles'); + +// false +``` + +You may check whether a forbidden permission exists directly on the model or through its roles: + +```php +if ($user->hasForbiddenPermission('delete articles')) { + // ... +} + +if ($user->hasForbiddenPermissionViaRoles('delete articles')) { + // ... +} +``` + +The second argument to `syncPermissions` contains forbidden permissions. If a permission is present in both arrays, the forbidden permission wins: + +```php +$user->syncPermissions( + ['view articles', 'edit articles'], + ['edit articles', 'delete articles'], +); +``` + + +### Revoking Permissions + +You may remove permissions from a model: + +```php +$user->revokePermissionTo('edit articles'); + +$user->revokePermissionTo('edit articles', 'delete articles'); +``` + + +### Retrieving Permissions + +You may retrieve the permissions a model receives directly and through roles: + +```php +$permissions = $user->getAllPermissions(); +``` + +To retrieve only permissions inherited through roles, use `getPermissionsViaRoles`: + +```php +$rolePermissions = $user->getPermissionsViaRoles(); +``` + +Forbidden permissions are excluded from these result sets. + + +## Using Enums + +Role and permission methods accept backed enums and unit enums. Backed enums use their `value`; unit enums use their case `name`. + +```php +enum Permission: string +{ + case EditArticles = 'edit articles'; + case DeleteArticles = 'delete articles'; + case PublishArticles = 'publish articles'; +} + +enum Role: string +{ + case Writer = 'writer'; + case Editor = 'editor'; + case Admin = 'admin'; +} +``` + +You may pass enum cases to role and permission methods: + +```php +$user->assignRole(Role::Writer); + +$user->givePermissionTo(Permission::EditArticles); + +if ($user->hasPermission(Permission::EditArticles)) { + // ... +} +``` + +Unit enum case names are used as the role or permission name: + +```php +enum SimplePermission +{ + case EditArticles; + case DeleteArticles; +} + +$user->givePermissionTo(SimplePermission::EditArticles); +``` + + +## Middleware + +The package includes route middleware for checking roles and permissions. Middleware checks require the authenticated user model to use the matching permission methods. + + +### Permission Middleware + +Use `PermissionMiddleware::using` to protect a route by permission: + +```php +use App\Http\Controllers\AdminController; +use Hypervel\Permission\Middleware\PermissionMiddleware; +use Hypervel\Support\Facades\Route; + +Route::get('/admin', [AdminController::class, 'index']) + ->middleware(PermissionMiddleware::using('view admin')); +``` + +When multiple permissions are provided, the user only needs one of them: + +```php +Route::get('/posts/edit', [PostController::class, 'edit']) + ->middleware(PermissionMiddleware::using('edit articles', 'edit all articles')); +``` + + +### Role Middleware + +Use `RoleMiddleware::using` to protect a route by role: + +```php +use App\Http\Controllers\AdminController; +use Hypervel\Permission\Middleware\RoleMiddleware; +use Hypervel\Support\Facades\Route; + +Route::get('/admin', [AdminController::class, 'index']) + ->middleware(RoleMiddleware::using('admin')); +``` + +When multiple roles are provided, the user only needs one of them: + +```php +Route::get('/editor', [EditorController::class, 'index']) + ->middleware(RoleMiddleware::using('editor', 'admin')); +``` + +Middleware may also receive enum cases: + +```php +Route::get('/admin', [AdminController::class, 'index']) + ->middleware(PermissionMiddleware::using(Permission::EditArticles)); + +Route::get('/editor', [EditorController::class, 'index']) + ->middleware(RoleMiddleware::using(Role::Editor, Role::Admin)); +``` + +If the user is not authenticated, the middleware throws `Hypervel\Permission\Exceptions\UnauthorizedException`. If the user is authenticated but does not have the required role or permission, it throws `Hypervel\Permission\Exceptions\RoleException` or `Hypervel\Permission\Exceptions\PermissionException`. + + +## Console Commands + +You may view the permission matrix using the `permission:show` command: + +```shell +php artisan permission:show +``` + +You may limit the output to a specific guard: + +```shell +php artisan permission:show web +``` + +The command supports the `default`, `borderless`, `compact`, and `box` table styles: + +```shell +php artisan permission:show web compact +``` + + +## Polymorphic Owners + +Roles and permissions use polymorphic relationships, so any Eloquent model may receive them: + +```php +assignRole('project-manager'); + +$team->givePermissionTo('manage projects'); +``` + + +## Custom Models + +You may extend the package's base models to add your own behavior: + +```php + [ + 'permission' => App\Models\Permission::class, + 'role' => App\Models\Role::class, +], +``` + + +## Caching + +The permission manager caches all roles with their permissions, each owner's roles, and each owner's direct permissions. The package clears the relevant cache entries when roles or permissions are assigned, removed, or synchronized. + +You may clear all cached role-permission data: + +```php +use Hypervel\Permission\PermissionManager; + +$manager = app(PermissionManager::class); + +$manager->clearAllRolesPermissionsCache(); +``` + +You may clear the cached roles and permissions for a specific owner: + +```php +$manager->clearOwnerCache(User::class, $user->getKey()); +``` + +You may warm the global role-permission cache during deployment: + +```php +$manager->getAllRolesWithPermissions(); +``` + + +## Performance + +Permission checks use cached role and permission data after the first lookup. This keeps repeated checks inexpensive while still allowing role and permission changes to invalidate the affected cache entries. + +If you need to display a model's roles or permissions, eager load the relationships you will render: + +```php +$users = User::with(['roles.permissions', 'permissions'])->get(); +``` + +Eager loading is not required for normal `hasPermission` or `hasRole` checks, since those checks use the package cache. diff --git a/_archive/src/permission/LICENSE.md b/_archive/src/permission/LICENSE.md new file mode 100644 index 000000000..691f6fe96 --- /dev/null +++ b/_archive/src/permission/LICENSE.md @@ -0,0 +1,25 @@ +The MIT License (MIT) + +Copyright (c) Spatie bvba info@spatie.be + +Copyright (c) Hyperf + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/_archive/src/permission/README.md b/_archive/src/permission/README.md new file mode 100644 index 000000000..33a2d1e01 --- /dev/null +++ b/_archive/src/permission/README.md @@ -0,0 +1,6 @@ +Permission for Hypervel +=== + +Migrated from: https://github.com/spatie/laravel-permission + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/permission) \ No newline at end of file diff --git a/_archive/src/permission/composer.json b/_archive/src/permission/composer.json new file mode 100644 index 000000000..c1936b9d1 --- /dev/null +++ b/_archive/src/permission/composer.json @@ -0,0 +1,55 @@ +{ + "name": "hypervel/permission", + "type": "library", + "description": "The permission package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "swoole", + "permission", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Permission\\": "src/" + } + }, + "require": { + "php": "^8.4", + "hypervel/auth": "^0.4", + "hypervel/cache": "^0.4", + "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/database": "^0.4", + "hypervel/http": "^0.4", + "hypervel/support": "^0.4" + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "hypervel": { + "providers": [ + "Hypervel\\Permission\\PermissionServiceProvider" + ] + } + } +} diff --git a/_archive/src/permission/config/permission.php b/_archive/src/permission/config/permission.php new file mode 100644 index 000000000..ac1de0523 --- /dev/null +++ b/_archive/src/permission/config/permission.php @@ -0,0 +1,194 @@ + [ + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Hypervel\Permission\Contracts\Role` contract. + */ + + 'role' => \Hypervel\Permission\Models\Role::class, + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Hypervel\Permission\Contracts\Permission` contract. + */ + + 'permission' => \Hypervel\Permission\Models\Permission::class, + ], + + /* + |-------------------------------------------------------------------------- + | Permission Storage Configuration + |-------------------------------------------------------------------------- + | + | This configuration determines the database connection that will be used + | to store permission-related data. You can specify a different connection + | if you want to store permissions in a separate database. + | + */ + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Permission Table Names + |-------------------------------------------------------------------------- + | + | The following table names are used by the permission package to store + | roles, permissions and their relationships. You may change these names + | to match your existing database schema or naming conventions. + | + */ + + 'table_names' => [ + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'owner_has_permissions' => 'owner_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'owner_has_roles' => 'owner_has_roles', + ], + + /* + |-------------------------------------------------------------------------- + | Permission Column Names + |-------------------------------------------------------------------------- + | + | This configuration allows you to customize the column names used in + | the pivot tables and relationships. You can modify these to match + | your database schema or to resolve naming conflicts. + | + */ + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + + 'role_pivot_key' => 'role_id', + 'permission_pivot_key' => 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `owner_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `owner_uuid`. + */ + + 'owner_morph_key' => 'owner_id', + + /* + * The name of the morphable relation for the owner model. + * This is used to determine the owner type when using polymorphic relations. + */ + + 'owner_name' => 'owner', + ], + /* + |-------------------------------------------------------------------------- + | Permission Cache Configuration + |-------------------------------------------------------------------------- + | + | By default all permissions are cached for 24 hours to speed up performance. + | When permissions or roles are updated the cache is flushed automatically. + | You may optionally indicate a specific cache driver to use for permission + | and role caching using any of the `store` drivers listed in the cache.php + | config file. Using 'default' here means to use the `default` set in cache.php. + | + */ + + 'cache' => [ + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_seconds' => 86400, // 24 hours in seconds + + 'keys' => [ + /* + * The cache key used to store all roles with their permissions. + * This is used for efficient role-permission lookups. + */ + 'roles' => 'hypervel.permission.cache.roles', + /* + * The cache key prefix used to store roles for individual owners. + * The actual key will be: {prefix}:{owner_type}:{owner_id} + */ + 'owner_roles' => 'hypervel.permission.cache.owner.roles', + /* + * The cache key prefix used to store permissions for individual owners. + * The actual key will be: {prefix}:{owner_type}:{owner_id} + */ + 'owner_permissions' => 'hypervel.permission.cache.owner.permissions', + ], + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => env('PERMISSION_CACHE_STORE', 'default'), + ], +]; diff --git a/_archive/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/_archive/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php new file mode 100644 index 000000000..51d24fcaa --- /dev/null +++ b/_archive/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -0,0 +1,87 @@ +getConnection()); + + $schema->create('roles', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name')->unique(); + $table->string('guard_name'); + $table->timestamps(); + + $table->index(['name', 'guard_name']); + }); + + $schema->create('permissions', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('name')->unique(); + $table->string('guard_name'); + $table->timestamps(); + $table->index(['name', 'guard_name']); + }); + $schema->create('role_has_permissions', function (Blueprint $table) { + $table->unsignedBigInteger('permission_id'); + $table->unsignedBigInteger('role_id'); + $table->boolean('is_forbidden'); + $table->timestamps(); + + $table->primary(['permission_id', 'role_id']); + $table->index('role_id'); + $table->index('permission_id'); + }); + + $schema->create('owner_has_permissions', function (Blueprint $table) { + $table->unsignedBigInteger('permission_id'); + $table->morphs('owner'); + $table->boolean('is_forbidden'); + $table->timestamps(); + + $table->primary(['permission_id', 'owner_id', 'owner_type']); + $table->index('owner_id'); + $table->index('permission_id'); + }); + + $schema->create('owner_has_roles', function (Blueprint $table) { + $table->unsignedBigInteger('role_id'); + $table->morphs('owner'); + $table->timestamps(); + + $table->primary(['role_id', 'owner_id', 'owner_type']); + $table->index('owner_id'); + $table->index('role_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $schema = Schema::connection($this->getConnection()); + $schema->dropIfExists('owner_has_roles'); + $schema->dropIfExists('owner_has_permissions'); + $schema->dropIfExists('role_has_permissions'); + $schema->dropIfExists('permissions'); + $schema->dropIfExists('roles'); + } +}; diff --git a/src/permission/src/Console/ShowCommand.php b/_archive/src/permission/src/Console/ShowCommand.php similarity index 100% rename from src/permission/src/Console/ShowCommand.php rename to _archive/src/permission/src/Console/ShowCommand.php diff --git a/src/permission/src/Contracts/Factory.php b/_archive/src/permission/src/Contracts/Factory.php similarity index 100% rename from src/permission/src/Contracts/Factory.php rename to _archive/src/permission/src/Contracts/Factory.php diff --git a/_archive/src/permission/src/Contracts/Permission.php b/_archive/src/permission/src/Contracts/Permission.php new file mode 100644 index 000000000..7e413d448 --- /dev/null +++ b/_archive/src/permission/src/Contracts/Permission.php @@ -0,0 +1,18 @@ +container->make('auth'); + $user = $auth->user(); + if (! $user) { + throw new UnauthorizedException( + 401, + sprintf( + 'User is not authenticated. Cannot check permissions: %s', + self::parsePermissionsToString($permissions) + ) + ); + } + + if (! method_exists($user, 'hasAnyPermissions')) { + throw new UnauthorizedException( + 500, + sprintf( + 'User "%s" does not have the "hasAnyPermissions" method. Cannot check permissions: %s', + /* @phpstan-ignore-next-line */ + $user->getAuthIdentifier(), + self::parsePermissionsToString($permissions) + ) + ); + } + $permissions = explode('|', self::parsePermissionsToString($permissions)); + /* @phpstan-ignore-next-line */ + if (! $user->hasAnyPermissions($permissions)) { + throw new PermissionException( + 403, + sprintf( + 'User "%s" does not have any of the required permissions: %s', + /* @phpstan-ignore-next-line */ + $user->getAuthIdentifier(), + self::parsePermissionsToString($permissions) + ), + null, + [], + 0, + $permissions + ); + } + + return $next($request); + } + + /** + * Generate a unique identifier for the middleware based on the permissions. + */ + public static function using(array|UnitEnum|int|string ...$permissions): string + { + return static::class . ':' . self::parsePermissionsToString($permissions); + } + + public static function parsePermissionsToString(array $permissions) + { + $permissions = Collection::make($permissions) + ->flatten() + ->values() + ->all(); + + $permission = array_map(function ($permission) { + return match (true) { + $permission instanceof BackedEnum => $permission->value, + $permission instanceof UnitEnum => $permission->name, + default => $permission, + }; + }, $permissions); + + return implode('|', $permission); + } +} diff --git a/_archive/src/permission/src/Middleware/RoleMiddleware.php b/_archive/src/permission/src/Middleware/RoleMiddleware.php new file mode 100644 index 000000000..ff4c6ef15 --- /dev/null +++ b/_archive/src/permission/src/Middleware/RoleMiddleware.php @@ -0,0 +1,100 @@ +container->make('auth'); + $user = $auth->user(); + if (! $user) { + throw new UnauthorizedException( + 401, + sprintf( + 'User is not authenticated. Cannot check roles: %s', + self::parseRolesToString($roles) + ) + ); + } + + if (! method_exists($user, 'hasAnyRoles')) { + throw new UnauthorizedException( + 500, + sprintf( + 'User "%s" does not have the "hasAnyRoles" method. Cannot check roles: %s', + /* @phpstan-ignore-next-line */ + $user->getAuthIdentifier(), + self::parseRolesToString($roles) + ) + ); + } + $roles = explode('|', self::parseRolesToString($roles)); + /* @phpstan-ignore-next-line */ + if (! $user->hasAnyRoles($roles)) { + throw new RoleException( + 403, + sprintf( + 'User "%s" does not have any of the required roles: %s', + /* @phpstan-ignore-next-line */ + $user->getAuthIdentifier(), + self::parseRolesToString($roles) + ), + null, + [], + 0, + $roles + ); + } + + return $next($request); + } + + /** + * Generate a unique identifier for the middleware based on the roles. + */ + public static function using(array|UnitEnum|int|string ...$roles): string + { + return static::class . ':' . self::parseRolesToString($roles); + } + + public static function parseRolesToString(array $roles) + { + $roles = Collection::make($roles) + ->flatten() + ->values() + ->all(); + + $role = array_map(function ($role) { + return match (true) { + $role instanceof BackedEnum => $role->value, + $role instanceof UnitEnum => $role->name, + default => $role, + }; + }, $roles); + + return implode('|', $role); + } +} diff --git a/_archive/src/permission/src/Models/Permission.php b/_archive/src/permission/src/Models/Permission.php new file mode 100644 index 000000000..b95a1c9c5 --- /dev/null +++ b/_archive/src/permission/src/Models/Permission.php @@ -0,0 +1,48 @@ + $roles + */ +class Permission extends Model implements PermissionContract +{ + use HasRole; + + /** + * The attributes that are mass assignable. + */ + protected array $fillable = [ + 'name', + 'guard_name', + ]; + + /** + * A permission can be applied to roles. + */ + public function roles(): BelongsToMany + { + return $this->belongsToMany( + config('permission.models.role', Role::class), + config('permission.table_names.role_has_permissions', 'role_has_permissions'), + config('permission.column_names.permission_pivot_key', 'permission_id'), + config('permission.column_names.role_pivot_key', 'role_id'), + ) + ->withTimestamps() + ->withPivot(['is_forbidden']); + } +} diff --git a/_archive/src/permission/src/Models/Role.php b/_archive/src/permission/src/Models/Role.php new file mode 100644 index 000000000..01a2b9748 --- /dev/null +++ b/_archive/src/permission/src/Models/Role.php @@ -0,0 +1,48 @@ + $permissions + */ +class Role extends Model implements RoleContract +{ + use HasPermission; + + /** + * The attributes that are mass assignable. + */ + protected array $fillable = [ + 'name', + 'guard_name', + ]; + + /** + * A role may be given various permissions. + */ + public function permissions(): BelongsToMany + { + return $this->belongsToMany( + config('permission.models.permission', Permission::class), + config('permission.table_names.role_has_permissions', 'role_has_permissions'), + config('permission.column_names.role_pivot_key', 'role_id'), + config('permission.column_names.permission_pivot_key', 'permission_id'), + ) + ->withTimestamps() + ->withPivot(['is_forbidden']); + } +} diff --git a/src/permission/src/PermissionManager.php b/_archive/src/permission/src/PermissionManager.php similarity index 100% rename from src/permission/src/PermissionManager.php rename to _archive/src/permission/src/PermissionManager.php diff --git a/_archive/src/permission/src/PermissionServiceProvider.php b/_archive/src/permission/src/PermissionServiceProvider.php new file mode 100644 index 000000000..522bd86b9 --- /dev/null +++ b/_archive/src/permission/src/PermissionServiceProvider.php @@ -0,0 +1,52 @@ +app->runningInConsole()) { + $this->registerPublishing(); + + $this->commands([ + ShowCommand::class, + ]); + } + } + + /** + * Register the service provider. + */ + public function register(): void + { + $this->mergeConfigFrom( + __DIR__ . '/../config/permission.php', + 'permission' + ); + + $this->app->singleton(Factory::class, PermissionManager::class); + } + + public function registerPublishing(): void + { + $this->publishes([ + __DIR__ . '/../config/permission.php' => config_path('permission.php'), + ], 'permission-config'); + + $this->publishes([ + __DIR__ . '/../database/migrations/2025_07_02_000000_create_permission_tables.php' => database_path( + 'migrations/2025_07_02_000000_create_permission_tables.php' + ), + ], 'permission-migrations'); + } +} diff --git a/src/permission/src/Traits/HasPermission.php b/_archive/src/permission/src/Traits/HasPermission.php similarity index 100% rename from src/permission/src/Traits/HasPermission.php rename to _archive/src/permission/src/Traits/HasPermission.php diff --git a/src/permission/src/Traits/HasRole.php b/_archive/src/permission/src/Traits/HasRole.php similarity index 100% rename from src/permission/src/Traits/HasRole.php rename to _archive/src/permission/src/Traits/HasRole.php diff --git a/tests/Permission/Enums/Permission.php b/_archive/tests/Permission/Enums/Permission.php similarity index 100% rename from tests/Permission/Enums/Permission.php rename to _archive/tests/Permission/Enums/Permission.php diff --git a/tests/Permission/Enums/Role.php b/_archive/tests/Permission/Enums/Role.php similarity index 100% rename from tests/Permission/Enums/Role.php rename to _archive/tests/Permission/Enums/Role.php diff --git a/tests/Permission/HasPermissionTest.php b/_archive/tests/Permission/HasPermissionTest.php similarity index 100% rename from tests/Permission/HasPermissionTest.php rename to _archive/tests/Permission/HasPermissionTest.php diff --git a/tests/Permission/HasRoleTest.php b/_archive/tests/Permission/HasRoleTest.php similarity index 100% rename from tests/Permission/HasRoleTest.php rename to _archive/tests/Permission/HasRoleTest.php diff --git a/_archive/tests/Permission/Middleware/PermissionMiddlewareTest.php b/_archive/tests/Permission/Middleware/PermissionMiddlewareTest.php new file mode 100644 index 000000000..3597f1b23 --- /dev/null +++ b/_archive/tests/Permission/Middleware/PermissionMiddlewareTest.php @@ -0,0 +1,149 @@ +container = m::mock(Container::class); + $this->authManager = m::mock(AuthManager::class); + $this->container->shouldReceive('make') + ->with('auth') + ->andReturn($this->authManager); + + $this->middleware = new PermissionMiddleware($this->container); + $this->request = Request::create('http://example.com'); + $this->response = new Response; + $this->next = fn () => $this->response; + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + public function testProcessThrowsUnauthorizedExceptionWhenUserNotLoggedIn(): void + { + $this->authManager->shouldReceive('user')->once()->andReturn(null); + + $this->expectException(UnauthorizedException::class); + + $this->middleware->handle($this->request, $this->next, 'view'); + } + + public function testProcessThrowsUnauthorizedExceptionWhenUserMissingHasAnyPermissionMethod(): void + { + $user = m::mock(); + $user->shouldReceive('getAuthIdentifier')->andReturn(''); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage( + 'User "" does not have the "hasAnyPermissions" method. Cannot check permissions: view' + ); + + $this->middleware->handle($this->request, $this->next, 'view'); + } + + public function testProcessThrowsPermissionExceptionWhenUserLacksPermission(): void + { + $user = User::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $this->expectException(PermissionException::class); + $this->expectExceptionMessage( + 'User "' . $user->getAuthIdentifier() . '" does not have any of the required permissions: view' + ); + + $this->middleware->handle($this->request, $this->next, 'view'); + } + + public function testProcessSucceedsWhenUserHasPermission(): void + { + $user = User::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + Permission::create([ + 'name' => 'view', + 'guard_name' => 'web', + ]); + + $user->givePermissionTo('view'); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $result = $this->middleware->handle($this->request, $this->next, 'view'); + + $this->assertSame($this->response, $result); + } + + public function testProcessWithMultiplePermissionsSucceedsWhenUserHasAny(): void + { + $user = User::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + Permission::create([ + 'name' => 'view', + 'guard_name' => 'web', + ]); + + $user->givePermissionTo('view'); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $result = $this->middleware->handle($this->request, $this->next, 'view', 'edit'); + + $this->assertSame($this->response, $result); + } + + public function testParsePermissionsToStringWithMixedArray(): void + { + $result = PermissionMiddleware::parsePermissionsToString([ + 'view', + PermissionEnum::Edit, + 'manage', + ]); + + $this->assertEquals('view|edit|manage', $result); + } +} diff --git a/_archive/tests/Permission/Middleware/RoleMiddlewareTest.php b/_archive/tests/Permission/Middleware/RoleMiddlewareTest.php new file mode 100644 index 000000000..958a6e524 --- /dev/null +++ b/_archive/tests/Permission/Middleware/RoleMiddlewareTest.php @@ -0,0 +1,149 @@ +container = m::mock(Container::class); + $this->authManager = m::mock(AuthManager::class); + $this->container->shouldReceive('make') + ->with('auth') + ->andReturn($this->authManager); + + $this->middleware = new RoleMiddleware($this->container); + $this->request = Request::create('http://example.com'); + $this->response = new Response; + $this->next = fn () => $this->response; + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + public function testProcessThrowsUnauthorizedExceptionWhenUserNotLoggedIn(): void + { + $this->authManager->shouldReceive('user')->once()->andReturn(null); + + $this->expectException(UnauthorizedException::class); + + $this->middleware->handle($this->request, $this->next, 'admin'); + } + + public function testProcessThrowsUnauthorizedExceptionWhenUserMissingHasAnyRolesMethod(): void + { + $user = m::mock(); + $user->shouldReceive('getAuthIdentifier')->andReturn(''); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage( + 'User "" does not have the "hasAnyRoles" method. Cannot check roles: admin' + ); + + $this->middleware->handle($this->request, $this->next, 'admin'); + } + + public function testProcessThrowsRoleExceptionWhenUserLacksRole(): void + { + $user = User::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $this->expectException(RoleException::class); + $this->expectExceptionMessage( + 'User "' . $user->getAuthIdentifier() . '" does not have any of the required roles: admin' + ); + + $this->middleware->handle($this->request, $this->next, 'admin'); + } + + public function testProcessSucceedsWhenUserHasRole(): void + { + $user = User::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + Role::create([ + 'name' => 'admin', + 'guard_name' => 'web', + ]); + + $user->assignRole('admin'); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $result = $this->middleware->handle($this->request, $this->next, 'admin'); + + $this->assertSame($this->response, $result); + } + + public function testProcessWithMultipleRolesSucceedsWhenUserHasAny(): void + { + $user = User::create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + Role::create([ + 'name' => 'admin', + 'guard_name' => 'web', + ]); + + $user->assignRole('admin'); + + $this->authManager->shouldReceive('user')->once()->andReturn($user); + + $result = $this->middleware->handle($this->request, $this->next, 'admin', 'viewer'); + + $this->assertSame($this->response, $result); + } + + public function testParseRolesToStringWithMixedArray(): void + { + $result = RoleMiddleware::parseRolesToString([ + 'admin', + RoleEnum::Viewer, + 'manager', + ]); + + $this->assertEquals('admin|viewer|manager', $result); + } +} diff --git a/tests/Permission/Models/User.php b/_archive/tests/Permission/Models/User.php similarity index 100% rename from tests/Permission/Models/User.php rename to _archive/tests/Permission/Models/User.php diff --git a/tests/Permission/PermissionManagerTest.php b/_archive/tests/Permission/PermissionManagerTest.php similarity index 100% rename from tests/Permission/PermissionManagerTest.php rename to _archive/tests/Permission/PermissionManagerTest.php diff --git a/tests/Permission/PermissionTestCase.php b/_archive/tests/Permission/PermissionTestCase.php similarity index 100% rename from tests/Permission/PermissionTestCase.php rename to _archive/tests/Permission/PermissionTestCase.php diff --git a/tests/Permission/migrations/2025_07_01_000000_create_users_table.php b/_archive/tests/Permission/migrations/2025_07_01_000000_create_users_table.php similarity index 100% rename from tests/Permission/migrations/2025_07_01_000000_create_users_table.php rename to _archive/tests/Permission/migrations/2025_07_01_000000_create_users_table.php diff --git a/composer.json b/composer.json index 66c61f7c3..7d82318dc 100644 --- a/composer.json +++ b/composer.json @@ -105,6 +105,7 @@ "src/log/src/functions.php", "src/prompts/src/helpers.php", "src/reflection/src/helpers.php", + "src/permission/src/helpers.php", "src/collections/src/functions.php", "src/collections/src/helpers.php", "src/support/src/functions.php", diff --git a/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md b/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md new file mode 100644 index 000000000..38a76915a --- /dev/null +++ b/docs/plans/2026-06-24-2406-permission-fresh-spatie-port.md @@ -0,0 +1,1184 @@ +# Fresh Port Plan: `hypervel/permission` From `spatie/laravel-permission` + +## Objective + +Fresh-port the current `spatie/laravel-permission` package into `src/permission` for Hypervel 0.4, while preserving the useful Hypervel-specific improvements from the existing package: + +- Forbidden permissions, where a denied permission overrides direct and role-granted allows. +- Hot permission-check caching tuned for long-lived Swoole workers. +- Coroutine-safe request/team state. +- Strict PHP 8.4+ types, docblocks, and PHPStan-clean code. + +The current Hypervel permission package was originally built for the older Hypervel 0.3 / Hyperf-based line and then carried forward. It is missing large parts of the current Spatie package. This work should be treated as a fresh package port, not a patch against the existing implementation. + +Before implementation starts, move the current package body into an archive location and leave only the empty package skeleton in place: + +```text +_archive/ +└── permission/ + └── ...current package files... + +src/permission/ +├── LICENSE.md +├── README.md +└── composer.json +``` + +The archive is reference material only. New source and tests should be copied from upstream Spatie one file at a time, then adapted for Hypervel. + +## Source Inputs + +Use these sources: + +- Upstream Spatie package: `/tmp/spatie-laravel-permission` +- Current Hypervel package: `src/permission` +- Current Hypervel tests: `tests/Permission` +- Current Hypervel docs: `src/boost/docs/permission.md` +- Upstream Spatie docs: `/tmp/spatie-laravel-permission/docs` +- Upstream Spatie Boost skill: `/tmp/spatie-laravel-permission/resources/boost/skills/laravel-permission-development/SKILL.md` +- Cache memo / stack behavior: `src/cache/src/CacheManager.php`, `src/cache/src/MemoizedStore.php`, `src/cache/src/StackStore.php`, `tests/Cache/CacheMemoizedStoreTest.php` +- Global test cleanup registry: `tests/AfterEachTestSubscriber.php` + +The upstream package should be current before porting: + +```sh +git -C /tmp/spatie-laravel-permission pull --ff-only +``` + +The current upstream state inspected for this plan was commit `c2c871a` with tags through `8.0.0`. + +## Main Decisions + +1. Fresh port, not incremental repair. + + The existing Hypervel package is much smaller than upstream Spatie. A fresh port is safer and clearer because it avoids preserving old 0.3-era structure by accident. + +2. Use Spatie / Laravel naming for schema and APIs. + + Use `model_has_roles`, `model_has_permissions`, `model_morph_key`, and the morph relation name `model`. The existing `owner_*` naming is a bad divergence: it makes docs, upstream tests, future merges, and AI-assisted maintenance harder without a real runtime benefit. + +3. Use Spatie plural traits as canonical. + + Implement `HasRoles` and `HasPermissions`. Do not create singular trait aliases named `HasRole` or `HasPermission` in the fresh port. The docs should teach only the Spatie-style plural traits. + +4. Port Passport client-credentials support. + + Hypervel Passport will be added soon. The permission package should already include Spatie's Passport client path so routes protected by role/permission middleware can authorize machine-to-machine clients once Passport lands. + +5. Do not port Laravel Octane support as behavior. + + Hypervel is Swoole-native, not Laravel running under Octane. Team/request state must live in `CoroutineContext`; cache freshness is handled by configured cache stores, per-coroutine memo, stack cache, and explicit invalidation. Porting `register_octane_reset_listener` would add a Laravel-specific mechanism that does not belong in Hypervel. + +6. Port teams, including the teams setup command. + + Teams are a supported Spatie feature and should be available in Hypervel. The base published migration should support teams when teams are enabled before the initial migration, and `permission:setup-teams` should be ported so an app can enable teams after its first install by publishing the teams upgrade migration stub. + +7. Preserve forbidden permissions. + + This is a real Hypervel improvement. It should be integrated into the Spatie API shape rather than kept as a separate old implementation. + +8. Use configured cache plus `CacheManager::memo()`. + + Permission data should be backed by the configured cache store so cache invalidation works across workers and nodes. Wrap hot reads in the cache manager's memoized repository so repeated checks inside one coroutine do not repeatedly hit Redis / database / file cache. This is better than `once()` because it composes with the cache repository, cache invalidation, and stack stores. + +9. Cache stable metadata in worker memory only when safe. + + Config-derived class names, table names, pivot keys, and booleans can be cached on the registrar for the worker lifetime because config is process-global after boot. Mutable permission assignment data must not be stored only in raw static properties because it would become stale across workers and deployments. + +10. Events should be dormant until useful. + + Keep Spatie's `events_enabled` config, and additionally avoid constructing / dispatching event objects unless the dispatcher has listeners for that event class. This matches Hypervel's event-cost policy. + +## Upstream Surface To Port + +Port these upstream source files, preserving upstream order inside classes and adapting namespaces/types: + +```text +Commands/AssignRoleCommand.php +Commands/CacheResetCommand.php +Commands/CreatePermissionCommand.php +Commands/CreateRoleCommand.php +Commands/ShowCommand.php +Commands/UpgradeForTeamsCommand.php +Contracts/Permission.php +Contracts/PermissionsTeamResolver.php +Contracts/Role.php +Contracts/Wildcard.php +DefaultTeamResolver.php +Events/PermissionAttachedEvent.php +Events/PermissionDetachedEvent.php +Events/RoleAttachedEvent.php +Events/RoleDetachedEvent.php +Exceptions/GuardDoesNotMatch.php +Exceptions/PermissionAlreadyExists.php +Exceptions/PermissionDoesNotExist.php +Exceptions/RoleAlreadyExists.php +Exceptions/RoleDoesNotExist.php +Exceptions/TeamModelNotConfigured.php +Exceptions/TeamsNotEnabled.php +Exceptions/UnauthorizedException.php +Exceptions/WildcardPermissionInvalidArgument.php +Exceptions/WildcardPermissionNotImplementsContract.php +Exceptions/WildcardPermissionNotProperlyFormatted.php +Guard.php +Middleware/PermissionMiddleware.php +Middleware/RoleMiddleware.php +Middleware/RoleOrPermissionMiddleware.php +Models/Permission.php +Models/Role.php +PermissionRegistrar.php +PermissionServiceProvider.php +Support/Config.php +Traits/HasAssignedModels.php +Traits/HasPermissions.php +Traits/HasRoles.php +Traits/RefreshesPermissionCache.php +WildcardPermission.php +helpers.php +``` + +Also port and adapt these upstream package files: + +```text +config/permission.php +database/migrations/create_permission_tables.php.stub +database/migrations/add_teams_fields.php.stub +``` + +## Current Hypervel Improvements To Preserve + +### Forbidden Permissions + +Preserve the current `is_forbidden` concept: + +- Add `is_forbidden` to `role_has_permissions`. +- Add `is_forbidden` to `model_has_permissions`. +- `giveForbiddenTo(...)` attaches permissions with `is_forbidden = true`. +- `hasForbiddenPermission(...)` checks direct forbidden permissions. +- `hasForbiddenPermissionViaRoles(...)` checks forbidden permissions inherited through assigned roles. +- `hasPermissionTo(...)`, `checkPermissionTo(...)`, Gate checks, and middleware checks must return false when a forbidden permission applies. +- If a permission is both allowed and forbidden, forbidden wins. +- `getAllPermissions()` and `getPermissionsViaRoles()` should exclude forbidden permissions from the allowed result sets. + +Use Spatie's canonical methods as the public surface: + +- `hasPermissionTo(...)` +- `hasAnyPermission(...)` +- `hasAllPermissions(...)` +- `hasDirectPermission(...)` +- `hasAnyDirectPermission(...)` +- `hasRole(...)` +- `hasAnyRole(...)` +- `hasAllRoles(...)` + +Do not keep the old Hypervel singular trait names or old convenience aliases such as `hasPermission(...)`, `hasAnyPermissions(...)`, `hasAnyRoles(...)`, or `hasPermissionViaRoles(...)`. Hypervel 0.4 is greenfield, and preserving old 0.3-era aliases would make the fresh port less Spatie-compatible without improving the new package. + +### Unit Enum Inputs + +Preserve Hypervel 0.3's unit-enum support. Spatie's package only types many public inputs as `BackedEnum|string`, but Hypervel's `enum_value()` also supports pure `UnitEnum` by using the case name. Widen native contracts and model signatures to `UnitEnum` where enum names are valid inputs so pure unit enums are not rejected before the method body runs. + +### Owner / Model Assignment Caching + +Keep the useful idea from the current `PermissionManager`: direct model roles and direct model permissions are hot paths and should not query the database repeatedly during one request/job. + +Implement this through the new `PermissionRegistrar`: + +- Global permission/role cache stores current role and permission records needed to hydrate checks, extended to include `is_forbidden` pivot data. +- Model direct-role cache stores assignment identifiers and pivot flags, not full role records. +- Model direct-permission cache stores assignment identifiers and pivot flags, not full permission records. +- Cache keys include morph class, model key, and active team id when teams are enabled. +- Cache keys include a registrar assignment-cache version so global role/permission saves, deletes, and cache resets cannot leave old model assignment caches pointing at stale role or permission records. +- Cache keys use Spatie naming (`model`, not `owner`). +- Affected model cache entries are forgotten when direct role/permission assignments change. +- Role/permission model saves and deletes clear the global cached catalog and bump the assignment-cache version. +- Role/permission model saves and deletes clear global cached permissions through `RefreshesPermissionCache`. + +Example key shape: + +```php +public const MODEL_ROLES_CACHE_KEY_PREFIX = 'hypervel.permission.cache.model.roles'; +public const MODEL_PERMISSIONS_CACHE_KEY_PREFIX = 'hypervel.permission.cache.model.permissions'; +public const MODEL_CACHE_VERSION_KEY = 'hypervel.permission.cache.model.version'; + +protected function modelCacheKey(string $prefix, Model $model): string +{ + $teamId = $this->teams ? (string) ($this->getPermissionsTeamId() ?? 'global') : 'none'; + $version = $this->assignmentCacheVersion(); + + return "{$prefix}:{$version}:{$model->getMorphClass()}:{$model->getKey()}:{$teamId}"; +} +``` + +Use `$model->getMorphClass()` because it matches the persisted `model_type` value and honors morph maps. + +## Hypervel Adaptations + +### Namespaces And File Conventions + +Convert: + +- `Spatie\Permission\...` to `Hypervel\Permission\...` +- `Illuminate\...` to the matching `Hypervel\...` namespace +- `Illuminate\Support\enum_value` to `Hypervel\Support\enum_value` +- Laravel facades/helpers in class code to injected dependencies or container-resolved services where constructor injection is not practical + +Every PHP file needs: + +```php +mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); + + $this->app->singleton(PermissionRegistrar::class, fn ($app) => new PermissionRegistrar( + $app->make(CacheManager::class), + $app->make('config'), + $app, + )); + + $this->app->bind(PermissionContract::class, fn ($app) => $app->make( + $app->make('config')->string('permission.models.permission') + )); + + $this->app->bind(RoleContract::class, fn ($app) => $app->make( + $app->make('config')->string('permission.models.role') + )); +} +``` + +Register middleware aliases at boot: + +```php +$router = $this->app->make('router'); + +$router->aliasMiddleware('role', RoleMiddleware::class); +$router->aliasMiddleware('permission', PermissionMiddleware::class); +$router->aliasMiddleware('role_or_permission', RoleOrPermissionMiddleware::class); +``` + +Register `AboutCommand` under `Hypervel Permissions`, not `Spatie Permissions`. Do not include Octane in the feature list. Include: + +- Teams +- Wildcard Permissions +- Passport Client Credentials +- Forbidden Permissions + +### Intentional Difference Comments + +At the spot where an Octane listener would otherwise be ported, include: + +```php +// Laravel Octane reset listeners are not ported. Hypervel stores transient team +// state in CoroutineContext and keeps permission cache freshness in the cache layer. +``` + +This comment is justified because it records a whole intentionally omitted upstream feature at the exact future-porting point. + +### Config + +Use Spatie's config shape with Hypervel namespaces and the Hypervel additions. + +Required keys: + +```php +return [ + 'models' => [ + 'permission' => \Hypervel\Permission\Models\Permission::class, + 'role' => \Hypervel\Permission\Models\Role::class, + 'team' => null, + 'default_model' => null, + ], + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + ], + ], + + 'table_names' => [ + 'roles' => 'roles', + 'permissions' => 'permissions', + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + 'role_pivot_key' => null, + 'permission_pivot_key' => null, + 'model_morph_key' => 'model_id', + 'team_foreign_key' => 'team_id', + ], + + 'register_permission_check_method' => true, + 'events_enabled' => false, + 'teams' => false, + 'team_resolver' => \Hypervel\Permission\DefaultTeamResolver::class, + 'use_passport_client_credentials' => false, + 'display_permission_in_exception' => false, + 'display_role_in_exception' => false, + 'enable_wildcard_permission' => false, + 'wildcard_permission' => \Hypervel\Permission\WildcardPermission::class, + + 'cache' => [ + 'expiration_seconds' => 86400, + 'store' => 'default', + 'keys' => [ + 'roles' => 'hypervel.permission.cache.roles', + 'model_roles' => 'hypervel.permission.cache.model.roles', + 'model_permissions' => 'hypervel.permission.cache.model.permissions', + 'model_version' => 'hypervel.permission.cache.model.version', + ], + 'column_names_except' => ['created_at', 'updated_at', 'deleted_at'], + ], +]; +``` + +Use typed config access inside package code. Do not mutate config at runtime. + +### Migrations + +Create the greenfield base migration for Hypervel 0.4 and port the teams upgrade migration stub. The base migration should read config table and column names at runtime and create the final schema directly when teams are already enabled. The teams setup command should publish or create the upgrade migration stub for apps that enable teams after the base migration has already run. + +Changes from Spatie: + +- Use Hypervel namespaces. +- Include `is_forbidden` columns. +- Keep teams support in the primary migration and also ship the teams upgrade stub for later opt-in. +- Use `timestamp`, not `timestampTz`. +- Include a real `down()` method on the base create migration, matching Spatie and the existing Hypervel migration. +- Use configured connection. +- Keep custom primary key support by using configured pivot key names and model key config. +- Preserve Spatie's pivot foreign keys with `cascadeOnDelete()` on all three pivot tables. +- Preserve Spatie's `(name, guard_name)` unique constraints for roles and permissions when teams are disabled, and `(team_foreign_key, name, guard_name)` for roles when teams are enabled. + +The schema should create: + +```text +permissions +roles +model_has_permissions +model_has_roles +role_has_permissions +``` + +When teams are enabled: + +- `roles` has nullable team foreign key. +- `model_has_permissions` primary key includes team foreign key. +- `model_has_roles` primary key includes team foreign key. +- role unique constraints include team foreign key. +- pivot foreign keys still cascade on delete. + +Add `is_forbidden`: + +```php +$table->boolean('is_forbidden')->default(false); +``` + +Use default false because this is a security flag and because old Hypervel semantics treat normal grants as allowed unless explicitly forbidden. + +Include `is_forbidden` in the primary keys for `model_has_permissions` and `role_has_permissions`. This allows an allow row and a forbidden row to coexist for the same permission tuple so forbidden permissions can override allows. + +### Permission Registrar + +Port Spatie's `PermissionRegistrar` as the central service, replacing the current `PermissionManager`. + +Keep the public class name `PermissionRegistrar` to match Spatie. Remove the current Hypervel-specific `Contracts\Factory` surface from the fresh port and bind `PermissionRegistrar` directly. Bind Spatie-compatible `Contracts\Permission` and `Contracts\Role` to the configured model classes. + +The registrar owns: + +- configured model classes +- configured team resolver +- configured cache repository +- cache key names +- pivot key names +- teams flag and team key +- Gate registration +- cache clearing +- cache hydration + +Avoid Spatie's process-global permission collection as a stale source of truth. Use the configured cache store plus memoized repository for read paths: + +```php +protected function cacheRepository(): Repository +{ + $store = $this->cacheStoreName === 'default' ? null : $this->cacheStoreName; + + return $this->cacheManager->memo($store); +} +``` + +The first read in a coroutine goes to the configured cache store. Later reads in the same coroutine come from the memo layer. Cross-worker freshness still depends on the configured cache backend and explicit invalidation. + +When serializing the global permission cache, use a simple explicit payload instead of Spatie's alias-compressed payload. Include: + +- permission attributes +- role relation +- role attributes +- `is_forbidden` pivot data for role-permission rows + +Honor `permission.cache.column_names_except` when building the explicit payload so timestamp and soft-delete columns can be stripped without keeping Spatie's alias-compression complexity. + +When hydrating, restore Eloquent model instances using `setRawAttributes()` and set the `roles` relation. Do not fire retrieved events for cache hydration. + +Do not port Spatie's `isLoadingPermissions` retry loop. That loop is a Laravel-process guard around in-memory cache hydration. Hypervel should use the configured cache repository plus the per-coroutine memo layer instead of adding a shared loading flag to the singleton registrar. This keeps the read path simpler and avoids cross-coroutine coordination state. + +### Worker-Lifetime Mutators + +Methods such as `setPermissionClass()`, `setRoleClass()`, `setTeamClass()`, and `initializeCache()` mutate singleton-held state. Keep them because Spatie exposes them and tests/custom boot flows use them, but treat them as boot/test configuration methods rather than normal runtime APIs. + +Do not call `Config::set()` or `config()->set()` inside these methods. + +They should: + +- update registrar-held class/config values +- update contract bindings +- clear relevant package caches +- have warning docblocks + +Normal read paths must not call these setters to look up custom models. Query helpers should accept the intended model class directly where needed so one coroutine cannot temporarily change a singleton-held class for another coroutine. + +Example: + +```php +/** + * Set the permission model class. + * + * Boot or tests only. The model class is stored on the singleton registrar + * and affects every later permission lookup in this worker. + */ +public function setPermissionClass(string $permissionClass): static +{ + $this->permissionClass = $permissionClass; + $this->app->bind(PermissionContract::class, $permissionClass); + $this->forgetCachedPermissions(); + + return $this; +} +``` + +### Teams And Coroutine Context + +Spatie's `DefaultTeamResolver` stores `$teamId` on an object property. That leaks between concurrent requests in Hypervel because the resolver is owned by a singleton registrar. + +Use `CoroutineContext`. + +```php +final class DefaultTeamResolver implements PermissionsTeamResolver +{ + public const TEAM_ID_CONTEXT_KEY = '__permission.team_id'; + + public function setPermissionsTeamId(int|string|Model|null $id): void + { + CoroutineContext::set( + self::TEAM_ID_CONTEXT_KEY, + $id instanceof Model ? $id->getKey() : $id, + ); + } + + public function getPermissionsTeamId(): int|string|null + { + return CoroutineContext::get(self::TEAM_ID_CONTEXT_KEY); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + CoroutineContext::forget(self::TEAM_ID_CONTEXT_KEY); + } +} +``` + +The context key is public so tests and nearby package code can assert against it. + +Add `DefaultTeamResolver::flushState()` and `PermissionRegistrar::flushState()` to `tests/AfterEachTestSubscriber.php`. Do not add these to Testbench's application flush list; that list is for testbench app bootstrap state, not global framework cleanup. + +`DefaultTeamResolver::flushState()` only clears the current coroutine/main context key. Normal test methods already run in fresh coroutines, so team state set inside a test method is naturally isolated. Tests that need a team id during setup should seed it inside `setUpInCoroutine()` or the test method itself, not in normal PHPUnit `setUp()`. + +`PermissionRegistrar::flushState()` is tests-only cleanup that forgets the registrar singleton from the container and clears any static/default registrar state added during implementation. It does not replace per-test setup: any test that changes permission config must apply config first, then forget or reconstruct the registrar in `setUp()` so the singleton reads that test's config. + +### Guards And Passport + +Port `Guard` with Hypervel auth/config types: + +- `getNames()` +- `getProviderModel()` +- `getConfigAuthGuards()` +- `getModelForGuard()` +- `getDefaultName()` +- `getPassportClient()` + +`getPassportClient()` must be included even before Hypervel Passport lands. It should use generic behavior: + +- scan configured auth guards where `driver === 'passport'` +- resolve that guard through auth manager +- if the guard has a `client()` method, call it +- return the client when no guard is requested, or when the client guard names include the requested guard + +Tests should use a local fake Passport guard and fake client model implementing `Authorizable`. Do not require a real Passport package in the permission test suite. + +### Models + +Port `Models\Role` and `Models\Permission` from Spatie, with Hypervel additions. + +Required model behavior: + +- Config-derived table names in `__construct()`. +- Default guard name when creating. +- `findByName()`. +- `findById()`. +- `findOrCreate()`. +- duplicate create exceptions. +- custom primary key support. +- UUID / ULID ID lookup via `PermissionRegistrar::isUid()`. +- `RefreshesPermissionCache`. +- role/permission relations use configured pivot keys. +- user/model inverse relations use `model` morph naming. +- teams support on role creation and queries. +- forbidden permission support in relations and checks. + +Use `CarbonInterface` or `CarbonImmutable` annotations where needed, not mutable `Carbon`. + +### Traits + +Port Spatie traits in this order: + +```text +RefreshesPermissionCache +HasAssignedModels +HasPermissions +HasRoles +``` + +Then merge Hypervel forbidden-permission behavior into `HasPermissions`. + +Important points: + +- `HasRoles` uses `HasPermissions`. +- `Role` uses `HasAssignedModels`, `HasPermissions`, and `RefreshesPermissionCache`. +- `Permission` uses `HasRoles` and `RefreshesPermissionCache`. +- `HasPermissions::permissions()` must include `withPivot('is_forbidden')`. +- `Role::permissions()` and `Permission::roles()` must include `withPivot('is_forbidden')`. +- team pivot behavior must match Spatie. +- `teams()` relation should return a harmless empty relation when teams are disabled, matching upstream v7.4.1+. +- `scopeRole`, `scopeWithoutRole`, `scopePermission`, `scopeWithoutPermission`, `scopeTeam`, and `scopeWithoutTeam` must be ported. +- string `'0'` must not be treated as empty. +- guard mismatch checks must be kept. +- `Model::preventLazyLoading()` tests should pass. +- `Permission::getPermissions()` and `Role::getRoles()` must not mutate singleton registrar model classes as part of normal lookup. Upstream Spatie calls setters in some subclass paths; in Hypervel, lookup methods must pass the requested model class into registrar query helpers so one coroutine cannot change the class used by another coroutine. + +Forbidden logic should fit into canonical methods: + +```php +public function hasPermissionTo($permission, ?string $guardName = null): bool +{ + if ($this->hasForbiddenPermission($permission, $guardName)) { + return false; + } + + if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + return false; + } + + if ($this->getWildcardClass()) { + return $this->hasWildcardPermission($permission, $guardName); + } + + $permission = $this->filterPermission($permission, $guardName); + + return $this->hasDirectPermission($permission) || $this->hasPermissionViaRole($permission); +} +``` + +When merging the archived forbidden-permission behavior, do not copy the old rough edges: + +- use strict comparisons and `(bool)` pivot casts for `is_forbidden` +- use `getKey()` / `getKeyName()` and configured pivot keys, never hardcoded `id` + +For role models, `hasPermissionTo()` must still validate guard compatibility against the permission's guard. + +### Wildcard Permissions + +Port `Contracts\Wildcard`, `WildcardPermission`, wildcard exceptions, and all wildcard-related trait behavior. + +Keep Spatie's algorithm. It is already optimized upstream and includes recent performance work. Do not add a worker-lifetime wildcard index unless implementation proves it is needed and can be invalidated with the same model assignment versioning. The first fresh port should keep the wildcard behavior easy to compare with upstream. + +Forbidden permissions must apply before wildcard allows. + +Forbidden checks that run before the wildcard path must match the input against the subject's forbidden permission names, IDs, and enum values without routing wildcard-pattern strings through `filterPermission()`. A wildcard input such as `posts.*` should not throw `PermissionDoesNotExist` before the wildcard matcher has a chance to evaluate it. + +### Events + +Port event classes: + +```text +RoleAttachedEvent +RoleDetachedEvent +PermissionAttachedEvent +PermissionDetachedEvent +``` + +Adapt dispatching: + +```php +if (Config::eventsEnabled() && $events->hasListeners(RoleAttachedEvent::class)) { + $events->dispatch(new RoleAttachedEvent($this->getModel(), $roles)); +} +``` + +This keeps the event path free when no listener exists. + +### Middleware + +Port: + +- `RoleMiddleware` +- `PermissionMiddleware` +- `RoleOrPermissionMiddleware` + +Use Spatie's API: + +- pipe-separated values +- array and enum input in `using()` +- optional guard argument +- Passport client fallback +- `canAny()` for permission checks +- `hasAnyRole()` for role checks + +Hypervel currently has only role and permission middleware and uses `hasAnyRoles` / `hasAnyPermissions`. The fresh port should use Spatie canonical singular names: + +- `hasAnyRole` +- `hasAnyPermission` + +Do not keep the old plural compatibility wrappers. Update tests and docs to the canonical Spatie names. + +### Blade Directives + +Port Spatie's directives: + +- `@haspermission` +- `@role` +- `@hasrole` +- `@hasanyrole` +- `@hasallroles` +- `@hasexactroles` +- `@unlessrole` +- `@endunlessrole` + +Use `callAfterResolving('blade.compiler', ...)` in the provider. + +### Route Macros + +Port route macros: + +- `role(...)` +- `permission(...)` +- `roleOrPermission(...)` + +The macros convert arrays/enums into pipe-separated middleware values and return the route with middleware attached. + +### Commands + +Port: + +- `permission:cache-reset` +- `permission:create-role` +- `permission:create-permission` +- `permission:show` +- `permission:assign-role` +- `permission:setup-teams` + +Use Symfony `#[AsCommand]`. + +`permission:setup-teams` should publish or create the teams migration stub, matching Spatie's purpose while using Hypervel paths and command patterns. It is useful when teams are enabled after the base permission migration has already run. + +### Exceptions + +Port upstream exception classes and replace the current broad `PermissionException` / `RoleException` shape with Spatie's typed exceptions. + +Keep accessors that tests and users need: + +- `UnauthorizedException::getRequiredRoles()` +- `UnauthorizedException::getRequiredPermissions()` + +Use Hypervel translator helper/function behavior for messages. + +### README And Boost Docs + +Update the package README to include: + +- `Ported from: https://github.com/spatie/laravel-permission` +- current status for Hypervel 0.4 +- installation +- key differences from Spatie Laravel Permission +- forbidden permissions +- Swoole/cache notes +- Passport support + +Add `Differences From Spatie Laravel Permission`: + +```md +## Differences From Spatie Laravel Permission + +- Laravel Octane reset listeners are not included. Hypervel is Swoole-native: + request/team state uses coroutine context, and permission cache freshness is + handled by configured cache stores, per-coroutine memoization, stack cache, + and explicit invalidation. +- Hypervel adds forbidden permissions. A forbidden permission explicitly denies + an ability and wins over direct or role-granted allows. +``` + +Update `src/boost/docs/permission.md` to match the fresh port: + +- Spatie plural trait names. +- `model_*` schema names. +- teams. +- wildcards. +- guards. +- Passport. +- events. +- commands. +- route macros. +- Blade directives. +- forbidden permissions. +- cache memo / stack advice. + +Remove old `owner_*` docs. + +## Composer And Autoloading + +Keep `src/permission/composer.json` wired for subtree split: + +- package name `hypervel/permission` +- provider discovery for `Hypervel\Permission\PermissionServiceProvider` +- autoload `Hypervel\Permission\` +- autoload file `helpers.php` +- dependencies on `hypervel/view`, `hypervel/routing`, and `hypervel/events`, in addition to the existing auth/cache/console/contracts/database/http/support dependencies + +Root `composer.json` must include `src/permission/src/helpers.php` in autoload files. The helpers define: + +- `getModelForGuard` +- `setPermissionsTeamId` +- `getPermissionsTeamId` + +The root autoload file list currently does not include permission helpers, so this must be added. + +No new third-party dependency is needed. + +Package composer autoload: + +```json +"autoload": { + "psr-4": { + "Hypervel\\Permission\\": "src/" + }, + "files": [ + "src/helpers.php" + ] +} +``` + +Root composer autoload should add the helper next to the other package helper files: + +```json +"files": [ + "src/prompts/src/helpers.php", + "src/reflection/src/helpers.php", + "src/permission/src/helpers.php" +] +``` + +## Implementation Steps + +### 1. Archive Current Package Body + +Move the current implementation into `_archive/permission` before porting. Keep: + +- `src/permission/composer.json` +- `src/permission/README.md` +- `src/permission/LICENSE.md` + +Why: this gives a clean slate while preserving the existing Hypervel-specific behavior for reference. + +### 2. Copy Upstream Source Files One At A Time + +Follow the components `AGENTS.md` porting workflow: + +1. Copy the upstream file with `cp`. +2. Read the copied file in full. +3. Update namespace/imports/types/docblocks. +4. Adapt Laravel-only internals to Hypervel. +5. Run focused checks for the copied file before moving to the next related file. + +Port files alphabetically within each directory. Keep method order matching upstream. + +### 3. Build The Hypervel Registrar And Config Layer + +Implement `Support\Config` first enough for the rest of the package to compile. Then implement `DefaultTeamResolver` and `PermissionRegistrar`. + +Why: nearly every model/trait calls through these classes. + +Key adaptations: + +- typed injected config repository +- no runtime config mutation +- configured cache repository plus memo wrapper +- forbidden pivot data in serialized cache +- coroutine-safe team id resolver +- cache key helpers for model role/direct permission caches +- static `flushState()` for test cleanup + +### 4. Port Models And Traits + +Port `Role`, `Permission`, and traits. Merge forbidden permission support into `HasPermissions` after the Spatie base is compiling. + +Why: this preserves upstream behavior first, then layers Hypervel's improvement into the correct points. + +Forbidden methods to add: + +```php +public function giveForbiddenTo(...$permissions): static; +public function hasForbiddenPermission($permission, ?string $guardName = null): bool; +public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = null): bool; +``` + +`syncPermissions()` keeps Spatie's variadic API and return type: + +```php +$model->syncPermissions(['edit articles', 'delete articles']); +``` + +Add a dedicated dual-list helper for forbidden sync so Spatie call sites stay intact: + +```php +public function syncPermissionsWithForbidden(array|Collection $allowed = [], array|Collection $forbidden = []): array; +``` + +The helper accepts allowed and forbidden permission lists, syncs both direct-pivot sets, invalidates direct permission caches, and returns the `BelongsToMany::sync()` change array. `syncPermissions(...$permissions)` must continue to return `static`, matching Spatie. + +### 5. Port Middleware, Events, Blade, Route Macros, Commands + +Port these after the models/traits are in place. + +Why: they depend on the canonical trait API and exception types. + +Register route macros and middleware aliases in the provider. + +### 6. Wire Composer Autoload + +Add `src/permission/src/helpers.php` to root `composer.json` autoload files and verify `src/permission/composer.json` includes the file autoload too. + +Run: + +```sh +composer dump-autoload +``` + +### 7. Update Docs + +Update: + +- `src/permission/README.md` +- `src/boost/docs/permission.md` + +Do targeted edits; do not replace unrelated docs. + +### 8. Add Test Cleanup + +Add package cleanup to `tests/AfterEachTestSubscriber.php`: + +```php +\Hypervel\Permission\DefaultTeamResolver::flushState(); +\Hypervel\Permission\PermissionRegistrar::flushState(); +``` + +Only add cleanup for actual static or coroutine-held state. Do not add permission cleanup to testbench application flush helpers. + +## Test Port Plan + +Port upstream tests from `/tmp/spatie-laravel-permission/tests` into `tests/Permission`, converting Pest tests to PHPUnit. + +Keep file layout close to upstream: + +```text +tests/Permission/Commands +tests/Permission/Integration +tests/Permission/Middleware +tests/Permission/Models +tests/Permission/Traits +tests/Permission/Fixtures +``` + +Use `Hypervel\Testbench\TestCase` for integration tests needing container, DB, routes, Blade, auth, or files. Use `Hypervel\Tests\TestCase` only for pure unit tests. + +### Test Harness + +Create `tests/Permission/TestCase.php` as the package base test case. It should: + +- extend `Hypervel\Testbench\TestCase` +- use `RefreshDatabase` +- configure auth guards/providers for the fixture user models +- set `permission.storage.database.connection` to the test database connection, or to `null` when the test database connection is already the framework default +- configure a deterministic permission cache store for tests +- after applying permission/cache config in `setUp()`, call `$this->app->forgetInstance(PermissionRegistrar::class)` so the registrar singleton is rebuilt from that test's config +- flush the configured permission cache store in `setUp()` so catalog and per-subject cache entries cannot leak across tests + +Team-specific tests should enable teams before reconstructing the registrar and should seed team ids inside `setUpInCoroutine()` or the test method when coroutine-local state is required. + +### Upstream Tests To Port + +Port and adapt: + +```text +Commands/CommandTest.php +Commands/TeamCommandTest.php +Integration/BladeTest.php +Integration/CacheTest.php +Integration/CustomGateTest.php +Integration/GateTest.php +Integration/MultipleGuardsTest.php +Integration/PermissionRegistrarTest.php +Integration/PolicyTest.php +Integration/RouteTest.php +Integration/WildcardRouteTest.php +Middleware/PermissionMiddlewareTest.php +Middleware/RoleMiddlewareTest.php +Middleware/RoleOrPermissionMiddlewareTest.php +Middleware/WildcardMiddlewareTest.php +Models/PermissionTest.php +Models/RoleTest.php +Models/RoleWithNestingTest.php +Models/TestPermissionEnum.php +Models/TestRoleEnum.php +Models/WildcardRoleTest.php +Traits/HasAssignedModelsTest.php +Traits/HasPermissionsTest.php +Traits/HasPermissionsWithCustomModelsTest.php +Traits/HasRolesTest.php +Traits/HasRolesWithCustomModelsTest.php +Traits/TeamHasPermissionsTest.php +Traits/TeamHasRolesTest.php +Traits/TeamScopeTest.php +Traits/WildcardHasPermissionsTest.php +``` + +Port upstream test support: + +```text +TestSupport/TestCase.php +TestSupport/ContentPolicy.php +TestSupport/TestHelper.php +TestSupport/TestModels/* +TestSupport/resources/views/* +``` + +Do not keep Pest bootstrap files. Read upstream `TestSupport/TestCase.php` as the fixture/setup source, then convert only the needed setup into Hypervel PHPUnit test cases. + +### Intentional Test Difference Comments + +For Octane reset listener tests, add: + +```php +// REMOVED: Laravel Octane reset listeners are not part of Hypervel. Team state +// is coroutine-scoped and cache freshness is handled by the cache layer. +``` + +Only add these comments where upstream tests would otherwise be ported. + +### Extra Hypervel Tests + +Add tests beyond upstream where Hypervel architecture or improvements require proof. + +#### Forbidden Permissions + +Cover: + +- direct forbidden permission denies `hasPermissionTo()` +- direct forbidden denies `can()` through Gate +- role forbidden denies role-granted allow +- role forbidden denies direct allow +- forbidden wins when allowed and forbidden are both passed +- forbidden permissions inherited through roles work with custom role and permission primary keys +- pure unit enums work for role and permission assignment/check paths +- `getAllPermissions()` excludes forbidden permissions +- `getPermissionsViaRoles()` excludes forbidden role permissions +- `syncPermissions()` keeps Spatie behavior +- forbidden sync helper returns correct attached/detached/updated data +- role-permission forbidden pivot is included in global cache +- model direct forbidden pivot is included in model direct-permission cache + +#### Cache And Memo + +Cover: + +- repeated permission checks in one coroutine do not repeatedly hit the underlying configured cache store +- `Cache::memo()` over a configured stack store works for permission cache reads +- model direct role cache invalidates after `assignRole`, `removeRole`, and `syncRoles` +- model direct permission cache invalidates after `givePermissionTo`, `giveForbiddenTo`, `revokePermissionTo`, and forbidden sync helper +- role/permission model save/delete clears global permission cache +- role/permission model save/delete bumps the model assignment-cache version so old per-subject cache entries are bypassed +- `permission:cache-reset` clears the global catalog and bumps the model assignment-cache version +- `HasAssignedModels` reverse assignment clears each touched model's per-subject cache +- custom role/permission primary keys are used in cache maps, not hardcoded `id` +- team id is included in model assignment cache keys when teams are enabled + +The existing `tests/Cache/CacheMemoizedStoreTest.php` already proves memo-over-stack at the cache component level. Permission tests should prove the permission registrar uses that path, not retest every cache store behavior. + +#### Coroutine Safety + +Cover: + +- two concurrent coroutines can set different permission team ids and read them back without leakage +- role/permission checks in concurrent team contexts use the correct team assignments +- changing team id in one coroutine does not affect another coroutine's permission cache key +- `PermissionRegistrar::flushState()` and `DefaultTeamResolver::flushState()` clean test state + +Use: + +```php +use function Hypervel\Coroutine\parallel; + +[$first, $second] = parallel([ + function () { + setPermissionsTeamId(1); + usleep(5000); + + return getPermissionsTeamId(); + }, + function () { + setPermissionsTeamId(2); + usleep(5000); + + return getPermissionsTeamId(); + }, +]); + +$this->assertSame([1, 2], [$first, $second]); +``` + +#### Events + +Cover: + +- no event is dispatched when `events_enabled` is false +- no event object is constructed/dispatched when enabled but no listener exists +- correct event dispatches when a listener exists +- events include role/permission IDs or models matching upstream expectations + +#### Passport + +Cover with fakes: + +- middleware falls back to fake Passport client when no user exists and request has bearer token +- no Passport path is used when `use_passport_client_credentials` is false +- client guard mismatch denies +- client can pass role middleware +- client can pass permission middleware +- client can pass role-or-permission middleware + +The tests must not depend on a real Hypervel Passport package. + +#### Schema And Config + +Cover: + +- default table names are Spatie-compatible +- custom table names are honored by migration and relations +- custom pivot key names are honored +- configured database connection is honored by migration/models +- teams enabled schema creates team columns and team-aware unique keys +- teams disabled schema does not create team columns +- `permission:setup-teams` publishes or creates the teams migration stub when teams are enabled after install +- `model_morph_key` customization works +- no `owner_*` names remain in source, tests, or docs except in archived reference files + +#### Docs / Public API Smoke + +Cover: + +- route macros attach expected middleware strings +- Blade directives compile and evaluate correctly +- command registration includes all ported commands +- `permission:setup-teams` is registered +- middleware aliases are registered +- helpers are autoloaded + +### Test Cadence + +For each test file: + +```sh +./vendor/bin/phpunit --no-progress tests/Permission/Path/To/Test.php +``` + +After each logical group: + +```sh +composer fix +``` + +`composer fix` runs cs-fixer, PHPStan, and `composer test:parallel`. The full suite must be green before the work is complete. + +## Acceptance Criteria + +- The package source is a fresh Hypervel port of the current upstream Spatie package. +- Public APIs match Spatie unless this plan records an intentional Hypervel difference. +- Forbidden permissions are preserved and fully tested. +- Teams are coroutine-safe. +- Passport client-credentials support is present and fake-tested. +- Events stay dormant when no listeners exist. +- Cache reads use configured cache plus per-coroutine memoization. +- Mutable permission assignment data is not stored only in worker-local static state. +- `owner_*` naming is gone from new source, tests, config, docs, and migrations. +- `permission:setup-teams` is registered and tested. +- Laravel Octane listener behavior is not registered, with durable comments explaining why. +- README and Boost docs match the implemented API. +- Root and package Composer files autoload helpers correctly. +- `composer fix` passes. + +## Fresh-Session Checklist + +1. Re-read monorepo root `CLAUDE.md`. +2. Re-read components repo `AGENTS.md`. +3. Pull `/tmp/spatie-laravel-permission`. +4. Confirm current package has been archived and only skeleton files remain. +5. Copy and port source files one at a time from upstream. +6. Merge the archived Hypervel forbidden-permission and cache improvements into the Spatie-shaped implementation. +7. Port tests file by file, running each file immediately. +8. Add Hypervel-specific tests listed above. +9. Update README and Boost docs. +10. Run `composer fix`. +11. Self-review against upstream source, archived Hypervel source, tests, docs, and this plan. diff --git a/docs/plans/permission-package-fresh-port.md b/docs/plans/permission-package-fresh-port.md new file mode 100644 index 000000000..649e7eca1 --- /dev/null +++ b/docs/plans/permission-package-fresh-port.md @@ -0,0 +1,851 @@ +# Permission Package — Fresh Port (spatie/laravel-permission v8 → Hypervel) + +## 0. How to use this document + +This is a complete, self-contained implementation plan. It assumes a fresh session with no prior context. Read it in full before starting. Every decision is made; there is nothing left to investigate or decide. + +**Upstream reference:** `spatie/laravel-permission` **v8.0.0** (git `c2c871a`, "Creation no longer requires ->value"). Repo: `https://github.com/spatie/laravel-permission`. Clone it to a scratch dir if not present; this plan cites file paths relative to that clone as `spatie:src/...`. + +**Working directory for all commands and edits:** `contrib/hypervel/components` (the Hypervel framework PR repo). Never edit outside it. Run all tooling (`phpunit`, `phpstan`, `php-cs-fixer`) from this directory. + +**Starting state:** Before implementation begins, the existing `src/permission` package is moved to `_archive/permission` (kept as reference — it holds the original forbidden-permission feature and per-owner cache code), and `src/permission` is reset to a skeleton: `composer.json`, `README.md`, `LICENSE.md` only. Implementation builds the package from that skeleton. + +**Authoritative conventions:** `contrib/hypervel/components/AGENTS.md` and the monorepo root `CLAUDE.md`. This plan follows them; where it makes a deliberate divergence from spatie, it says so and records it (README "Differences From Spatie" + source comment), per the AGENTS.md "Record intentional Laravel differences" rule. + +--- + +## 1. Background + +### 1.1 What the package is + +`hypervel/permission` is role-based access control for Eloquent models: create roles and permissions, assign them to users (or any model), and check access by role, direct permission, or permission inherited through a role. It is a port of `spatie/laravel-permission`. + +### 1.2 Why a fresh port (not incremental) + +The current package is a 0.3-era (Hyperf-based) rewrite that diverged structurally from spatie: only ~6 of spatie's ~41 source files have any equivalent, trait/method/table names differ (`HasRole` vs `HasRoles`, `owner_*` vs `model_*`, `PermissionManager` vs `PermissionRegistrar`, `hasPermission` vs `hasPermissionTo`), and large feature areas are missing (teams, wildcards, guard support, events, gate integration, blade, route macros, reverse relations, find/create helpers, five commands). The existing code also carries real bugs (half-implemented custom primary keys, missing cache invalidation, `name`-only unique index, no pivot foreign keys, restrictive `fillable`). + +Bringing that to parity means hand-reconciling two different APIs and repairing buggy bespoke code with weak test coverage (~79 tests vs spatie's ~676). A fresh port copies spatie's correct, well-tested logic, re-applies the genuine Hypervel improvements deliberately, and restores upstream merge-ability (a core AGENTS.md goal: keep packages close to 1:1 so upstream changes merge easily). The package is pre-stable (0.4), so this is the cheapest time to realign. + +### 1.3 What Hypervel provides (all verified present) + +- `enum_value()` — `Hypervel\Support\enum_value` (`src/collections/src/functions.php`), handles backed (`->value`) **and** unit (`->name`) enums. +- `Gate::before(callable)` — `src/auth/src/Access/Gate.php:266`; `canAny` on `Hypervel\Foundation\Auth\Access\Authorizable`. +- `BladeCompiler::if()` / `::directive()` — `src/view/src/Compilers/BladeCompiler.php:667/897`. +- `Hypervel\Routing\Route` and `Router` use `Macroable` — route macros + fluent `->role()` work. +- `morphedByMany` — `src/database/src/Eloquent/Concerns/HasRelationships.php:781`. +- `__()` translation helper — `src/foundation/src/helpers.php:927`. +- `AboutCommand::add(string, callable|string|array, ?string)` — `src/foundation/src/Console/AboutCommand.php:261`. +- `event()` helper — `src/foundation/src/helpers.php`. +- `CoroutineContext::get/set` — `src/context/src/CoroutineContext.php` (`set(UnitEnum|string $id, mixed $value, ?int $coroutineId = null)`, `get(UnitEnum|string $id, mixed $default = null, ?int $coroutineId = null)`). +- Cache drivers: `StackStore` (write-through multi-tier with back-fill), `SwooleStore` (Swoole-table-backed, coroutine-safe), `MemoizedStore` (request-local memo), all config-selectable via `CacheManager::createStackDriver`/`createSwooleDriver`. +- ServiceProvider hooks: `mergeConfigFrom`, `loadMigrationsFrom`, `publishes`, `publishesMigrations`, `callAfterResolving`, `commands`. +- Eloquent boot hooks: `static::saved`, `static::deleting`, `static::deleted` (`src/database/src/Eloquent/Concerns/HasEvents.php`). + +So **no spatie feature is blocked by the framework**. Every omission below is a deliberate choice, not a limitation. + +--- + +## 2. Decisions (all final) + +| # | Decision | Rationale | +|---|---|---| +| D1 | **Fresh port from spatie v8.0.0**, replacing the archived package. | §1.2. | +| D2 | **Adopt spatie naming and structure**: `HasRoles`/`HasPermissions` traits, `PermissionRegistrar`, `model_*` tables/columns, `model_morph_key`, spatie method names (`hasPermissionTo`, `hasAnyRole`, `getRoleNames`, …). Drop `owner_*`, singular traits, `PermissionManager`, the `Factory` contract. | Plural traits match the many-to-many cardinality and ecosystem norm; `model_*` is the de-facto standard for permission tables → upstream merge-ability + familiarity. The `owner_*`/singular renames were cosmetic 0.3 divergences with no functional benefit. | +| D3 | **Keep the forbidden-permission feature** (an `is_forbidden` pivot flag on `model_has_permissions` and `role_has_permissions`, with `giveForbiddenTo`, `hasForbiddenPermission`, `hasForbiddenPermissionViaRoles`, and the two-argument `syncPermissions($allow, $forbidden)`). A forbidden permission denies access even when granted directly or via a role. | Genuine Hypervel feature; reference impl in `_archive/permission`. Documented divergence. | +| D4 | **Keep per-subject assignment caching** (cache each subject's assigned roles and direct permissions in the cache store), layered on top of spatie's structure. | This is the real performance win over spatie, which re-queries a subject's assignments from the DB on every request. Documented divergence. | +| D5 | **Registrar holds no cached collection / no per-request state.** No `$permissions` in-memory collection, no `$isLoadingPermissions` lock, no `$wildcardPermissionsIndex`, no `config()->set()`/runtime-`bind()` setters, no Octane listener. Immutable config only. | Coroutine safety: a singleton's mutable per-request fields leak across concurrent coroutines on a long-lived Swoole worker. This is the central safety divergence. | +| D6 | **Store-agnostic caching.** All cache reads/writes go through the configured `permission.cache.store` repository. No bespoke tiering (no CoroutineContext two-tier cache). Tiering and request-local memo are deployment/config choices: recommend a `stack` store (`swoole` + `redis`) and/or memoization. | The framework already provides `StackStore`/`SwooleStore`/`MemoizedStore`. The package must not reinvent them. | +| D7 | **Port teams** (optional, default off). Team id stored in **`CoroutineContext`**, never on a singleton/static. `DefaultTeamResolver` reads/writes CoroutineContext. Teams columns live in a separate dormant migration that only runs when `permission.teams` is `true`. | Teams is a permission-*scoping* mechanism (adds `team_id` to roles + assignment pivots), not a teams-management domain — spatie ships no Team model or teams table; `models.team` is the app's own model. It belongs in the permission package. The team id is the textbook per-request value → must be coroutine-scoped. | +| D8 | **Port wildcard permissions.** The wildcard index is computed on demand from the subject's (cached) permissions; it is **not** held on the registrar. | Faithful feature; avoids the singleton `$wildcardPermissionsIndex` coroutine hazard. | +| D9 | **Port Passport client-credentials support now** (config `use_passport_client_credentials`, `Guard::getPassportClient`, middleware bearer-token block). | Passport is on the roadmap. The hook has no hard Passport class dependency (duck-types `client()` via `method_exists`), uses only `Auth`/`Request`/`Authorizable` (all present), is double-gated (config default-false + bearer token), and degrades to inert when no `passport`-driver guard exists. Classify as pending-dependency, not omission. | +| D10 | **Port gate integration** (`register_permission_check_method`, `registerPermissions(Gate)`, `checkPermissionTo`), **blade directives**, **route macros**, **events**, **all commands**, **find/create helpers**, **`HasAssignedModels` reverse relations**, **`Support/Config`**, **`RefreshesPermissionCache`**, **`RoleOrPermissionMiddleware`**, **`Guard`**, **About integration**. | Full parity; all framework prerequisites verified present (§1.3). | +| D11 | **Base models use `protected $guarded = []`** (guard only the primary key, set in constructor), not a `fillable` whitelist. | Lets users add custom columns (`tenant_id`, json `data`, …) to extended models without overriding `$fillable`. Matches spatie. | +| D12 | **Models honor `table_names.*` and `storage.database.connection`**, and the migration is config-driven (reads `table_names`/`column_names`). | Fixes a current bug where the config knobs were advertised but ignored. | +| D13 | **Composite unique index `(name, guard_name)`** on `roles`/`permissions` (or `(team_foreign_key, name, guard_name)` under teams), and **foreign keys with `cascadeOnDelete`** on all pivots. | Fixes the current `name`-only unique index (which blocked same-name-different-guard) and the missing FKs (orphaned pivot rows). Matches spatie. | +| D14 | **Omit Octane reset listener.** | No Octane runtime in Hypervel, and the registrar holds no per-request state to reset (D5); team id auto-clears with the coroutine (D7). Documented. | +| D15 | **Skip spatie's cache alias-compression** (`getSerializedPermissionsForCache` field aliasing). | Complexity for marginal payload reduction; our role→permissions catalog is clearer. Documented simplification. | +| D16 | **No `Factory` contract; bind `PermissionRegistrar` as a singleton concrete** (`$this->app->singleton(PermissionRegistrar::class)`). Traits/middleware/models resolve the concrete. | Matches spatie; removes the current double-singleton (concrete auto-singletoned separately from the `Factory` binding). The registrar holds only immutable state, so a single shared instance is correct. | +| D17 | **Find/create helpers query the database directly** (Eloquent), not via a cached flat-permission catalog. | These run on write/admin paths (assignment, commands), not the hot read path; a direct query is simpler and correct. Hot reads use the per-subject + catalog caches. | +| D18 | **Use `CarbonImmutable`, `declare(strict_types=1)`, full type hints, `===`/`!==`, constructor promotion, enums** throughout, per CLAUDE.md. Method docblocks (title-only, Laravel-style) on all methods. | Repo standards. The archived package violated strict-comparison (`== true`) and lacked types in places — do not carry those over. | + +--- + +## 3. Coroutine-safety & performance contract + +These are invariants the implementation must hold. They are the reason the registrar's structure diverges from spatie internally. + +1. **No per-request mutable state on the registrar (singleton) or in static properties.** The registrar's constructor reads immutable config (model classes, cache key, TTL, pivot/morph names, teams flag/key) once. Everything per-request (a subject's roles/permissions, the current team id, wildcard indexes) is either request-derived-and-recomputed or stored in the cache store / `CoroutineContext`. +2. **Team id lives in `CoroutineContext`** under key `__permission.team_id`. It auto-clears when the coroutine ends — no terminate listener needed. +3. **The cache store is the only persistence for derived data.** A resolved cache `Repository`/`Store` is a stateless wrapper over the connection pool and is safe to share. `StackStore`/`SwooleStore` are coroutine-safe (Swoole Table is a concurrent structure). Do not hold hydrated collections on the registrar. +4. **No `config()->set()` at runtime, no runtime container `bind()`.** Model classes are fixed at boot from config. +5. **Static caches, if any, expose `flushState()` and register with `AfterEachTestSubscriber`.** (This port introduces none — see §6.4 note on `Guard`.) +6. **Performance:** per-subject caching makes warm permission checks DB-free; pointing `permission.cache.store` at a `stack` store (swoole + redis) makes them network-free within a worker. No bespoke tiering in the package. + +--- + +## 4. Target package structure + +``` +src/permission/ +├── composer.json # add files autoload for helpers.php; deps +├── LICENSE.md +├── README.md # add "Differences From Spatie" section +├── config/ +│ └── permission.php +├── database/ +│ └── migrations/ +│ ├── 2025_07_02_000000_create_permission_tables.php +│ └── 2025_07_02_000001_add_teams_fields.php +└── src/ + ├── Commands/ + │ ├── AssignRoleCommand.php + │ ├── CacheResetCommand.php + │ ├── CreatePermissionCommand.php + │ ├── CreateRoleCommand.php + │ ├── ShowCommand.php + │ └── UpgradeForTeamsCommand.php + ├── Contracts/ + │ ├── Permission.php + │ ├── PermissionsTeamResolver.php + │ ├── Role.php + │ └── Wildcard.php + ├── DefaultTeamResolver.php + ├── Events/ + │ ├── PermissionAttachedEvent.php + │ ├── PermissionDetachedEvent.php + │ ├── RoleAttachedEvent.php + │ └── RoleDetachedEvent.php + ├── Exceptions/ + │ ├── GuardDoesNotMatch.php + │ ├── PermissionAlreadyExists.php + │ ├── PermissionDoesNotExist.php + │ ├── RoleAlreadyExists.php + │ ├── RoleDoesNotExist.php + │ ├── TeamModelNotConfigured.php + │ ├── TeamsNotEnabled.php + │ ├── UnauthorizedException.php + │ ├── WildcardPermissionInvalidArgument.php + │ ├── WildcardPermissionNotImplementsContract.php + │ └── WildcardPermissionNotProperlyFormatted.php + ├── Guard.php + ├── helpers.php + ├── Middleware/ + │ ├── PermissionMiddleware.php + │ ├── RoleMiddleware.php + │ └── RoleOrPermissionMiddleware.php + ├── Models/ + │ ├── Permission.php + │ └── Role.php + ├── PermissionRegistrar.php + ├── PermissionServiceProvider.php + ├── Support/ + │ └── Config.php + ├── Traits/ + │ ├── HasAssignedModels.php + │ ├── HasPermissions.php + │ ├── HasRoles.php + │ └── RefreshesPermissionCache.php + └── WildcardPermission.php +``` + +Tests live in `tests/Permission/` (see §8). Namespaces: source `Hypervel\Permission\…`, tests `Hypervel\Tests\Permission\…`. + +--- + +## 5. Cache design (detailed) + +Two store-backed cache layers, both coroutine-safe (no singleton/static state): + +### 5.1 Catalog cache — role → permissions + +One entry mapping every role (by its primary key) to its record and its permissions (each permission carries its `is_forbidden` pivot value). Used to resolve "what does role X grant" without a DB join on every check. + +- Key: `permission.cache.keys.roles` (default `hypervel.permission.cache.roles`). +- Shape: `[ (string)$role->getKey() => ['role' => $role->toArray(), 'permissions' => $role->permissions->toArray()] ]`. Keyed by `getKey()`, **never** hardcoded `id`. +- Built lazily via `Repository::remember(key, ttl, fn)`. +- Invalidated by `PermissionRegistrar::forgetCachedPermissions()`. + +### 5.2 Per-subject caches — a subject's roles and direct permissions + +For each subject (any model using `HasRoles`/`HasPermissions`), cache its assigned role records and its direct permission records (each with `is_forbidden`). + +- Keys (prefix from config; include team id when teams enabled): + - roles: `{cache.keys.model_roles}:{morphType}:{key}[:team:{teamId}]` + - permissions: `{cache.keys.model_permissions}:{morphType}:{key}[:team:{teamId}]` +- The morph type is `$subject->getMorphClass()`; the key is `$subject->getKey()`. +- Invalidated (targeted) when that subject's assignments change. +- **Role subjects are the exception:** a Role's own direct permissions are read from the **catalog** entry for that role (its `role_has_permissions`), not from a per-subject permissions cache — the catalog already holds role→permissions, so caching a Role's permissions separately would duplicate it. The per-subject permissions cache applies only to non-Role subjects (users, teams, etc.). Role subjects still use the per-subject **roles** cache only if they themselves are assigned roles, which is not a supported pattern; in practice a Role uses the catalog for permissions and has no assigned roles. + +### 5.3 Why two layers / how invalidation stays correct + +Per-subject caches store **role records and direct-permission records**; role→permission resolution goes through the catalog. Therefore: + +- **Subject assignment change** (assign/remove/sync role; give/forbid/revoke/sync permission on a non-Role subject) → clear that subject's two keys only. Cheap, targeted. +- **A Role's permissions change** (give/forbid/revoke/sync permission on a Role) → `forgetCachedPermissions()` (catalog only). Per-subject role lists remain valid (they store role identity, not the role's resolved permissions). +- **Any Role/Permission model created/updated/deleted** → `forgetCachedPermissions()` via the `RefreshesPermissionCache` boot hook (catalog). This fixes the current bug where model lifecycle changes didn't flush. + +A deleted permission/role drops out naturally: the per-subject cache may still list its key, but resolution through the catalog finds nothing → not granted (and a deleted forbidden entry can't grant anything either). FKs (`cascadeOnDelete`) remove the pivot rows; cache entries expire or are cleared on the next mutation. No false grants. + +### 5.4 Check flow (`hasPermissionTo`) + +``` +hasPermissionTo($permission, $guard = null): + if wildcard enabled for this model: return hasWildcardPermission(...) + resolve $permission to a Permission record (filterPermission → DB find helper) + if hasForbiddenPermission($permission): return false # direct forbidden + if not a Role and hasForbiddenPermissionViaRoles($permission): return false + return hasDirectPermission($permission) || hasPermissionViaRole($permission) +``` + +- `hasDirectPermission` / `hasForbiddenPermission` read the per-subject **permissions** cache and filter on `is_forbidden`. +- `hasPermissionViaRole` / `hasForbiddenPermissionViaRoles` read the per-subject **roles** cache, then the **catalog** for each role's permissions, filtering on `is_forbidden`. +- All cache-backed; DB only on cold cache. + +### 5.5 Recommended store config (docs, not code) + +```php +// config/cache.php +'stores' => [ + 'permission' => [ + 'driver' => 'stack', + 'stores' => ['swoole', 'redis'], // worker-local L1 + shared L2, write-through + back-fill + ], +], +// config/permission.php +'cache' => ['store' => 'permission', ...], +``` + +Document this; ship `store => 'default'` as the safe default. + +--- + +## 6. Implementation — file by file + +Port in the order below (dependencies first). For each **faithful port**, copy the spatie file, then apply the standard AGENTS.md transforms: namespace `Spatie\Permission\…` → `Hypervel\Permission\…`; `Illuminate\…` → `Hypervel\…`; add `declare(strict_types=1)`; full type hints + return types; title-only method docblocks; replace `config()`/`app()` per the rules below; `===`/`!==`; remove any deprecated/Octane/Passport-version shims except where D9 keeps them. For each **divergent file**, use the code/spec given here. + +### 6.1 `composer.json` (skeleton already has name/license/autoload psr-4) + +- Add `autoload.files`: `["src/helpers.php"]` (the skeleton currently has none — required for the global team-id helpers). +- Dependencies (this is a monorepo sub-package with no lockfile, so editing `src/permission/composer.json` directly is allowed per CLAUDE.md): keep the existing `hypervel/*` requires (`auth`, `cache`, `collections`, `console`, `contracts`, `database`, `http`, `support`) and add `hypervel/view` (blade directives), `hypervel/routing` (route macros), `hypervel/events` (event dispatch via the `'events'` binding). All three are confirmed sub-packages (`hypervel/view`, `hypervel/routing`, `hypervel/events`). The `auth()`, `event` dispatcher, and `__()` helpers come from already-required packages — no extra require needed for those. +- Confirm the root `composer.json` still maps `Hypervel\\Permission\\` psr-4 (it does, line 89) and lists `hypervel/permission` (line 252). The root `composer.json` has its own `autoload.files` array (sub-package `files` are **not** auto-aggregated for monorepo dev/tests), so **add `"src/permission/src/helpers.php"` to the root `autoload.files`** and run `composer dump-autoload` from the components root. Without this, `getPermissionsTeamId()`/`setPermissionsTeamId()`/`getModelForGuard()` are undefined in the test suite. + +### 6.2 `config/permission.php` (divergent — full spec) + +Adopt spatie's keys with `model_*` naming, plus the Hypervel cache shape and the forbidden feature needs no config. Final content: + +```php + [ + 'permission' => Permission::class, + 'role' => Role::class, + // Teams: the app's own team/tenant/org model. Null until teams are used. + 'team' => null, + // Used by HasAssignedModels when raw ids are passed; falls back to the guard's model. + 'default_model' => null, + ], + + 'table_names' => [ + 'roles' => 'roles', + 'permissions' => 'permissions', + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + 'role_pivot_key' => 'role_id', + 'permission_pivot_key' => 'permission_id', + 'model_morph_key' => 'model_id', + 'team_foreign_key' => 'team_id', + ], + + // Storage connection for the permission tables (migration + models honor this). + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + ], + ], + + // Register the Gate::before permission check so $user->can('permission') works. + 'register_permission_check_method' => true, + + // Fire RoleAttached/Detached and PermissionAttached/Detached events on assignment changes. + 'events_enabled' => false, + + // Teams (permission scoping by team_foreign_key). Set true BEFORE migrating. + 'teams' => false, + + // Resolver for the current team id (coroutine-scoped in Hypervel). + 'team_resolver' => DefaultTeamResolver::class, + + // Use Passport clients (client-credentials grant) as the authorizable in middleware. + 'use_passport_client_credentials' => false, + + // Include role/permission names in 403 exception messages (info-leak; default off). + 'display_permission_in_exception' => false, + 'display_role_in_exception' => false, + + // Wildcard permission matching. + 'enable_wildcard_permission' => false, + // 'wildcard_permission' => Hypervel\Permission\WildcardPermission::class, + + 'cache' => [ + 'expiration_seconds' => 86400, // 24 hours + 'keys' => [ + 'roles' => 'hypervel.permission.cache.roles', + 'model_roles' => 'hypervel.permission.cache.model.roles', + 'model_permissions' => 'hypervel.permission.cache.model.permissions', + ], + 'store' => env('PERMISSION_CACHE_STORE', 'default'), + ], +]; +``` + +Notes: +- `cache.expiration_seconds` (int) is the Hypervel shape (spatie uses a `DateInterval` `expiration_time`). Documented divergence — keep. +- `cache.keys.{model_roles,model_permissions}` are the per-subject prefixes (renamed from the archived `owner_*`). `cache.keys.roles` is the catalog. +- No `register_octane_reset_listener` (D14). No `cache.key` (single) — we use `cache.keys`. + +### 6.3 Migrations (divergent — full spec) + +**`2025_07_02_000000_create_permission_tables.php`** — config-driven, with `is_forbidden`, composite unique, FKs, storage connection. Use Hypervel `Migration`/`Blueprint`/`Schema`. Use `id()` for bigint primary keys. **Do not use `$table->morphs()`** for the morph columns — the morph key name is configurable (`column_names.model_morph_key`), so declare `string('model_type')` + `unsignedBigInteger($columnNames['model_morph_key'])` + a composite index explicitly (matches spatie; UUID-keyed subjects adjust the published migration). Key requirements: + +- `getConnection()` returns `config('permission.storage.database.connection') ?: parent::getConnection()`. +- Read `$tableNames = config('permission.table_names')`, `$columnNames = config('permission.column_names')`, `$teams = config('permission.teams')`, pivot keys with defaults. +- `permissions`: `id`, `string('name')`, `string('guard_name')`, `timestamps()`, `unique(['name','guard_name'])`. +- `roles`: `id`; if `$teams`, nullable `team_foreign_key` + index; `string('name')`, `string('guard_name')`, `timestamps()`; unique `(team_foreign_key, name, guard_name)` when teams else `(name, guard_name)`. +- `model_has_permissions`: `unsignedBigInteger(permission_pivot_key)`, `string('model_type')`, `unsignedBigInteger(model_morph_key)`, **`boolean('is_forbidden')->default(false)`**, index on `(model_morph_key, model_type)`, FK `permission_pivot_key → permissions(id) cascadeOnDelete`; primary key `(permission_pivot_key, model_morph_key, model_type)` (prefixed with `team_foreign_key` when teams). The `is_forbidden` column is the Hypervel divergence — add a brief comment. +- `model_has_roles`: same shape minus `is_forbidden`, FK to `roles(id)`, primary `(role_pivot_key, model_morph_key, model_type)` (team-prefixed when teams). +- `role_has_permissions`: `unsignedBigInteger(permission_pivot_key)`, `unsignedBigInteger(role_pivot_key)`, **`boolean('is_forbidden')->default(false)`**, FKs to both with `cascadeOnDelete`, primary `(permission_pivot_key, role_pivot_key)`. +- End of `up()`: forget the catalog cache key (best-effort): resolve the configured store and `forget(config('permission.cache.keys.roles'))`. +- `down()`: drop in reverse FK-safe order. + +**`2025_07_02_000001_add_teams_fields.php`** — faithful port of `spatie:database/migrations/add_teams_fields.php.stub`, adapted to Hypervel `Schema`/`Blueprint`, reading `model_*` table names. `up()` returns early unless `config('permission.teams')`. Adds `team_foreign_key` (nullable on `roles`, default on the two `model_has_*`), rebuilds unique/primary indexes and FKs, forgets the catalog cache. `down()` empty (matches spatie). + +Both migrations are published via the provider (see §6.18); the create migration is also auto-loaded for tests (see §8.1). + +### 6.4 `Guard.php` (faithful port) + +Port `spatie:src/Guard.php` verbatim in behavior: `getNames`, `getProviderModel`, `getConfigAuthGuards`, `getModelForGuard`, `getDefaultName`, `getPassportClient`. Adapt: `Illuminate\Support\Collection` → `Hypervel\Support\Collection`; `Illuminate\Support\Facades\Auth` → `Hypervel\Support\Facades\Auth`; `Authorizable` contract → `Hypervel\Contracts\Auth\Access\Authorizable`; `config(...)` reads stay as the `config()` helper is acceptable in this static utility, but prefer `Container::getInstance()->make('config')` to match the DI rule — use the latter. `getPassportClient` (D9) is kept as-is; it duck-types `client()` via `method_exists`, so no Passport dependency. + +**Static-cache note (decided, do NOT add):** `Guard::getNames` partly depends on per-instance `guard_name` (a DB column), so caching resolved guard names by class is unsafe. Reflection cost is negligible. Do not add a static cache here. + +### 6.5 `Support/Config.php` (faithful port, adapted) + +Port `spatie:src/Support/Config.php`: `teamsEnabled`, `ensureTeamsEnabled`, `teamModel`, `modelHasRolesTable`, `modelHasPermissionsTable`, `roleHasPermissionsTable`, `rolesTable`, `permissionsTable`, `morphKey`, `teamForeignKey`, `roleModel`, `permissionModel`, `eventsEnabled`, `usePassportClientCredentials`, `displayRoleInException`, `displayPermissionInException`, `wildcardPermissionsEnabled`, `wildcardPermissionClass`. Replace `config(...)` with `Container::getInstance()->make('config')->...` and `app(PermissionRegistrar::class)` with `Container::getInstance()->make(PermissionRegistrar::class)`. Throws `TeamsNotEnabled`/`TeamModelNotConfigured` as spatie does. **Add a Hypervel-specific `storageConnection(): ?string`** returning `permission.storage.database.connection` (used by the models, D12) — this has no spatie equivalent. + +### 6.6 Contracts (faithful ports) + +- `Contracts/Permission.php`: `roles(): BelongsToMany`; statics `findByName(UnitEnum|string $name, ?string $guardName): self`, `findById(int|string $id, ?string $guardName): self`, `findOrCreate(UnitEnum|string $name, ?string $guardName): self`. Use `UnitEnum|string` (kept Hypervel improvement — `enum_value` handles both backed and unit enums), not spatie's `BackedEnum|string`. Keep `@mixin`/`@phpstan-require-extends` to the base model. +- `Contracts/Role.php`: `permissions(): BelongsToMany`; the same three statics; `hasPermissionTo(string|int|Permission|UnitEnum $permission, ?string $guardName = null): bool`. +- `Contracts/Wildcard.php`: `getIndex(): array`, `implies(string $permission, string $guardName, array $index): bool`. +- `Contracts/PermissionsTeamResolver.php`: `getPermissionsTeamId(): int|string|null`, `setPermissionsTeamId(int|string|Model|null $id): void`. + +### 6.7 Exceptions (faithful ports) + +Port all eleven from `spatie:src/Exceptions/`, replacing `__(...)` (Hypervel has it), `Illuminate\Support\Collection` → `Hypervel\Support\Collection`, `Symfony\…\HttpException` stays. Files: `RoleDoesNotExist`, `PermissionDoesNotExist` (static `create`/`named`/`withId`), `RoleAlreadyExists`, `PermissionAlreadyExists` (static `create`), `GuardDoesNotMatch` (static `create(string, Collection)`), `TeamsNotEnabled`, `TeamModelNotConfigured`, `WildcardPermissionInvalidArgument`, `WildcardPermissionNotImplementsContract`, `WildcardPermissionNotProperlyFormatted`, and `UnauthorizedException` (the rich one: `forRoles`/`forPermissions`/`forRolesOrPermissions`/`missingTraitHasRoles`/`notLoggedIn`/`getRequiredRoles`/`getRequiredPermissions`, gated by `Config::displayRoleInException`/`displayPermissionInException`). + +**Divergence note:** the archived package had `PermissionException`/`RoleException` (HTTP exceptions carrying the required list) and an empty `UnauthorizedException`. The fresh port adopts spatie's single rich `UnauthorizedException` for middleware failures (it carries `getRequiredRoles`/`getRequiredPermissions` and honors `display_*_in_exception`). Drop `PermissionException`/`RoleException`. Middleware throws `UnauthorizedException::forPermissions(...)` / `forRoles(...)` / `notLoggedIn()` / `missingTraitHasRoles(...)`. + +### 6.8 Events (faithful ports, simplified) + +Plain data-carrier classes with `declare(strict_types=1)`, public readonly promoted properties, no broadcast/serialization traits. They are dispatched from the traits via the container's `'events'` dispatcher — `Container::getInstance()->make('events')->dispatch(new RoleAttachedEvent(...))` — not the `event()` helper, since trait code follows the DI-over-helpers rule (§6.14/§6.15): + +```php +final class RoleAttachedEvent +{ + public function __construct(public Model $model, public mixed $rolesOrIds) {} +} +``` + +Same for `RoleDetachedEvent`, `PermissionAttachedEvent`, `PermissionDetachedEvent`. Keep the docblock noting the payload may be ids or models. + +### 6.9 `DefaultTeamResolver.php` (divergent — CoroutineContext) + +```php +getKey(); + } + + CoroutineContext::set(self::TEAM_ID_CONTEXT_KEY, $id); + } + + public function getPermissionsTeamId(): int|string|null + { + return CoroutineContext::get(self::TEAM_ID_CONTEXT_KEY); + } +} +``` + +The resolver instance holds no state; the team id is coroutine-scoped and auto-clears at coroutine end. The registrar may hold the resolver instance (immutable singleton). + +### 6.10 `helpers.php` (faithful port) + +```php +make(PermissionRegistrar::class)->setPermissionsTeamId($id); + } +} + +if (! function_exists('getPermissionsTeamId')) { + function getPermissionsTeamId(): int|string|null + { + return Container::getInstance()->make(PermissionRegistrar::class)->getPermissionsTeamId(); + } +} +``` + +(Global functions in a non-class file: helper usage is allowed here per CLAUDE.md.) + +### 6.11 `PermissionRegistrar.php` (divergent — full spec) + +The registrar is the cache layer + team-id delegate + class registry + gate hook. **Immutable config only**; cache derived data in the store. Public surface (parity-named where it maps): + +Immutable state (set in constructor from config): `$permissionClass`, `$roleClass`, `$teamClass`, `$teamResolver` (the `PermissionsTeamResolver`), `$teams` (bool), `$teamsKey` (string), `$cacheKey` (catalog key), `$modelRolesCacheKeyPrefix`, `$modelPermissionsCacheKeyPrefix`, `$cacheExpirationSeconds`, `$pivotRole`, `$pivotPermission`, `$cache` (resolved `Repository`). No `$permissions` collection, no `$isLoadingPermissions`, no `$wildcardPermissionsIndex`, no `$alias`/`$except`/`$cachedRoles`. + +Constructor: inject `Container` and `CacheManager` (`Hypervel\Contracts\Cache\Factory`). Read config via the injected config repository. Resolve the cache store via `getCacheStoreFromConfig()` (port the archived logic: `cache.store` of `'default'` → `cacheManager->store()`; unknown store → fall back to `'array'`; use the **injected config** for `cache.stores`, not the `config()` helper — fix the archived inconsistency). Build the team resolver: `new (config('permission.team_resolver', DefaultTeamResolver::class))`. + +Methods: + +- `getPermissionClass(): string`, `getRoleClass(): string`, `getTeamClass(): ?string`. **No setters** (drop `setPermissionClass`/`setRoleClass`/`setTeamClass` — they used `config()->set()`/`bind()`). +- `setPermissionsTeamId(int|string|Model|null $id): void` → `$this->teamResolver->setPermissionsTeamId($id)`. +- `getPermissionsTeamId(): int|string|null` → `$this->teamResolver->getPermissionsTeamId()`. +- `getCacheRepository(): Repository`, `getCacheStore(): Store` (used by `CacheResetCommand`). +- **Catalog:** + - `getAllRolesWithPermissions(): array` — `remember(cacheKey, ttl, fn)` building `[(string)$role->getKey() => ['role' => $role->toArray(), 'permissions' => $role->permissions->toArray()]]` from `roleClass::with('permissions')->get()`. Key by `getKey()`. + - `forgetCachedPermissions(): bool` — `$this->cache->forget($this->cacheKey)`. +- **Per-subject cache** (keyed by morph type + key + optional team id): + - `getModelRolesCacheKey(string $morphType, int|string $key): string` + - `getModelPermissionsCacheKey(string $morphType, int|string $key): string` + - `cacheModelRoles(...)`, `cacheModelPermissions(...)`, `getCachedModelRoles(...)`, `getCachedModelPermissions(...)`, `clearModelCache(string $morphType, int|string $key): void`. Team id (when teams enabled) is appended to the key via `getPermissionsTeamId()`. +- **Gate:** `registerPermissions(Gate $gate): bool` — port spatie's `$gate->before(...)` calling `$user->checkPermissionTo($ability, $guard ?? null)`. +- **`isUid(mixed $value): bool`** — port spatie's static UUID/ULID detector (used by trait id-vs-name resolution). + +Key snippet (catalog + a per-subject helper): + +```php +public function getAllRolesWithPermissions(): array +{ + return $this->cache->remember($this->cacheKey, $this->cacheExpirationSeconds, function (): array { + $roleClass = $this->getRoleClass(); + + return $roleClass::with('permissions')->get() + ->mapWithKeys(fn ($role) => [ + (string) $role->getKey() => [ + 'role' => $role->toArray(), + 'permissions' => $role->permissions->toArray(), + ], + ])->all(); + }); +} + +protected function modelCacheKeySuffix(): string +{ + if (! $this->teams) { + return ''; + } + + return ':team:' . ($this->getPermissionsTeamId() ?? 'null'); +} +``` + +### 6.12 `WildcardPermission.php` (faithful port) + wildcard exceptions + +Port `spatie:src/WildcardPermission.php` verbatim (it is pure — operates on the injected `$record`): `getIndex()`, `buildIndex()`, `implies()`, `checkIndex()`, the three delimiter constants. Adapt namespace/types; `Illuminate\Support\Str` → `Hypervel\Support\Str`; throws `WildcardPermissionNotProperlyFormatted`. The index is computed on demand in `HasPermissions::hasWildcardPermission` from `$this->getAllPermissions()` (cache-backed) — **not** cached on the registrar (D8). + +### 6.13 `Traits/RefreshesPermissionCache.php` (faithful port) + +```php +trait RefreshesPermissionCache +{ + public static function bootRefreshesPermissionCache(): void + { + static::saved(fn () => Container::getInstance()->make(PermissionRegistrar::class)->forgetCachedPermissions()); + static::deleted(fn () => Container::getInstance()->make(PermissionRegistrar::class)->forgetCachedPermissions()); + } +} +``` + +Used by both models (fixes the missing cache-invalidation bug — §5.3). + +### 6.14 `Traits/HasRoles.php` (divergent body, spatie surface) + +Public surface = spatie's: `bootHasRoles` (deleting → detach roles, and detach users if Permission), `getRoleClass`, `roles()` (morphToMany with team scoping when enabled), `scopeRole`/`scopeWithoutRole`, `teams()`/`scopeTeam`/`scopeWithoutTeam` (team feature), `assignRole(...$roles)`, `removeRole(...$roles)`, `syncRoles(...$roles)`, `hasRole($roles, $guard = null)` (string|int|array|Role|Collection|UnitEnum, pipe-strings, guard filter), `hasAnyRole(...$roles)`, `hasAllRoles($roles, $guard = null)`, `hasExactRoles($roles, $guard = null)`, `getRoleNames(): Collection`, `getStoredRole`, `convertPipeToArray`. + +Bodies: port spatie's logic, with these Hypervel changes: +- Use `enum_value()` for normalization; accept `UnitEnum` (not only `BackedEnum`). +- **`hasRole`/`hasAnyRole`/`hasAllRoles`/`hasExactRoles` read the per-subject roles cache** (`PermissionRegistrar::getCachedModelRoles` → hydrate) instead of `loadMissing('roles')` on every call. Cold cache loads from DB and caches. +- **`assignRole`/`removeRole`/`syncRoles`** mutate the pivot (resolve names/ids → keys using `getKeyName()`/`getKey()`, never hardcoded `id`), then `clearModelCache(getMorphClass(), getKey())`, then (if `Config::eventsEnabled()`) dispatch `RoleAttachedEvent`/`RoleDetachedEvent` via `Container::getInstance()->make('events')->dispatch(...)`. Preserve spatie's before-save isolation (register a one-shot `static::saved` hook to attach when the subject is not yet persisted). +- Team pivot: when teams enabled and the subject is not a Permission, include `[teamsKey => getPermissionsTeamId()]` on attach. +- `roles()` relation: when teams enabled, `withPivot(teamsKey)` + `wherePivot(teamsKey, getPermissionsTeamId())` + nullable-team `orWhere`. Replace the spatie pattern of toggling `app(PermissionRegistrar::class)->teams` with coroutine-safe logic — do **not** mutate the registrar's `teams` flag at runtime; in `bootHasRoles` deletion cleanup, detach without the team filter by querying the pivot table directly (see §6.16 note). + +`collectRoles(...)` resolves inputs to role keys via `getStoredRole` (which uses `findById`/`findByName`), `ensureModelSharesGuard`, dedup by `getKey()`. + +### 6.15 `Traits/HasPermissions.php` (divergent body, spatie surface + forbidden) + +Public surface = spatie's plus the forbidden additions: `bootHasPermissions` (deleting → detach permissions/users), `getPermissionClass`, `getWildcardClass`, `permissions()` (morphToMany, `withPivot('is_forbidden')`, team scoping), `scopePermission`/`scopeWithoutPermission`, `convertToPermissionModels`, `filterPermission`, `hasPermissionTo` (with forbidden override), `hasWildcardPermission`, `checkPermissionTo`, `hasAnyPermission`, `hasAllPermissions`, `hasPermissionViaRole`, `hasDirectPermission`, `getPermissionsViaRoles`, `getAllPermissions`, `givePermissionTo`, `syncPermissions`, `revokePermissionTo`, `getPermissionNames`, `getStoredPermission`, `ensureModelSharesGuard`, `getGuardNames`, `getDefaultGuardName`, `forgetCachedPermissions`, `hasAllDirectPermissions`, `hasAnyDirectPermission`. + +**Forbidden additions (Hypervel feature, port from `_archive/permission`):** public `giveForbiddenTo(...$permissions)`, public `hasForbiddenPermission($permission)`, public `hasForbiddenPermissionViaRoles($permission)`, and `syncPermissions(array $allow = [], array $forbidden = [])` (two-arg form; forbidden wins on conflict). `permissions()` carries `is_forbidden` in `withPivot`. + +**Method-visibility decisions (final):** the public check surface is spatie's (`hasPermissionTo`, `hasAnyPermission`, `hasAllPermissions`, `hasDirectPermission`, `hasAllDirectPermissions`, `hasAnyDirectPermission`, `getPermissionsViaRoles`, `getAllPermissions`) plus the three public forbidden methods above. `hasPermissionViaRole(Permission $permission)` stays **protected** (spatie's internal helper). Do not add a public `hasPermissionViaRoles` — the archived public name is dropped; tests assert via `hasPermissionTo`/`getPermissionsViaRoles`/the forbidden methods. + +Bodies: port spatie's logic with Hypervel cache + forbidden integration: +- **`hasPermissionTo`** order (the forbidden override): wildcard short-circuit → resolve permission → if `hasForbiddenPermission` return false → if not Role and `hasForbiddenPermissionViaRoles` return false → `hasDirectPermission || hasPermissionViaRole`. +- `hasDirectPermission`/`hasForbiddenPermission` read the subject's direct permissions, filter on `is_forbidden`. **For a non-Role subject**, read the per-subject permissions cache. **For a Role subject** (a Role uses `HasPermissions`; its direct permissions are its `role_has_permissions`), read them from the **catalog** entry for that role rather than a separate per-subject cache (avoids double-caching). Guard the catalog read: if the role's key is absent from the catalog (e.g. catalog momentarily stale), the catalog is rebuilt on read (the `RefreshesPermissionCache` saved-hook cleared it when the role was created), so the entry will be present; treat a still-missing key as "no permissions" rather than indexing into a missing key (this fixes the archived undefined-key bug). +- `hasPermissionViaRole`/`hasForbiddenPermissionViaRoles` read the per-subject roles cache, resolve each role's permissions via the **catalog**, filter on `is_forbidden`. +- `getAllPermissions`/`getPermissionsViaRoles` exclude forbidden entries (match the archived behavior and our tests). +- `givePermissionTo`/`giveForbiddenTo`/`revokePermissionTo`/`syncPermissions` mutate pivots with `is_forbidden`, then: if the subject is a Role → `forgetCachedPermissions()` (catalog); else → `clearModelCache(...)`. Then (if `Config::eventsEnabled()`) dispatch `PermissionAttachedEvent`/`PermissionDetachedEvent` via `Container::getInstance()->make('events')->dispatch(...)`. +- All key handling via `getKeyName()`/`getKey()` — no hardcoded `id`. No loose `==` — use `===` (the archived `is_forbidden == true` becomes a strict boolean check; cast the pivot value with `(bool)`). +- `getWildcardClass`/`hasWildcardPermission` compute the index from `$this->getAllPermissions()` on demand (no registrar index state). Drop `forgetWildcardPermissionIndex` (no index to forget). + +### 6.16 `Traits/HasAssignedModels.php` (faithful port) + +Port `spatie:src/Traits/HasAssignedModels.php`: `assignToModels`, `removeFromModels`, `syncModels`, `relationForModel` (morphedByMany), `groupModelsByMorphClass`, `resolveDefaultModelClass`, `teamPivot`, `newPivotQueryForRole`. Used by `Models/Role` (reverse assignment: assign a role to many models). Adapt namespace/types; `Illuminate\Database\Query\Builder` → `Hypervel\Database\Query\Builder`; team pivot via `getPermissionsTeamId()`. After mutations, also `forgetCachedPermissions()` is unnecessary (role identity unchanged) but the affected subjects' caches should be cleared — since enumerating them is impractical, document that reverse-assignment relies on per-subject TTL/next-mutation for cache freshness, OR clear each touched subject's cache in the loop (preferred: clear per touched model key). **Decision: clear each touched subject's per-subject cache in the loop** (we already iterate the ids). + +Note for §6.14 deletion cleanup: instead of spatie's `app(PermissionRegistrar::class)->teams = false` toggle (a runtime singleton mutation — banned), detach in `bootHasRoles`/`bootHasPermissions` by operating on the relation without the team filter. Implement a small protected helper that builds the morph pivot query directly (like `newPivotQueryForRole`) and deletes the subject's rows, avoiding the team-scoped relation. This keeps deletion cleanup correct without mutating shared state. + +### 6.17 Models (divergent — full spec) + +**`Models/Permission.php`:** + +```php + $roles + */ +class Permission extends Model implements PermissionContract +{ + use HasRoles; + use RefreshesPermissionCache; + + protected array $guarded = []; + + public function __construct(array $attributes = []) + { + $attributes['guard_name'] ??= Guard::getDefaultName(static::class); + + parent::__construct($attributes); + + $this->guarded[] = $this->getKeyName(); + $this->setTable(Config::permissionsTable() ?: $this->getTable()); + $this->setConnection(Config::storageConnection() ?: $this->getConnectionName()); + } + + public static function create(array $attributes = []): PermissionContract + { + $attributes['guard_name'] ??= Guard::getDefaultName(static::class); + $attributes['name'] = enum_value($attributes['name']); + + if (static::findByParam(['name' => $attributes['name'], 'guard_name' => $attributes['guard_name']])) { + throw PermissionAlreadyExists::create($attributes['name'], $attributes['guard_name']); + } + + return static::query()->create($attributes); + } + + public function roles(): BelongsToMany { /* belongsToMany(Role, role_has_permissions, permission_pivot_key, role_pivot_key)->withPivot('is_forbidden') */ } + + public function users(): BelongsToMany { /* morphedByMany(getModelForGuard(guard_name), 'model', model_has_permissions, permission_pivot_key, model_morph_key) */ } + + public static function findByName(UnitEnum|string $name, ?string $guardName = null): PermissionContract { /* enum_value, default guard, findByParam, throw PermissionDoesNotExist::create */ } + + public static function findById(int|string $id, ?string $guardName = null): PermissionContract { /* findByParam([keyName => id, guard]), throw ::withId */ } + + public static function findOrCreate(UnitEnum|string $name, ?string $guardName = null): PermissionContract { /* findByParam or create */ } + + protected static function findByParam(array $params = []): ?PermissionContract { /* query, where each param, first() — NO team filter: the permissions table has no team_foreign_key; only roles + assignments are team-scoped */ } +} +``` + +Key divergences from spatie's `Models/Permission`: +- `$guarded = []` + guard the key in the constructor (D11). +- `findByName`/`findById`/`findOrCreate`/`findByParam` query the DB directly (D17) — do **not** route through a registrar `getPermissions()` catalog. (spatie's `Permission` uses `getPermission`/`getPermissions`; we replace those with `findByParam`-style direct queries, mirroring spatie's `Role` model.) +- `users()` reverse relation via `morphedByMany`. +- Table/connection from `Config` (D12). Add `Config::storageConnection()` helper to `Support/Config` (reads `permission.storage.database.connection`). +- `roles()` uses `withPivot('is_forbidden')`. + +**`Models/Role.php`:** mirror of Permission with `HasRoles` + `HasPermissions` + `HasAssignedModels` + `RefreshesPermissionCache`, `permissions()` (`belongsToMany(Permission, role_has_permissions, role_pivot_key, permission_pivot_key)->withPivot('is_forbidden')`), `users()` (`morphedByMany(..., model_has_roles, role_pivot_key, model_morph_key)`), the three find/create statics, `findByParam`, `create()` (with `RoleAlreadyExists` + team handling), and `hasPermissionTo($permission, $guardName = null)` (forbidden-aware, via `HasPermissions`). Constructor sets guard default, guards the key, table from `Config::rolesTable()`, connection from `Config::storageConnection()`. **Unlike `Permission::findByParam`, `Role::findByParam` applies the team filter when teams are enabled** (the `roles` table has `team_foreign_key`): scope to rows where the team key is null or equals `getPermissionsTeamId()` (port spatie's `Role::findByParam`). `create()` and `findOrCreate()` set the team key from `getPermissionsTeamId()` when teams are enabled. + +### 6.18 Middleware (faithful ports, adapted to `UnauthorizedException`) + +Port `spatie:src/Middleware/{PermissionMiddleware,RoleMiddleware,RoleOrPermissionMiddleware}.php`: +- `handle(Request $request, Closure $next, $arg, ?string $guard = null)` — resolve `Auth::guard($guard)->user()`; Passport fallback (D9); `UnauthorizedException::notLoggedIn()`; `missingTraitHasRoles($user)`; check via `canAny` (permission) / `hasAnyRole` (role) / both (role-or-permission); throw `forPermissions`/`forRoles`/`forRolesOrPermissions`. +- Static `using(...)` returning `static::class . ':' . $args` (with `,$guard` when set), parsing enums via `enum_value`. +- Keep `BackedEnum`-accepting `using()` signatures but normalize via `enum_value` so unit enums work too. + +**Divergence from archived:** the archived middleware used `hasAnyPermissions`/`hasAnyRoles` (plural) + injected `Container` + `PermissionException`/`RoleException`. The port uses spatie's `Auth::guard()` + `canAny`/`hasAnyRole` + `UnauthorizedException`. Note: `canAny` requires the Gate permission hook (D10) to be registered for permission middleware to work via the gate; `RoleMiddleware` uses `hasAnyRole` directly (no gate needed). This matches spatie exactly. + +### 6.19 Commands (faithful ports) + +Port all six from `spatie:src/Commands/` to `Hypervel\Console\Command` with `#[AsCommand(name: '...')]` (required for lazy resolution): `permission:create-role`, `permission:create-permission`, `permission:cache-reset`, `permission:assign-role`, `permission:show`, `permission:upgrade-for-teams`. Adapt: +- Resolve models via `Container::getInstance()->make(...)` of the configured classes (spatie uses `app(RoleContract::class)`; we have no contract binding for models, so resolve `config('permission.models.role')`/`permission` — or bind the contracts in the provider, see §6.20, and resolve the contract). **Decision:** bind `Contracts\Role`/`Contracts\Permission` to the configured models in the provider (spatie does this), and resolve the contracts in commands. This also serves blade/gate. +- `CacheResetCommand` calls `registrar->forgetCachedPermissions()` (catalog). Document that per-subject caches expire via TTL or clear on next mutation (the global catalog is the user-facing "permission cache"). +- `ShowCommand`: port spatie's full version (with teams columns when enabled), using `getKeyName()`. +- `UpgradeForTeamsCommand`: port faithfully (publishes/runs the teams migration concept); adapt to Hypervel console. + +### 6.20 `PermissionServiceProvider.php` (divergent — full spec) + +```php +public function register(): void +{ + $this->mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); + + $this->app->singleton(PermissionRegistrar::class); + + $this->registerModelBindings(); +} + +public function boot(): void +{ + $this->registerPublishing(); // config + both migrations (publishesMigrations) + $this->registerCommands(); // runningInConsole guard + $this->registerBladeExtensions(); // callAfterResolving('blade.compiler', ...) + $this->registerMacroHelpers(); // Route::macro role/permission/roleOrPermission via Middleware::using + $this->registerGateCheck(); // callAfterResolving(Gate::class, ...) when register_permission_check_method + $this->registerAbout(); // AboutCommand::add (features enabled) +} +``` + +- `registerModelBindings`: `$this->app->bind(Contracts\Permission::class, fn ($app) => $app->make($app->make('config')->get('permission.models.permission')))`; same for `Contracts\Role`. (No `Factory` contract — D16.) +- `registerGateCheck`: `callAfterResolving(Gate::class, function (Gate $gate) { if (config('permission.register_permission_check_method')) { $this->app->make(PermissionRegistrar::class)->registerPermissions($gate); } })`. No `clearPermissionsCollection()` call (no such state). +- `registerBladeExtensions`: `callAfterResolving('blade.compiler', fn (BladeCompiler $b) => ...)` registering `@haspermission`, `@role`, `@hasrole`, `@hasanyrole`, `@hasallroles`, `@hasexactroles`, `@endunlessrole` via a `bladeMethodWrapper(string $method, $arg, ?string $guard = null)` that calls `auth($guard)->check() && auth($guard)->user()->{$method}($arg)`. Map `@haspermission` → `checkPermissionTo`. +- `registerMacroHelpers`: register macros on `\Hypervel\Routing\Route` (the class is `Macroable`, and `Router::get()`/`post()`/etc. return a `Hypervel\Routing\Route` instance, so the fluent `Route::get(...)->role('admin')` resolves the macro): `\Hypervel\Routing\Route::macro('role', fn ($roles = []) => $this->middleware(RoleMiddleware::using(Arr::wrap($roles))))`; `permission` → `PermissionMiddleware::using`; `roleOrPermission` → `RoleOrPermissionMiddleware::using`. (Use `Middleware::using()` rather than string aliases — no alias registration needed; matches the existing `using()` pattern.) Inside the macro, `$this` is the route instance; map enums via `enum_value` before building the `using()` string. +- `registerPublishing`: publish config (`permission-config`), and both migrations via `publishesMigrations` (`permission-migrations`). +- `registerAbout`: `AboutCommand::add('Permission', fn () => ['Features Enabled' => ...teams/wildcard/passport..., 'Forbidden Permissions' => 'Enabled'])`. Drop spatie's `register_octane_reset_listener` row (D14). +- **No** `registerOctaneListener` (D14). + +--- + +## 7. Documentation updates + +### 7.1 `src/boost/docs/permission.md` + +The archived doc is accurate to the old API. Rewrite the affected sections (using `Edit` on the published doc once it exists, or write fresh sections) to cover the new surface. Required additions/changes: +- Trait names: `HasRoles`/`HasPermissions` (plural). Method names: `hasPermissionTo`, `hasAnyRole`/`hasAnyPermission`, `hasAllRoles`, `hasExactRoles`, `getRoleNames`/`getPermissionNames`. +- Tables/columns: `model_has_roles`, `model_has_permissions`, `model_morph_key`/`model_id`/`model_type`. +- New sections: **Find & Create Helpers** (`findByName`/`findById`/`findOrCreate`/`create` with guard + enum), **Guards** (default guard, guard matching, multiple guards), **Teams** (enable, team resolver, `setPermissionsTeamId`, team-scoped assignments, custom team model + custom columns), **Wildcard Permissions**, **Events** (`events_enabled` + the four events), **Gate Integration** (`$user->can(...)`, `register_permission_check_method`), **Blade Directives**, **Route Macros** (`Route::role`/`permission`/`roleOrPermission`), **Reverse Assignment** (`Role::assignToModels`), **Role/Permission Middleware + RoleOrPermission**, **Passport client credentials**, **Console Commands** (all six), **Cache** (per-subject + catalog, recommend a `stack` store, `permission:cache-reset`). +- Keep the **Forbidden Permissions** section (the Hypervel feature) updated to the new method names. +- Custom models: document `models.role`/`permission`/`team` swap + that `$guarded = []` lets you add columns (`tenant_id`, json `data`). + +### 7.2 `README.md` — "Differences From Spatie" + +Add a section listing the intentional divergences (per AGENTS.md): +- Forbidden permissions (`is_forbidden` pivot, `giveForbiddenTo`, two-arg `syncPermissions`). +- Per-subject assignment caching + store-agnostic design (recommend `stack` store); catalog keyed by role. +- Registrar holds no in-memory permission collection / no load-lock (coroutine safety); team id via `CoroutineContext`. +- `cache.expiration_seconds` (int) instead of `expiration_time` (DateInterval); `cache.keys.*` instead of a single `cache.key`. +- No Octane reset listener (N/A); no alias-compression of the cache payload. +- Keep README header `Migrated from: https://github.com/spatie/laravel-permission`. + +### 7.3 Config comments + +In `config/permission.php`, do **not** claim auto-flush in a misleading way: the cache *is* flushed automatically on role/permission model changes (now true via `RefreshesPermissionCache`) and on assignment changes — phrase accurately. + +### 7.4 `src/boost/todo.md` + +Remove the existing find-helpers entry (it is implemented by this port). Add no new "deferred" entries — there is nothing deferred. Add a one-line note for the Passport follow-up: when the Passport package lands, verify its `passport`-driver guard exposes a `client()` method matching `Guard::getPassportClient`. + +--- + +## 8. Test plan + +Tests extend `Hypervel\Testbench\TestCase` (DB + container) or `Hypervel\Tests\TestCase` (unit/mock). Coroutine isolation is inherited from the base class — never add `RunTestsInCoroutine`. Mockery as `m`; never add `Mockery::close()`. Fixtures in `tests/Permission/Fixtures/`. Run each file immediately after writing it (`./vendor/bin/phpunit --no-progress tests/Permission/.php`), then the whole group, then `composer test:parallel`. + +### 8.1 Test harness + +- `tests/Permission/PermissionTestCase.php`: extend `Testbench\TestCase`, `use RefreshDatabase`, set `permission` config (with `model_*` names, the new `cache.keys`), set a working `cache` config, and `migrateFreshUsing()` loading `src/permission/database/migrations` + `tests/Permission/migrations` (users table). Remove the archived dead `cache.keys.owner` key. **Set `permission.storage.database.connection` to the test/refresh connection (or `null`)** — required now that the models honor the storage connection (D12); otherwise the models query the package default (`mysql`) while the schema is on the test connection. Provide a teams-enabled variant (or a `withTeams()` helper that re-publishes config with `teams => true` and runs the teams migration) for team tests. +- **Registrar reset + cache flush in `setUp()` (required).** The `PermissionRegistrar` is a singleton that reads `permission` config once at construction; the cache store (e.g. `array`) is memoized for the worker lifetime. Both persist across tests. So in `setUp()`, **after** setting `permission`/`cache` config: call `$this->app->forgetInstance(PermissionRegistrar::class)` (so the next resolution reconstructs it from this test's config — essential for the teams-enabled variant) and flush the configured permission cache store (e.g. `$this->app->make('cache')->store(...)->flush()`) so cached catalog/per-subject entries don't leak between tests. `RefreshDatabase` resets the DB but not the cache. +- **Auth config (required).** Guard defaulting (`Guard::getDefaultName`), guard matching, and the `users()` reverse relation depend on `auth` config. In the harness, set `auth.defaults.guard = 'web'`, `auth.guards.web = ['driver' => 'session', 'provider' => 'users']`, `auth.providers.users = ['driver' => 'eloquent', 'model' => Fixtures\User::class]`. Multi-guard tests add a second guard/provider pointing at a second fixture model. +- Fixtures: `Fixtures/User.php` (uses `HasRoles`, implements `Authenticatable` + `Authorizable`, `$guarded = []`), `Fixtures/Admin.php` / other guard models for multi-guard tests, `Fixtures/Team.php` for team tests, `Fixtures/enums` (`Role`/`Permission` backed enums + a unit enum), `migrations/...create_users_table.php` and any guard tables. + +### 8.2 Port spatie's tests (adapt to Hypervel + our method names) + +From `spatie:tests/`, port and adapt (namespace `Hypervel\Tests\Permission\…`, base class, strict types, `m::`, typed model props, `andReturnSelf()` where needed). Map spatie helpers to ours where names match (they now do, since we adopted spatie naming). Files to port: + +| Spatie test | Port to | Notes | +|---|---|---| +| `Traits/HasRolesTest.php` (60) | `tests/Permission/HasRolesTest.php` | Full assign/remove/sync/has matrix, scopes, guard enforcement, before-save isolation, soft-delete non-detach, lazy-load restriction. | +| `Traits/HasPermissionsTest.php` (59) | `tests/Permission/HasPermissionsTest.php` | Direct/role matrix, scopes, guard enforcement, type/null errors, before-save isolation. | +| `Traits/HasRolesWithCustomModelsTest.php` (65) | `tests/Permission/HasRolesWithCustomModelsTest.php` | Custom models, **custom primary key (non-`id`)**, cascade/touch. Verifies the custom-primary-key fix (§1.2 bug list; all key handling via `getKeyName()`/`getKey()`). | +| `Traits/HasPermissionsWithCustomModelsTest.php` (66) | `tests/Permission/HasPermissionsWithCustomModelsTest.php` | Same, permissions. | +| `Traits/HasAssignedModelsTest.php` (20) | `tests/Permission/HasAssignedModelsTest.php` | Reverse assignment. | +| `Traits/TeamHasRolesTest.php` (64) | `tests/Permission/TeamHasRolesTest.php` | Teams (teams-enabled harness). | +| `Traits/TeamHasPermissionsTest.php` (63) | `tests/Permission/TeamHasPermissionsTest.php` | Teams. | +| `Traits/TeamScopeTest.php` (13) | `tests/Permission/TeamScopeTest.php` | Team scopes + not-enabled/not-configured errors. | +| `Traits/WildcardHasPermissionsTest.php` (17) | `tests/Permission/WildcardHasPermissionsTest.php` | Wildcards. | +| `Models/RoleTest.php` (27) | `tests/Permission/Models/RoleTest.php` | create/find/findOrCreate, duplicate, guard, string-`"0"`. | +| `Models/PermissionTest.php` (11) | `tests/Permission/Models/PermissionTest.php` | Same. | +| `Models/WildcardRoleTest.php` (9) | `tests/Permission/Models/WildcardRoleTest.php` | Wildcard role checks. | +| `Models/RoleWithNestingTest.php` (1) | `tests/Permission/Models/RoleWithNestingTest.php` | `withCount` nested. | +| `Middleware/PermissionMiddlewareTest.php` (24) | `tests/Permission/Middleware/PermissionMiddlewareTest.php` | guest/wrong-guard/super-admin/via-role/exception-payload/`using()`/enum/Passport. | +| `Middleware/RoleMiddlewareTest.php` (23) | `tests/Permission/Middleware/RoleMiddlewareTest.php` | Same for roles. | +| `Middleware/RoleOrPermissionMiddlewareTest.php` (14) | `tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php` | Combined. | +| `Middleware/WildcardMiddlewareTest.php` (7) | `tests/Permission/Middleware/WildcardMiddlewareTest.php` | Wildcards. | +| `Integration/CacheTest.php` (16) | `tests/Permission/CacheTest.php` | Cache flush on create/update/delete + assignment; `permission:cache-reset`; adapt to our catalog + per-subject keys. | +| `Integration/GateTest.php` (7) | `tests/Permission/GateTest.php` | Gate `before` grants via direct/role. | +| `Integration/CustomGateTest.php` (2) | `tests/Permission/CustomGateTest.php` | `register_permission_check_method` off. | +| `Integration/PolicyTest.php` (1) | `tests/Permission/PolicyTest.php` | Policy before interception. | +| `Integration/MultipleGuardsTest.php` (3) | `tests/Permission/MultipleGuardsTest.php` | Multi-guard + `guardName()` override. | +| `Integration/BladeTest.php` (25) | `tests/Permission/BladeTest.php` | All directives. Requires blade view fixtures. | +| `Integration/RouteTest.php` (10) | `tests/Permission/RouteTest.php` | `Route::role/permission/roleOrPermission` macros (incl. enums). | +| `Integration/WildcardRouteTest.php` (2) | `tests/Permission/WildcardRouteTest.php` | Wildcard via route. | +| `Integration/PermissionRegistrarTest.php` (7) | `tests/Permission/PermissionRegistrarTest.php` | `isUid`, get class, set team id, `forgetCachedPermissions`. Drop spatie's `clearPermissionsCollection`/`setPermissionClass` cases (no such methods — D5/D16); replace with our catalog/per-subject cache assertions. | +| `Commands/CommandTest.php` (17) | `tests/Permission/Commands/CommandTest.php` | create-role/permission, assign-role, show, about. | +| `Commands/TeamCommandTest.php` (3) | `tests/Permission/Commands/TeamCommandTest.php` | Team assignment via command. | + +**Signature adaptation:** our `syncPermissions(array $allow = [], array $forbidden = [])` is two-arg (the forbidden divergence, D3), whereas spatie's is variadic (`syncPermissions(...$permissions)`). When porting spatie's sync cases, convert `syncPermissions('a', 'b')` → `syncPermissions(['a', 'b'])`. All other method names already match because we adopted spatie naming (D2), so spatie test bodies port with only namespace/base-class/type changes. + +When porting, remove cases that exercise spatie-only internals we intentionally don't have (the in-memory collection clear, `config()->set()` model-class swap). Replace each with the equivalent Hypervel behavior assertion (catalog/per-subject cache). Do not silently drop a case — if it maps to a real behavior, assert that behavior; if it tests an internal we removed by design, replace it with the divergent-design equivalent and note it. + +### 8.3 Carry over the forbidden-permission tests (from `_archive/permission`) + +Port the archived `HasPermissionTest.php` forbidden cases into `HasPermissionsTest.php` (or a dedicated `ForbiddenPermissionsTest.php`), adapted to spatie method names (`hasPermissionTo`, `hasAnyPermission`): +- give/check forbidden direct; forbidden overrides allowed (direct + via role); role-forbidden overrides user-direct; `syncPermissions($allow, $forbidden)` with forbidden precedence; `getAllPermissions`/`getPermissionsViaRoles` exclude forbidden; pivot `is_forbidden` values; priority matrix (direct-forbidden, role-forbidden). These are the D3 spec — keep all of them. + +### 8.4 New tests for gaps (Hypervel-specific) + +- **`tests/Permission/CoroutineSafetyTest.php`** (name per AGENTS.md): using `parallel()` + `usleep()`: + - Two coroutines operating on **different subjects** never see each other's cached roles/permissions; `clearModelCache(A)` does not affect B. + - Concurrent read vs `forgetCachedPermissions()` rebuild of the catalog yields a consistent snapshot (no torn read). + - With teams enabled, `setPermissionsTeamId` in one coroutine does not leak into another (CoroutineContext isolation) — assert each coroutine resolves its own team id and team-scoped assignments. +- **`tests/Permission/PermissionRegistrarTest.php`** additions: `getCacheStoreFromConfig()` all three branches (`'default'`, named store, unknown→`array`); per-subject cache round-trip (`cacheModelRoles`→`getCachedModelRoles`); team-aware cache key suffix. +- **Custom primary key regression** (covered by the WithCustomModels ports, but add a focused case asserting `assignRole`/`hasRole`/`getAllPermissions` work with a non-`id` key) — verifies the custom-primary-key bug fix (§1.2). +- **Connection/table config**: a test setting `table_names.roles`/`storage.database.connection` and asserting the model uses them. +- **`guarded = []` custom column**: a custom Role with an extra column, `create([... extra column ...])` mass-assigns it (D11). +- **Schema**: assert `(name, guard_name)` composite unique allows same name under two guards; assert pivot FK `cascadeOnDelete` removes pivot rows when a role/permission is deleted. + +### 8.5 Coverage target + +Every public method on the registrar, both traits, both models, `Guard`, middleware, commands, and the wildcard/teams/events/gate/blade/route surfaces has at least one test. The forbidden feature and coroutine safety are fully covered. No `@coversNothing`/`#[CoversClass]` (per AGENTS.md). No weakened assertions. + +--- + +## 9. Verification & completion checklist + +Run from `contrib/hypervel/components`: + +1. After each source file: re-read it in full, confirm strict types, types, docblocks, `===`, no `config()->set()`/runtime-`bind()`, no hardcoded `id`. +2. After each test file: `./vendor/bin/phpunit --no-progress tests/Permission/.php`. +3. Group: `./vendor/bin/phpunit --no-progress tests/Permission`. +4. `./vendor/bin/phpstan` (tests excluded) — fix at the source per AGENTS.md narrowing order; no new neon ignores. +5. `./vendor/bin/php-cs-fixer fix`. +6. `composer test:parallel` — full suite green. +7. Confirm the package is discovered: provider auto-registered, `permission:*` commands listed, migrations publishable. +8. Docs: `permission.md` updated; README "Differences From Spatie" added; `todo.md` find-helper entry removed + Passport follow-up noted. +9. Register any new static-cache `flushState()` with `AfterEachTestSubscriber` — **none expected** (the port introduces no static caches; if one is added, register it). + +--- + +## 10. Intentional divergences from spatie (record in README + source comments) + +1. **Forbidden permissions** — `is_forbidden` pivot + `giveForbiddenTo`/`hasForbiddenPermission`/`hasForbiddenPermissionViaRoles` + two-arg `syncPermissions`. (Feature.) +2. **Cache architecture** — store-backed catalog (role→permissions) + per-subject caches; no in-memory registrar collection; no load-lock; `cache.expiration_seconds` (int) + `cache.keys.*`. (Coroutine safety + performance.) +3. **Team id via `CoroutineContext`** (`DefaultTeamResolver`); no Octane reset listener. (Coroutine safety.) +4. **No registrar model-class setters** (`setPermissionClass`/`setRoleClass`/`setTeamClass`) — classes fixed at boot from config. (No `config()->set()`/runtime-`bind()`.) +5. **No `Factory` contract** — `PermissionRegistrar` bound as a singleton concrete. (Simplification; removes double-singleton.) +6. **Find helpers query the DB directly**; no flat-permission catalog. (Simplification.) +7. **No cache alias-compression.** (Simplification.) +8. **Wildcard index computed on demand** from cached permissions; not held on the registrar. (Coroutine safety.) + +Each gets a one-line README entry and, where a method/feature is omitted at its natural location, a concise source comment per the AGENTS.md "Record intentional Laravel differences" rule. + +--- + +## 11. Parity map (quick reference) + +| spatie file | Hypervel disposition | +|---|---| +| `PermissionRegistrar.php` | Divergent (§6.11) — store-backed, no per-request state. | +| `Guard.php` | Faithful (§6.4). | +| `WildcardPermission.php` | Faithful (§6.12). | +| `DefaultTeamResolver.php` | Divergent (§6.9) — CoroutineContext. | +| `helpers.php` | Faithful (§6.10). | +| `Support/Config.php` | Faithful + `storageConnection()` (§6.5). | +| `Traits/HasRoles.php` | Spatie surface, cache-backed body (§6.14). | +| `Traits/HasPermissions.php` | Spatie surface + forbidden, cache-backed body (§6.15). | +| `Traits/HasAssignedModels.php` | Faithful (§6.16). | +| `Traits/RefreshesPermissionCache.php` | Faithful (§6.13). | +| `Models/Role.php`, `Models/Permission.php` | Divergent (§6.17) — `$guarded=[]`, config table/conn, direct-query finds, `is_forbidden` pivot. | +| `Contracts/*` | Faithful, `UnitEnum|string` (§6.6). | +| `Middleware/*` (3) | Faithful, `UnauthorizedException` (§6.18). | +| `Commands/*` (6) | Faithful (§6.19). | +| `Events/*` (4) | Faithful, simplified (§6.8). | +| `Exceptions/*` (11) | Faithful (§6.7); drop archived `PermissionException`/`RoleException`. | +| `PermissionServiceProvider.php` | Divergent (§6.20) — singleton concrete, gate/blade/macros/about, no Octane. | +| `config/permission.php` | Divergent (§6.2). | +| `database/migrations/*` | Divergent (§6.3) — config-driven, `is_forbidden`, composite unique, FKs. | +| Octane reset listener | Omitted (D14). | +| Cache alias-compression | Omitted (D15). | + +--- + +*End of plan.* diff --git a/src/boost/docs/permission.md b/src/boost/docs/permission.md index 75224ddfa..359cd28c6 100644 --- a/src/boost/docs/permission.md +++ b/src/boost/docs/permission.md @@ -10,17 +10,21 @@ - [Table and Column Names](#table-and-column-names) - [Cache](#cache) - [Model Setup](#model-setup) +- [Multiple Guards](#multiple-guards) - [Creating Roles and Permissions](#creating-roles-and-permissions) - [Creating Permissions](#creating-permissions) - [Creating Roles](#creating-roles) - [Assigning Permissions to Roles](#assigning-permissions-to-roles) - [Working With Roles](#working-with-roles) - [Assigning Roles](#assigning-roles) + - [Assigning Models to a Role](#assigning-models-to-a-role) - [Checking Roles](#checking-roles) + - [Role and Team Scopes](#role-and-team-scopes) - [Removing Roles](#removing-roles) - [Working With Permissions](#working-with-permissions) - [Assigning Permissions](#assigning-permissions) - [Checking Permissions](#checking-permissions) + - [Gate and Super Admins](#gate-and-super-admins) - [Forbidden Permissions](#forbidden-permissions) - [Revoking Permissions](#revoking-permissions) - [Retrieving Permissions](#retrieving-permissions) @@ -28,11 +32,24 @@ - [Middleware](#middleware) - [Permission Middleware](#permission-middleware) - [Role Middleware](#role-middleware) + - [Role Or Permission Middleware](#role-or-permission-middleware) + - [Passport Client Credentials](#passport-client-credentials) +- [Blade Directives](#blade-directives) +- [Route Macros](#route-macros) +- [Custom Permission Checks](#custom-permission-checks) +- [Events](#events) - [Console Commands](#console-commands) -- [Polymorphic Owners](#polymorphic-owners) +- [Teams](#teams) +- [Wildcard Permissions](#wildcard-permissions) +- [Polymorphic Models](#polymorphic-models) - [Custom Models](#custom-models) +- [UUID and ULID Keys](#uuid-and-ulid-keys) - [Caching](#caching) +- [Testing and Seeding](#testing-and-seeding) +- [Best Practices](#best-practices) - [Performance](#performance) +- [Exceptions](#exceptions) +- [Differences From Spatie Laravel Permission](#differences-from-spatie-laravel-permission) ## Introduction @@ -83,10 +100,10 @@ The published migration creates the following tables: - `roles` - `permissions` - `role_has_permissions` -- `owner_has_permissions` -- `owner_has_roles` +- `model_has_permissions` +- `model_has_roles` -The `role_has_permissions` and `owner_has_permissions` tables include an `is_forbidden` column used by forbidden permissions. +The `role_has_permissions` and `model_has_permissions` tables include an `is_forbidden` column used by forbidden permissions. > [!WARNING] > If you customize the table or column names in the permission configuration file, update the published migration before running it. @@ -136,8 +153,8 @@ You may customize the table names used by the relationships: 'roles' => 'roles', 'permissions' => 'permissions', 'role_has_permissions' => 'role_has_permissions', - 'owner_has_permissions' => 'owner_has_permissions', - 'owner_has_roles' => 'owner_has_roles', + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', ], ``` @@ -147,8 +164,8 @@ You may also customize the pivot and morph column names: 'column_names' => [ 'role_pivot_key' => 'role_id', 'permission_pivot_key' => 'permission_id', - 'owner_morph_key' => 'owner_id', - 'owner_name' => 'owner', + 'model_morph_key' => 'model_id', + 'team_foreign_key' => 'team_id', ], ``` @@ -162,19 +179,27 @@ The package caches role and permission data to reduce database queries during pe 'expiration_seconds' => 86400, 'keys' => [ 'roles' => 'hypervel.permission.cache.roles', - 'owner_roles' => 'hypervel.permission.cache.owner.roles', - 'owner_permissions' => 'hypervel.permission.cache.owner.permissions', + 'model_roles' => 'hypervel.permission.cache.model.roles', + 'model_permissions' => 'hypervel.permission.cache.model.permissions', + 'model_version' => 'hypervel.permission.cache.model.version', ], 'store' => env('PERMISSION_CACHE_STORE', 'default'), ], ``` -When `store` is `default`, the application's default cache store is used. If an unknown cache store is configured, the permission manager falls back to the `array` cache store. +When `store` is `default`, the application's default cache store is used. + +You may include required role or permission names in authorization exception messages: + +```php +'display_permission_in_exception' => true, +'display_role_in_exception' => true, +``` ## Model Setup -To assign roles and permissions to a model, add the `Hypervel\Permission\Traits\HasRole` trait: +To assign roles and permissions to a model, add the `Hypervel\Permission\Traits\HasRoles` trait: ```php +## Multiple Guards + +Roles and permissions are scoped by guard name. If your app uses multiple guards, create the role or permission for the guard that will authorize it: + +```php +use Hypervel\Permission\Models\Permission; +use Hypervel\Permission\Models\Role; + +Role::create(['name' => 'manager', 'guard_name' => 'admin']); + +Permission::create(['name' => 'publish articles', 'guard_name' => 'admin']); +``` + +You may pass the guard name when checking a permission or role: + +```php +$user->hasPermissionTo('publish articles', 'admin'); + +$user->hasRole('manager', 'admin'); +``` + +When a model can use more than one guard, define a `guardName` method or `$guard_name` property: + +```php +public function guardName(): array +{ + return ['web', 'admin']; } ``` -The `HasRole` trait includes the `HasPermission` trait, so a model using `HasRole` may receive roles and direct permissions. +If your app uses a single guard for all roles and permissions, return that guard from the model so you do not need duplicate role and permission records: + +```php +protected string $guard_name = 'web'; +``` ## Creating Roles and Permissions @@ -235,6 +297,18 @@ $editor = Role::create([ ]); ``` +You may also retrieve existing records or create them if they do not exist: + +```php +$role = Role::findByName('writer'); + +$role = Role::findOrCreate('writer', 'web'); + +$permission = Permission::findByName('edit articles'); + +$permission = Permission::findOrCreate('edit articles', 'web'); +``` + ### Assigning Permissions to Roles @@ -252,12 +326,12 @@ $role->givePermissionTo('delete articles', 'publish articles'); $role->syncPermissions(['edit articles', 'publish articles']); ``` -To replace a role's allowed and forbidden permissions at the same time, pass forbidden permissions as the second argument to `syncPermissions`: +To replace a role's allowed and forbidden permissions at the same time, use `syncPermissionsWithForbidden`: ```php -$role->syncPermissions( - ['edit articles'], - ['delete articles'], +$role->syncPermissionsWithForbidden( + allowed: ['edit articles'], + forbidden: ['delete articles'], ); ``` @@ -285,6 +359,29 @@ To replace all of a model's roles, use `syncRoles`: $user->syncRoles('writer', 'editor'); ``` + +### Assigning Models to a Role + +You may also assign models from the role side: + +```php +use Hypervel\Permission\Models\Role; + +$role = Role::findByName('writer'); + +$role->assignToModels([$userA, $userB]); + +$role->removeFromModels($userA); + +$role->syncModels([$userB, $userC]); +``` + +These methods accept models, model IDs, arrays, and collections. When you pass raw IDs, pass the model class as the second argument or configure `permission.models.default_model`: + +```php +$role->assignToModels([1, 2, 3], App\Models\User::class); +``` + ### Checking Roles @@ -295,7 +392,7 @@ if ($user->hasRole('writer')) { // ... } -if ($user->hasAnyRoles(['writer', 'editor'])) { +if ($user->hasAnyRole(['writer', 'editor'])) { // ... } @@ -304,10 +401,23 @@ if ($user->hasAllRoles(['writer', 'editor'])) { } ``` -You may retrieve only the roles that match a given list: + +### Role and Team Scopes + +You may query models by assigned roles: ```php -$matchingRoles = $user->onlyRoles(['writer', 'admin']); +$writers = User::role('writer')->get(); + +$usersWithoutWriterRole = User::withoutRole('writer')->get(); +``` + +When teams are enabled, you may also scope models by team: + +```php +$teamMembers = User::team($team)->get(); + +$outsideTeam = User::withoutTeam($team)->get(); ``` @@ -346,22 +456,22 @@ $user->syncPermissions(['edit articles', 'publish articles']); ### Checking Permissions -The `hasPermission` method checks direct permissions and permissions inherited through roles: +The `hasPermissionTo` method checks direct permissions and permissions inherited through roles: ```php -if ($user->hasPermission('edit articles')) { +if ($user->hasPermissionTo('edit articles')) { // ... } ``` -You may also check direct permissions or role permissions separately: +You may also check direct permissions, or inspect permissions inherited through roles: ```php if ($user->hasDirectPermission('edit articles')) { // ... } -if ($user->hasPermissionViaRoles('edit articles')) { +if ($user->getPermissionsViaRoles()->contains('name', 'edit articles')) { // ... } ``` @@ -369,7 +479,7 @@ if ($user->hasPermissionViaRoles('edit articles')) { You may check whether a model has any or all of a given set of permissions: ```php -if ($user->hasAnyPermissions(['edit articles', 'delete articles'])) { +if ($user->hasAnyPermission(['edit articles', 'delete articles'])) { // ... } @@ -378,10 +488,10 @@ if ($user->hasAllPermissions(['edit articles', 'delete articles'])) { } ``` -To check only direct permissions, use `hasAnyDirectPermissions` or `hasAllDirectPermissions`: +To check only direct permissions, use `hasAnyDirectPermission` or `hasAllDirectPermissions`: ```php -if ($user->hasAnyDirectPermissions(['edit articles', 'delete articles'])) { +if ($user->hasAnyDirectPermission(['edit articles', 'delete articles'])) { // ... } @@ -390,6 +500,37 @@ if ($user->hasAllDirectPermissions(['edit articles', 'delete articles'])) { } ``` +You may query models by permissions: + +```php +$editors = User::permission('edit articles')->get(); + +$usersWithoutEditPermission = User::withoutPermission('edit articles')->get(); +``` + + +### Gate and Super Admins + +The package registers a Gate `before` check by default, so normal authorization calls work with permissions: + +```php +if ($user->can('edit articles')) { + // ... +} +``` + +For super-admin behavior, register your own Gate `before` callback before normal policy checks: + +```php +use Hypervel\Support\Facades\Gate; + +Gate::before(function (User $user, string $ability): ?bool { + return $user->hasRole('super-admin') ? true : null; +}); +``` + +Direct package calls such as `hasPermissionTo` do not pass through Gate callbacks. Use `can`, `canAny`, policies, middleware, or Blade authorization checks when you want Gate-level behavior to apply. + ### Forbidden Permissions @@ -400,7 +541,7 @@ $user->givePermissionTo('delete articles'); $user->giveForbiddenTo('delete articles'); -$user->hasPermission('delete articles'); +$user->hasPermissionTo('delete articles'); // false ``` @@ -417,12 +558,12 @@ if ($user->hasForbiddenPermissionViaRoles('delete articles')) { } ``` -The second argument to `syncPermissions` contains forbidden permissions. If a permission is present in both arrays, the forbidden permission wins: +Use `syncPermissionsWithForbidden` to replace allowed and forbidden direct permissions together. If a permission is present in both arrays, the forbidden permission wins: ```php -$user->syncPermissions( - ['view articles', 'edit articles'], - ['edit articles', 'delete articles'], +$user->syncPermissionsWithForbidden( + allowed: ['view articles', 'edit articles'], + forbidden: ['edit articles', 'delete articles'], ); ``` @@ -482,7 +623,7 @@ $user->assignRole(Role::Writer); $user->givePermissionTo(Permission::EditArticles); -if ($user->hasPermission(Permission::EditArticles)) { +if ($user->hasPermissionTo(Permission::EditArticles)) { // ... } ``` @@ -522,7 +663,7 @@ When multiple permissions are provided, the user only needs one of them: ```php Route::get('/posts/edit', [PostController::class, 'edit']) - ->middleware(PermissionMiddleware::using('edit articles', 'edit all articles')); + ->middleware(PermissionMiddleware::using(['edit articles', 'edit all articles'])); ``` @@ -543,7 +684,7 @@ When multiple roles are provided, the user only needs one of them: ```php Route::get('/editor', [EditorController::class, 'index']) - ->middleware(RoleMiddleware::using('editor', 'admin')); + ->middleware(RoleMiddleware::using(['editor', 'admin'])); ``` Middleware may also receive enum cases: @@ -553,10 +694,152 @@ Route::get('/admin', [AdminController::class, 'index']) ->middleware(PermissionMiddleware::using(Permission::EditArticles)); Route::get('/editor', [EditorController::class, 'index']) - ->middleware(RoleMiddleware::using(Role::Editor, Role::Admin)); + ->middleware(RoleMiddleware::using([Role::Editor, Role::Admin])); +``` + + +### Role Or Permission Middleware + +Use `RoleOrPermissionMiddleware::using` when the user may pass with either a role or a permission: + +```php +use Hypervel\Permission\Middleware\RoleOrPermissionMiddleware; + +Route::get('/content', [ContentController::class, 'index']) + ->middleware(RoleOrPermissionMiddleware::using(['editor', 'edit articles'])); +``` + +If the user is not authenticated or does not have the required role or permission, the middleware throws `Hypervel\Permission\Exceptions\UnauthorizedException`. + + +### Passport Client Credentials + +The middleware can authorize Passport client-credentials clients when no authenticated user exists: + +```php +'use_passport_client_credentials' => true, ``` -If the user is not authenticated, the middleware throws `Hypervel\Permission\Exceptions\UnauthorizedException`. If the user is authenticated but does not have the required role or permission, it throws `Hypervel\Permission\Exceptions\RoleException` or `Hypervel\Permission\Exceptions\PermissionException`. +The Passport client model must implement Hypervel's `Authorizable` contract and use `HasRoles`: + +```php +use Hypervel\Contracts\Auth\Access\Authorizable as AuthorizableContract; +use Hypervel\Foundation\Auth\Access\Authorizable; +use Hypervel\Permission\Traits\HasRoles; + +// Extend the client model class provided by your Passport package. +class Client extends BaseClient implements AuthorizableContract +{ + use Authorizable; + use HasRoles; + + protected string $guard_name = 'api'; +} +``` + +Set the client model in Passport, then protect client-credentials routes with this package's role or permission middleware. The permission middleware will use the Passport client when the request has a bearer token and no normal authenticated user. + + +## Blade Directives + +The package registers Blade conditionals for roles and permissions: + +```blade +@haspermission('edit articles') + ... +@endhaspermission + +@role('admin') + ... +@endrole + +@hasanyrole(['writer', 'editor']) + ... +@endhasanyrole + +@hasallroles(['writer', 'editor']) + ... +@endhasallroles + +@hasexactroles(['writer', 'editor']) + ... +@endhasexactroles + +@unlessrole('guest') + ... +@endunlessrole +``` + +Pass the guard name as the second argument when needed: + +```blade +@role('admin', 'api') + ... +@endrole +``` + + +## Route Macros + +Routes also receive permission macros: + +```php +Route::get('/admin', [AdminController::class, 'index'])->role('admin'); + +Route::get('/posts/edit', [PostController::class, 'edit'])->permission('edit articles'); + +Route::get('/content', [ContentController::class, 'index']) + ->roleOrPermission(['editor', 'edit articles']); +``` + + +## Custom Permission Checks + +By default, the package registers a Gate `before` callback that delegates permission checks to `hasPermissionTo`: + +```php +'register_permission_check_method' => true, +``` + +Set this to `false` only when you want to register your own Gate logic: + +```php +'register_permission_check_method' => false, +``` + +```php +use Hypervel\Support\Facades\Gate; + +Gate::before(function (User $user, string $ability): ?bool { + return $user->hasTokenPermission($ability) ?: null; +}); +``` + + +## Events + +Role and permission assignment events are disabled by default: + +```php +'events_enabled' => false, +``` + +Enable them when your app listens for assignment changes: + +```php +'events_enabled' => true, +``` + +The package may dispatch these events: + +```php +Hypervel\Permission\Events\RoleAttachedEvent::class; +Hypervel\Permission\Events\RoleDetachedEvent::class; +Hypervel\Permission\Events\PermissionAttachedEvent::class; +Hypervel\Permission\Events\PermissionDetachedEvent::class; +``` + +Events are only dispatched when events are enabled and the event dispatcher has listeners for the event class. ## Console Commands @@ -579,8 +862,102 @@ The command supports the `default`, `borderless`, `compact`, and `box` table sty php artisan permission:show web compact ``` - -## Polymorphic Owners +Other commands are available for common setup and maintenance tasks: + +```shell +php artisan permission:create-role writer +php artisan permission:create-permission "edit articles" +php artisan permission:create-role writer web "edit articles|publish articles" +php artisan permission:assign-role writer 1 web "App\Models\User" +php artisan permission:create-role writer web --team-id=1 +php artisan permission:cache-reset +php artisan permission:setup-teams +``` + + +## Teams + +Teams scope roles and role or permission assignments by a configured team foreign key. Enable teams before running the base permission migration if you want the base tables to include team columns: + +```php +'teams' => true, +'models' => [ + 'team' => App\Models\Team::class, +], +``` + +Use the helpers to set the current team for the current coroutine: + +```php +setPermissionsTeamId($team->getKey()); + +$user->assignRole('writer'); +``` + +You may also pass a team model: + +```php +setPermissionsTeamId($team); +``` + +Roles may be global or team-specific: + +```php +Role::create(['name' => 'writer', 'team_id' => null]); + +Role::create(['name' => 'writer', 'team_id' => $team->getKey()]); +``` + +If teams are enabled after the package tables already exist, run `permission:setup-teams` and then migrate. + +When you change the active team during a request or job, unset loaded permission relations before checking that same model again: + +```php +setPermissionsTeamId($newTeamId); + +$user->unsetRelation('roles')->unsetRelation('permissions'); + +$user->hasRole('writer'); +``` + + +## Wildcard Permissions + +Wildcard permissions allow one stored permission to match many checks: + +```php +'enable_wildcard_permission' => true, +``` + +```php +Permission::create(['name' => 'posts.*']); + +$user->givePermissionTo('posts.*'); + +$user->hasPermissionTo('posts.create'); +// true +``` + +A wildcard permission string is split into dot-separated parts. The `*` part means all values for that part, not any permission in the system: + +```php +Permission::create(['name' => 'posts.*']); + +$user->givePermissionTo('posts.*'); +``` + +Subparts may be comma-separated: + +```php +Permission::create(['name' => 'posts,users.create,update,view']); + +$user->givePermissionTo('posts,users.create,update,view'); +``` + +The wildcard permission or wildcard pattern must exist as a permission record before it can be assigned or checked. + + +## Polymorphic Models Roles and permissions use polymorphic relationships, so any Eloquent model may receive them: @@ -592,11 +969,11 @@ declare(strict_types=1); namespace App\Models; use Hypervel\Database\Eloquent\Model; -use Hypervel\Permission\Traits\HasRole; +use Hypervel\Permission\Traits\HasRoles; class Team extends Model { - use HasRole; + use HasRoles; } ``` @@ -650,37 +1027,138 @@ After creating custom models, update the permission configuration: ], ``` +If you replace the package models instead of extending them, your role model must implement `Hypervel\Permission\Contracts\Role` and your permission model must implement `Hypervel\Permission\Contracts\Permission`. + + +## UUID and ULID Keys + +If your user models use UUIDs or ULIDs, update the published migration before running it so `model_has_roles` and `model_has_permissions` use the correct morph key column type: + +```php +$table->uuid($columnNames['model_morph_key']); +``` + +If your role or permission models use UUIDs or ULIDs, extend the package models and set the primary key details on your custom models: + +```php +use Hypervel\Database\Eloquent\Concerns\HasUuids; +use Hypervel\Permission\Models\Role as BaseRole; + +class Role extends BaseRole +{ + use HasUuids; + + protected string $primaryKey = 'uuid'; +} +``` + +Then update the published migration so the `roles`, `permissions`, and pivot tables use the same key type and references. You may also rename the model morph key in config: + +```php +'column_names' => [ + 'model_morph_key' => 'model_uuid', +], +``` + ## Caching -The permission manager caches all roles with their permissions, each owner's roles, and each owner's direct permissions. The package clears the relevant cache entries when roles or permissions are assigned, removed, or synchronized. +The permission registrar caches role and permission metadata using the configured cache store. Hot checks also use Hypervel's memo cache layer for the current coroutine, so repeated checks in one request or job avoid repeated cache-store reads. + +Built-in mutation methods refresh the relevant cache automatically: + +```php +$role->givePermissionTo('edit articles'); +$role->revokePermissionTo('edit articles'); +$role->syncPermissions(['edit articles']); + +$user->assignRole('writer'); +$user->removeRole('writer'); +$user->syncRoles(['writer']); + +$user->givePermissionTo('edit articles'); +$user->giveForbiddenTo('delete articles'); +$user->syncPermissionsWithForbidden( + allowed: ['edit articles'], + forbidden: ['delete articles'], +); +``` + +If you change permission tables directly with queries, reset the permission cache yourself. + +You may clear cached permission data with the command: + +```shell +php artisan permission:cache-reset +``` + +You may also clear it from code: -You may clear all cached role-permission data: +```php +use Hypervel\Permission\PermissionRegistrar; + +app(PermissionRegistrar::class)->forgetCachedPermissions(); +``` + + +## Testing and Seeding + +If tests create roles or permissions after the Gate has already registered its permission callback, clear the package cache in the test setup: ```php -use Hypervel\Permission\PermissionManager; +use Hypervel\Permission\PermissionRegistrar; -$manager = app(PermissionManager::class); +protected function setUp(): void +{ + parent::setUp(); -$manager->clearAllRolesPermissionsCache(); + $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); +} ``` -You may clear the cached roles and permissions for a specific owner: +Seeders that create roles and permissions should clear the cache before seeding. If your seeder disables model events, clear it again after creating roles and permissions and before assigning them: ```php -$manager->clearOwnerCache(User::class, $user->getKey()); +use Hypervel\Database\Seeder; +use Hypervel\Permission\Models\Permission; +use Hypervel\Permission\Models\Role; +use Hypervel\Permission\PermissionRegistrar; + +class RolesAndPermissionsSeeder extends Seeder +{ + public function run(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + Permission::create(['name' => 'edit articles']); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + Role::create(['name' => 'writer']) + ->givePermissionTo('edit articles'); + } +} ``` -You may warm the global role-permission cache during deployment: + +## Best Practices + +Use permissions for application behavior and roles for grouping permissions. For example, check `can('edit articles')` in controllers, policies, middleware, and Blade, then assign that permission to whichever roles should receive it. + +Use direct role checks for role-management screens or rare app rules that truly depend on the role itself: ```php -$manager->getAllRolesWithPermissions(); +if ($user->hasRole('admin')) { + // ... +} ``` +Prefer policies and Gate checks when authorization depends on both the user and a specific model instance. + ## Performance -Permission checks use cached role and permission data after the first lookup. This keeps repeated checks inexpensive while still allowing role and permission changes to invalidate the affected cache entries. +Permission checks use cached role and permission data after the first lookup. Model role assignments and direct permission assignments have their own cache keys, and those keys include the model type, model key, active team id when teams are enabled, and an assignment-cache version. If you need to display a model's roles or permissions, eager load the relationships you will render: @@ -688,4 +1166,34 @@ If you need to display a model's roles or permissions, eager load the relationsh $users = User::with(['roles.permissions', 'permissions'])->get(); ``` -Eager loading is not required for normal `hasPermission` or `hasRole` checks, since those checks use the package cache. +Eager loading is not required for normal `hasPermissionTo` or `hasRole` checks, since those checks use the package cache. + + +## Exceptions + +Authorization failures throw `Hypervel\Permission\Exceptions\UnauthorizedException`. You may handle it with Hypervel's normal exception handling: + +```php +use Hypervel\Permission\Exceptions\UnauthorizedException; + +$exceptions->render(function (UnauthorizedException $exception) { + return response()->json([ + 'message' => 'You do not have the required authorization.', + ], 403); +}); +``` + +The exception exposes the required roles or permissions: + +```php +$exception->getRequiredRoles(); + +$exception->getRequiredPermissions(); +``` + + +## Differences From Spatie Laravel Permission + +- Hypervel adds forbidden permissions. A forbidden permission explicitly denies an ability and wins over direct or role-granted allows. +- Hypervel accepts pure unit enums anywhere enum names are valid role or permission inputs. Backed enums use their values; unit enums use their case names. +- Hypervel's cache config uses `expiration_seconds` and separate named cache keys so role, model-role, model-permission, and assignment-version caches can be invalidated independently. diff --git a/src/boost/docs/testbench.md b/src/boost/docs/testbench.md index ce5eeecc9..888d6ff10 100644 --- a/src/boost/docs/testbench.md +++ b/src/boost/docs/testbench.md @@ -25,6 +25,7 @@ - [Defining Routes](#defining-routes) - [Cached Routes](#cached-routes) - [Workbench](#workbench) + - [Installing Workbench](#installing-workbench) - [Workbench Configuration](#workbench-configuration) - [Discovering Workbench Files](#discovering-workbench-files) - [Serving the Workbench Application](#serving-the-workbench-application) @@ -861,6 +862,26 @@ class CourierWorkbenchTest extends TestCase Workbench configuration is read from `testbench.yaml`. + +### Installing Workbench + +You may scaffold a Workbench application for your package using the `package:install` command: + +```shell +vendor/bin/testbench package:install +``` + +The command creates a `workbench` directory, writes a `testbench.yaml` file, adds Workbench PSR-4 autoloading to `composer.json`, creates a SQLite database file for the runtime skeleton, and refreshes Composer's autoloader. The default scaffold is auth-ready and includes a `User` model, factory, seeder, route files, and Workbench discovery. + +Existing files are not overwritten unless you pass the `--force` option. To generate only the core Workbench model, factory, seeder, provider, and `testbench.yaml` file, pass the `--basic` option: + +```shell +vendor/bin/testbench package:install --basic +``` + +> [!NOTE] +> Hypervel Testbench does not include Orchestra Workbench's devtool, build, Canvas, or asset-scaffolding commands. Use normal Hypervel frontend tooling alongside the Testbench CLI. + ### Workbench Configuration diff --git a/src/boost/todo.md b/src/boost/todo.md index d3c30cc04..c9e5de473 100644 --- a/src/boost/todo.md +++ b/src/boost/todo.md @@ -5,6 +5,7 @@ - Create hypervel/react-starter-kit - Port Fortify package - Port Passport package +- Replace permission package fake Passport client-credentials coverage with real Passport tests once Passport is ported. The current tests use a local fake guard/client so the permission package can keep Passport middleware parity without depending on a package that does not exist yet. ## Artisan diff --git a/src/contracts/src/Auth/Factory.php b/src/contracts/src/Auth/Factory.php index 08f940f7d..2e8ce7f18 100644 --- a/src/contracts/src/Auth/Factory.php +++ b/src/contracts/src/Auth/Factory.php @@ -11,6 +11,11 @@ interface Factory */ public function guard(?string $name = null): Guard|StatefulGuard; + /** + * Get the default authentication driver name. + */ + public function getDefaultDriver(): string; + /** * Set the default guard the factory should serve. */ diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index dea46fda1..66ae2171e 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -2287,6 +2287,14 @@ public function broadcastChannel(): string return str_replace('\\', '.', get_class($this)) . '.' . $this->getKey(); } + /** + * Flush cached guardable column metadata. + */ + public static function flushGuardableColumns(): void + { + static::$guardableColumns = []; + } + /** * Flush all static state. */ @@ -2314,7 +2322,7 @@ public static function flushState(): void static::$isMassPrunable = []; static::$resolvedCollectionClasses = []; static::$relationResolvers = []; - static::$guardableColumns = []; + static::flushGuardableColumns(); static::$snakeAttributes = true; static::$mutatorCache = []; static::$attributeMutatorCache = []; diff --git a/src/permission/LICENSE.md b/src/permission/LICENSE.md index 691f6fe96..0f26d3a73 100644 --- a/src/permission/LICENSE.md +++ b/src/permission/LICENSE.md @@ -2,8 +2,6 @@ The MIT License (MIT) Copyright (c) Spatie bvba info@spatie.be -Copyright (c) Hyperf - Copyright (c) Hypervel Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/src/permission/README.md b/src/permission/README.md index 33a2d1e01..fcb62b54f 100644 --- a/src/permission/README.md +++ b/src/permission/README.md @@ -1,6 +1,47 @@ -Permission for Hypervel -=== +# Hypervel Permission -Migrated from: https://github.com/spatie/laravel-permission +Ported from: https://github.com/spatie/laravel-permission -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/permission) \ No newline at end of file +This package provides Spatie-style roles and permissions for Hypervel applications, adapted for long-lived Swoole workers and coroutine-safe request state. + +## Features + +- Roles and permissions for Eloquent models via `Hypervel\Permission\Traits\HasRoles`. +- Permission checks through model methods, Gate, middleware, Blade directives, and route macros. +- Teams support with coroutine-scoped current team state. +- Wildcard permissions. +- Passport client-credentials middleware support. +- Hypervel-only forbidden permissions, where an explicit deny wins over direct or role-granted allows. +- Configured cache store plus per-coroutine memoization for hot permission checks. + +## Installation + +```shell +composer require hypervel/permission +``` + +Publish the config and migration: + +```shell +php artisan vendor:publish --provider="Hypervel\Permission\PermissionServiceProvider" +php artisan migrate +``` + +Add `HasRoles` to any model that should receive roles and permissions: + +```php +use Hypervel\Permission\Traits\HasRoles; + +class User extends Model +{ + use HasRoles; +} +``` + +## Differences From Spatie Laravel Permission + +- Hypervel adds forbidden permissions. A forbidden permission explicitly denies an ability and wins over direct or role-granted allows. +- Hypervel accepts pure unit enums anywhere enum names are valid role or permission inputs. Backed enums use their values; unit enums use their case names. +- Hypervel's cache config uses `expiration_seconds` and separate named cache keys so role, model-role, model-permission, and assignment-version caches can be invalidated independently. + +Full usage docs are available in `src/boost/docs/permission.md`. diff --git a/src/permission/composer.json b/src/permission/composer.json index c1936b9d1..5501c5071 100644 --- a/src/permission/composer.json +++ b/src/permission/composer.json @@ -26,18 +26,29 @@ "autoload": { "psr-4": { "Hypervel\\Permission\\": "src/" - } + }, + "files": [ + "src/helpers.php" + ] }, "require": { "php": "^8.4", "hypervel/auth": "^0.4", + "hypervel/broadcasting": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/database": "^0.4", + "hypervel/events": "^0.4", + "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", - "hypervel/support": "^0.4" + "hypervel/queue": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/support": "^0.4", + "hypervel/view": "^0.4" }, "config": { "sort-packages": true diff --git a/src/permission/config/permission.php b/src/permission/config/permission.php index ac1de0523..2f0b1263d 100644 --- a/src/permission/config/permission.php +++ b/src/permission/config/permission.php @@ -2,193 +2,109 @@ declare(strict_types=1); -return [ - /* - |-------------------------------------------------------------------------- - | Permission Models - |-------------------------------------------------------------------------- - | - | When using the "HasRoles" and "HasPermissions" traits from this package, - | we need to know which Eloquent models should be used to retrieve your - | roles and permissions. You may use whatever models you like. - | - */ +use Hypervel\Permission\DefaultTeamResolver; +use Hypervel\Permission\Models\Permission; +use Hypervel\Permission\Models\Role; +use Hypervel\Permission\WildcardPermission; +return [ 'models' => [ /* - * When using the "HasRoles" trait from this package, we need to know which - * Eloquent model should be used to retrieve your roles. Of course, it - * is often just the "Role" model but you may use whatever you like. - * - * The model you want to use as a Role model needs to implement the - * `Hypervel\Permission\Contracts\Role` contract. + * The model used to retrieve permissions. */ + 'permission' => Permission::class, - 'role' => \Hypervel\Permission\Models\Role::class, + /* + * The model used to retrieve roles. + */ + 'role' => Role::class, /* - * When using the "HasPermissions" trait from this package, we need to know which - * Eloquent model should be used to retrieve your permissions. Of course, it - * is often just the "Permission" model but you may use whatever you like. - * - * The model you want to use as a Permission model needs to implement the - * `Hypervel\Permission\Contracts\Permission` contract. + * The app-owned team model used by the teams feature. */ + 'team' => null, - 'permission' => \Hypervel\Permission\Models\Permission::class, + /* + * The model used when raw IDs are passed to reverse-assignment helpers. + */ + 'default_model' => null, ], - /* - |-------------------------------------------------------------------------- - | Permission Storage Configuration - |-------------------------------------------------------------------------- - | - | This configuration determines the database connection that will be used - | to store permission-related data. You can specify a different connection - | if you want to store permissions in a separate database. - | - */ - 'storage' => [ 'database' => [ 'connection' => env('DB_CONNECTION', 'mysql'), ], ], - /* - |-------------------------------------------------------------------------- - | Permission Table Names - |-------------------------------------------------------------------------- - | - | The following table names are used by the permission package to store - | roles, permissions and their relationships. You may change these names - | to match your existing database schema or naming conventions. - | - */ - 'table_names' => [ - /* - * When using the "HasRoles" trait from this package, we need to know which - * table should be used to retrieve your roles. We have chosen a basic - * default value but you may easily change it to any table you like. - */ - 'roles' => 'roles', - - /* - * When using the "HasPermissions" trait from this package, we need to know which - * table should be used to retrieve your permissions. We have chosen a basic - * default value but you may easily change it to any table you like. - */ - 'permissions' => 'permissions', - - /* - * When using the "HasRoles" trait from this package, we need to know which - * table should be used to retrieve your roles permissions. We have chosen a - * basic default value but you may easily change it to any table you like. - */ - + 'model_has_permissions' => 'model_has_permissions', + 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions', + ], - /* - * When using the "HasPermissions" trait from this package, we need to know which - * table should be used to retrieve your models permissions. We have chosen a - * basic default value but you may easily change it to any table you like. - */ - - 'owner_has_permissions' => 'owner_has_permissions', - - /* - * When using the "HasRoles" trait from this package, we need to know which - * table should be used to retrieve your models roles. We have chosen a - * basic default value but you may easily change it to any table you like. - */ - - 'owner_has_roles' => 'owner_has_roles', + 'column_names' => [ + 'role_pivot_key' => null, + 'permission_pivot_key' => null, + 'model_morph_key' => 'model_id', + 'team_foreign_key' => 'team_id', ], /* - |-------------------------------------------------------------------------- - | Permission Column Names - |-------------------------------------------------------------------------- - | - | This configuration allows you to customize the column names used in - | the pivot tables and relationships. You can modify these to match - | your database schema or to resolve naming conflicts. - | - */ + * Register the Gate::before permission check so $user->can('permission') works. + */ + 'register_permission_check_method' => true, - 'column_names' => [ - /* - * Change this if you want to name the related pivots other than defaults - */ + /* + * Fire role and permission assignment events when listeners are registered. + */ + 'events_enabled' => false, - 'role_pivot_key' => 'role_id', - 'permission_pivot_key' => 'permission_id', + /* + * Scope roles and assignments by the configured team foreign key. + */ + 'teams' => false, - /* - * Change this if you want to name the related model primary key other than - * `owner_id`. - * - * For example, this would be nice if your primary keys are all UUIDs. In - * that case, name this `owner_uuid`. - */ + /* + * Resolve the current team id. + */ + 'team_resolver' => DefaultTeamResolver::class, - 'owner_morph_key' => 'owner_id', + /* + * Allow Passport client-credentials clients to authorize through middleware. + */ + 'use_passport_client_credentials' => false, - /* - * The name of the morphable relation for the owner model. - * This is used to determine the owner type when using polymorphic relations. - */ + /* + * Include required permission names in exception messages. + */ + 'display_permission_in_exception' => false, - 'owner_name' => 'owner', - ], /* - |-------------------------------------------------------------------------- - | Permission Cache Configuration - |-------------------------------------------------------------------------- - | - | By default all permissions are cached for 24 hours to speed up performance. - | When permissions or roles are updated the cache is flushed automatically. - | You may optionally indicate a specific cache driver to use for permission - | and role caching using any of the `store` drivers listed in the cache.php - | config file. Using 'default' here means to use the `default` set in cache.php. - | - */ + * Include required role names in exception messages. + */ + 'display_role_in_exception' => false, - 'cache' => [ - /* - * By default all permissions are cached for 24 hours to speed up performance. - * When permissions or roles are updated the cache is flushed automatically. - */ + /* + * Enable wildcard permission matching. + */ + 'enable_wildcard_permission' => false, - 'expiration_seconds' => 86400, // 24 hours in seconds + /* + * The class used to parse wildcard permissions. + */ + 'wildcard_permission' => WildcardPermission::class, + 'cache' => [ + 'expiration_seconds' => 86400, + 'store' => env('PERMISSION_CACHE_STORE', 'default'), 'keys' => [ - /* - * The cache key used to store all roles with their permissions. - * This is used for efficient role-permission lookups. - */ 'roles' => 'hypervel.permission.cache.roles', - /* - * The cache key prefix used to store roles for individual owners. - * The actual key will be: {prefix}:{owner_type}:{owner_id} - */ - 'owner_roles' => 'hypervel.permission.cache.owner.roles', - /* - * The cache key prefix used to store permissions for individual owners. - * The actual key will be: {prefix}:{owner_type}:{owner_id} - */ - 'owner_permissions' => 'hypervel.permission.cache.owner.permissions', + 'model_roles' => 'hypervel.permission.cache.model.roles', + 'model_permissions' => 'hypervel.permission.cache.model.permissions', + 'model_version' => 'hypervel.permission.cache.model.version', ], - - /* - * You may optionally indicate a specific cache driver to use for permission and - * role caching using any of the `store` drivers listed in the cache.php config - * file. Using 'default' here means to use the `default` set in cache.php. - */ - - 'store' => env('PERMISSION_CACHE_STORE', 'default'), + 'column_names_except' => ['created_at', 'updated_at', 'deleted_at'], ], ]; diff --git a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php index 51d24fcaa..789d6b27f 100644 --- a/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php +++ b/src/permission/database/migrations/2025_07_02_000000_create_permission_tables.php @@ -12,8 +12,7 @@ */ public function getConnection(): ?string { - return config('permission.storage.database.connection') - ?: parent::getConnection(); + return config('permission.storage.database.connection') ?: parent::getConnection(); } /** @@ -23,53 +22,109 @@ public function up(): void { $schema = Schema::connection($this->getConnection()); - $schema->create('roles', function (Blueprint $table) { - $table->bigIncrements('id'); - $table->string('name')->unique(); + $teams = (bool) config('permission.teams'); + $tableNames = (array) config('permission.table_names'); + $columnNames = (array) config('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key'] ?? 'role_id'; + $pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id'; + $teamForeignKey = $columnNames['team_foreign_key'] ?? 'team_id'; + $modelMorphKey = $columnNames['model_morph_key'] ?? 'model_id'; + + throw_if($tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + throw_if($teams && $teamForeignKey === '', 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + + $schema->create($tableNames['permissions'], static function (Blueprint $table): void { + $table->id(); + $table->string('name'); $table->string('guard_name'); $table->timestamps(); - $table->index(['name', 'guard_name']); + $table->unique(['name', 'guard_name']); }); - $schema->create('permissions', function (Blueprint $table) { - $table->bigIncrements('id'); - $table->string('name')->unique(); + $schema->create($tableNames['roles'], static function (Blueprint $table) use ($teams, $teamForeignKey): void { + $table->id(); + + if ($teams) { + $table->unsignedBigInteger($teamForeignKey)->nullable(); + $table->index($teamForeignKey, 'roles_team_foreign_key_index'); + } + + $table->string('name'); $table->string('guard_name'); $table->timestamps(); - $table->index(['name', 'guard_name']); + + if ($teams) { + $table->unique([$teamForeignKey, 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } }); - $schema->create('role_has_permissions', function (Blueprint $table) { - $table->unsignedBigInteger('permission_id'); - $table->unsignedBigInteger('role_id'); - $table->boolean('is_forbidden'); - $table->timestamps(); - $table->primary(['permission_id', 'role_id']); - $table->index('role_id'); - $table->index('permission_id'); + $schema->create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $modelMorphKey, $pivotPermission, $teams, $teamForeignKey): void { + $table->unsignedBigInteger($pivotPermission); + $table->string('model_type'); + $table->unsignedBigInteger($modelMorphKey); + $table->boolean('is_forbidden')->default(false); + $table->index([$modelMorphKey, 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + if ($teams) { + $table->unsignedBigInteger($teamForeignKey); + $table->index($teamForeignKey, 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$teamForeignKey, $pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], 'model_has_permissions_permission_model_type_primary'); + } }); - $schema->create('owner_has_permissions', function (Blueprint $table) { - $table->unsignedBigInteger('permission_id'); - $table->morphs('owner'); - $table->boolean('is_forbidden'); - $table->timestamps(); + $schema->create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $modelMorphKey, $pivotRole, $teams, $teamForeignKey): void { + $table->unsignedBigInteger($pivotRole); + $table->string('model_type'); + $table->unsignedBigInteger($modelMorphKey); + $table->index([$modelMorphKey, 'model_type'], 'model_has_roles_model_id_model_type_index'); - $table->primary(['permission_id', 'owner_id', 'owner_type']); - $table->index('owner_id'); - $table->index('permission_id'); + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + if ($teams) { + $table->unsignedBigInteger($teamForeignKey); + $table->index($teamForeignKey, 'model_has_roles_team_foreign_key_index'); + + $table->primary([$teamForeignKey, $pivotRole, $modelMorphKey, 'model_type'], 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $modelMorphKey, 'model_type'], 'model_has_roles_role_model_type_primary'); + } }); - $schema->create('owner_has_roles', function (Blueprint $table) { - $table->unsignedBigInteger('role_id'); - $table->morphs('owner'); - $table->timestamps(); + $schema->create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission): void { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + $table->boolean('is_forbidden')->default(false); + + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); - $table->primary(['role_id', 'owner_id', 'owner_type']); - $table->index('owner_id'); - $table->index('role_id'); + $table->primary([$pivotPermission, $pivotRole, 'is_forbidden'], 'role_has_permissions_permission_id_role_id_primary'); }); + + app('cache') + ->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.keys.roles')); } /** @@ -78,10 +133,15 @@ public function up(): void public function down(): void { $schema = Schema::connection($this->getConnection()); - $schema->dropIfExists('owner_has_roles'); - $schema->dropIfExists('owner_has_permissions'); - $schema->dropIfExists('role_has_permissions'); - $schema->dropIfExists('permissions'); - $schema->dropIfExists('roles'); + + $tableNames = (array) config('permission.table_names'); + + throw_if($tableNames === [], 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + $schema->dropIfExists($tableNames['role_has_permissions']); + $schema->dropIfExists($tableNames['model_has_roles']); + $schema->dropIfExists($tableNames['model_has_permissions']); + $schema->dropIfExists($tableNames['roles']); + $schema->dropIfExists($tableNames['permissions']); } }; diff --git a/src/permission/database/migrations/add_teams_fields.php.stub b/src/permission/database/migrations/add_teams_fields.php.stub new file mode 100644 index 000000000..29fef2131 --- /dev/null +++ b/src/permission/database/migrations/add_teams_fields.php.stub @@ -0,0 +1,104 @@ +getConnection()); + + $teams = (bool) config('permission.teams'); + $tableNames = (array) config('permission.table_names'); + $columnNames = (array) config('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key'] ?? 'role_id'; + $pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id'; + $teamForeignKey = $columnNames['team_foreign_key'] ?? 'team_id'; + $modelMorphKey = $columnNames['model_morph_key'] ?? 'model_id'; + + if (! $teams) { + return; + } + + throw_if($tableNames === [], 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + throw_if($teamForeignKey === '', 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + + if (! $schema->hasColumn($tableNames['roles'], $teamForeignKey)) { + $schema->table($tableNames['roles'], static function (Blueprint $table) use ($teamForeignKey): void { + $table->unsignedBigInteger($teamForeignKey)->nullable()->after('id'); + $table->index($teamForeignKey, 'roles_team_foreign_key_index'); + + $table->dropUnique('roles_name_guard_name_unique'); + $table->unique([$teamForeignKey, 'name', 'guard_name']); + }); + } + + if (! $schema->hasColumn($tableNames['model_has_permissions'], $teamForeignKey)) { + $schema->table($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $teamForeignKey, $modelMorphKey, $pivotPermission): void { + $table->unsignedBigInteger($teamForeignKey)->default(1); + $table->index($teamForeignKey, 'model_has_permissions_team_foreign_key_index'); + + if (DB::getDriverName() !== 'sqlite') { + $table->dropForeign([$pivotPermission]); + } + + $table->dropPrimary(); + + $table->primary([$teamForeignKey, $pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], 'model_has_permissions_permission_model_type_primary'); + + if (DB::getDriverName() !== 'sqlite') { + $table->foreign($pivotPermission) + ->references('id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + } + }); + } + + if (! $schema->hasColumn($tableNames['model_has_roles'], $teamForeignKey)) { + $schema->table($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $teamForeignKey, $modelMorphKey, $pivotRole): void { + $table->unsignedBigInteger($teamForeignKey)->default(1); + $table->index($teamForeignKey, 'model_has_roles_team_foreign_key_index'); + + if (DB::getDriverName() !== 'sqlite') { + $table->dropForeign([$pivotRole]); + } + + $table->dropPrimary(); + + $table->primary([$teamForeignKey, $pivotRole, $modelMorphKey, 'model_type'], 'model_has_roles_role_model_type_primary'); + + if (DB::getDriverName() !== 'sqlite') { + $table->foreign($pivotRole) + ->references('id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + } + }); + } + + app('cache') + ->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.keys.roles')); + } + + /** + * Reverse the migrations. + */ + public function down(): void {} +}; diff --git a/src/permission/src/Commands/AssignRoleCommand.php b/src/permission/src/Commands/AssignRoleCommand.php new file mode 100644 index 000000000..efc365d11 --- /dev/null +++ b/src/permission/src/Commands/AssignRoleCommand.php @@ -0,0 +1,85 @@ +argument('name'); + $userId = (string) $this->argument('userId'); + $guardName = $this->argument('guard'); + $userModelClass = $this->argument('userModelNamespace'); + + if (! $permissionRegistrar->teams && $this->option('team-id')) { + $this->warn('Teams feature disabled, argument --team-id has no effect. Either enable it in permissions config file or remove --team-id parameter'); + + return self::SUCCESS; + } + + if (! is_string($userModelClass) || ! class_exists($userModelClass)) { + $this->error("User model class [{$userModelClass}] does not exist."); + + return self::FAILURE; + } + + if (! is_subclass_of($userModelClass, Model::class)) { + $this->error("User model class [{$userModelClass}] must extend [" . Model::class . '].'); + + return self::FAILURE; + } + + $user = $userModelClass::query()->whereKey($userId)->first(); + + if (! $user) { + $this->error("User with ID {$userId} not found."); + + return self::FAILURE; + } + + if (! method_exists($user, 'assignRole')) { + $this->error("User model class [{$userModelClass}] must use the HasRoles trait."); + + return self::FAILURE; + } + + $teamIdAux = getPermissionsTeamId(); + setPermissionsTeamId($this->option('team-id') ?: null); + + $roleClass = $permissionRegistrar->getRoleClass(); + + try { + $role = $roleClass::findOrCreate($roleName, is_string($guardName) ? $guardName : null); + + $assignRole = Closure::fromCallable([$user, 'assignRole']); + $assignRole($role); + } finally { + setPermissionsTeamId($teamIdAux); + } + + $this->info("Role `{$role->name}` assigned to user ID {$userId} successfully."); + + return self::SUCCESS; + } +} diff --git a/src/permission/src/Commands/CacheResetCommand.php b/src/permission/src/Commands/CacheResetCommand.php new file mode 100644 index 000000000..26b6b0d9e --- /dev/null +++ b/src/permission/src/Commands/CacheResetCommand.php @@ -0,0 +1,33 @@ +getCacheRepository()->has($permissionRegistrar->cacheKey); + + if ($permissionRegistrar->forgetCachedPermissions()) { + $this->info('Permission cache flushed.'); + } elseif ($cacheExists) { + $this->error('Unable to flush cache.'); + } + + return self::SUCCESS; + } +} diff --git a/src/permission/src/Commands/CreatePermissionCommand.php b/src/permission/src/Commands/CreatePermissionCommand.php new file mode 100644 index 000000000..caad5571f --- /dev/null +++ b/src/permission/src/Commands/CreatePermissionCommand.php @@ -0,0 +1,34 @@ +getPermissionClass(); + $guard = $this->argument('guard'); + + $permission = $permissionClass::findOrCreate((string) $this->argument('name'), is_string($guard) ? $guard : null); + + $this->info("Permission `{$permission->name}` " . ($permission->wasRecentlyCreated ? 'created' : 'already exists')); + + return self::SUCCESS; + } +} diff --git a/src/permission/src/Commands/CreateRoleCommand.php b/src/permission/src/Commands/CreateRoleCommand.php new file mode 100644 index 000000000..3e8243300 --- /dev/null +++ b/src/permission/src/Commands/CreateRoleCommand.php @@ -0,0 +1,80 @@ +teams && $this->option('team-id')) { + $this->warn('Teams feature disabled, argument --team-id has no effect. Either enable it in permissions config file or remove --team-id parameter'); + + return self::SUCCESS; + } + + $roleClass = $permissionRegistrar->getRoleClass(); + $guard = $this->argument('guard'); + $teamIdAux = getPermissionsTeamId(); + + try { + setPermissionsTeamId($this->option('team-id') ?: null); + + $role = $roleClass::findOrCreate((string) $this->argument('name'), is_string($guard) ? $guard : null); + } finally { + setPermissionsTeamId($teamIdAux); + } + + $teamsKey = $permissionRegistrar->teamsKey; + if ($permissionRegistrar->teams && $this->option('team-id') && is_null($role->{$teamsKey})) { + $this->warn("Role `{$role->name}` already exists on the global team; argument --team-id has no effect"); + } + + $role->givePermissionTo($this->makePermissions($permissionRegistrar, $this->argument('permissions'))); + + $this->info("Role `{$role->name}` " . ($role->wasRecentlyCreated ? 'created' : 'updated')); + + return self::SUCCESS; + } + + /** + * Make the given permission models. + */ + protected function makePermissions(PermissionRegistrar $permissionRegistrar, mixed $permissions): ?Collection + { + if (! is_string($permissions) || $permissions === '') { + return null; + } + + $permissionClass = $permissionRegistrar->getPermissionClass(); + $guard = $this->argument('guard'); + + $permissions = explode('|', $permissions); + + $models = []; + + foreach ($permissions as $permission) { + $models[] = $permissionClass::findOrCreate(trim($permission), is_string($guard) ? $guard : null); + } + + return new Collection($models); + } +} diff --git a/src/permission/src/Commands/ShowCommand.php b/src/permission/src/Commands/ShowCommand.php new file mode 100644 index 000000000..460f64ad2 --- /dev/null +++ b/src/permission/src/Commands/ShowCommand.php @@ -0,0 +1,101 @@ +getPermissionClass(); + $roleClass = $permissionRegistrar->getRoleClass(); + $permissionKey = (new $permissionClass)->getKeyName(); + $teamsEnabled = Config::teamsEnabled(); + $teamKey = Config::teamForeignKey(); + + $style = (string) ($this->argument('style') ?? 'default'); + $guard = $this->argument('guard'); + + if ($guard) { + $guards = Collection::make([(string) $guard]); + } else { + $guards = $permissionClass::query() + ->pluck('guard_name') + ->merge($roleClass::query()->pluck('guard_name')) + ->unique(); + } + + foreach ($guards as $guard) { + $this->info("Guard: {$guard}"); + + $roles = $roleClass::query() + ->where('guard_name', $guard) + ->with('permissions') + ->when($teamsEnabled, fn ($q) => $q->orderBy($teamKey)) + ->orderBy('name')->get()->mapWithKeys(fn ($role) => [ + $role->name . '_' . ($teamsEnabled ? (string) ($role->{$teamKey} ?? '') : '') => [ + 'permissions' => $role->permissions->pluck($permissionKey), + $teamKey => $teamsEnabled ? $role->{$teamKey} : null, + ], + ]); + + $permissions = $permissionClass::query() + ->where('guard_name', $guard) + ->orderBy('name') + ->pluck('name', $permissionKey); + + $body = $permissions->map( + fn ($permission, $id) => $roles->map( + fn (array $role_data) => $role_data['permissions']->contains($id) ? ' ✔' : ' ·' + )->prepend($permission) + ); + + $teams = null; + + if ($teamsEnabled) { + $teams = $roles->groupBy($teamKey)->map( + fn ($group, $id) => new TableCell('Team ID: ' . ($id === null || $id === '' ? 'NULL' : $id), ['colspan' => $group->count()]) + ); + } + + $roleHeaders = $roles->keys()->map(function ($val) { + $name = explode('_', $val); + array_pop($name); + + return implode('_', $name); + })->toArray(); + array_unshift($roleHeaders, new TableCell('')); + + $teamHeaders = $teams ? $teams->toArray() : []; + if ($teamHeaders !== []) { + array_unshift($teamHeaders, new TableCell('')); + } + + $this->table( + array_merge($teamHeaders, $roleHeaders), + $body->toArray(), + $style + ); + } + + return self::SUCCESS; + } +} diff --git a/src/permission/src/Commands/UpgradeForTeamsCommand.php b/src/permission/src/Commands/UpgradeForTeamsCommand.php new file mode 100644 index 000000000..e6856206e --- /dev/null +++ b/src/permission/src/Commands/UpgradeForTeamsCommand.php @@ -0,0 +1,126 @@ +boolean('permission.teams', false)) { + $this->error('Teams feature is disabled in your permission.php file.'); + $this->warn('Please enable the teams setting in your configuration.'); + + return self::FAILURE; + } + + $this->line(''); + $this->info('The teams feature setup is going to add a migration and a model'); + + $existingMigrations = $this->alreadyExistingMigrations(); + + if ($existingMigrations) { + $this->line(''); + + $this->warn($this->getExistingMigrationsWarning($existingMigrations)); + } + + $this->line(''); + + if (! $this->confirm('Proceed with the migration creation?', true)) { + return self::SUCCESS; + } + + $this->line(''); + + $this->line('Creating migration'); + + if ($this->createMigration()) { + $this->info('Migration created successfully.'); + } else { + $this->error( + "Couldn't create migration.\n" + . 'Check the write permissions within the database/migrations directory.' + ); + } + + $this->line(''); + + return self::SUCCESS; + } + + /** + * Create the teams migration. + */ + protected function createMigration(): bool + { + try { + $migrationStub = __DIR__ . "/../../database/migrations/{$this->migrationSuffix}.stub"; + copy($migrationStub, $this->getMigrationPath()); + + return true; + } catch (Throwable $e) { + $this->error($e->getMessage()); + + return false; + } + } + + /** + * Get a warning for existing teams migrations. + * + * @param array $existingMigrations + */ + protected function getExistingMigrationsWarning(array $existingMigrations): string + { + if (count($existingMigrations) > 1) { + $base = "Setup teams migrations already exist.\nFollowing files were found: "; + } else { + $base = "Setup teams migration already exists.\nFollowing file was found: "; + } + + return $base . array_reduce($existingMigrations, fn ($carry, $fileName) => $carry . "\n - " . $fileName); + } + + /** + * Get already existing teams migrations. + * + * @return array + */ + protected function alreadyExistingMigrations(): array + { + $matchingFiles = glob($this->getMigrationPath('*')); + + if ($matchingFiles === false) { + return []; + } + + return array_map(fn ($path) => basename($path), $matchingFiles); + } + + /** + * Get the migration path. + */ + protected function getMigrationPath(?string $date = null): string + { + $date = $date ?: date('Y_m_d_His'); + + return database_path("migrations/{$date}_{$this->migrationSuffix}"); + } +} diff --git a/src/permission/src/Contracts/Permission.php b/src/permission/src/Contracts/Permission.php index 7e413d448..1127c595b 100644 --- a/src/permission/src/Contracts/Permission.php +++ b/src/permission/src/Contracts/Permission.php @@ -5,14 +5,41 @@ namespace Hypervel\Permission\Contracts; use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Permission\Exceptions\PermissionDoesNotExist; +use UnitEnum; /** + * @property int|string $id + * @property string $name + * @property null|string $guard_name + * * @mixin \Hypervel\Permission\Models\Permission + * + * @phpstan-require-extends \Hypervel\Permission\Models\Permission */ interface Permission { /** - * A role may be given various permissions. + * Get the roles that have this permission. */ public function roles(): BelongsToMany; + + /** + * Find a permission by its name. + * + * @throws PermissionDoesNotExist + */ + public static function findByName(UnitEnum|string $name, ?string $guardName): self; + + /** + * Find a permission by its id. + * + * @throws PermissionDoesNotExist + */ + public static function findById(int|string $id, ?string $guardName): self; + + /** + * Find or Create a permission by its name and guard name. + */ + public static function findOrCreate(UnitEnum|string $name, ?string $guardName): self; } diff --git a/src/permission/src/Contracts/PermissionsTeamResolver.php b/src/permission/src/Contracts/PermissionsTeamResolver.php new file mode 100644 index 000000000..6f82525d7 --- /dev/null +++ b/src/permission/src/Contracts/PermissionsTeamResolver.php @@ -0,0 +1,20 @@ +> + */ + public function getIndex(): array; + + /** + * Determine if the wildcard permission implies another permission. + * + * @param array> $index + */ + public function implies(string $permission, string $guardName, array $index): bool; +} diff --git a/src/permission/src/DefaultTeamResolver.php b/src/permission/src/DefaultTeamResolver.php new file mode 100644 index 000000000..a182ec144 --- /dev/null +++ b/src/permission/src/DefaultTeamResolver.php @@ -0,0 +1,42 @@ +getKey(); + } + + CoroutineContext::set(self::TEAM_ID_CONTEXT_KEY, $id); + } + + /** + * Get the current permissions team id. + */ + public function getPermissionsTeamId(): int|string|null + { + return CoroutineContext::get(self::TEAM_ID_CONTEXT_KEY); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + CoroutineContext::forget(self::TEAM_ID_CONTEXT_KEY); + } +} diff --git a/src/permission/src/Events/PermissionAttachedEvent.php b/src/permission/src/Events/PermissionAttachedEvent.php new file mode 100644 index 000000000..3c408ee8f --- /dev/null +++ b/src/permission/src/Events/PermissionAttachedEvent.php @@ -0,0 +1,30 @@ +|array|Collection|Permission $permissionsOrIds + */ + public function __construct(public Model $model, public mixed $permissionsOrIds) + { + } +} diff --git a/src/permission/src/Events/PermissionDetachedEvent.php b/src/permission/src/Events/PermissionDetachedEvent.php new file mode 100644 index 000000000..e63f03ca2 --- /dev/null +++ b/src/permission/src/Events/PermissionDetachedEvent.php @@ -0,0 +1,30 @@ +|array|Collection|Permission $permissionsOrIds + */ + public function __construct(public Model $model, public mixed $permissionsOrIds) + { + } +} diff --git a/src/permission/src/Events/RoleAttachedEvent.php b/src/permission/src/Events/RoleAttachedEvent.php new file mode 100644 index 000000000..0c02fd3aa --- /dev/null +++ b/src/permission/src/Events/RoleAttachedEvent.php @@ -0,0 +1,30 @@ +|array|Collection|Role $rolesOrIds + */ + public function __construct(public Model $model, public mixed $rolesOrIds) + { + } +} diff --git a/src/permission/src/Events/RoleDetachedEvent.php b/src/permission/src/Events/RoleDetachedEvent.php new file mode 100644 index 000000000..6d078fd98 --- /dev/null +++ b/src/permission/src/Events/RoleDetachedEvent.php @@ -0,0 +1,30 @@ +|array|Collection|Role $rolesOrIds + */ + public function __construct(public Model $model, public mixed $rolesOrIds) + { + } +} diff --git a/src/permission/src/Exceptions/GuardDoesNotMatch.php b/src/permission/src/Exceptions/GuardDoesNotMatch.php new file mode 100644 index 000000000..018bce48e --- /dev/null +++ b/src/permission/src/Exceptions/GuardDoesNotMatch.php @@ -0,0 +1,22 @@ + $expectedGuards->implode(', '), + 'given' => $givenGuard, + ])); + } +} diff --git a/src/permission/src/Exceptions/PermissionAlreadyExists.php b/src/permission/src/Exceptions/PermissionAlreadyExists.php new file mode 100644 index 000000000..8b9f3a32c --- /dev/null +++ b/src/permission/src/Exceptions/PermissionAlreadyExists.php @@ -0,0 +1,21 @@ + $permissionName, + 'guard' => $guardName, + ])); + } +} diff --git a/src/permission/src/Exceptions/PermissionDoesNotExist.php b/src/permission/src/Exceptions/PermissionDoesNotExist.php new file mode 100644 index 000000000..606ea6dd4 --- /dev/null +++ b/src/permission/src/Exceptions/PermissionDoesNotExist.php @@ -0,0 +1,32 @@ + $permissionName, + 'guard' => $guardName, + ])); + } + + /** + * Create a new permission does not exist exception for an id. + */ + public static function withId(int|string $permissionId, ?string $guardName): static + { + return new static(__('There is no [permission] with ID `:id` for guard `:guard`.', [ + 'id' => $permissionId, + 'guard' => $guardName, + ])); + } +} diff --git a/src/permission/src/Exceptions/RoleAlreadyExists.php b/src/permission/src/Exceptions/RoleAlreadyExists.php new file mode 100644 index 000000000..f6a963aa4 --- /dev/null +++ b/src/permission/src/Exceptions/RoleAlreadyExists.php @@ -0,0 +1,21 @@ + $roleName, + 'guard' => $guardName, + ])); + } +} diff --git a/src/permission/src/Exceptions/RoleDoesNotExist.php b/src/permission/src/Exceptions/RoleDoesNotExist.php new file mode 100644 index 000000000..6199bea7e --- /dev/null +++ b/src/permission/src/Exceptions/RoleDoesNotExist.php @@ -0,0 +1,32 @@ + $roleName, + 'guard' => $guardName, + ])); + } + + /** + * Create a new role does not exist exception for an id. + */ + public static function withId(int|string $roleId, ?string $guardName): static + { + return new static(__('There is no role with ID `:id` for guard `:guard`.', [ + 'id' => $roleId, + 'guard' => $guardName, + ])); + } +} diff --git a/src/permission/src/Exceptions/TeamModelNotConfigured.php b/src/permission/src/Exceptions/TeamModelNotConfigured.php new file mode 100644 index 000000000..a58609f1b --- /dev/null +++ b/src/permission/src/Exceptions/TeamModelNotConfigured.php @@ -0,0 +1,18 @@ + + */ + private array $requiredRoles = []; + + /** + * The permissions required by the failed check. + * + * @var array + */ + private array $requiredPermissions = []; + + /** + * Create an exception for missing roles. + * + * @param array $roles + */ + public static function forRoles(array $roles): static + { + $message = __('User does not have the right roles.'); + + if (Config::displayRoleInException()) { + $message .= ' ' . __('Necessary roles are :roles', ['roles' => implode(', ', $roles)]); + } + + $exception = new static(403, $message, null, []); + $exception->requiredRoles = $roles; + + return $exception; + } + + /** + * Create an exception for missing permissions. + * + * @param array $permissions + */ + public static function forPermissions(array $permissions): static + { + $message = __('User does not have the right permissions.'); + + if (Config::displayPermissionInException()) { + $message .= ' ' . __('Necessary permissions are :permissions', ['permissions' => implode(', ', $permissions)]); + } + + $exception = new static(403, $message, null, []); + $exception->requiredPermissions = $permissions; + + return $exception; + } + + /** + * Create an exception for missing roles or permissions. + * + * @param array $rolesOrPermissions + */ + public static function forRolesOrPermissions(array $rolesOrPermissions): static + { + $message = __('User does not have any of the necessary access rights.'); + + if (Config::displayPermissionInException() && Config::displayRoleInException()) { + $message .= ' ' . __('Necessary roles or permissions are :values', ['values' => implode(', ', $rolesOrPermissions)]); + } + + $exception = new static(403, $message, null, []); + $exception->requiredPermissions = $rolesOrPermissions; + + return $exception; + } + + /** + * Create an exception for a user missing the HasRoles trait. + */ + public static function missingTraitHasRoles(Authorizable $user): static + { + return new static(403, __('Authorizable class `:class` must use Hypervel\Permission\Traits\HasRoles trait.', [ + 'class' => $user::class, + ]), null, []); + } + + /** + * Create an exception for a missing authenticated user. + */ + public static function notLoggedIn(): static + { + return new static(403, __('User is not logged in.'), null, []); + } + + /** + * Get the roles required by the failed check. + * + * @return array + */ + public function getRequiredRoles(): array + { + return $this->requiredRoles; + } + + /** + * Get the permissions required by the failed check. + * + * @return array + */ + public function getRequiredPermissions(): array + { + return $this->requiredPermissions; + } } diff --git a/src/permission/src/Exceptions/WildcardPermissionInvalidArgument.php b/src/permission/src/Exceptions/WildcardPermissionInvalidArgument.php new file mode 100644 index 000000000..75f6f2e49 --- /dev/null +++ b/src/permission/src/Exceptions/WildcardPermissionInvalidArgument.php @@ -0,0 +1,18 @@ + $permission, + ])); + } +} diff --git a/src/permission/src/Guard.php b/src/permission/src/Guard.php new file mode 100644 index 000000000..fd5c1e3ec --- /dev/null +++ b/src/permission/src/Guard.php @@ -0,0 +1,180 @@ +guardName(); + } else { + $guardName = $model->getAttributeValue('guard_name'); + } + } + + if (! isset($guardName)) { + $guardName = (new ReflectionClass($class))->getDefaultProperties()['guard_name'] ?? null; + } + + if ($guardName) { + return new Collection($guardName); + } + + return self::getConfigAuthGuards($class); + } + + /** + * Get the model class associated with a given provider. + * + * @return null|class-string + */ + protected static function getProviderModel(string $provider): ?string + { + $providerConfig = self::config()->array("auth.providers.{$provider}", []); + + // Handle LDAP provider or standard Eloquent provider + if (isset($providerConfig['driver']) && $providerConfig['driver'] === 'ldap') { + return $providerConfig['database']['model'] ?? null; + } + + if (isset($providerConfig['model'])) { + return $providerConfig['model']; + } + + return null; + } + + /** + * Get the config repository. + */ + protected static function config(): Repository + { + return Container::getInstance()->make('config'); + } + + /** + * Get the auth factory. + */ + protected static function auth(): AuthFactory + { + return Container::getInstance()->make(AuthFactory::class); + } + + /** + * Get the configured auth guards. + * + * @return array> + */ + protected static function guards(): array + { + return self::config()->array('auth.guards', []); + } + + /** + * Get list of relevant guards for the $class model based on config(auth) settings. + * + * Lookup flow: + * - get names of models for guards defined in auth.guards where a provider is set + * - filter for provider models matching the model $class being checked + * - keys() gives just the names of the matched guards + * - return collection of guard names + */ + protected static function getConfigAuthGuards(string $class): Collection + { + return (new Collection(self::guards())) + ->map(function (array $guard): ?string { + if (! isset($guard['provider'])) { + return null; + } + + /** @var string $provider */ + $provider = $guard['provider']; + + return static::getProviderModel($provider); + }) + ->filter(fn ($model) => $class === $model) + ->keys(); + } + + /** + * Get the model associated with a given guard name. + */ + public static function getModelForGuard(string $guard): ?string + { + $provider = self::config()->get("auth.guards.{$guard}.provider"); + + if (! $provider) { + return null; + } + + /** @var string $provider */ + return static::getProviderModel($provider); + } + + /** + * Lookup a guard name relevant for the $class model and the current user. + */ + public static function getDefaultName(string|Model $class): string + { + $default = self::auth()->getDefaultDriver(); + + $possibleGuards = static::getNames($class); + + if ($possibleGuards->contains($default)) { + return $default; + } + + return $possibleGuards->first() ?: $default; + } + + /** + * Lookup a Passport guard. + */ + public static function getPassportClient(?string $guard): ?Authorizable + { + $guards = (new Collection(self::guards()))->where('driver', 'passport'); + + if (! $guards->count()) { + return null; + } + + /** @var string $passportGuard */ + $passportGuard = $guards->keys()[0]; + + $authGuard = self::auth()->guard($passportGuard); + + if (! method_exists($authGuard, 'client')) { + return null; + } + + $client = $authGuard->client(); + + if (! $guard || ! $client) { + return $client; + } + + if (self::getNames($client)->contains($guard)) { + return $client; + } + + return null; + } +} diff --git a/src/permission/src/Middleware/PermissionMiddleware.php b/src/permission/src/Middleware/PermissionMiddleware.php index a351c7ed9..b4d837cac 100644 --- a/src/permission/src/Middleware/PermissionMiddleware.php +++ b/src/permission/src/Middleware/PermissionMiddleware.php @@ -4,97 +4,76 @@ namespace Hypervel\Permission\Middleware; -use BackedEnum; use Closure; -use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Auth\Access\Authorizable; use Hypervel\Http\Request; -use Hypervel\Permission\Exceptions\PermissionException; +use Hypervel\Http\Response; use Hypervel\Permission\Exceptions\UnauthorizedException; -use Hypervel\Support\Collection; -use Symfony\Component\HttpFoundation\Response; +use Hypervel\Permission\Guard; +use Hypervel\Permission\Support\Config; +use Hypervel\Support\Facades\Auth; use UnitEnum; +use function Hypervel\Support\enum_value; + class PermissionMiddleware { - /** - * Create a new middleware instance. - */ - public function __construct(protected Container $container) - { - } - /** * Handle an incoming request. */ - public function handle(Request $request, Closure $next, string ...$permissions): Response + public function handle(Request $request, Closure $next, mixed $permission, ?string $guard = null): Response { - $auth = $this->container->make('auth'); - $user = $auth->user(); + $authGuard = Auth::guard($guard); + + $user = $authGuard->user(); + + // For machine-to-machine Passport clients + if (! $user && $request->bearerToken() && Config::usePassportClientCredentials()) { + $user = Guard::getPassportClient($guard); + } + if (! $user) { - throw new UnauthorizedException( - 401, - sprintf( - 'User is not authenticated. Cannot check permissions: %s', - self::parsePermissionsToString($permissions) - ) - ); + throw UnauthorizedException::notLoggedIn(); } - if (! method_exists($user, 'hasAnyPermissions')) { - throw new UnauthorizedException( - 500, - sprintf( - 'User "%s" does not have the "hasAnyPermissions" method. Cannot check permissions: %s', - /* @phpstan-ignore-next-line */ - $user->getAuthIdentifier(), - self::parsePermissionsToString($permissions) - ) - ); + if (! $user instanceof Authorizable || ! method_exists($user, 'hasAnyPermission')) { + throw UnauthorizedException::missingTraitHasRoles($user); } - $permissions = explode('|', self::parsePermissionsToString($permissions)); - /* @phpstan-ignore-next-line */ - if (! $user->hasAnyPermissions($permissions)) { - throw new PermissionException( - 403, - sprintf( - 'User "%s" does not have any of the required permissions: %s', - /* @phpstan-ignore-next-line */ - $user->getAuthIdentifier(), - self::parsePermissionsToString($permissions) - ), - null, - [], - 0, - $permissions - ); + + $permissions = explode('|', self::parsePermissionsToString($permission)); + + foreach ($permissions as $permission) { + if ($user->can($permission)) { + return $next($request); + } } - return $next($request); + throw UnauthorizedException::forPermissions($permissions); } /** - * Generate a unique identifier for the middleware based on the permissions. + * Specify the permission and guard for the middleware. */ - public static function using(array|UnitEnum|int|string ...$permissions): string + public static function using(array|string|UnitEnum $permission, ?string $guard = null): string { - return static::class . ':' . self::parsePermissionsToString($permissions); + $permissionString = self::parsePermissionsToString(enum_value($permission)); + + $args = is_null($guard) ? $permissionString : "{$permissionString},{$guard}"; + + return static::class . ':' . $args; } - public static function parsePermissionsToString(array $permissions) + /** + * Parse the permissions into a pipe-delimited string. + */ + protected static function parsePermissionsToString(array|string|UnitEnum $permission): string { - $permissions = Collection::make($permissions) - ->flatten() - ->values() - ->all(); - - $permission = array_map(function ($permission) { - return match (true) { - $permission instanceof BackedEnum => $permission->value, - $permission instanceof UnitEnum => $permission->name, - default => $permission, - }; - }, $permissions); - - return implode('|', $permission); + $permission = enum_value($permission); + + if (is_array($permission)) { + return implode('|', array_map(fn ($r) => enum_value($r), $permission)); + } + + return (string) $permission; } } diff --git a/src/permission/src/Middleware/RoleMiddleware.php b/src/permission/src/Middleware/RoleMiddleware.php index ff4c6ef15..2b0d0c1de 100644 --- a/src/permission/src/Middleware/RoleMiddleware.php +++ b/src/permission/src/Middleware/RoleMiddleware.php @@ -4,97 +4,74 @@ namespace Hypervel\Permission\Middleware; -use BackedEnum; use Closure; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\Request; -use Hypervel\Permission\Exceptions\RoleException; +use Hypervel\Http\Response; use Hypervel\Permission\Exceptions\UnauthorizedException; -use Hypervel\Support\Collection; -use Symfony\Component\HttpFoundation\Response; +use Hypervel\Permission\Guard; +use Hypervel\Permission\Support\Config; +use Hypervel\Support\Facades\Auth; use UnitEnum; +use function Hypervel\Support\enum_value; + class RoleMiddleware { - /** - * Create a new middleware instance. - */ - public function __construct(protected Container $container) - { - } - /** * Handle an incoming request. */ - public function handle(Request $request, Closure $next, string ...$roles): Response + public function handle(Request $request, Closure $next, mixed $role, ?string $guard = null): Response { - $auth = $this->container->make('auth'); - $user = $auth->user(); + $authGuard = Auth::guard($guard); + + $user = $authGuard->user(); + + // For machine-to-machine Passport clients + if (! $user && $request->bearerToken() && Config::usePassportClientCredentials()) { + $user = Guard::getPassportClient($guard); + } + if (! $user) { - throw new UnauthorizedException( - 401, - sprintf( - 'User is not authenticated. Cannot check roles: %s', - self::parseRolesToString($roles) - ) - ); + throw UnauthorizedException::notLoggedIn(); } - if (! method_exists($user, 'hasAnyRoles')) { - throw new UnauthorizedException( - 500, - sprintf( - 'User "%s" does not have the "hasAnyRoles" method. Cannot check roles: %s', - /* @phpstan-ignore-next-line */ - $user->getAuthIdentifier(), - self::parseRolesToString($roles) - ) - ); + if (! is_object($user) || ! method_exists($user, 'hasAnyRole')) { + throw UnauthorizedException::missingTraitHasRoles($user); } - $roles = explode('|', self::parseRolesToString($roles)); - /* @phpstan-ignore-next-line */ - if (! $user->hasAnyRoles($roles)) { - throw new RoleException( - 403, - sprintf( - 'User "%s" does not have any of the required roles: %s', - /* @phpstan-ignore-next-line */ - $user->getAuthIdentifier(), - self::parseRolesToString($roles) - ), - null, - [], - 0, - $roles - ); + + $roles = explode('|', self::parseRolesToString($role)); + $hasAnyRole = Closure::fromCallable([$user, 'hasAnyRole']); + + if (! $hasAnyRole($roles)) { + throw UnauthorizedException::forRoles($roles); } return $next($request); } /** - * Generate a unique identifier for the middleware based on the roles. + * Specify the role and guard for the middleware. */ - public static function using(array|UnitEnum|int|string ...$roles): string + public static function using(array|string|UnitEnum $role, ?string $guard = null): string { - return static::class . ':' . self::parseRolesToString($roles); + $roleString = self::parseRolesToString($role); + + $args = is_null($guard) ? $roleString : "{$roleString},{$guard}"; + + return static::class . ':' . $args; } - public static function parseRolesToString(array $roles) + /** + * Parse the roles into a pipe-delimited string. + */ + protected static function parseRolesToString(array|string|UnitEnum $role): string { - $roles = Collection::make($roles) - ->flatten() - ->values() - ->all(); - - $role = array_map(function ($role) { - return match (true) { - $role instanceof BackedEnum => $role->value, - $role instanceof UnitEnum => $role->name, - default => $role, - }; - }, $roles); - - return implode('|', $role); + $role = enum_value($role); + + if (is_array($role)) { + return implode('|', array_map(fn ($r) => enum_value($r), $role)); + } + + return (string) $role; } } diff --git a/src/permission/src/Middleware/RoleOrPermissionMiddleware.php b/src/permission/src/Middleware/RoleOrPermissionMiddleware.php new file mode 100644 index 000000000..a59b9be37 --- /dev/null +++ b/src/permission/src/Middleware/RoleOrPermissionMiddleware.php @@ -0,0 +1,83 @@ +user(); + + // For machine-to-machine Passport clients + if (! $user && $request->bearerToken() && Config::usePassportClientCredentials()) { + $user = Guard::getPassportClient($guard); + } + + if (! $user) { + throw UnauthorizedException::notLoggedIn(); + } + + if (! $user instanceof Authorizable || ! method_exists($user, 'hasAnyRole') || ! method_exists($user, 'hasAnyPermission')) { + throw UnauthorizedException::missingTraitHasRoles($user); + } + + $rolesOrPermissions = explode('|', self::parseRoleOrPermissionToString($roleOrPermission)); + $hasAnyRole = Closure::fromCallable([$user, 'hasAnyRole']); + + foreach ($rolesOrPermissions as $roleOrPermission) { + if ($user->can($roleOrPermission)) { + return $next($request); + } + } + + if ($hasAnyRole($rolesOrPermissions)) { + return $next($request); + } + + throw UnauthorizedException::forRolesOrPermissions($rolesOrPermissions); + } + + /** + * Specify the role or permission and guard for the middleware. + */ + public static function using(array|string|UnitEnum $roleOrPermission, ?string $guard = null): string + { + $roleOrPermissionString = self::parseRoleOrPermissionToString($roleOrPermission); + $args = is_null($guard) ? $roleOrPermissionString : "{$roleOrPermissionString},{$guard}"; + + return static::class . ':' . $args; + } + + /** + * Parse the roles or permissions into a pipe-delimited string. + */ + protected static function parseRoleOrPermissionToString(array|string|UnitEnum $roleOrPermission): string + { + $roleOrPermission = enum_value($roleOrPermission); + + if (is_array($roleOrPermission)) { + return implode('|', array_map(fn ($r) => enum_value($r), $roleOrPermission)); + } + + return (string) $roleOrPermission; + } +} diff --git a/src/permission/src/Models/Permission.php b/src/permission/src/Models/Permission.php index b95a1c9c5..47b35693a 100644 --- a/src/permission/src/Models/Permission.php +++ b/src/permission/src/Models/Permission.php @@ -9,40 +9,165 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Permission\Contracts\Permission as PermissionContract; -use Hypervel\Permission\Traits\HasRole; +use Hypervel\Permission\Exceptions\PermissionAlreadyExists; +use Hypervel\Permission\Exceptions\PermissionDoesNotExist; +use Hypervel\Permission\Guard; +use Hypervel\Permission\PermissionRegistrar; +use Hypervel\Permission\Support\Config; +use Hypervel\Permission\Traits\HasRoles; +use Hypervel\Permission\Traits\RefreshesPermissionCache; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** - * @property int $id + * @property int|string $id * @property string $name * @property string $guard_name - * @property CarbonInterface $created_at - * @property CarbonInterface $updated_at - * @property-read Collection $roles + * @property null|CarbonInterface $created_at + * @property null|CarbonInterface $updated_at + * @property-read Collection $roles + * @property-read Collection $users */ class Permission extends Model implements PermissionContract { - use HasRole; + use HasRoles; + use RefreshesPermissionCache; + + protected array $guarded = []; + + public function __construct(array $attributes = []) + { + $attributes['guard_name'] ??= Guard::getDefaultName(static::class); + + parent::__construct($attributes); + + $this->guarded[] = $this->primaryKey; + $this->table = Config::permissionsTable() ?: parent::getTable(); + } /** - * The attributes that are mass assignable. + * @return Permission|PermissionContract + * + * @throws PermissionAlreadyExists */ - protected array $fillable = [ - 'name', - 'guard_name', - ]; + public static function create(array $attributes = []): PermissionContract + { + $attributes['guard_name'] ??= Guard::getDefaultName(static::class); + + $attributes['name'] = enum_value($attributes['name']); + + $permission = static::getPermission(['name' => $attributes['name'], 'guard_name' => $attributes['guard_name']]); + + if ($permission) { + throw PermissionAlreadyExists::create($attributes['name'], $attributes['guard_name']); + } + + return static::query()->create($attributes); + } /** * A permission can be applied to roles. */ public function roles(): BelongsToMany { + $registrar = app(PermissionRegistrar::class); + return $this->belongsToMany( - config('permission.models.role', Role::class), - config('permission.table_names.role_has_permissions', 'role_has_permissions'), - config('permission.column_names.permission_pivot_key', 'permission_id'), - config('permission.column_names.role_pivot_key', 'role_id'), - ) - ->withTimestamps() - ->withPivot(['is_forbidden']); + Config::roleModel(), + Config::roleHasPermissionsTable(), + $registrar->pivotPermission, + $registrar->pivotRole + )->withPivot('is_forbidden'); + } + + /** + * A permission belongs to some users of the model associated with its guard. + */ + public function users(): BelongsToMany + { + return $this->morphedByMany( + getModelForGuard($this->attributes['guard_name'] ?? Config::defaultGuard()), + 'model', + Config::modelHasPermissionsTable(), + app(PermissionRegistrar::class)->pivotPermission, + Config::morphKey() + ); + } + + /** + * Find a permission by its name (and optionally guardName). + * + * @return Permission|PermissionContract + * + * @throws PermissionDoesNotExist + */ + public static function findByName(UnitEnum|string $name, ?string $guardName = null): PermissionContract + { + $name = enum_value($name); + $guardName ??= Guard::getDefaultName(static::class); + $permission = static::getPermission(['name' => $name, 'guard_name' => $guardName]); + if (! $permission) { + throw PermissionDoesNotExist::create($name, $guardName); + } + + return $permission; + } + + /** + * Find a permission by its id (and optionally guardName). + * + * @return Permission|PermissionContract + * + * @throws PermissionDoesNotExist + */ + public static function findById(int|string $id, ?string $guardName = null): PermissionContract + { + $guardName ??= Guard::getDefaultName(static::class); + $permission = static::getPermission([(new static)->getKeyName() => $id, 'guard_name' => $guardName]); + + if (! $permission) { + throw PermissionDoesNotExist::withId($id, $guardName); + } + + return $permission; + } + + /** + * Find or create permission by its name (and optionally guardName). + * + * @return Permission|PermissionContract + */ + public static function findOrCreate(UnitEnum|string $name, ?string $guardName = null): PermissionContract + { + $name = enum_value($name); + $guardName ??= Guard::getDefaultName(static::class); + $permission = static::getPermission(['name' => $name, 'guard_name' => $guardName]); + + if (! $permission) { + return static::query()->create(['name' => $name, 'guard_name' => $guardName]); + } + + return $permission; + } + + /** + * Get the current cached permissions. + */ + protected static function getPermissions(array $params = [], bool $onlyOne = false): Collection + { + return app(PermissionRegistrar::class) + ->getPermissions($params, $onlyOne, static::class); + } + + /** + * Get the current cached first permission. + * + * @return null|Permission|PermissionContract + */ + protected static function getPermission(array $params = []): ?PermissionContract + { + /** @var null|PermissionContract */ + return static::getPermissions($params, true)->first(); } } diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index 01a2b9748..6f211b44d 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -8,41 +8,231 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Permission\Contracts\Permission as PermissionContract; use Hypervel\Permission\Contracts\Role as RoleContract; -use Hypervel\Permission\Traits\HasPermission; +use Hypervel\Permission\Exceptions\GuardDoesNotMatch; +use Hypervel\Permission\Exceptions\PermissionDoesNotExist; +use Hypervel\Permission\Exceptions\RoleAlreadyExists; +use Hypervel\Permission\Exceptions\RoleDoesNotExist; +use Hypervel\Permission\Guard; +use Hypervel\Permission\PermissionRegistrar; +use Hypervel\Permission\Support\Config; +use Hypervel\Permission\Traits\HasAssignedModels; +use Hypervel\Permission\Traits\HasPermissions; +use Hypervel\Permission\Traits\RefreshesPermissionCache; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** - * @property int $id + * @property int|string $id * @property string $name * @property string $guard_name - * @property CarbonInterface $created_at - * @property CarbonInterface $updated_at - * @property-read Collection $permissions + * @property null|CarbonInterface $created_at + * @property null|CarbonInterface $updated_at + * @property-read Collection $permissions + * @property-read Collection $users */ class Role extends Model implements RoleContract { - use HasPermission; + use HasAssignedModels; + use HasPermissions; + use RefreshesPermissionCache; + + protected array $guarded = []; + + public function __construct(array $attributes = []) + { + $attributes['guard_name'] ??= Guard::getDefaultName(static::class); + + parent::__construct($attributes); + + $this->guarded[] = $this->primaryKey; + $this->table = Config::rolesTable() ?: parent::getTable(); + } /** - * The attributes that are mass assignable. + * @return Role|RoleContract + * + * @throws RoleAlreadyExists */ - protected array $fillable = [ - 'name', - 'guard_name', - ]; + public static function create(array $attributes = []): RoleContract + { + $attributes['guard_name'] ??= Guard::getDefaultName(static::class); + $attributes['name'] = enum_value($attributes['name']); + + $params = ['name' => $attributes['name'], 'guard_name' => $attributes['guard_name']]; + + $registrar = app(PermissionRegistrar::class); + + if ($registrar->teams) { + $teamsKey = $registrar->teamsKey; + + if (array_key_exists($teamsKey, $attributes)) { + $params[$teamsKey] = $attributes[$teamsKey]; + } else { + $attributes[$teamsKey] = getPermissionsTeamId(); + } + } + + if (static::findByParam($params)) { + throw RoleAlreadyExists::create($attributes['name'], $attributes['guard_name']); + } + + return static::query()->create($attributes); + } /** * A role may be given various permissions. */ public function permissions(): BelongsToMany { + $registrar = app(PermissionRegistrar::class); + return $this->belongsToMany( - config('permission.models.permission', Permission::class), - config('permission.table_names.role_has_permissions', 'role_has_permissions'), - config('permission.column_names.role_pivot_key', 'role_id'), - config('permission.column_names.permission_pivot_key', 'permission_id'), - ) - ->withTimestamps() - ->withPivot(['is_forbidden']); + Config::permissionModel(), + Config::roleHasPermissionsTable(), + $registrar->pivotRole, + $registrar->pivotPermission + )->withPivot('is_forbidden'); + } + + /** + * A role belongs to some users of the model associated with its guard. + */ + public function users(): BelongsToMany + { + return $this->morphedByMany( + getModelForGuard($this->attributes['guard_name'] ?? Config::defaultGuard()), + 'model', + Config::modelHasRolesTable(), + app(PermissionRegistrar::class)->pivotRole, + Config::morphKey() + ); + } + + /** + * Find a role by its name and guard name. + * + * @return Role|RoleContract + * + * @throws RoleDoesNotExist + */ + public static function findByName(UnitEnum|string $name, ?string $guardName = null): RoleContract + { + $name = enum_value($name); + $guardName ??= Guard::getDefaultName(static::class); + + $role = static::findByParam(['name' => $name, 'guard_name' => $guardName]); + + if (! $role) { + throw RoleDoesNotExist::named($name, $guardName); + } + + return $role; + } + + /** + * Find a role by its id (and optionally guardName). + * + * @return Role|RoleContract + */ + public static function findById(int|string $id, ?string $guardName = null): RoleContract + { + $guardName ??= Guard::getDefaultName(static::class); + + $role = static::findByParam([(new static)->getKeyName() => $id, 'guard_name' => $guardName]); + + if (! $role) { + throw RoleDoesNotExist::withId($id, $guardName); + } + + return $role; + } + + /** + * Find or create role by its name (and optionally guardName). + * + * @return Role|RoleContract + */ + public static function findOrCreate(UnitEnum|string $name, ?string $guardName = null): RoleContract + { + $name = enum_value($name); + $guardName ??= Guard::getDefaultName(static::class); + + $attributes = ['name' => $name, 'guard_name' => $guardName]; + + $role = static::findByParam($attributes); + + if (! $role) { + $registrar = app(PermissionRegistrar::class); + if ($registrar->teams) { + $teamsKey = $registrar->teamsKey; + $attributes[$teamsKey] = getPermissionsTeamId(); + } + + return static::query()->create($attributes); + } + + return $role; + } + + /** + * Finds a role based on an array of parameters. + * + * @return null|Role|RoleContract + */ + protected static function findByParam(array $params = []): ?RoleContract + { + $query = static::query(); + + $registrar = app(PermissionRegistrar::class); + + if ($registrar->teams) { + $teamsKey = $registrar->teamsKey; + + $query->where( + fn ($q) => $q->whereNull($teamsKey) + ->orWhere($teamsKey, $params[$teamsKey] ?? getPermissionsTeamId()) + ); + unset($params[$teamsKey]); + } + + foreach ($params as $key => $value) { + $query->where($key, $value); + } + + return $query->first(); + } + + /** + * Determine if the role may perform the given permission. + * + * @throws GuardDoesNotMatch|PermissionDoesNotExist + */ + public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permission, ?string $guardName = null): bool + { + if ($this->hasForbiddenPermission($permission, $guardName)) { + return false; + } + + if ($this->getWildcardClass()) { + return $this->hasWildcardPermission($permission, $guardName); + } + + $permission = $this->filterPermission($permission, $guardName); + + if (! $this->getGuardNames()->contains($permission->guard_name)) { + throw GuardDoesNotMatch::create($permission->guard_name, $guardName ? collect([$guardName]) : $this->getGuardNames()); + } + + $matchedPermission = $this->loadMissing('permissions') + ->getRelation('permissions') + ->first(fn (Model $rolePermission): bool => $rolePermission->getKey() === $permission->getKey()); + $pivot = $matchedPermission?->getRelation('pivot'); + + return $matchedPermission !== null + && (! $pivot instanceof Pivot || ! (bool) $pivot->getAttribute('is_forbidden')); } } diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php new file mode 100644 index 000000000..d7892d994 --- /dev/null +++ b/src/permission/src/PermissionRegistrar.php @@ -0,0 +1,754 @@ +initializeCache(); + } + + /** + * Initialize cache and config-backed registrar state. + * + * Boot or tests only. The values are stored on the singleton registrar and + * affect every later permission lookup in this worker. + */ + public function initializeCache(): void + { + /** @var class-string $permissionClass */ + $permissionClass = $this->config->get('permission.models.permission', Permission::class); + /** @var class-string $roleClass */ + $roleClass = $this->config->get('permission.models.role', Role::class); + /** @var null|class-string $teamClass */ + $teamClass = $this->config->get('permission.models.team'); + /** @var class-string $teamResolverClass */ + $teamResolverClass = $this->config->get('permission.team_resolver', DefaultTeamResolver::class); + + $this->permissionClass = $permissionClass; + $this->roleClass = $roleClass; + $this->teamClass = $teamClass; + $this->teamResolver = $this->app->make($teamResolverClass); + + $this->cacheExpirationTime = $this->config->integer('permission.cache.expiration_seconds', 86400); + $this->teams = $this->config->boolean('permission.teams', false); + $this->teamsKey = $this->config->string('permission.column_names.team_foreign_key', 'team_id'); + + $this->cacheKey = $this->config->string('permission.cache.keys.roles', 'hypervel.permission.cache.roles'); + $this->modelRolesCacheKeyPrefix = $this->config->string('permission.cache.keys.model_roles', self::MODEL_ROLES_CACHE_KEY_PREFIX); + $this->modelPermissionsCacheKeyPrefix = $this->config->string('permission.cache.keys.model_permissions', self::MODEL_PERMISSIONS_CACHE_KEY_PREFIX); + $this->modelCacheVersionKey = $this->config->string('permission.cache.keys.model_version', self::MODEL_CACHE_VERSION_KEY); + + $pivotRole = $this->config->get('permission.column_names.role_pivot_key'); + $pivotPermission = $this->config->get('permission.column_names.permission_pivot_key'); + $this->pivotRole = is_string($pivotRole) && $pivotRole !== '' ? $pivotRole : 'role_id'; + $this->pivotPermission = is_string($pivotPermission) && $pivotPermission !== '' ? $pivotPermission : 'permission_id'; + + $cacheStore = $this->config->string('permission.cache.store', 'default'); + $this->cacheStoreName = $cacheStore === 'default' ? null : $cacheStore; + + $this->clearPermissionsCollection(); + $this->validateModelClasses(); + } + + /** + * Validate the configured model classes. + */ + protected function validateModelClasses(): void + { + if (! is_a($this->roleClass, RoleContract::class, true)) { + throw new InvalidArgumentException(sprintf( + 'Role class "%s" must implement "%s" interface.', + $this->roleClass, + RoleContract::class, + )); + } + + if (! is_a($this->permissionClass, PermissionContract::class, true)) { + throw new InvalidArgumentException(sprintf( + 'Permission class "%s" must implement "%s" interface.', + $this->permissionClass, + PermissionContract::class, + )); + } + } + + /** + * Get the configured cache repository. + */ + protected function configuredCacheRepository(): Repository + { + return $this->cacheManager->store($this->cacheStoreName); + } + + /** + * Get the memoized cache repository for the current coroutine. + */ + protected function cacheRepository(): Repository + { + return $this->cacheManager->memo($this->cacheStoreName); + } + + /** + * Set the current permissions team id. + */ + public function setPermissionsTeamId(int|string|Model|null $id): void + { + $this->teamResolver->setPermissionsTeamId($id); + } + + /** + * Get the current permissions team id. + */ + public function getPermissionsTeamId(): int|string|null + { + return $this->teamResolver->getPermissionsTeamId(); + } + + /** + * Register the permission check method on the gate. + */ + public function registerPermissions(Gate $gate): bool + { + $gate->before(function (Authorizable $user, string $ability, array &$arguments = []) { + $guard = null; + + if (is_string($arguments[0] ?? null) && ! class_exists($arguments[0])) { + $guard = array_shift($arguments); + } + + if (method_exists($user, 'checkPermissionTo')) { + return $user->checkPermissionTo($ability, $guard) ?: null; + } + + return null; + }); + + return true; + } + + /** + * Flush the permission cache. + */ + public function forgetCachedPermissions(): bool + { + $this->clearPermissionsCollection(); + $this->bumpModelAssignmentCacheVersion(); + + return $this->cacheRepository()->forget($this->cacheKey); + } + + /** + * Forget a model's direct role and permission assignment caches. + */ + public function forgetModelAssignmentCache(Model $model): void + { + $cache = $this->cacheRepository(); + + $cache->forget($this->modelCacheKey($this->modelRolesCacheKeyPrefix, $model)); + $cache->forget($this->modelCacheKey($this->modelPermissionsCacheKeyPrefix, $model)); + } + + /** + * Forget a model's cached role assignments. + */ + public function forgetModelRoleCache(Model $model): void + { + $this->cacheRepository()->forget( + $this->modelCacheKey($this->modelRolesCacheKeyPrefix, $model) + ); + } + + /** + * Forget a model's cached permission assignments. + */ + public function forgetModelPermissionCache(Model $model): void + { + $this->cacheRepository()->forget( + $this->modelCacheKey($this->modelPermissionsCacheKeyPrefix, $model) + ); + } + + /** + * Remember a model's role assignment ids. + * + * @param Closure(): array> $callback + * @return array> + */ + public function rememberModelRoleAssignments(Model $model, Closure $callback): array + { + return $this->cacheRepository()->remember( + $this->modelCacheKey($this->modelRolesCacheKeyPrefix, $model), + $this->cacheExpirationTime, + $callback, + ); + } + + /** + * Remember a model's permission assignment ids. + * + * @param Closure(): array> $callback + * @return array> + */ + public function rememberModelPermissionAssignments(Model $model, Closure $callback): array + { + return $this->cacheRepository()->remember( + $this->modelCacheKey($this->modelPermissionsCacheKeyPrefix, $model), + $this->cacheExpirationTime, + $callback, + ); + } + + /** + * Build the cache key for model assignment caches. + */ + protected function modelCacheKey(string $prefix, Model $model): string + { + $teamId = $this->teams ? (string) ($this->getPermissionsTeamId() ?? 'global') : 'none'; + + return implode(':', [ + $prefix, + $this->modelAssignmentCacheVersion(), + $model->getMorphClass(), + $model->getKey(), + $teamId, + ]); + } + + /** + * Get the current model assignment cache version. + */ + public function modelAssignmentCacheVersion(): int + { + return $this->cacheRepository()->rememberForever($this->modelCacheVersionKey, fn () => 1); + } + + /** + * Bump the model assignment cache version. + */ + public function bumpModelAssignmentCacheVersion(): int + { + $cache = $this->cacheRepository(); + $cache->add($this->modelCacheVersionKey, 1); + + $version = $cache->increment($this->modelCacheVersionKey); + + if (is_int($version)) { + return $version; + } + + $version = ((int) $cache->get($this->modelCacheVersionKey, 1)) + 1; + $cache->forever($this->modelCacheVersionKey, $version); + + return $version; + } + + /** + * Forget the cached wildcard permission index. + */ + public function forgetWildcardPermissionIndex(?Model $record = null): void + { + $indexes = CoroutineContext::get(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, []); + + if ($record) { + unset($indexes[$this->wildcardPermissionIndexKey($record)]); + CoroutineContext::set(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $indexes); + + return; + } + + CoroutineContext::forget(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY); + } + + /** + * Get the wildcard permission index for a model. + * + * @return array> + */ + public function getWildcardPermissionIndex(Model $record): array + { + $key = $this->wildcardPermissionIndexKey($record); + $indexes = CoroutineContext::get(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, []); + + if (isset($indexes[$key])) { + return $indexes[$key]; + } + + $getWildcardClass = Closure::fromCallable([$record, 'getWildcardClass']); + + /** @var array> $index */ + $index = $this->app->make($getWildcardClass(), ['record' => $record])->getIndex(); + + $indexes[$key] = $index; + CoroutineContext::set(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY, $indexes); + + return $index; + } + + /** + * Build the coroutine-local wildcard permission index key. + */ + protected function wildcardPermissionIndexKey(Model $record): string + { + $teamId = $this->teams ? (string) ($this->getPermissionsTeamId() ?? 'global') : 'none'; + + return implode(':', [ + $this->modelAssignmentCacheVersion(), + $record->getMorphClass(), + $record->getKey(), + $teamId, + ]); + } + + /** + * Clear already-loaded permissions collection. + */ + public function clearPermissionsCollection(): void + { + CoroutineContext::forget(self::PERMISSION_CATALOG_CONTEXT_KEY); + CoroutineContext::forget(self::WILDCARD_PERMISSION_INDEX_CONTEXT_KEY); + } + + /** + * Get the hydrated permission catalog. + * + * @return array{permissions: Collection, roles: Collection} + */ + private function permissionCatalog(): array + { + $catalog = CoroutineContext::get(self::PERMISSION_CATALOG_CONTEXT_KEY); + + if (is_array($catalog)) { + return $catalog; + } + + /** @var array{permissions: array>, roles: array>} $payload */ + $payload = $this->cacheRepository()->remember( + $this->cacheKey, + $this->cacheExpirationTime, + fn () => $this->getSerializedPermissionsForCache(), + ); + + $roles = $this->getHydratedRoleCollection($payload['roles']); + + $catalog = [ + 'roles' => $roles, + 'permissions' => $this->getHydratedPermissionCollection($payload['permissions'], $roles), + ]; + + CoroutineContext::set(self::PERMISSION_CATALOG_CONTEXT_KEY, $catalog); + + return $catalog; + } + + /** + * Get the permissions based on the passed params. + * + * @param array $params + */ + public function getPermissions(array $params = [], bool $onlyOne = false, ?string $permissionClass = null): Collection + { + if ($permissionClass !== null && $permissionClass !== $this->permissionClass) { + return $this->filterModels( + $this->getPermissionsWithRoles($permissionClass), + $params, + $onlyOne, + ); + } + + $permissions = $this->permissionCatalog()['permissions']; + + return $this->filterModels($permissions, $params, $onlyOne); + } + + /** + * Get the roles based on the passed params. + * + * @param array $params + */ + public function getRoles(array $params = [], bool $onlyOne = false, ?string $roleClass = null): Collection + { + if ($roleClass !== null && $roleClass !== $this->roleClass) { + return $this->filterModels( + $roleClass::select()->get(), + $params, + $onlyOne, + ); + } + + $roles = $this->permissionCatalog()['roles']; + + return $this->filterModels($roles, $params, $onlyOne); + } + + /** + * Filter a model collection by attributes. + * + * @param array $params + */ + protected function filterModels(Collection $models, array $params, bool $onlyOne): Collection + { + $method = $onlyOne ? 'first' : 'filter'; + + $result = $models->{$method}(static function (Model $model) use ($params): bool { + return array_all($params, fn ($value, $attribute) => self::attributeMatches( + $model->getAttribute($attribute), + $value, + )); + }); + + if ($onlyOne) { + return new Collection($result ? [$result] : []); + } + + return $result; + } + + /** + * Determine if an attribute matches a requested value. + */ + protected static function attributeMatches(mixed $actual, mixed $expected): bool + { + if (is_array($expected)) { + return array_any($expected, fn ($value): bool => self::attributeMatches($actual, $value)); + } + + if ($actual === $expected) { + return true; + } + + return (is_int($actual) || is_string($actual)) + && (is_int($expected) || is_string($expected)) + && (string) $actual === (string) $expected; + } + + /** + * Get the permission model class. + * + * @return class-string + */ + public function getPermissionClass(): string + { + return $this->permissionClass; + } + + /** + * Set the permission model class. + * + * Boot or tests only. The model class is stored on the singleton registrar + * and affects every later permission lookup in this worker. + * + * @param class-string $permissionClass + */ + public function setPermissionClass(string $permissionClass): static + { + $this->permissionClass = $permissionClass; + $this->app->bind(PermissionContract::class, $permissionClass); + $this->forgetCachedPermissions(); + + return $this; + } + + /** + * Get the role model class. + * + * @return class-string + */ + public function getRoleClass(): string + { + return $this->roleClass; + } + + /** + * Set the role model class. + * + * Boot or tests only. The model class is stored on the singleton registrar + * and affects every later role lookup in this worker. + * + * @param class-string $roleClass + */ + public function setRoleClass(string $roleClass): static + { + $this->roleClass = $roleClass; + $this->app->bind(RoleContract::class, $roleClass); + $this->forgetCachedPermissions(); + + return $this; + } + + /** + * Get the team model class. + * + * @return null|class-string + */ + public function getTeamClass(): ?string + { + return $this->teamClass; + } + + /** + * Set the team model class. + * + * Boot or tests only. The model class is stored on the singleton registrar + * and affects every later team permission lookup in this worker. + * + * @param null|class-string $teamClass + */ + public function setTeamClass(?string $teamClass): static + { + $this->teamClass = $teamClass; + $this->forgetCachedPermissions(); + + return $this; + } + + /** + * Get the cache repository. + */ + public function getCacheRepository(): Repository + { + return $this->configuredCacheRepository(); + } + + /** + * Get the cache store. + */ + public function getCacheStore(): Store + { + return $this->configuredCacheRepository()->getStore(); + } + + /** + * Get permissions with their roles. + */ + protected function getPermissionsWithRoles(?string $permissionClass = null): Collection + { + $permissionClass ??= $this->permissionClass; + + return $permissionClass::select()->with('roles')->get(); + } + + /** + * Get roles for cache. + */ + protected function getRolesForCache(): Collection + { + return $this->roleClass::select()->get(); + } + + /** + * Serialize permissions for cache. + * + * @return array{permissions: array>, roles: array>} + */ + private function getSerializedPermissionsForCache(): array + { + $except = $this->config->array('permission.cache.column_names_except', ['created_at', 'updated_at', 'deleted_at']); + + return [ + 'permissions' => $this->getPermissionsWithRoles() + ->map(fn (Model $permission): array => [ + 'attributes' => Arr::except($permission->getAttributes(), $except), + 'roles' => $this->relationCollection($permission, 'roles') + ->map(fn (Model $role): array => [ + 'pivot' => [ + $this->pivotPermission => $permission->getKey(), + $this->pivotRole => $role->getKey(), + 'is_forbidden' => $this->pivotIsForbidden($role), + ], + ]) + ->values() + ->all(), + ]) + ->values() + ->all(), + 'roles' => $this->getRolesForCache() + ->map(fn (Model $role): array => [ + 'attributes' => Arr::except($role->getAttributes(), $except), + ]) + ->values() + ->all(), + ]; + } + + /** + * Determine if a hydrated pivot marks the permission as forbidden. + */ + protected function pivotIsForbidden(Model $model): bool + { + $pivot = $model->getRelation('pivot'); + + return $pivot instanceof Pivot && (bool) $pivot->getAttribute('is_forbidden'); + } + + /** + * Get a hydrated relation collection. + */ + protected function relationCollection(Model $model, string $relation): Collection + { + $value = $model->getRelation($relation); + + return $value instanceof Collection ? $value : new Collection; + } + + /** + * Get the hydrated permission collection. + * + * @param array> $permissions + */ + private function getHydratedPermissionCollection(array $permissions, Collection $roles): Collection + { + $permissionInstance = (new ($this->getPermissionClass())())->newInstance([], true); + + return Collection::make(array_map( + fn (array $item) => (clone $permissionInstance) + ->setRawAttributes((array) $item['attributes'], true) + ->setRelation('roles', $this->getHydratedPermissionRoleCollection((array) $item['roles'], $permissionInstance, $roles)), + $permissions, + )); + } + + /** + * Get the hydrated role collection. + * + * @param array> $roles + */ + private function getHydratedRoleCollection(array $roles): Collection + { + $roleInstance = (new ($this->getRoleClass())())->newInstance([], true); + + return Collection::make(array_map( + fn (array $item): Model => (clone $roleInstance)->setRawAttributes((array) $item['attributes'], true), + $roles, + )); + } + + /** + * Get the hydrated role collection for a cached permission. + * + * @param array> $roles + */ + private function getHydratedPermissionRoleCollection(array $roles, Model $permission, Collection $roleCatalog): Collection + { + return Collection::make(array_values(array_filter(array_map(function (array $item) use ($permission, $roleCatalog): ?Model { + $role = $roleCatalog->first(fn (Model $role): bool => self::attributeMatches( + $role->getKey(), + $item['pivot'][$this->pivotRole] ?? null, + )); + + if (! $role) { + return null; + } + + $role = clone $role; + $role->setRelation('pivot', Pivot::fromRawAttributes( + $permission, + (array) $item['pivot'], + $this->config->string('permission.table_names.role_has_permissions'), + true, + )); + + return $role; + }, $roles)))); + } + + /** + * Determine if a value is a UUID or ULID. + */ + public static function isUid(mixed $value): bool + { + if (! is_string($value) || trim($value) === '') { + return false; + } + + $uid = preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0; + + if ($uid) { + return true; + } + + return strlen($value) === 26 + && strspn($value, '0123456789ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz') === 26 + && $value[0] <= '7'; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + $app = BaseContainer::getInstance(); + + if ($app->bound(self::class)) { + $app->forgetInstance(self::class); + } + } +} diff --git a/src/permission/src/PermissionServiceProvider.php b/src/permission/src/PermissionServiceProvider.php index 522bd86b9..bddd62315 100644 --- a/src/permission/src/PermissionServiceProvider.php +++ b/src/permission/src/PermissionServiceProvider.php @@ -4,49 +4,222 @@ namespace Hypervel\Permission; -use Hypervel\Permission\Console\ShowCommand; -use Hypervel\Permission\Contracts\Factory; +use Composer\InstalledVersions; +use Hypervel\Cache\CacheManager; +use Hypervel\Contracts\Auth\Access\Gate as GateContract; +use Hypervel\Foundation\Console\AboutCommand; +use Hypervel\Permission\Commands\AssignRoleCommand; +use Hypervel\Permission\Commands\CacheResetCommand; +use Hypervel\Permission\Commands\CreatePermissionCommand; +use Hypervel\Permission\Commands\CreateRoleCommand; +use Hypervel\Permission\Commands\ShowCommand; +use Hypervel\Permission\Commands\UpgradeForTeamsCommand; +use Hypervel\Permission\Contracts\Permission as PermissionContract; +use Hypervel\Permission\Contracts\Role as RoleContract; +use Hypervel\Permission\Middleware\PermissionMiddleware; +use Hypervel\Permission\Middleware\RoleMiddleware; +use Hypervel\Permission\Middleware\RoleOrPermissionMiddleware; +use Hypervel\Routing\Route; +use Hypervel\Support\Arr; +use Hypervel\Support\Collection; use Hypervel\Support\ServiceProvider; +use Hypervel\View\Compilers\BladeCompiler; + +use function Hypervel\Support\enum_value; class PermissionServiceProvider extends ServiceProvider { /** - * Bootstrap the service provider. + * Register any package services. + */ + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../config/permission.php', 'permission'); + + $this->app->singleton(PermissionRegistrar::class, fn ($app) => new PermissionRegistrar( + $app->make(CacheManager::class), + $app->make('config'), + $app, + )); + + $this->registerModelBindings(); + + $this->commands([ + AssignRoleCommand::class, + CacheResetCommand::class, + CreatePermissionCommand::class, + CreateRoleCommand::class, + ShowCommand::class, + UpgradeForTeamsCommand::class, + ]); + } + + /** + * Bootstrap any package services. */ public function boot(): void { - if ($this->app->runningInConsole()) { - $this->registerPublishing(); + $this->registerPublishing(); + $this->registerMacroHelpers(); + $this->registerMiddleware(); + $this->registerBladeExtensions(); + $this->registerGateHook(); + $this->registerAbout(); - $this->commands([ - ShowCommand::class, - ]); - } + // Laravel Octane reset listeners are not ported. Hypervel stores transient team + // state in CoroutineContext and keeps permission cache freshness in the cache layer. } /** - * Register the service provider. + * Wrap a Blade authorization check. */ - public function register(): void + public static function bladeMethodWrapper(string $method, mixed $role, ?string $guard = null): bool { - $this->mergeConfigFrom( - __DIR__ . '/../config/permission.php', - 'permission' - ); + $authGuard = auth($guard); - $this->app->singleton(Factory::class, PermissionManager::class); + return $authGuard->check() && $authGuard->user()->{$method}($role); } - public function registerPublishing(): void + /** + * Register package publishing. + */ + protected function registerPublishing(): void { + if (! $this->app->runningInConsole()) { + return; + } + $this->publishes([ __DIR__ . '/../config/permission.php' => config_path('permission.php'), ], 'permission-config'); - $this->publishes([ - __DIR__ . '/../database/migrations/2025_07_02_000000_create_permission_tables.php' => database_path( - 'migrations/2025_07_02_000000_create_permission_tables.php' - ), + $this->publishesMigrations([ + __DIR__ . '/../database/migrations/2025_07_02_000000_create_permission_tables.php' => database_path('migrations/2025_07_02_000000_create_permission_tables.php'), ], 'permission-migrations'); } + + /** + * Register Blade directives. + */ + protected function registerBladeExtensions(): void + { + $this->callAfterResolving('blade.compiler', function (BladeCompiler $bladeCompiler): void { + $bladeMethodWrapper = '\\' . static::class . '::bladeMethodWrapper'; + + // permission checks + $bladeCompiler->if('haspermission', fn () => $bladeMethodWrapper('checkPermissionTo', ...func_get_args())); + + // role checks + $bladeCompiler->if('role', fn () => $bladeMethodWrapper('hasRole', ...func_get_args())); + $bladeCompiler->if('hasrole', fn () => $bladeMethodWrapper('hasRole', ...func_get_args())); + $bladeCompiler->if('hasanyrole', fn () => $bladeMethodWrapper('hasAnyRole', ...func_get_args())); + $bladeCompiler->if('hasallroles', fn () => $bladeMethodWrapper('hasAllRoles', ...func_get_args())); + $bladeCompiler->if('hasexactroles', fn () => $bladeMethodWrapper('hasExactRoles', ...func_get_args())); + $bladeCompiler->directive('endunlessrole', fn () => ''); + }); + } + + /** + * Register model contract bindings. + */ + protected function registerModelBindings(): void + { + $this->app->bind(PermissionContract::class, fn ($app) => $app->make( + $app->make('config')->string('permission.models.permission') + )); + + $this->app->bind(RoleContract::class, fn ($app) => $app->make( + $app->make('config')->string('permission.models.role') + )); + } + + /** + * Register route macros. + */ + protected function registerMacroHelpers(): void + { + Route::macro('role', function ($roles = []) { + $roles = Arr::wrap($roles); + $roles = array_map(fn ($role) => enum_value($role), $roles); + + /** @var Route $this */ + return $this->middleware('role:' . implode('|', $roles)); + }); + + Route::macro('permission', function ($permissions = []) { + $permissions = Arr::wrap($permissions); + $permissions = array_map(fn ($permission) => enum_value($permission), $permissions); + + /** @var Route $this */ + return $this->middleware('permission:' . implode('|', $permissions)); + }); + + Route::macro('roleOrPermission', function ($rolesOrPermissions = []) { + $rolesOrPermissions = Arr::wrap($rolesOrPermissions); + $rolesOrPermissions = array_map(fn ($item) => enum_value($item), $rolesOrPermissions); + + /** @var Route $this */ + return $this->middleware('role_or_permission:' . implode('|', $rolesOrPermissions)); + }); + } + + /** + * Register middleware aliases. + */ + protected function registerMiddleware(): void + { + $router = $this->app->make('router'); + + $router->aliasMiddleware('role', RoleMiddleware::class); + $router->aliasMiddleware('permission', PermissionMiddleware::class); + $router->aliasMiddleware('role_or_permission', RoleOrPermissionMiddleware::class); + } + + /** + * Register the Gate permission hook. + */ + protected function registerGateHook(): void + { + $this->callAfterResolving(GateContract::class, function (GateContract $gate): void { + $config = $this->app->make('config'); + + if (! $config->boolean('permission.register_permission_check_method', true)) { + return; + } + + $permissionRegistrar = $this->app->make(PermissionRegistrar::class); + $permissionRegistrar->clearPermissionsCollection(); + $permissionRegistrar->registerPermissions($gate); + }); + } + + /** + * Register package information for the about command. + */ + protected function registerAbout(): void + { + if (! class_exists(InstalledVersions::class) || ! class_exists(AboutCommand::class)) { + return; + } + + $features = [ + 'Teams' => 'teams', + 'Wildcard Permissions' => 'enable_wildcard_permission', + 'Passport Client Credentials' => 'use_passport_client_credentials', + 'Forbidden Permissions' => null, + ]; + + $config = $this->app->make('config'); + + AboutCommand::add('Hypervel Permissions', static fn () => [ + 'Features Enabled' => Collection::make($features) + ->filter(fn (?string $feature): bool => $feature === null || $config->boolean("permission.{$feature}", false)) + ->keys() + ->whenEmpty(fn (Collection $collection) => $collection->push('Default')) + ->join(', '), + 'Version' => InstalledVersions::isInstalled('hypervel/permission') + ? InstalledVersions::getPrettyVersion('hypervel/permission') + : null, + ]); + } } diff --git a/src/permission/src/Support/Config.php b/src/permission/src/Support/Config.php new file mode 100644 index 000000000..330641e5b --- /dev/null +++ b/src/permission/src/Support/Config.php @@ -0,0 +1,212 @@ +make('config'); + } + + /** + * Get the permission registrar. + */ + protected static function registrar(): PermissionRegistrar + { + return Container::getInstance()->make(PermissionRegistrar::class); + } + + /** + * Determine if teams are enabled. + */ + public static function teamsEnabled(): bool + { + return self::registrar()->teams; + } + + /** + * Ensure teams are enabled. + */ + public static function ensureTeamsEnabled(): void + { + if (! self::teamsEnabled()) { + throw TeamsNotEnabled::create(); + } + } + + /** + * @return class-string + */ + public static function teamModel(): string + { + self::ensureTeamsEnabled(); + + $teamModel = self::registrar()->getTeamClass(); + + if (! $teamModel) { + throw TeamModelNotConfigured::create(); + } + + return $teamModel; + } + + /** + * Get the model_has_roles table name. + */ + public static function modelHasRolesTable(): string + { + return self::repository()->string('permission.table_names.model_has_roles'); + } + + /** + * Get the model_has_permissions table name. + */ + public static function modelHasPermissionsTable(): string + { + return self::repository()->string('permission.table_names.model_has_permissions'); + } + + /** + * Get the role_has_permissions table name. + */ + public static function roleHasPermissionsTable(): string + { + return self::repository()->string('permission.table_names.role_has_permissions'); + } + + /** + * Get the roles table name. + */ + public static function rolesTable(): string + { + return self::repository()->string('permission.table_names.roles'); + } + + /** + * Get the permissions table name. + */ + public static function permissionsTable(): string + { + return self::repository()->string('permission.table_names.permissions'); + } + + /** + * Get the configured storage connection. + */ + public static function storageConnection(): ?string + { + return self::repository()->get('permission.storage.database.connection'); + } + + /** + * Get the model morph key column. + */ + public static function morphKey(): string + { + return self::repository()->string('permission.column_names.model_morph_key'); + } + + /** + * Get the team foreign key column. + */ + public static function teamForeignKey(): string + { + return self::registrar()->teamsKey; + } + + /** + * Get the default model class for raw permission assignments. + * + * @return null|class-string + */ + public static function defaultModel(): ?string + { + return self::repository()->get('permission.models.default_model'); + } + + /** + * Get the default auth guard name. + */ + public static function defaultGuard(): string + { + return self::repository()->string('auth.defaults.guard'); + } + + /** + * @return class-string + */ + public static function roleModel(): string + { + return self::registrar()->getRoleClass(); + } + + /** + * @return class-string + */ + public static function permissionModel(): string + { + return self::registrar()->getPermissionClass(); + } + + /** + * Determine if permission events are enabled. + */ + public static function eventsEnabled(): bool + { + return self::repository()->boolean('permission.events_enabled'); + } + + /** + * Determine if Passport client credentials should be checked. + */ + public static function usePassportClientCredentials(): bool + { + return self::repository()->boolean('permission.use_passport_client_credentials'); + } + + /** + * Determine if role names should be shown in exceptions. + */ + public static function displayRoleInException(): bool + { + return self::repository()->boolean('permission.display_role_in_exception'); + } + + /** + * Determine if permission names should be shown in exceptions. + */ + public static function displayPermissionInException(): bool + { + return self::repository()->boolean('permission.display_permission_in_exception'); + } + + /** + * Determine if wildcard permissions are enabled. + */ + public static function wildcardPermissionsEnabled(): bool + { + return self::repository()->boolean('permission.enable_wildcard_permission'); + } + + /** + * @return class-string + */ + public static function wildcardPermissionClass(): string + { + return self::repository()->string('permission.wildcard_permission'); + } +} diff --git a/src/permission/src/Traits/HasAssignedModels.php b/src/permission/src/Traits/HasAssignedModels.php new file mode 100644 index 000000000..e98441bc1 --- /dev/null +++ b/src/permission/src/Traits/HasAssignedModels.php @@ -0,0 +1,161 @@ +|Collection|int|Model|string $models + * @return $this + */ + public function assignToModels(array|Collection|Model|int|string $models, ?string $modelClass = null): static + { + if (! $this->exists) { + return $this; + } + + $registrar = app(PermissionRegistrar::class); + $teamPivot = $this->teamPivot(); + + foreach ($this->groupModelsByMorphClass($models, $modelClass) as $morphClass => $ids) { + $relation = $this->relationForModel($morphClass); + $existingIds = $relation->pluck(Config::morphKey())->all(); + + $relation->attach(array_diff($ids, $existingIds), $teamPivot); + } + + $this->unsetRelation('users'); + $registrar->bumpModelAssignmentCacheVersion(); + + return $this; + } + + /** + * Remove this role from the given models. + * + * @param array|Collection|int|Model|string $models + * @return $this + */ + public function removeFromModels(array|Collection|Model|int|string $models, ?string $modelClass = null): static + { + $registrar = app(PermissionRegistrar::class); + + foreach ($this->groupModelsByMorphClass($models, $modelClass) as $morphClass => $ids) { + $this->relationForModel($morphClass)->detach($ids); + } + + $this->unsetRelation('users'); + $registrar->bumpModelAssignmentCacheVersion(); + + return $this; + } + + /** + * Remove all current model associations and set the given ones. + * + * @param array|Collection|int|Model|string $models + * @return $this + */ + public function syncModels(array|Collection|Model|int|string $models, ?string $modelClass = null): static + { + $registrar = app(PermissionRegistrar::class); + + if ($this->exists) { + $this->newPivotQueryForRole()->delete(); + } + + $teamPivot = $this->teamPivot(); + + foreach ($this->groupModelsByMorphClass($models, $modelClass) as $morphClass => $ids) { + $this->relationForModel($morphClass)->attach($ids, $teamPivot); + } + + $this->unsetRelation('users'); + $registrar->bumpModelAssignmentCacheVersion(); + + return $this; + } + + /** + * Build a morphedByMany relation pointing to a specific model class. + */ + protected function relationForModel(string $modelClass): MorphToMany + { + return $this->morphedByMany( + $modelClass, + 'model', + Config::modelHasRolesTable(), + app(PermissionRegistrar::class)->pivotRole, + Config::morphKey(), + ); + } + + /** + * Group the given models by class, deduplicating IDs within each class. + * + * @param array|Collection|int|Model|string $models + * @return array, list> + */ + private function groupModelsByMorphClass( + array|Collection|Model|int|string $models, + ?string $modelClass, + ): array { + $defaultModelClass = $this->resolveDefaultModelClass($modelClass); + + return collect(Arr::flatten(Arr::wrap($models))) + ->reject(fn ($value) => $value === null || $value === '') + ->reduce(function (array $grouped, $value) use ($defaultModelClass) { + $class = $value instanceof Model ? $value::class : $defaultModelClass; + $id = $value instanceof Model ? $value->getKey() : $value; + + if (! in_array($id, $grouped[$class] ?? [], strict: true)) { + $grouped[$class][] = $id; + } + + return $grouped; + }, []); + } + + /** + * Resolve the model class to use when raw ids are passed. + * + * @return class-string + */ + private function resolveDefaultModelClass(?string $modelClass): string + { + return $modelClass + ?? Config::defaultModel() + ?? getModelForGuard($this->attributes['guard_name'] ?? Config::defaultGuard()); + } + + /** + * @return array + */ + private function teamPivot(): array + { + if (! Config::teamsEnabled()) { + return []; + } + + return [Config::teamForeignKey() => getPermissionsTeamId()]; + } + + private function newPivotQueryForRole(): Builder + { + return $this->getConnection() + ->table(Config::modelHasRolesTable()) + ->where(app(PermissionRegistrar::class)->pivotRole, $this->getKey()); + } +} diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php new file mode 100644 index 000000000..2f9cca6f0 --- /dev/null +++ b/src/permission/src/Traits/HasPermissions.php @@ -0,0 +1,964 @@ +, pivot: array}> + */ + private array $queuedPermissionAssignments = []; + + /** + * Boot the permission relation cleanup callback. + */ + public static function bootHasPermissions(): void + { + static::deleting(function (Model $model): void { + if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) { + return; + } + + if (! $model instanceof Permission && ! $model instanceof Role) { + $model->getConnection() + ->table(Config::modelHasPermissionsTable()) + ->where(Config::morphKey(), $model->getKey()) + ->where('model_type', $model->getMorphClass()) + ->delete(); + } + + if ($model instanceof Role) { + $model->getConnection() + ->table(Config::modelHasRolesTable()) + ->where(app(PermissionRegistrar::class)->pivotRole, $model->getKey()) + ->delete(); + + $model->getConnection() + ->table(Config::roleHasPermissionsTable()) + ->where(app(PermissionRegistrar::class)->pivotRole, $model->getKey()) + ->delete(); + } + + app(PermissionRegistrar::class)->bumpModelAssignmentCacheVersion(); + }); + + static::saved(function (Model $model): void { + if (method_exists($model, 'attachQueuedPermissionAssignments')) { + $model->attachQueuedPermissionAssignments(); + } + }); + } + + /** + * Get the permission model class. + */ + public function getPermissionClass(): string + { + if (! $this->permissionClass) { + $this->permissionClass = app(PermissionRegistrar::class)->getPermissionClass(); + } + + return $this->permissionClass; + } + + /** + * Get the wildcard permission class. + */ + public function getWildcardClass(): string + { + if (! is_null($this->wildcardClass)) { + return $this->wildcardClass; + } + + $this->wildcardClass = ''; + + if (Config::wildcardPermissionsEnabled()) { + $this->wildcardClass = Config::wildcardPermissionClass(); + + if (! is_subclass_of($this->wildcardClass, Wildcard::class)) { + throw WildcardPermissionNotImplementsContract::create(); + } + } + + return $this->wildcardClass; + } + + /** + * A model may have multiple direct permissions. + */ + public function permissions(): BelongsToMany + { + $relation = $this->morphToMany( + Config::permissionModel(), + 'model', + Config::modelHasPermissionsTable(), + Config::morphKey(), + app(PermissionRegistrar::class)->pivotPermission + )->withPivot('is_forbidden'); + + if (! Config::teamsEnabled()) { + return $relation; + } + + $teamsKey = Config::teamForeignKey(); + $relation->withPivot($teamsKey, 'is_forbidden'); + + return $relation->wherePivot($teamsKey, getPermissionsTeamId()); + } + + /** + * Get cached direct permission assignments for this model. + */ + protected function getCachedDirectPermissions(): Collection + { + $model = $this; + + if ($this instanceof Role || $this instanceof Permission || ! $model->exists || $this->relationLoaded('permissions')) { + $this->loadMissing('permissions'); + + return $this->relationCollection($this, 'permissions'); + } + + $registrar = app(PermissionRegistrar::class); + $permissionKey = (new ($this->getPermissionClass())())->getKeyName(); + $assignments = $registrar->rememberModelPermissionAssignments( + $model, + fn (): array => $this->permissions() + ->get() + ->map(fn (Model $permission): array => [ + $permissionKey => $permission->getKey(), + 'is_forbidden' => $this->pivotIsForbidden($permission), + ]) + ->values() + ->all(), + ); + + $permissions = $registrar->getPermissions( + [$permissionKey => array_column($assignments, $permissionKey)], + false, + $this->getPermissionClass(), + )->keyBy(fn (Model $permission): string => (string) $permission->getKey()); + + return Collection::make($assignments) + ->map(function (array $assignment) use ($permissions, $model, $permissionKey, $registrar): ?Model { + $permission = $permissions->get((string) $assignment[$permissionKey]); + + if (! $permission instanceof Model) { + return null; + } + + $pivot = [ + $registrar->pivotPermission => $permission->getKey(), + Config::morphKey() => $model->getKey(), + 'model_type' => $model->getMorphClass(), + 'is_forbidden' => (bool) ($assignment['is_forbidden'] ?? false), + ]; + + if ($registrar->teams) { + $pivot[$registrar->teamsKey] = getPermissionsTeamId(); + } + + $permission = clone $permission; + $permission->setRelation('pivot', Pivot::fromRawAttributes( + $model, + $pivot, + Config::modelHasPermissionsTable(), + true, + )); + + return $permission; + }) + ->filter() + ->values(); + } + + /** + * Scope the model query to certain permissions only. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ + public function scopePermission(Builder $query, $permissions, bool $without = false): Builder + { + $permissions = $this->convertToPermissionModels($permissions); + + $permissionKey = (new ($this->getPermissionClass())())->getKeyName(); + $roleKey = (new ($this instanceof Role ? static::class : $this->getRoleClass())())->getKeyName(); + + $rolesWithPermissions = $this instanceof Role ? [] : array_unique( + array_reduce($permissions, fn ($result, $permission) => array_merge($result, $this->relationCollection($permission, 'roles')->all()), []) + ); + + return $query->where( + fn (Builder $query) => $query + ->{! $without ? 'whereHas' : 'whereDoesntHave'}( + 'permissions', + fn (Builder $subQuery) => $subQuery + ->whereIn(Config::permissionsTable() . ".{$permissionKey}", array_column($permissions, $permissionKey)) + ) + ->when( + count($rolesWithPermissions), + fn ($whenQuery) => $whenQuery + ->{! $without ? 'orWhereHas' : 'whereDoesntHave'}( + 'roles', + fn (Builder $subQuery) => $subQuery + ->whereIn(Config::rolesTable() . ".{$roleKey}", array_column($rolesWithPermissions, $roleKey)) + ) + ) + ); + } + + /** + * Scope the model query to only those without certain permissions, + * whether indirectly by role or by direct permission. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ + public function scopeWithoutPermission(Builder $query, $permissions): Builder + { + return $this->scopePermission($query, $permissions, true); + } + + /** + * @param array|Collection|int|Permission|string|UnitEnum $permissions + * + * @throws PermissionDoesNotExist + */ + protected function convertToPermissionModels($permissions): array + { + if ($permissions instanceof Collection) { + $permissions = $permissions->all(); + } + + return array_map(function ($permission) { + if ($permission instanceof Permission) { + return $permission; + } + + $permission = enum_value($permission); + + $method = is_int($permission) || PermissionRegistrar::isUid($permission) ? 'findById' : 'findByName'; + + return $this->getPermissionClass()::{$method}($permission, $this->getDefaultGuardName()); + }, Arr::wrap($permissions)); + } + + /** + * Find a permission. + * + * @param int|Permission|string|UnitEnum $permission + * + * @throws PermissionDoesNotExist + */ + public function filterPermission($permission, ?string $guardName = null): Permission + { + $permission = enum_value($permission); + + if (is_int($permission) || PermissionRegistrar::isUid($permission)) { + $permission = $this->getPermissionClass()::findById( + $permission, + $guardName ?? $this->getDefaultGuardName() + ); + } + + if (is_string($permission)) { + $permission = $this->getPermissionClass()::findByName( + $permission, + $guardName ?? $this->getDefaultGuardName() + ); + } + + if (! $permission instanceof Permission) { + throw new PermissionDoesNotExist; + } + + return $permission; + } + + /** + * Determine if the model may perform the given permission. + * + * @param int|Permission|string|UnitEnum $permission + * + * @throws PermissionDoesNotExist + */ + public function hasPermissionTo($permission, ?string $guardName = null): bool + { + if ($this->hasForbiddenPermission($permission, $guardName)) { + return false; + } + + if ($this->hasForbiddenPermissionViaRoles($permission, $guardName)) { + return false; + } + + if ($this->getWildcardClass()) { + return $this->hasWildcardPermission($permission, $guardName); + } + + $permission = $this->filterPermission($permission, $guardName); + + return $this->hasDirectPermission($permission) || $this->hasPermissionViaRole($permission); + } + + /** + * Validates a wildcard permission against all permissions of a user. + * + * @param int|Permission|string|UnitEnum $permission + */ + protected function hasWildcardPermission($permission, ?string $guardName = null): bool + { + $guardName = $guardName ?? $this->getDefaultGuardName(); + + $permission = enum_value($permission); + + if (is_int($permission) || PermissionRegistrar::isUid($permission)) { + $permission = $this->getPermissionClass()::findById($permission, $guardName); + } + + if ($permission instanceof Permission) { + $guardName = $permission->guard_name ?? $guardName; + $permission = $permission->name; + } + + if (! is_string($permission)) { + throw WildcardPermissionInvalidArgument::create(); + } + + return app($this->getWildcardClass(), ['record' => $this])->implies( + $permission, + $guardName, + app(PermissionRegistrar::class)->getWildcardPermissionIndex($this), + ); + } + + /** + * An alias to hasPermissionTo(), but avoids throwing an exception. + * + * @param int|Permission|string|UnitEnum $permission + */ + public function checkPermissionTo($permission, ?string $guardName = null): bool + { + try { + return $this->hasPermissionTo($permission, $guardName); + } catch (PermissionDoesNotExist $e) { + return false; + } + } + + /** + * Determine if the model has any of the given permissions. + * + * @param array|Collection|int|Permission|string|UnitEnum ...$permissions + */ + public function hasAnyPermission(...$permissions): bool + { + $permissions = collect($permissions)->flatten(); + + foreach ($permissions as $permission) { + if ($this->checkPermissionTo($permission)) { + return true; + } + } + + return false; + } + + /** + * Determine if the model has all of the given permissions. + * + * @param array|Collection|int|Permission|string|UnitEnum ...$permissions + */ + public function hasAllPermissions(...$permissions): bool + { + $permissions = collect($permissions)->flatten(); + + foreach ($permissions as $permission) { + if (! $this->checkPermissionTo($permission)) { + return false; + } + } + + return true; + } + + /** + * Determine if the model has, via roles, the given permission. + */ + protected function hasPermissionViaRole(Permission $permission): bool + { + if ($this instanceof Role) { + return false; + } + + if (! $permission instanceof Model) { + return false; + } + + return $this->hasRole( + $this->relationCollection($permission, 'roles') + ->reject(fn (Model $role): bool => $this->pivotIsForbidden($role)) + ); + } + + /** + * Determine if the model has the given permission. + * + * @param int|Permission|string|UnitEnum $permission + * + * @throws PermissionDoesNotExist + */ + public function hasDirectPermission($permission): bool + { + $permission = $this->filterPermission($permission); + + $matchedPermission = $this->getCachedDirectPermissions() + ->first(fn (Model $directPermission): bool => $directPermission->getKey() === $permission->getKey()); + + return $matchedPermission !== null + && ! $this->pivotIsForbidden($matchedPermission); + } + + /** + * Return all the permissions the model has via roles. + */ + public function getPermissionsViaRoles(): Collection + { + if ($this instanceof Role || $this instanceof Permission) { + return collect(); + } + + $roles = $this->getCachedRoles(); + $roleKey = (new ($this->getRoleClass())())->getKeyName(); + $roleIds = $roles->map(fn (Model $role): string => (string) $role->getKey())->all(); + + return app(PermissionRegistrar::class) + ->getPermissions([], false, $this->getPermissionClass()) + ->flatMap( + fn (Model $permission): Collection => $this->relationCollection($permission, 'roles') + ->filter(fn (Model $role): bool => in_array((string) $role->getAttribute($roleKey), $roleIds, true)) + ->map(fn (Model $role): Model => $this->permissionWithRolePivot($permission, $role)) + ) + ->reject(fn (Model $permission): bool => $this->pivotIsForbidden($permission)) + ->sort()->values(); + } + + /** + * Return all the permissions the model has, both directly and via roles. + */ + public function getAllPermissions(): Collection + { + /** @var Collection $permissions */ + $permissions = $this->getCachedDirectPermissions(); + + if (! $this instanceof Permission) { + $permissions = $permissions->merge($this->getPermissionsViaRoles()); + } + + return $permissions + ->reject( + fn (Model $permission): bool => $this->hasForbiddenPermission($permission->getKey(), $permission->getAttribute('guard_name')) + || $this->hasForbiddenPermissionViaRoles($permission->getKey(), $permission->getAttribute('guard_name')) + ) + ->unique(fn (Model $permission): string => (string) $permission->getKey()) + ->sort() + ->values(); + } + + /** + * Returns array of permissions ids. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ + private function collectPermissions(...$permissions): array + { + return collect($permissions) + ->flatten() + ->reduce(function ($array, $permission) { + if ($permission === null || $permission === '') { + return $array; + } + + $permission = $this->getStoredPermission($permission); + if (! $permission instanceof Permission) { + return $array; + } + + if (! in_array($permission->getKey(), $array, true)) { + $this->ensureModelSharesGuard($permission); + $array[] = $permission->getKey(); + } + + return $array; + }, []); + } + + /** + * Grant the given permission(s) to a role. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ + public function givePermissionTo(...$permissions): static + { + return $this->attachPermissions($permissions, false); + } + + /** + * Grant the given forbidden permission(s) to a role. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ + public function giveForbiddenTo(...$permissions): static + { + return $this->attachPermissions($permissions, true); + } + + /** + * Attach permissions with the given forbidden flag. + * + * @param array $permissions + */ + private function attachPermissions(array $permissions, bool $isForbidden): static + { + $permissions = $this->collectPermissions($permissions); + $model = $this; + $registrar = app(PermissionRegistrar::class); + $teamPivot = $registrar->teams && ! $this instanceof Role + ? [$registrar->teamsKey => getPermissionsTeamId()] : []; + $pivot = $teamPivot + ['is_forbidden' => $isForbidden]; + + if ($model->exists) { + $currentPermissions = $this->relationCollection($this->loadMissing('permissions'), 'permissions') + ->filter(fn (Model $permission): bool => $this->pivotIsForbidden($permission) === $isForbidden) + ->map(fn (Model $permission) => $permission->getKey()) + ->toArray(); + + $this->permissions()->attach(array_diff($permissions, $currentPermissions), $pivot); + $model->unsetRelation('permissions'); + } else { + $this->queuePermissionAssignments($permissions, $pivot); + } + + if ($this instanceof Role) { + $this->forgetCachedPermissions(); + } elseif ($model->exists) { + $registrar->forgetModelPermissionCache($model); + } + + $this->dispatchPermissionAttachedEvent($permissions); + + $this->forgetWildcardPermissionIndex(); + + return $this; + } + + /** + * Queue permission assignments until the model is saved. + * + * @param array $permissions + * @param array $pivot + */ + protected function queuePermissionAssignments(array $permissions, array $pivot): void + { + $this->queuedPermissionAssignments[] = [ + 'permissions' => $permissions, + 'pivot' => $pivot, + ]; + } + + /** + * Attach permission assignments queued before the model was saved. + */ + protected function attachQueuedPermissionAssignments(): void + { + if ($this->queuedPermissionAssignments === []) { + return; + } + + $registrar = app(PermissionRegistrar::class); + + foreach ($this->queuedPermissionAssignments as $assignment) { + $this->permissions()->attach($assignment['permissions'], $assignment['pivot']); + } + + $this->queuedPermissionAssignments = []; + $this->unsetRelation('permissions'); + + if ($this instanceof Role) { + $this->forgetCachedPermissions(); + } else { + $registrar->forgetModelPermissionCache($this); + } + + $this->forgetWildcardPermissionIndex(); + } + + /** + * Dispatch the permission attached event when enabled and listened for. + * + * @param array $permissions + */ + protected function dispatchPermissionAttachedEvent(array $permissions): void + { + if (! Config::eventsEnabled()) { + return; + } + + $events = app(Dispatcher::class); + + if ($events->hasListeners(PermissionAttachedEvent::class)) { + $events->dispatch(new PermissionAttachedEvent($this, $permissions)); + } + } + + public function forgetWildcardPermissionIndex(): void + { + app(PermissionRegistrar::class)->forgetWildcardPermissionIndex( + $this instanceof Role ? null : $this, + ); + } + + /** + * Remove all current permissions and set the given ones. + * + * @param array|Collection|int|Permission|string|UnitEnum $permissions + */ + public function syncPermissions(...$permissions): static + { + if ($this->exists) { + $this->collectPermissions($permissions); + $this->permissions()->detach(); + $this->setRelation('permissions', collect()); + } + + $this->givePermissionTo($permissions); + + return $this; + } + + /** + * Remove all current permissions and set allowed and forbidden permissions. + * + * @param array|Collection $allowed + * @param array|Collection $forbidden + * @return array{attached: array, detached: array, updated: array} + */ + public function syncPermissionsWithForbidden(array|Collection $allowed = [], array|Collection $forbidden = []): array + { + $model = $this; + + if (! $model->exists) { + return ['attached' => [], 'detached' => [], 'updated' => []]; + } + + $allowedIds = $this->collectPermissions($allowed); + $forbiddenIds = $this->collectPermissions($forbidden); + $allowedIds = array_values(array_filter( + $allowedIds, + fn (int|string $allowedId): bool => ! in_array($allowedId, $forbiddenIds, true), + )); + $registrar = app(PermissionRegistrar::class); + $teamPivot = $registrar->teams && ! $this instanceof Role + ? [$registrar->teamsKey => getPermissionsTeamId()] : []; + + $syncData = []; + + foreach ($allowedIds as $permissionId) { + $syncData[$permissionId] = $teamPivot + ['is_forbidden' => false]; + } + + foreach ($forbiddenIds as $permissionId) { + $syncData[$permissionId] = $teamPivot + ['is_forbidden' => true]; + } + + /** @var array{attached: array, detached: array, updated: array} $changes */ + $changes = $this->permissions()->sync($syncData); + + $this->unsetRelation('permissions'); + + if ($this instanceof Role) { + $this->forgetCachedPermissions(); + } else { + app(PermissionRegistrar::class)->forgetModelPermissionCache($model); + } + + $this->forgetWildcardPermissionIndex(); + + return $changes; + } + + /** + * Revoke the given permission(s). + * + * @param Permission|Permission[]|string|string[]|UnitEnum $permission + */ + public function revokePermissionTo($permission): static + { + $storedPermission = $this->getStoredPermission($permission); + + $this->permissions()->detach($storedPermission); + + if ($this instanceof Role) { + $this->forgetCachedPermissions(); + } else { + app(PermissionRegistrar::class)->forgetModelPermissionCache($this); + } + + $this->dispatchPermissionDetachedEvent($storedPermission); + + $this->forgetWildcardPermissionIndex(); + + $this->unsetRelation('permissions'); + + return $this; + } + + /** + * Dispatch the permission detached event when enabled and listened for. + */ + protected function dispatchPermissionDetachedEvent(mixed $permission): void + { + if (! Config::eventsEnabled()) { + return; + } + + $events = app(Dispatcher::class); + + if ($events->hasListeners(PermissionDetachedEvent::class)) { + $events->dispatch(new PermissionDetachedEvent($this, $permission)); + } + } + + /** + * Determine if the model has a forbidden direct permission. + * + * @param int|Permission|string|UnitEnum $permission + */ + public function hasForbiddenPermission($permission, ?string $guardName = null): bool + { + return $this->getCachedDirectPermissions() + ->contains( + fn (Model $storedPermission): bool => $this->pivotIsForbidden($storedPermission) + && $this->storedPermissionMatches($storedPermission, $permission, $guardName) + ); + } + + /** + * Determine if the model has a forbidden permission via roles. + * + * @param int|Permission|string|UnitEnum $permission + */ + public function hasForbiddenPermissionViaRoles($permission, ?string $guardName = null): bool + { + if ($this instanceof Role || $this instanceof Permission) { + return false; + } + + $roles = $this->getCachedRoles(); + $roleKey = (new ($this->getRoleClass())())->getKeyName(); + $roleIds = $roles->map(fn (Model $role): string => (string) $role->getKey())->all(); + + return app(PermissionRegistrar::class) + ->getPermissions([], false, $this->getPermissionClass()) + ->flatMap( + fn (Model $permission): Collection => $this->relationCollection($permission, 'roles') + ->filter(fn (Model $role): bool => in_array((string) $role->getAttribute($roleKey), $roleIds, true)) + ->map(fn (Model $role): Model => $this->permissionWithRolePivot($permission, $role)) + ) + ->contains( + fn (Model $storedPermission): bool => $this->pivotIsForbidden($storedPermission) + && $this->storedPermissionMatches($storedPermission, $permission, $guardName) + ); + } + + /** + * Clone a permission with the matching role-permission pivot. + */ + protected function permissionWithRolePivot(Model $permission, Model $role): Model + { + $permission = clone $permission; + $permission->setRelation('pivot', $role->getRelation('pivot')); + + return $permission; + } + + /** + * Determine if a hydrated pivot marks the permission as forbidden. + */ + protected function pivotIsForbidden(Model $model): bool + { + if (! $model->relationLoaded('pivot')) { + return false; + } + + $pivot = $model->getRelation('pivot'); + + return $pivot instanceof Pivot && (bool) $pivot->getAttribute('is_forbidden'); + } + + /** + * Get a hydrated relation collection. + */ + protected function relationCollection(Model $model, string $relation): Collection + { + if (! $model->relationLoaded($relation)) { + $model->loadMissing($relation); + } + + $value = $model->getRelation($relation); + + return $value instanceof Collection ? $value : new Collection; + } + + /** + * Determine if a stored permission matches an input permission. + */ + protected function storedPermissionMatches(Model $storedPermission, mixed $permission, ?string $guardName = null): bool + { + if ($guardName !== null && $storedPermission->getAttribute('guard_name') !== $guardName) { + return false; + } + + if ($permission instanceof Permission) { + return $storedPermission->getKey() === $permission->getKey(); + } + + $permission = enum_value($permission); + + if (is_int($permission) || PermissionRegistrar::isUid($permission)) { + return (string) $storedPermission->getKey() === (string) $permission; + } + + return is_string($permission) + && $storedPermission->getAttribute('name') === $permission; + } + + /** + * Get the permission names. + */ + public function getPermissionNames(): Collection + { + return $this->getCachedDirectPermissions()->pluck('name'); + } + + /** + * @param array|Collection|int|Permission|string|UnitEnum $permissions + * @return Collection|Permission|Permission[] + */ + protected function getStoredPermission($permissions) + { + $permissions = enum_value($permissions); + + if (is_int($permissions) || PermissionRegistrar::isUid($permissions)) { + return $this->getPermissionClass()::findById($permissions, $this->getDefaultGuardName()); + } + + if (is_string($permissions)) { + return $this->getPermissionClass()::findByName($permissions, $this->getDefaultGuardName()); + } + + if (is_array($permissions)) { + $permissions = array_map(fn ($permission) => $permission instanceof Permission ? $permission->name : enum_value($permission), $permissions); + + return $this->getPermissionClass()::whereIn('name', $permissions) + ->whereIn('guard_name', $this->getGuardNames()) + ->get(); + } + + return $permissions; + } + + /** + * @param Permission|Role $roleOrPermission + * + * @throws GuardDoesNotMatch + */ + protected function ensureModelSharesGuard($roleOrPermission): void + { + if (! $this->getGuardNames()->contains($roleOrPermission->guard_name)) { + throw GuardDoesNotMatch::create($roleOrPermission->guard_name, $this->getGuardNames()); + } + } + + protected function getGuardNames(): Collection + { + return Guard::getNames($this); + } + + protected function getDefaultGuardName(): string + { + return Guard::getDefaultName($this); + } + + /** + * Forget the cached permissions. + */ + public function forgetCachedPermissions(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + /** + * Check if the model has All of the requested Direct permissions. + * + * @param array|Collection|int|Permission|string|UnitEnum ...$permissions + */ + public function hasAllDirectPermissions(...$permissions): bool + { + $permissions = collect($permissions)->flatten(); + + foreach ($permissions as $permission) { + if (! $this->hasDirectPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * Check if the model has Any of the requested Direct permissions. + * + * @param array|Collection|int|Permission|string|UnitEnum ...$permissions + */ + public function hasAnyDirectPermission(...$permissions): bool + { + $permissions = collect($permissions)->flatten(); + + foreach ($permissions as $permission) { + if ($this->hasDirectPermission($permission)) { + return true; + } + } + + return false; + } +} diff --git a/src/permission/src/Traits/HasRoles.php b/src/permission/src/Traits/HasRoles.php new file mode 100644 index 000000000..250afb751 --- /dev/null +++ b/src/permission/src/Traits/HasRoles.php @@ -0,0 +1,662 @@ +, pivot: array}> + */ + private array $queuedRoleAssignments = []; + + /** + * Boot the role relation cleanup and queued assignment callbacks. + */ + public static function bootHasRoles(): void + { + static::deleting(function (Model $model): void { + if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) { + return; + } + + if ($model instanceof Permission) { + $model->getConnection() + ->table(Config::modelHasPermissionsTable()) + ->where(app(PermissionRegistrar::class)->pivotPermission, $model->getKey()) + ->delete(); + + $model->getConnection() + ->table(Config::roleHasPermissionsTable()) + ->where(app(PermissionRegistrar::class)->pivotPermission, $model->getKey()) + ->delete(); + } else { + $model->getConnection() + ->table(Config::modelHasRolesTable()) + ->where(Config::morphKey(), $model->getKey()) + ->where('model_type', $model->getMorphClass()) + ->delete(); + } + + app(PermissionRegistrar::class)->bumpModelAssignmentCacheVersion(); + }); + + static::saved(function (Model $model): void { + if (method_exists($model, 'attachQueuedRoleAssignments')) { + $model->attachQueuedRoleAssignments(); + } + }); + } + + /** + * Get the role model class. + */ + public function getRoleClass(): string + { + if (! $this->roleClass) { + $this->roleClass = app(PermissionRegistrar::class)->getRoleClass(); + } + + return $this->roleClass; + } + + /** + * A model may have multiple roles. + */ + public function roles(): BelongsToMany + { + $relation = $this->morphToMany( + Config::roleModel(), + 'model', + Config::modelHasRolesTable(), + Config::morphKey(), + app(PermissionRegistrar::class)->pivotRole + ); + + if (! Config::teamsEnabled()) { + return $relation; + } + + $teamsKey = Config::teamForeignKey(); + $relation->withPivot($teamsKey); + $teamField = Config::rolesTable() . '.' . $teamsKey; + + return $relation->wherePivot($teamsKey, getPermissionsTeamId()) + ->where(fn ($q) => $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId())); + } + + /** + * Get cached role assignments for this model. + */ + protected function getCachedRoles(): Collection + { + $model = $this; + + if ($this instanceof Permission || ! $model->exists || $this->relationLoaded('roles')) { + return $this->relationCollection($this->loadMissing('roles'), 'roles'); + } + + $registrar = app(PermissionRegistrar::class); + $roleKey = (new ($this->getRoleClass())())->getKeyName(); + $assignments = $registrar->rememberModelRoleAssignments( + $model, + fn (): array => $this->roles() + ->get() + ->map(fn (Model $role): array => [$roleKey => $role->getKey()]) + ->values() + ->all(), + ); + + return $registrar + ->getRoles([$roleKey => array_column($assignments, $roleKey)], false, $this->getRoleClass()) + ->values(); + } + + /** + * Scope the model query to certain roles only. + * + * @param array|Collection|int|Role|string|UnitEnum $roles + */ + public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $without = false): Builder + { + if ($roles instanceof Collection) { + $roles = $roles->all(); + } + + $roles = array_map(function ($role) use ($guard) { + if ($role instanceof Role) { + return $role; + } + + $role = enum_value($role); + + $method = is_int($role) || PermissionRegistrar::isUid($role) ? 'findById' : 'findByName'; + + return $this->getRoleClass()::{$method}($role, $guard ?: $this->getDefaultGuardName()); + }, Arr::wrap($roles)); + + $key = (new ($this->getRoleClass())())->getKeyName(); + + return $query->{! $without ? 'whereHas' : 'whereDoesntHave'}( + 'roles', + fn (Builder $subQuery) => $subQuery + ->whereIn(Config::rolesTable() . ".{$key}", array_column($roles, $key)) + ); + } + + /** + * Scope the model query to only those without certain roles. + * + * @param array|Collection|int|Role|string|UnitEnum $roles + */ + public function scopeWithoutRole(Builder $query, $roles, ?string $guard = null): Builder + { + return $this->scopeRole($query, $roles, $guard, true); + } + + /** + * A model may be part of multiple teams. + * + * When the teams feature is disabled this returns an empty BelongsToMany so + * tooling that introspects model relations (e.g. ide-helper:models) does not + * break. Querying it is a no-op and produces no rows. + */ + public function teams(): BelongsToMany + { + if (! Config::teamsEnabled()) { + $relation = $this->morphToMany( + Config::permissionModel(), + 'model', + Config::modelHasRolesTable(), + Config::morphKey(), + Config::teamForeignKey() + ); + + $relation->whereRaw('1 = 0'); + + return $relation; + } + + $relation = $this->morphToMany( + Config::teamModel(), + 'model', + Config::modelHasRolesTable(), + Config::morphKey(), + Config::teamForeignKey() + ); + + $relation->distinct(); + + return $relation; + } + + /** + * Scope the model query to certain teams only. + * + * @param array|Collection|int|Model|string $teams + */ + public function scopeTeam(Builder $query, $teams, bool $without = false): Builder + { + $teamModel = Config::teamModel(); + + if ($teams instanceof Collection) { + $teams = $teams->all(); + } + + $teamIds = array_map( + fn ($team) => $team instanceof $teamModel ? $team->getKey() : $team, + Arr::wrap($teams), + ); + + $pivotTable = Config::modelHasRolesTable(); + $morphKey = Config::morphKey(); + $teamsKey = Config::teamForeignKey(); + + $query->{! $without ? 'whereExists' : 'whereNotExists'}( + fn ($subQuery) => $subQuery + ->from($pivotTable) + ->whereColumn($morphKey, $query->getModel()->getQualifiedKeyName()) + ->where('model_type', $query->getModel()->getMorphClass()) + ->whereIn($teamsKey, $teamIds) + ); + + return $query; + } + + /** + * Scope the model query to those without certain teams. + * + * @param array|Collection|int|Model|string $teams + */ + public function scopeWithoutTeam(Builder $query, $teams): Builder + { + return $this->scopeTeam($query, $teams, true); + } + + /** + * Returns array of role ids. + * + * @param array|Collection|int|Role|string|UnitEnum $roles + */ + private function collectRoles(...$roles): array + { + return collect($roles) + ->flatten() + ->reduce(function ($array, $role) { + if ($role === null || $role === '') { + return $array; + } + + $role = $this->getStoredRole($role); + + if (! in_array($role->getKey(), $array, true)) { + $this->ensureModelSharesGuard($role); + $array[] = $role->getKey(); + } + + return $array; + }, []); + } + + /** + * Assign the given role to the model. + * + * @param array|Collection|int|Role|string|UnitEnum ...$roles + * @return $this + */ + public function assignRole(...$roles): static + { + $roles = $this->collectRoles($roles); + + $model = $this; + $registrar = app(PermissionRegistrar::class); + $teamPivot = $registrar->teams && ! $this instanceof Permission + ? [$registrar->teamsKey => getPermissionsTeamId()] : []; + + if ($model->exists) { + if ($registrar->teams) { + // explicit reload in case team has been changed since last load + $this->load('roles'); + } + + $currentRoles = $this->getCachedRoles()->map(fn ($role) => $role->getKey())->toArray(); + + $this->roles()->attach(array_diff($roles, $currentRoles), $teamPivot); + $model->unsetRelation('roles'); + } else { + $this->queueRoleAssignments($roles, $teamPivot); + } + + if ($this instanceof Permission) { + $this->forgetCachedPermissions(); + } elseif ($model->exists) { + $registrar->forgetModelRoleCache($model); + } + + $this->forgetWildcardPermissionIndex(); + + $this->dispatchRoleAttachedEvent($roles); + + return $this; + } + + /** + * Queue role assignments until the model is saved. + * + * @param array $roles + * @param array $pivot + */ + protected function queueRoleAssignments(array $roles, array $pivot): void + { + $this->queuedRoleAssignments[] = [ + 'roles' => $roles, + 'pivot' => $pivot, + ]; + } + + /** + * Attach role assignments queued before the model was saved. + */ + protected function attachQueuedRoleAssignments(): void + { + if ($this->queuedRoleAssignments === []) { + return; + } + + $registrar = app(PermissionRegistrar::class); + + foreach ($this->queuedRoleAssignments as $assignment) { + $this->roles()->attach($assignment['roles'], $assignment['pivot']); + } + + $this->queuedRoleAssignments = []; + $this->unsetRelation('roles'); + + if ($this instanceof Permission) { + $this->forgetCachedPermissions(); + } else { + $registrar->forgetModelRoleCache($this); + } + + $this->forgetWildcardPermissionIndex(); + } + + /** + * Dispatch the role attached event when enabled and listened for. + * + * @param array $roles + */ + protected function dispatchRoleAttachedEvent(array $roles): void + { + if (! Config::eventsEnabled()) { + return; + } + + $events = app(Dispatcher::class); + + if ($events->hasListeners(RoleAttachedEvent::class)) { + $events->dispatch(new RoleAttachedEvent($this, $roles)); + } + } + + /** + * Revoke the given role from the model. + * + * @param array|Collection|int|Role|string|UnitEnum ...$role + * @return $this + */ + public function removeRole(...$role): static + { + $roles = $this->collectRoles($role); + + $this->roles()->detach($roles); + + $this->unsetRelation('roles'); + + if ($this instanceof Permission) { + $this->forgetCachedPermissions(); + } else { + app(PermissionRegistrar::class)->forgetModelRoleCache($this); + } + + $this->forgetWildcardPermissionIndex(); + + $this->dispatchRoleDetachedEvent($roles); + + return $this; + } + + /** + * Dispatch the role detached event when enabled and listened for. + * + * @param array $roles + */ + protected function dispatchRoleDetachedEvent(array $roles): void + { + if (! Config::eventsEnabled()) { + return; + } + + $events = app(Dispatcher::class); + + if ($events->hasListeners(RoleDetachedEvent::class)) { + $events->dispatch(new RoleDetachedEvent($this, $roles)); + } + } + + /** + * Remove all current roles and set the given ones. + * + * @param array|Collection|int|Role|string|UnitEnum ...$roles + * @return $this + */ + public function syncRoles(...$roles): static + { + if (! $this->exists) { + return $this->assignRole($roles); + } + + $roles = $this->collectRoles($roles); + + if (Config::eventsEnabled()) { + $currentRoles = $this->roles()->get(); + if ($currentRoles->isNotEmpty()) { + $this->removeRole($currentRoles); + } + } else { + $this->roles()->detach(); + $this->setRelation('roles', collect()); + } + + $registrar = app(PermissionRegistrar::class); + $teamPivot = $registrar->teams && ! $this instanceof Permission + ? [$registrar->teamsKey => getPermissionsTeamId()] : []; + + if ($roles !== []) { + $this->roles()->attach($roles, $teamPivot); + } + + $this->unsetRelation('roles'); + + if ($this instanceof Permission) { + $this->forgetCachedPermissions(); + } else { + $registrar->forgetModelRoleCache($this); + } + + $this->forgetWildcardPermissionIndex(); + $this->dispatchRoleAttachedEvent($roles); + + return $this; + } + + /** + * Determine if the model has (one of) the given role(s). + * + * @param array|Collection|int|Role|string|UnitEnum $roles + */ + public function hasRole($roles, ?string $guard = null): bool + { + $roleCollection = $this->getCachedRoles(); + + if (is_string($roles) && str_contains($roles, '|')) { + $roles = $this->convertPipeToArray($roles); + } + + if ($roles instanceof UnitEnum) { + $roles = enum_value($roles); + } + + if (is_int($roles) || PermissionRegistrar::isUid($roles)) { + $key = (new ($this->getRoleClass())())->getKeyName(); + + return $guard + ? $roleCollection->where('guard_name', $guard)->contains($key, $roles) + : $roleCollection->contains($key, $roles); + } + + if (is_string($roles)) { + $roleNames = $guard + ? $roleCollection->where('guard_name', $guard)->pluck('name') + : $roleCollection->pluck('name'); + + return $roleNames->contains(fn ($name): bool => enum_value($name) === $roles); + } + + if ($roles instanceof Role) { + return $roleCollection->contains($roles->getKeyName(), $roles->getKey()); + } + + if (is_array($roles)) { + foreach ($roles as $role) { + if ($this->hasRole($role, $guard)) { + return true; + } + } + + return false; + } + + if ($roles instanceof Collection) { + return $roles->intersect($guard ? $roleCollection->where('guard_name', $guard) : $roleCollection)->isNotEmpty(); + } + + throw new TypeError('Unsupported type for $roles parameter to hasRole().'); + } + + /** + * Determine if the model has any of the given role(s). + * + * Alias to hasRole() but without Guard controls + * + * @param array|Collection|int|Role|string|UnitEnum $roles + */ + public function hasAnyRole(...$roles): bool + { + return $this->hasRole($roles); + } + + /** + * Determine if the model has all of the given role(s). + * + * @param array|Collection|Role|string|UnitEnum $roles + */ + public function hasAllRoles($roles, ?string $guard = null): bool + { + $roleCollection = $this->getCachedRoles(); + + $roles = enum_value($roles); + + if (is_string($roles) && str_contains($roles, '|')) { + $roles = $this->convertPipeToArray($roles); + } + + if (is_string($roles)) { + return $this->hasRole($roles, $guard); + } + + if ($roles instanceof Role) { + return $roleCollection->contains($roles->getKeyName(), $roles->getKey()); + } + + $roles = collect()->make($roles)->map(fn ($role) => $role instanceof Role ? $role->name : enum_value($role)); + + $roleNames = $guard + ? $roleCollection->where('guard_name', $guard)->pluck('name') + : $this->getRoleNames(); + + $roleNames = $roleNames->transform(fn ($roleName) => enum_value($roleName)); + + return $roles->intersect($roleNames)->count() === $roles->count(); + } + + /** + * Determine if the model has exactly all of the given role(s). + * + * @param array|Collection|Role|string|UnitEnum $roles + */ + public function hasExactRoles($roles, ?string $guard = null): bool + { + $roleCollection = $this->getCachedRoles(); + + if (is_string($roles) && str_contains($roles, '|')) { + $roles = $this->convertPipeToArray($roles); + } + + if (is_string($roles)) { + $roles = [$roles]; + } + + if ($roles instanceof Role) { + $roles = [$roles->name]; + } + + $roles = collect()->make($roles)->map( + fn ($role) => $role instanceof Role ? $role->name : enum_value($role) + ); + + return $roleCollection->count() === $roles->count() && $this->hasAllRoles($roles, $guard); + } + + /** + * Return all permissions directly coupled to the model. + */ + public function getDirectPermissions(): Collection + { + return $this->getCachedDirectPermissions(); + } + + /** + * Get the role names. + */ + public function getRoleNames(): Collection + { + return $this->getCachedRoles()->pluck('name'); + } + + /** + * Get a stored role instance. + * @param mixed $role + */ + protected function getStoredRole($role): Role + { + $role = enum_value($role); + + if (is_int($role) || PermissionRegistrar::isUid($role)) { + return $this->getRoleClass()::findById($role, $this->getDefaultGuardName()); + } + + if (is_string($role)) { + return $this->getRoleClass()::findByName($role, $this->getDefaultGuardName()); + } + + return $role; + } + + /** + * Convert a pipe-delimited role string to an array. + */ + protected function convertPipeToArray(string $pipeString): array + { + $pipeString = trim($pipeString); + + if (strlen($pipeString) <= 2) { + return [str_replace('|', '', $pipeString)]; + } + + $quoteCharacter = substr($pipeString, 0, 1); + $endCharacter = substr($quoteCharacter, -1, 1); + + if ($quoteCharacter !== $endCharacter) { + return explode('|', $pipeString); + } + + if (! in_array($quoteCharacter, ["'", '"'], true)) { + return explode('|', $pipeString); + } + + return explode('|', trim($pipeString, $quoteCharacter)); + } +} diff --git a/src/permission/src/Traits/RefreshesPermissionCache.php b/src/permission/src/Traits/RefreshesPermissionCache.php new file mode 100644 index 000000000..deee8954e --- /dev/null +++ b/src/permission/src/Traits/RefreshesPermissionCache.php @@ -0,0 +1,24 @@ +forgetCachedPermissions(); + }); + + static::deleted(function (): void { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + }); + } +} diff --git a/src/permission/src/WildcardPermission.php b/src/permission/src/WildcardPermission.php new file mode 100644 index 000000000..60fc82ea4 --- /dev/null +++ b/src/permission/src/WildcardPermission.php @@ -0,0 +1,143 @@ +> + */ + public function getIndex(): array + { + $index = []; + + $getAllPermissions = Closure::fromCallable([$this->record, 'getAllPermissions']); + + foreach ($getAllPermissions() as $permission) { + $index[$permission->guard_name] = $this->buildIndex( + $index[$permission->guard_name] ?? [], + explode(static::PART_DELIMITER, $permission->name), + $permission->name, + ); + } + + return $index; + } + + /** + * Build the wildcard permission index. + * + * @param array $index + * @param array $parts + * @return array + */ + protected function buildIndex(array $index, array $parts, string $permission): array + { + if ($parts === []) { + $index[''] = true; + + return $index; + } + + $part = array_shift($parts); + + if (blank($part)) { + throw WildcardPermissionNotProperlyFormatted::create($permission); + } + + if (! Str::contains($part, static::SUBPART_DELIMITER)) { + $index[$part] = $this->buildIndex( + $index[$part] ?? [], + $parts, + $permission, + ); + } + + $subParts = explode(static::SUBPART_DELIMITER, $part); + + foreach ($subParts as $subPart) { + if (blank($subPart)) { + throw WildcardPermissionNotProperlyFormatted::create($permission); + } + + $index[$subPart] = $this->buildIndex( + $index[$subPart] ?? [], + $parts, + $permission, + ); + } + + return $index; + } + + /** + * Determine if the wildcard permission implies another permission. + * + * @param array> $index + */ + public function implies(string $permission, string $guardName, array $index): bool + { + if (! array_key_exists($guardName, $index)) { + return false; + } + + $permission = explode(static::PART_DELIMITER, $permission); + + return $this->checkIndex($permission, $index[$guardName]); + } + + /** + * Check the permission against the wildcard index. + * + * @param array $permission + * @param array $index + */ + protected function checkIndex(array $permission, array $index): bool + { + if (array_key_exists(strval(null), $index)) { + return true; + } + + if (empty($permission)) { + return false; + } + + $firstPermission = array_shift($permission); + + if ( + array_key_exists($firstPermission, $index) + && $this->checkIndex($permission, $index[$firstPermission]) + ) { + return true; + } + + if (array_key_exists(static::WILDCARD_TOKEN, $index)) { + return $this->checkIndex($permission, $index[static::WILDCARD_TOKEN]); + } + + return false; + } +} diff --git a/src/permission/src/helpers.php b/src/permission/src/helpers.php new file mode 100644 index 000000000..33334e854 --- /dev/null +++ b/src/permission/src/helpers.php @@ -0,0 +1,39 @@ + + */ + function getModelForGuard(string $guard): ?string + { + return Guard::getModelForGuard($guard); + } +} + +if (! function_exists('setPermissionsTeamId')) { + /** + * Set the current permissions team id. + */ + function setPermissionsTeamId(int|string|Model|null $id): void + { + app(PermissionRegistrar::class)->setPermissionsTeamId($id); + } +} + +if (! function_exists('getPermissionsTeamId')) { + /** + * Get the current permissions team id. + */ + function getPermissionsTeamId(): int|string|null + { + return app(PermissionRegistrar::class)->getPermissionsTeamId(); + } +} diff --git a/src/testbench/src/Console/Commander.php b/src/testbench/src/Console/Commander.php index 690b19817..0cd65e48f 100644 --- a/src/testbench/src/Console/Commander.php +++ b/src/testbench/src/Console/Commander.php @@ -155,7 +155,7 @@ function ($app) use ($filesystem, $hasEnvironmentFile) { resolvingCallback: $this->resolveApplicationCallback(), options: array_filter([ 'load_environment_variables' => $hasEnvironmentFile(), - 'extra' => $this->config->getExtraAttributes(), + 'extra' => $this->extraAttributes(), ]), ); @@ -180,13 +180,26 @@ protected function resolveApplicationCallback(): Closure $this->config['migrations'] ?? [], $this->config['seeders'] ?? false, ))->bootstrap($app); - - foreach ($this->providers as $provider) { - $app->register($provider); - } }; } + /** + * Get extra attributes for the Testbench application. + * + * @return array + */ + protected function extraAttributes(): array + { + $attributes = $this->config->getExtraAttributes(); + + $attributes['providers'] = array_values(array_unique([ + ...$attributes['providers'], + ...$this->providers, + ])); + + return $attributes; + } + /** * Resolve the application's base path. */ diff --git a/src/testbench/src/Foundation/Console/InstallCommand.php b/src/testbench/src/Foundation/Console/InstallCommand.php new file mode 100644 index 000000000..a0d463404 --- /dev/null +++ b/src/testbench/src/Foundation/Console/InstallCommand.php @@ -0,0 +1,346 @@ + + */ + protected const array WORKBENCH_AUTOLOAD_MAPPINGS = [ + 'workbench/app/' => 'Workbench\App\\', + 'workbench/database/factories/' => 'Workbench\Database\Factories\\', + 'workbench/database/seeders/' => 'Workbench\Database\Seeders\\', + ]; + + /** + * Execute the console command. + */ + public function handle(Filesystem $filesystem, Composer $composer): int + { + $workingPath = package_path(); + $namespaces = $this->configureComposerAutoloads($composer, $workingPath); + + $this->prepareWorkbenchDirectories($filesystem, $workingPath); + $this->copyTestbenchConfigurationFile($filesystem, $workingPath, $namespaces); + $this->copyWorkbenchFiles($filesystem, $workingPath, $namespaces); + $this->copyWorkbenchDotEnvFile($filesystem, $workingPath); + + $this->call('package:create-sqlite-db', ['--force' => true]); + + return $composer->setWorkingPath($workingPath)->dumpAutoloads() === self::SUCCESS + ? self::SUCCESS + : self::FAILURE; + } + + /** + * Configure Composer autoloading for the Workbench classes. + * + * @return array{app: string, factories: string, seeders: string} + */ + protected function configureComposerAutoloads(Composer $composer, string $workingPath): array + { + $namespaces = []; + + $composer->setWorkingPath($workingPath)->modify(function (array $content) use (&$namespaces): array { + /** @var array{autoload-dev?: array{psr-4?: array|string>}} $content */ + $content['autoload-dev'] ??= []; + $content['autoload-dev']['psr-4'] ??= []; + + foreach (self::WORKBENCH_AUTOLOAD_MAPPINGS as $path => $defaultNamespace) { + $namespace = $this->namespaceForPath($content['autoload-dev']['psr-4'], $path); + + if ($namespace === null) { + $this->ensureNamespaceCanBeAdded($content['autoload-dev']['psr-4'], $defaultNamespace, $path); + + $content['autoload-dev']['psr-4'][$defaultNamespace] = $path; + $namespace = $defaultNamespace; + } + + $namespaces[$path] = $namespace; + } + + return $content; + }); + + return [ + 'app' => $namespaces['workbench/app/'], + 'factories' => $namespaces['workbench/database/factories/'], + 'seeders' => $namespaces['workbench/database/seeders/'], + ]; + } + + /** + * Resolve the namespace mapped to the given path. + * + * @param array|string> $autoloads + */ + protected function namespaceForPath(array $autoloads, string $path): ?string + { + $path = $this->normalizeAutoloadPath($path); + + foreach ($autoloads as $namespace => $paths) { + foreach ((array) $paths as $candidate) { + if ($this->normalizeAutoloadPath($candidate) === $path) { + return $namespace; + } + } + } + + return null; + } + + /** + * Ensure a default namespace can be added safely. + * + * @param array|string> $autoloads + */ + protected function ensureNamespaceCanBeAdded(array $autoloads, string $namespace, string $path): void + { + if (! array_key_exists($namespace, $autoloads)) { + return; + } + + foreach ((array) $autoloads[$namespace] as $candidate) { + if ($this->normalizeAutoloadPath($candidate) === $this->normalizeAutoloadPath($path)) { + return; + } + } + + throw new LogicException(sprintf( + 'Unable to add Workbench autoload mapping [%s => %s] because [%s] is already mapped to a different path.', + $namespace, + $path, + $namespace + )); + } + + /** + * Normalize a Composer autoload path. + */ + protected function normalizeAutoloadPath(string $path): string + { + return trim(str_replace('\\', '/', $path), '/') . '/'; + } + + /** + * Prepare Workbench directories. + */ + protected function prepareWorkbenchDirectories(Filesystem $filesystem, string $workingPath): void + { + $directories = [ + join_paths('workbench', 'app', 'Models'), + join_paths('workbench', 'app', 'Providers'), + join_paths('workbench', 'database', 'factories'), + join_paths('workbench', 'database', 'migrations'), + join_paths('workbench', 'database', 'seeders'), + join_paths('workbench', 'storage'), + ]; + + if ($this->option('basic') === false) { + $directories = [ + ...$directories, + join_paths('workbench', 'config'), + join_paths('workbench', 'resources', 'views'), + join_paths('workbench', 'routes'), + ]; + } + + (new EnsureDirectoryExists( + filesystem: $filesystem, + components: $this->components, + workingPath: $workingPath, + ))->handle( + (new Collection($directories)) + ->map(static fn (string $directory): string => join_paths($workingPath, $directory)) + ); + } + + /** + * Copy the "testbench.yaml" file. + * + * @param array{app: string, factories: string, seeders: string} $namespaces + */ + protected function copyTestbenchConfigurationFile(Filesystem $filesystem, string $workingPath, array $namespaces): void + { + $this->copyStub( + $filesystem, + $this->option('basic') === true ? 'testbench.basic.yaml.stub' : 'testbench.yaml.stub', + join_paths($workingPath, 'testbench.yaml'), + $workingPath, + $this->workbenchReplacements($namespaces) + ); + } + + /** + * Copy Workbench files. + * + * @param array{app: string, factories: string, seeders: string} $namespaces + */ + protected function copyWorkbenchFiles(Filesystem $filesystem, string $workingPath, array $namespaces): void + { + $replacements = $this->workbenchReplacements($namespaces); + + foreach ([ + 'workbench.gitignore' => join_paths('workbench', '.gitignore'), + 'provider.stub' => join_paths('workbench', 'app', 'Providers', 'WorkbenchServiceProvider.php'), + 'user.stub' => join_paths('workbench', 'app', 'Models', 'User.php'), + 'user-factory.stub' => join_paths('workbench', 'database', 'factories', 'UserFactory.php'), + 'database-seeder.stub' => join_paths('workbench', 'database', 'seeders', 'DatabaseSeeder.php'), + ] as $stub => $target) { + $this->copyStub($filesystem, $stub, join_paths($workingPath, $target), $workingPath, $replacements); + } + + if ($this->option('basic') === true) { + return; + } + + foreach ([ + 'routes.web.stub' => join_paths('workbench', 'routes', 'web.php'), + 'routes.api.stub' => join_paths('workbench', 'routes', 'api.php'), + 'routes.console.stub' => join_paths('workbench', 'routes', 'console.php'), + ] as $stub => $target) { + $this->copyStub($filesystem, $stub, join_paths($workingPath, $target), $workingPath, $replacements); + } + } + + /** + * Copy the ".env" file. + */ + protected function copyWorkbenchDotEnvFile(Filesystem $filesystem, string $workingPath): void + { + if (! $this->input->isInteractive()) { + return; + } + + $from = $this->hypervel->basePath('.env.example'); + + if (! $filesystem->isFile($from)) { + return; + } + + $choices = (new Collection($this->environmentFiles())) + ->when( + ! $this->option('force'), + fn (Collection $files): Collection => $files->reject( + static fn (string $file): bool => $filesystem->isFile(join_paths($workingPath, 'workbench', $file)) + ) + ) + ->values(); + + if (! $this->option('force') && $choices->isEmpty()) { + $this->components->twoColumnDetail( + 'File [.env] already exists', + 'SKIPPED' + ); + + return; + } + + $targetEnvironmentFile = select( + "Export '.env' file as?", + [ + 'skip' => 'Skip exporting .env', + ...$choices->mapWithKeys(static fn (string $file): array => [$file => $file])->all(), + ], + ); + + if (! is_string($targetEnvironmentFile) || $targetEnvironmentFile === 'skip') { + return; + } + + $this->copyStub($filesystem, $from, join_paths($workingPath, 'workbench', $targetEnvironmentFile), $workingPath); + } + + /** + * Get possible environment files. + * + * @return array + */ + protected function environmentFiles(): array + { + return [ + '.env', + '.env.example', + '.env.dist', + ]; + } + + /** + * Copy a stub file. + * + * @param array $replacements + */ + protected function copyStub( + Filesystem $filesystem, + string $stub, + string $target, + string $workingPath, + array $replacements = [] + ): void { + $source = $filesystem->isFile($stub) + ? $stub + : join_paths(__DIR__, 'stubs', $stub); + + $willGenerateFile = $this->option('force') === true || ! $filesystem->exists($target); + + (new GeneratesFile( + filesystem: $filesystem, + components: $this->components, + force: (bool) $this->option('force'), + workingPath: $workingPath, + ))->handle($source, $target); + + if ($willGenerateFile && $filesystem->exists($target) && $replacements !== []) { + $filesystem->replaceInFile(array_keys($replacements), array_values($replacements), $target); + } + } + + /** + * Build Workbench stub replacements. + * + * @param array{app: string, factories: string, seeders: string} $namespaces + * @return array + */ + protected function workbenchReplacements(array $namespaces): array + { + $appNamespace = rtrim($namespaces['app'], '\\'); + $factoryNamespace = rtrim($namespaces['factories'], '\\'); + $seederNamespace = rtrim($namespaces['seeders'], '\\'); + + return [ + '{{ WorkbenchAppNamespace }}' => $appNamespace, + '{{ WorkbenchFactoryNamespace }}' => $factoryNamespace, + '{{ WorkbenchSeederNamespace }}' => $seederNamespace, + '{{ WorkbenchServiceProvider }}' => $appNamespace . '\Providers\WorkbenchServiceProvider', + '{{ WorkbenchDatabaseSeeder }}' => $seederNamespace . '\DatabaseSeeder', + '{{ WorkbenchUserModel }}' => $appNamespace . '\Models\User', + '{{ WorkbenchUserFactory }}' => $factoryNamespace . '\UserFactory', + ]; + } +} diff --git a/src/testbench/src/Foundation/Console/stubs/database-seeder.stub b/src/testbench/src/Foundation/Console/stubs/database-seeder.stub new file mode 100644 index 000000000..73f89987b --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/database-seeder.stub @@ -0,0 +1,27 @@ +times(10)->create(); + + UserFactory::new()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/src/testbench/src/Foundation/Console/stubs/provider.stub b/src/testbench/src/Foundation/Console/stubs/provider.stub new file mode 100644 index 000000000..b80054308 --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/provider.stub @@ -0,0 +1,18 @@ +loadMigrationsFrom(realpath(__DIR__ . '/../../database/migrations')); + } +} diff --git a/src/testbench/src/Foundation/Console/stubs/routes.api.stub b/src/testbench/src/Foundation/Console/stubs/routes.api.stub new file mode 100644 index 000000000..68d68e6e5 --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/routes.api.stub @@ -0,0 +1,7 @@ + ['name' => 'Workbench']); diff --git a/src/testbench/src/Foundation/Console/stubs/routes.console.stub b/src/testbench/src/Foundation/Console/stubs/routes.console.stub new file mode 100644 index 000000000..c6bab51b5 --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/routes.console.stub @@ -0,0 +1,9 @@ +comment('Workbench is ready.'); +// })->purpose('Display an inspiring message'); diff --git a/src/testbench/src/Foundation/Console/stubs/routes.web.stub b/src/testbench/src/Foundation/Console/stubs/routes.web.stub new file mode 100644 index 000000000..55746bcdb --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/routes.web.stub @@ -0,0 +1,7 @@ + response('Workbench')); diff --git a/src/testbench/src/Foundation/Console/stubs/testbench.basic.yaml.stub b/src/testbench/src/Foundation/Console/stubs/testbench.basic.yaml.stub new file mode 100644 index 000000000..7b22dab84 --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/testbench.basic.yaml.stub @@ -0,0 +1,12 @@ +providers: + - {{ WorkbenchServiceProvider }} + +migrations: + - workbench/database/migrations + +seeders: + - {{ WorkbenchDatabaseSeeder }} + +workbench: + install: true + auth: true diff --git a/src/testbench/src/Foundation/Console/stubs/testbench.yaml.stub b/src/testbench/src/Foundation/Console/stubs/testbench.yaml.stub new file mode 100644 index 000000000..6301708cc --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/testbench.yaml.stub @@ -0,0 +1,25 @@ +providers: + - {{ WorkbenchServiceProvider }} + +migrations: + - workbench/database/migrations + +seeders: + - {{ WorkbenchDatabaseSeeder }} + +workbench: + install: true + auth: true + health: true + discovers: + config: true + factories: true + web: true + api: true + commands: true + components: false + views: true + sync: + - from: storage + to: workbench/storage + reverse: true diff --git a/src/testbench/src/Foundation/Console/stubs/user-factory.stub b/src/testbench/src/Foundation/Console/stubs/user-factory.stub new file mode 100644 index 000000000..e61589e91 --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/user-factory.stub @@ -0,0 +1,46 @@ + + */ + protected ?string $model = User::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes): array => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/src/testbench/src/Foundation/Console/stubs/user.stub b/src/testbench/src/Foundation/Console/stubs/user.stub new file mode 100644 index 000000000..fc3e5194d --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/user.stub @@ -0,0 +1,45 @@ + + */ + protected array $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected array $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected array $casts = [ + 'email_verified_at' => 'datetime', + ]; +} diff --git a/src/testbench/src/Foundation/Console/stubs/workbench.gitignore b/src/testbench/src/Foundation/Console/stubs/workbench.gitignore new file mode 100644 index 000000000..6a8224d5a --- /dev/null +++ b/src/testbench/src/Foundation/Console/stubs/workbench.gitignore @@ -0,0 +1,2 @@ +/storage +.env diff --git a/src/testbench/src/TestbenchServiceProvider.php b/src/testbench/src/TestbenchServiceProvider.php index 338fcb249..1bdccab24 100644 --- a/src/testbench/src/TestbenchServiceProvider.php +++ b/src/testbench/src/TestbenchServiceProvider.php @@ -41,6 +41,7 @@ public function boot(): void : Foundation\Console\TestFallbackCommand::class, Foundation\Console\CreateSqliteDbCommand::class, Foundation\Console\DropSqliteDbCommand::class, + Foundation\Console\InstallCommand::class, Foundation\Console\PurgeSkeletonCommand::class, Foundation\Console\ServeCommand::class, Foundation\Console\SyncSkeletonCommand::class, diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index 59d0bfd5b..9ca8e47d0 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -146,6 +146,8 @@ public function notify(Finished $event): void \Hypervel\Notifications\ChannelManager::flushState(); \Hypervel\Pagination\AbstractCursorPaginator::flushState(); \Hypervel\Pagination\AbstractPaginator::flushState(); + \Hypervel\Permission\DefaultTeamResolver::flushState(); + \Hypervel\Permission\PermissionRegistrar::flushState(); \Hypervel\Pipeline\Pipeline::flushState(); \Hypervel\Process\Factory::flushState(); \Hypervel\Prompts\Prompt::flushState(); diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php index c82634dc3..15acfce37 100644 --- a/tests/Cache/CacheMemoizedStoreTest.php +++ b/tests/Cache/CacheMemoizedStoreTest.php @@ -11,6 +11,8 @@ use Hypervel\Cache\MemoizedStore; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Repository; +use Hypervel\Cache\StackStore; +use Hypervel\Cache\StackStoreProxy; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Support\Carbon; use Hypervel\Tests\TestCase; @@ -86,7 +88,58 @@ public function testPlainFlexibleTreatsCachedSentinelAsHitThroughMemoizedStore() $this->assertFalse($invoked); } - public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedStack(): void + public function testMemoizedStoreCanWrapStackStore(): void + { + $stackRepo = $this->createStackRepository(); + $memoizedRepo = new Repository(new MemoizedStore('stack', $stackRepo)); + + $invocations = 0; + $first = $memoizedRepo->remember('permission.roles', 60, function () use (&$invocations) { + ++$invocations; + + return ['writer' => ['edit articles']]; + }); + + $second = $memoizedRepo->remember('permission.roles', 60, function () use (&$invocations) { + ++$invocations; + + return ['writer' => ['stale value']]; + }); + + $this->assertSame(['writer' => ['edit articles']], $first); + $this->assertSame(['writer' => ['edit articles']], $second); + $this->assertSame(1, $invocations); + + $freshMemoizedRepo = new Repository(new MemoizedStore('stack', $stackRepo)); + + $this->assertSame(['writer' => ['edit articles']], $freshMemoizedRepo->get('permission.roles')); + } + + public function testMemoizedStorePreservesCachedNullHitsWhenWrappingStackStore(): void + { + $stackRepo = $this->createStackRepository(); + $memoizedRepo = new Repository(new MemoizedStore('stack', $stackRepo)); + + $invocations = 0; + $first = $memoizedRepo->rememberNullable('permission.missing', 60, function () use (&$invocations) { + ++$invocations; + + return null; + }); + + $second = $memoizedRepo->remember('permission.missing', 60, function () use (&$invocations) { + ++$invocations; + + return 'should-not-run'; + }); + + $this->assertNull($first); + $this->assertNull($second); + $this->assertSame(['permission.missing' => null], $memoizedRepo->many(['permission.missing'])); + $this->assertSame(1, $invocations); + } + + public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedStore(): void { $innerRepo = new Repository(new ArrayStore(serializesValues: true)); $outerRepo = new Repository(new MemoizedStore('memoized', $innerRepo)); @@ -114,4 +167,12 @@ public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedSta $this->assertNull($captured[1]->value); $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); } + + protected function createStackRepository(): Repository + { + return new Repository(new StackStore([ + new StackStoreProxy(new ArrayStore(serializesValues: true), 3), + new StackStoreProxy(new ArrayStore(serializesValues: true)), + ])); + } } diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 072a3f7fa..ebe7495bb 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -3403,6 +3403,20 @@ public function testWithoutTouchingOnCallback() $this->assertTrue($called); } + public function testFlushGuardableColumnsRestoresOnlyGuardableColumnMetadata(): void + { + $reflection = new ReflectionClass(Model::class); + $resolver = m::mock(Resolver::class); + + Model::setConnectionResolver($resolver); + $reflection->setStaticPropertyValue('guardableColumns', [ModelStub::class => ['id']]); + + Model::flushGuardableColumns(); + + $this->assertSame($resolver, Model::getConnectionResolver()); + $this->assertSame([], $reflection->getStaticPropertyValue('guardableColumns')); + } + public function testFlushStateRestoresStaticState() { $reflection = new ReflectionClass(Model::class); diff --git a/tests/Permission/CacheTest.php b/tests/Permission/CacheTest.php new file mode 100644 index 000000000..7c7947de8 --- /dev/null +++ b/tests/Permission/CacheTest.php @@ -0,0 +1,110 @@ +testUserRole->givePermissionTo('edit-articles'); + $registrar = $this->app->make(PermissionRegistrar::class); + + $this->testUser->hasPermissionTo('edit-articles'); + + $payload = $registrar->getCacheRepository()->get($registrar->cacheKey); + + $this->assertIsArray($payload); + + $permission = collect($payload['permissions'])->first( + fn (array $permission): bool => $permission['attributes']['name'] === 'edit-articles', + ); + + $this->assertIsArray($permission); + $this->assertArrayNotHasKey('attributes', $permission['roles'][0]); + $this->assertSame($this->testUserRole->getKey(), $permission['roles'][0]['pivot'][$registrar->pivotRole]); + $this->assertFalse($permission['roles'][0]['pivot']['is_forbidden']); + } + + public function testRoleForbiddenPivotHydratesFromGlobalCache(): void + { + $this->testUser->assignRole('testRole'); + $this->testUserRole->giveForbiddenTo('edit-articles'); + + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + + $this->app->make(PermissionRegistrar::class)->clearPermissionsCollection(); + + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + } + + public function testPermissionCacheResetBumpsModelAssignmentCacheVersion(): void + { + $this->testUser->assignRole('testRole'); + $registrar = $this->app->make(PermissionRegistrar::class); + + $this->assertTrue($this->testUser->hasRole('testRole')); + + $firstVersion = $registrar->modelAssignmentCacheVersion(); + + $registrar->forgetCachedPermissions(); + + $this->assertGreaterThan($firstVersion, $registrar->modelAssignmentCacheVersion()); + $this->assertTrue($this->testUser->hasRole('testRole')); + } + + public function testGlobalCatalogIsNotHeldOnWorkerSingletonAfterSharedCacheIsForgotten(): void + { + $this->testUser->assignRole('testRole'); + $permission = $this->app->make(PermissionContract::class)::create(['name' => 'publish-articles']); + $registrar = $this->app->make(PermissionRegistrar::class); + + $this->assertFalse($this->testUser->hasPermissionTo('publish-articles')); + + $this->testUserRole->getConnection() + ->table(Config::roleHasPermissionsTable()) + ->insert([ + $registrar->pivotPermission => $permission->getKey(), + $registrar->pivotRole => $this->testUserRole->getKey(), + 'is_forbidden' => false, + ]); + + $registrar->getCacheRepository()->forget($registrar->cacheKey); + + $results = parallel([ + fn (): bool => $this->testUser->hasPermissionTo('publish-articles'), + ]); + + $this->assertTrue($results[0]); + } + + public function testDeletingRoleCleansRolePermissionPivotWithoutForeignKeyCascades(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->assertSame(1, $this->testUserRole->getConnection()->table(Config::roleHasPermissionsTable())->count()); + + $this->testUserRole->delete(); + + $this->assertSame(0, $this->testUserRole->getConnection()->table(Config::roleHasPermissionsTable())->count()); + } + + public function testDeletingPermissionCleansRolePermissionPivotWithoutForeignKeyCascades(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->assertSame(1, $this->testUserRole->getConnection()->table(Config::roleHasPermissionsTable())->count()); + + $this->testUserPermission->delete(); + + $this->assertSame(0, $this->testUserRole->getConnection()->table(Config::roleHasPermissionsTable())->count()); + } +} diff --git a/tests/Permission/Commands/CommandTest.php b/tests/Permission/Commands/CommandTest.php new file mode 100644 index 000000000..05dde7522 --- /dev/null +++ b/tests/Permission/Commands/CommandTest.php @@ -0,0 +1,246 @@ + 'new-role']); + + $role = Role::query()->where('name', 'new-role')->first(); + + $this->assertNotNull($role); + $this->assertCount(0, $role->permissions); + } + + public function testItCanCreateARoleWithASpecificGuard(): void + { + Artisan::call('permission:create-role', [ + 'name' => 'new-role', + 'guard' => 'api', + ]); + + $this->assertTrue(Role::query()->where('name', 'new-role')->where('guard_name', 'api')->exists()); + } + + public function testItCanCreateAPermission(): void + { + Artisan::call('permission:create-permission', ['name' => 'new-permission']); + + $this->assertTrue(Permission::query()->where('name', 'new-permission')->exists()); + } + + public function testItCanCreateAPermissionWithASpecificGuard(): void + { + Artisan::call('permission:create-permission', [ + 'name' => 'new-permission', + 'guard' => 'api', + ]); + + $this->assertTrue(Permission::query()->where('name', 'new-permission')->where('guard_name', 'api')->exists()); + } + + public function testItCanCreateARoleAndPermissionsAtTheSameTime(): void + { + Artisan::call('permission:create-role', [ + 'name' => 'new-role', + 'permissions' => 'first permission | second permission', + ]); + + $role = Role::query()->where('name', 'new-role')->first(); + + $this->assertTrue($role->hasPermissionTo('first permission')); + $this->assertTrue($role->hasPermissionTo('second permission')); + } + + public function testItCanCreateARoleWithoutDuplication(): void + { + Artisan::call('permission:create-role', ['name' => 'new-role']); + Artisan::call('permission:create-role', ['name' => 'new-role']); + + $this->assertCount(1, Role::query()->where('name', 'new-role')->get()); + } + + public function testItCanCreateAPermissionWithoutDuplication(): void + { + Artisan::call('permission:create-permission', ['name' => 'new-permission']); + Artisan::call('permission:create-permission', ['name' => 'new-permission']); + + $this->assertCount(1, Permission::query()->where('name', 'new-permission')->get()); + } + + public function testItCanShowPermissionTables(): void + { + Role::query()->where('name', 'testRole2')->delete(); + Role::create(['name' => 'testRole_2']); + + Artisan::call('permission:show'); + $output = Artisan::output(); + + $this->assertStringContainsString('Guard: web', $output); + $this->assertStringContainsString('Guard: admin', $output); + $this->assertMatchesRegularExpression('/\|\s+\|\s+testRole\s+\|\s+testRole_2\s+\|/', $output); + $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+·\s+\|\s+·\s+\|/', $output); + + Role::findByName('testRole')->givePermissionTo('edit-articles'); + $this->reloadPermissions(); + + Artisan::call('permission:show'); + + $this->assertMatchesRegularExpression('/\|\s+edit-articles\s+\|\s+✔\s+\|\s+·\s+\|/', Artisan::output()); + } + + public function testItCanShowPermissionsForGuard(): void + { + Artisan::call('permission:show', ['guard' => 'web']); + $output = Artisan::output(); + + $this->assertStringContainsString('Guard: web', $output); + $this->assertStringNotContainsString('Guard: admin', $output); + } + + public function testItCanSetupTeamsUpgrade(): void + { + $this->app->make('config')->set('permission.teams', true); + $this->app->make(PermissionRegistrar::class)->initializeCache(); + $before = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; + + try { + Artisan::call('permission:setup-teams', [ + '--no-interaction' => true, + ]); + + $matchingFiles = array_values(array_diff(glob(database_path('migrations/*_add_teams_fields.php')) ?: [], $before)); + + $this->assertNotEmpty($matchingFiles); + + $migration = require $matchingFiles[count($matchingFiles) - 1]; + $migration->up(); + $migration->up(); + Model::flushGuardableColumns(); + + Role::create(['name' => 'new-role', 'team_test_id' => 1]); + $role = Role::query()->where('name', 'new-role')->first(); + + $this->assertNotNull($role); + $this->assertSame(1, (int) $role->team_test_id); + } finally { + foreach (array_diff(glob(database_path('migrations/*_add_teams_fields.php')) ?: [], $before) as $path) { + unlink($path); + } + } + } + + public function testItCanRespondToAboutCommandWithDefaultFeatures(): void + { + if (! class_exists(InstalledVersions::class) || ! method_exists(AboutCommand::class, 'flushState')) { + $this->markTestSkipped('About command package metadata is unavailable in this environment.'); + } + + $this->app->make(PermissionRegistrar::class)->initializeCache(); + + Artisan::call('about'); + $output = str_replace("\r\n", "\n", Artisan::output()); + + $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Forbidden Permissions[ .\n]*Version/', $output); + } + + public function testItCanRespondToAboutCommandWithTeams(): void + { + if (! class_exists(InstalledVersions::class) || ! method_exists(AboutCommand::class, 'flushState')) { + $this->markTestSkipped('About command package metadata is unavailable in this environment.'); + } + + $this->app->make('config')->set('permission.teams', true); + $this->app->make(PermissionRegistrar::class)->initializeCache(); + + Artisan::call('about'); + $output = str_replace("\r\n", "\n", Artisan::output()); + + $this->assertMatchesRegularExpression('/Hypervel Permissions[ .\n]*Features Enabled[ .]*Teams, Forbidden Permissions[ .\n]*Version/', $output); + } + + public function testItCanAssignRoleToUser(): void + { + $user = User::query()->first(); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => User::class, + ]); + + $this->assertStringContainsString("Role `testRole` assigned to user ID {$user->id} successfully.", Artisan::output()); + $this->assertTrue($user->fresh()->hasRole('testRole')); + } + + public function testItFailsToAssignRoleWhenUserDoesNotExist(): void + { + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => '99999', + 'guard' => 'web', + 'userModelNamespace' => User::class, + ]); + + $this->assertStringContainsString('User with ID 99999 not found.', Artisan::output()); + } + + public function testItFailsToAssignRoleWhenNamespaceInvalid(): void + { + $user = User::query()->first(); + $userModelClass = 'App\Models\NonExistentUser'; + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => $userModelClass, + ]); + + $this->assertStringContainsString("User model class [{$userModelClass}] does not exist.", Artisan::output()); + } + + public function testItFailsToAssignRoleWhenModelDoesNotUseHasRoles(): void + { + $user = UserWithoutHasRoles::create(['email' => 'plain@user.com']); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => UserWithoutHasRoles::class, + ]); + + $this->assertStringContainsString('must use the HasRoles trait', Artisan::output()); + } + + public function testItWarnsWhenAssigningRoleWithTeamIdButTeamsDisabled(): void + { + $user = User::query()->first(); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'userModelNamespace' => User::class, + '--team-id' => 1, + ]); + + $this->assertStringContainsString('Teams feature disabled', Artisan::output()); + } +} diff --git a/tests/Permission/Commands/TeamCommandTest.php b/tests/Permission/Commands/TeamCommandTest.php new file mode 100644 index 000000000..4dc665ee9 --- /dev/null +++ b/tests/Permission/Commands/TeamCommandTest.php @@ -0,0 +1,129 @@ +make('config')->set([ + 'permission.teams' => true, + 'permission.models.team' => Team::class, + ]); + } + + public function testItCanAssignRoleToUserWithTeamId(): void + { + $user = User::query()->first(); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => User::class, + '--team-id' => 1, + ]); + + $this->assertStringContainsString("Role `testRole` assigned to user ID {$user->id} successfully.", Artisan::output()); + + setPermissionsTeamId(1); + $this->assertTrue($user->fresh()->hasRole('testRole')); + } + + public function testItCanAssignRolesToUserOnDifferentTeams(): void + { + $user = User::query()->first(); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => User::class, + '--team-id' => 1, + ]); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole2', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => User::class, + '--team-id' => 2, + ]); + + setPermissionsTeamId(1); + $user = $user->fresh(); + $this->assertTrue($user->hasRole('testRole')); + $this->assertFalse($user->hasRole('testRole2')); + + setPermissionsTeamId(2); + $user = $user->fresh(); + $this->assertTrue($user->hasRole('testRole2')); + $this->assertFalse($user->hasRole('testRole')); + } + + public function testItRestoresPreviousTeamIdAfterAssigningRole(): void + { + $user = User::query()->first(); + + setPermissionsTeamId(5); + + Artisan::call('permission:assign-role', [ + 'name' => 'testRole', + 'userId' => (string) $user->id, + 'guard' => 'web', + 'userModelNamespace' => User::class, + '--team-id' => 1, + ]); + + $this->assertSame(5, getPermissionsTeamId()); + } + + public function testItCanCreateTeamsMigration(): void + { + $before = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; + + try { + Artisan::call('permission:setup-teams', [ + '--no-interaction' => true, + ]); + + $after = glob(database_path('migrations/*_add_teams_fields.php')) ?: []; + + $this->assertCount(count($before) + 1, $after); + $this->assertStringContainsString('Migration created successfully.', Artisan::output()); + } finally { + foreach (array_diff(glob(database_path('migrations/*_add_teams_fields.php')) ?: [], $before) as $path) { + unlink($path); + } + } + } + + public function testItCanShowRolesByTeams(): void + { + $this->app->make(PermissionRegistrar::class)->initializeCache(); + + Role::query()->where('name', 'testRole2')->delete(); + Role::create(['name' => 'testRole_2']); + Role::create(['name' => 'testRole_Team', 'team_test_id' => 1]); + Role::create(['name' => 'testRole_Team', 'team_test_id' => 2]); + + Artisan::call('permission:show'); + + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/\|\s+\|\s+Team ID: NULL\s+\|\s+Team ID: 1\s+\|\s+Team ID: 2\s+\|/', $output); + $this->assertMatchesRegularExpression('/\|\s+\|\s+testRole\s+\|\s+testRole_2\s+\|\s+testRole_Team\s+\|\s+testRole_Team\s+\|/', $output); + } +} diff --git a/tests/Permission/CoroutineIsolationTest.php b/tests/Permission/CoroutineIsolationTest.php new file mode 100644 index 000000000..0819b0aa1 --- /dev/null +++ b/tests/Permission/CoroutineIsolationTest.php @@ -0,0 +1,32 @@ +assertSame(1, $first); + $this->assertSame(2, $second); + $this->assertNull(getPermissionsTeamId()); + } +} diff --git a/tests/Permission/CustomGateTest.php b/tests/Permission/CustomGateTest.php new file mode 100644 index 000000000..91130c5b8 --- /dev/null +++ b/tests/Permission/CustomGateTest.php @@ -0,0 +1,34 @@ +make('config')->set('permission.register_permission_check_method', false); + } + + public function testItDoesNotRegisterPermissionCheckMethodWhenDisabled(): void + { + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertSame([], $this->app->make(Gate::class)->abilities()); + $this->assertFalse($this->testUser->can('edit-articles')); + } + + public function testItCanAuthorizeUsingCustomGateDefinition(): void + { + $this->app->make(Gate::class)->define('edit-articles', fn (): bool => true); + + $this->assertArrayHasKey('edit-articles', $this->app->make(Gate::class)->abilities()); + $this->assertTrue($this->testUser->can('edit-articles')); + } +} diff --git a/tests/Permission/CustomSchemaConfigTest.php b/tests/Permission/CustomSchemaConfigTest.php new file mode 100644 index 000000000..6a7e73dd8 --- /dev/null +++ b/tests/Permission/CustomSchemaConfigTest.php @@ -0,0 +1,59 @@ +make('config')->set([ + 'permission.table_names.roles' => 'custom_roles', + 'permission.table_names.permissions' => 'custom_permissions', + 'permission.table_names.model_has_permissions' => 'custom_model_has_permissions', + 'permission.table_names.model_has_roles' => 'custom_model_has_roles', + 'permission.table_names.role_has_permissions' => 'custom_role_has_permissions', + ]); + } + + public function testCustomTableNamesAreUsedBySchemaModelsAndRelations(): void + { + $this->assertTrue(Schema::hasTable('custom_roles')); + $this->assertTrue(Schema::hasTable('custom_permissions')); + $this->assertTrue(Schema::hasTable('custom_model_has_roles')); + $this->assertTrue(Schema::hasTable('custom_model_has_permissions')); + $this->assertTrue(Schema::hasTable('custom_role_has_permissions')); + $this->assertFalse(Schema::hasTable('roles')); + $this->assertFalse(Schema::hasTable('permissions')); + + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-articles'); + $this->testUserRole->givePermissionTo('edit-news'); + + $this->assertDatabaseHas('custom_roles', ['name' => 'testRole']); + $this->assertDatabaseHas('custom_permissions', ['name' => 'edit-articles']); + $this->assertDatabaseHas('custom_model_has_roles', [ + 'role_test_id' => $this->testUserRole->getKey(), + 'model_test_id' => $this->testUser->getKey(), + ]); + $this->assertDatabaseHas('custom_model_has_permissions', [ + 'permission_test_id' => $this->app->make(Permission::class)::findByName('edit-articles')->getKey(), + 'model_test_id' => $this->testUser->getKey(), + ]); + $this->assertDatabaseHas('custom_role_has_permissions', [ + 'role_test_id' => $this->testUserRole->getKey(), + 'permission_test_id' => $this->app->make(Permission::class)::findByName('edit-news')->getKey(), + ]); + + $this->assertTrue($this->app->make(Role::class)::where('name', 'testRole')->exists()); + $this->assertTrue($this->app->make(Permission::class)::where('name', 'edit-articles')->exists()); + } +} diff --git a/tests/Permission/Events/EventTest.php b/tests/Permission/Events/EventTest.php new file mode 100644 index 000000000..b2876e2b6 --- /dev/null +++ b/tests/Permission/Events/EventTest.php @@ -0,0 +1,103 @@ +testUser->assignRole('testRole'); + + Event::assertNotDispatched(RoleAttachedEvent::class); + } + + public function testRoleAttachedEventChecksListenersBeforeDispatching(): void + { + $this->app->make('config')->set('permission.events_enabled', true); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->byDefault()->andReturnFalse(); + $events->shouldReceive('hasListeners')->once()->with(RoleAttachedEvent::class)->andReturnFalse(); + $events->shouldReceive('dispatch')->with(m::type(RoleAttachedEvent::class), m::any(), m::any())->never(); + + $this->app->instance('events', $events); + $this->app->instance(Dispatcher::class, $events); + + $this->testUser->assignRole('testRole'); + } + + public function testRoleAttachedEventIsDispatchedWhenEnabledAndListenedFor(): void + { + $this->app->make('config')->set('permission.events_enabled', true); + + Event::fake([RoleAttachedEvent::class]); + + $this->testUser->assignRole('testRole'); + + Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event): bool { + return $event->model->is($this->testUser) + && $event->rolesOrIds === [$this->testUserRole->getKey()]; + }); + } + + public function testRoleDetachedEventIsDispatchedWhenEnabledAndListenedFor(): void + { + $this->testUser->assignRole('testRole'); + $this->app->make('config')->set('permission.events_enabled', true); + + Event::fake([RoleDetachedEvent::class]); + + $this->testUser->removeRole('testRole'); + + Event::assertDispatched(RoleDetachedEvent::class, function (RoleDetachedEvent $event): bool { + return $event->model->is($this->testUser) + && $event->rolesOrIds === [$this->testUserRole->getKey()]; + }); + } + + public function testPermissionAttachedEventIsDispatchedWhenEnabledAndListenedFor(): void + { + $this->app->make('config')->set('permission.events_enabled', true); + + Event::fake([PermissionAttachedEvent::class]); + + $this->testUser->givePermissionTo('edit-articles'); + + Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event): bool { + return $event->model->is($this->testUser) + && $event->permissionsOrIds === [$this->testUserPermission->getKey()]; + }); + } + + public function testPermissionDetachedEventIsDispatchedWhenEnabledAndListenedFor(): void + { + $this->testUser->givePermissionTo('edit-articles'); + $this->app->make('config')->set('permission.events_enabled', true); + + Event::fake([PermissionDetachedEvent::class]); + + $this->testUser->revokePermissionTo('edit-articles'); + + Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event): bool { + $permission = $event->permissionsOrIds; + + return $event->model->is($this->testUser) + && $permission instanceof PermissionContract + && $permission->getKey() === $this->testUserPermission->getKey(); + }); + } +} diff --git a/tests/Permission/Fixtures/ContentPolicy.php b/tests/Permission/Fixtures/ContentPolicy.php new file mode 100644 index 000000000..9892cf103 --- /dev/null +++ b/tests/Permission/Fixtures/ContentPolicy.php @@ -0,0 +1,26 @@ +hasRole('testAdminRole', 'admin') ?: null; + } + + public function view(User $user, Content $content): bool + { + return $user->id === $content->user_id; + } + + public function update(User $user, Content $modelRecord): bool + { + return $user->id === $modelRecord->user_id || $user->can('edit-articles'); + } +} diff --git a/tests/Permission/Fixtures/Models/Admin.php b/tests/Permission/Fixtures/Models/Admin.php new file mode 100644 index 000000000..f0ba2d2ba --- /dev/null +++ b/tests/Permission/Fixtures/Models/Admin.php @@ -0,0 +1,12 @@ +getAttribute($model->getKeyName()) === null || $model->getAttribute($model->getKeyName()) === '') { + $model->{$model->getKeyName()} = Str::uuid()->toString(); + } + }); + } + + public function getIncrementing(): bool + { + return false; + } + + public function getKeyType(): string + { + return 'string'; + } +} diff --git a/tests/Permission/Fixtures/Models/Role.php b/tests/Permission/Fixtures/Models/Role.php new file mode 100644 index 000000000..7475f8453 --- /dev/null +++ b/tests/Permission/Fixtures/Models/Role.php @@ -0,0 +1,76 @@ +attributes['name']; + + if (str_contains($name, 'casted_enum')) { + return TestRolePermissionsEnum::from($name); + } + + return $name; + } + + public function parents(): BelongsToMany + { + return $this->belongsToMany( + static::class, + static::HIERARCHY_TABLE, + 'child_id', + 'parent_id' + ); + } + + public function children(): BelongsToMany + { + return $this->belongsToMany( + static::class, + static::HIERARCHY_TABLE, + 'parent_id', + 'child_id' + ); + } + + protected static function boot(): void + { + parent::boot(); + static::creating(static function ($model) { + if ($model->getAttribute($model->getKeyName()) === null || $model->getAttribute($model->getKeyName()) === '') { + $model->{$model->getKeyName()} = Str::uuid()->toString(); + } + }); + } + + public function getIncrementing(): bool + { + return false; + } + + public function getKeyType(): string + { + return 'string'; + } +} diff --git a/tests/Permission/Fixtures/Models/RuntimeRole.php b/tests/Permission/Fixtures/Models/RuntimeRole.php new file mode 100644 index 000000000..82597167a --- /dev/null +++ b/tests/Permission/Fixtures/Models/RuntimeRole.php @@ -0,0 +1,15 @@ + 'Writers', + self::Editor => 'Editors', + self::UserManager => 'User Managers', + self::Admin => 'Admins', + + self::ViewArticles => 'View Articles', + self::EditArticles => 'Edit Articles', + + default => Str::words($this->value), + }; + } +} diff --git a/tests/Permission/Fixtures/Models/User.php b/tests/Permission/Fixtures/Models/User.php new file mode 100644 index 000000000..48e9da8a0 --- /dev/null +++ b/tests/Permission/Fixtures/Models/User.php @@ -0,0 +1,12 @@ +client; + } +} diff --git a/tests/Permission/Fixtures/TestHelper.php b/tests/Permission/Fixtures/TestHelper.php new file mode 100644 index 000000000..ec6fe169e --- /dev/null +++ b/tests/Permission/Fixtures/TestHelper.php @@ -0,0 +1,26 @@ +handle(new Request, function () { + return (new Response)->setContent(''); + }, $parameter)->status(); + } catch (HttpException $e) { + return $e->getStatusCode(); + } + } +} diff --git a/tests/Permission/Fixtures/views/can.blade.php b/tests/Permission/Fixtures/views/can.blade.php new file mode 100644 index 000000000..f0c6c8214 --- /dev/null +++ b/tests/Permission/Fixtures/views/can.blade.php @@ -0,0 +1,5 @@ +@can($permission, $guard ?? null) +has permission +@else +does not have permission +@endcan diff --git a/tests/Permission/Fixtures/views/guardHasAllRoles.blade.php b/tests/Permission/Fixtures/views/guardHasAllRoles.blade.php new file mode 100644 index 000000000..239935501 --- /dev/null +++ b/tests/Permission/Fixtures/views/guardHasAllRoles.blade.php @@ -0,0 +1,5 @@ +@hasallroles($roles, $guard) +does have all of the given roles +@else +does not have all of the given roles +@endhasallroles diff --git a/tests/Permission/Fixtures/views/guardHasAllRolesArray.blade.php b/tests/Permission/Fixtures/views/guardHasAllRolesArray.blade.php new file mode 100644 index 000000000..6ba51c1d8 --- /dev/null +++ b/tests/Permission/Fixtures/views/guardHasAllRolesArray.blade.php @@ -0,0 +1,5 @@ +@hasallroles(['super-admin', 'moderator'], $guard) +does have all of the given roles +@else +does not have all of the given roles +@endhasallroles diff --git a/tests/Permission/Fixtures/views/guardHasAllRolesPipe.blade.php b/tests/Permission/Fixtures/views/guardHasAllRolesPipe.blade.php new file mode 100644 index 000000000..618df0a8b --- /dev/null +++ b/tests/Permission/Fixtures/views/guardHasAllRolesPipe.blade.php @@ -0,0 +1,5 @@ +@hasallroles("super-admin|moderator", $guard) +does have all of the given roles +@else +does not have all of the given roles +@endhasallroles diff --git a/tests/Permission/Fixtures/views/guardHasAnyRole.blade.php b/tests/Permission/Fixtures/views/guardHasAnyRole.blade.php new file mode 100644 index 000000000..fb5127c08 --- /dev/null +++ b/tests/Permission/Fixtures/views/guardHasAnyRole.blade.php @@ -0,0 +1,5 @@ +@hasanyrole($roles, $guard) +does have some of the roles +@else +does not have any of the given roles +@endhasanyrole diff --git a/tests/Permission/Fixtures/views/guardHasAnyRolePipe.blade.php b/tests/Permission/Fixtures/views/guardHasAnyRolePipe.blade.php new file mode 100644 index 000000000..c6e8191cc --- /dev/null +++ b/tests/Permission/Fixtures/views/guardHasAnyRolePipe.blade.php @@ -0,0 +1,5 @@ +@hasanyrole("super-admin|moderator", $guard) +does have some of the roles +@else +does not have any of the given roles +@endhasanyrole diff --git a/tests/Permission/Fixtures/views/guardHasRole.blade.php b/tests/Permission/Fixtures/views/guardHasRole.blade.php new file mode 100644 index 000000000..8c0c3cdd8 --- /dev/null +++ b/tests/Permission/Fixtures/views/guardHasRole.blade.php @@ -0,0 +1,5 @@ +@hasrole($role, $guard) +has role +@else +does not have role +@endhasrole diff --git a/tests/Permission/Fixtures/views/guardRole.blade.php b/tests/Permission/Fixtures/views/guardRole.blade.php new file mode 100644 index 000000000..3ae98758c --- /dev/null +++ b/tests/Permission/Fixtures/views/guardRole.blade.php @@ -0,0 +1,5 @@ +@role($role, $guard) +has role for guard +@else +does not have role for guard +@endrole diff --git a/tests/Permission/Fixtures/views/guardunlessrole.blade.php b/tests/Permission/Fixtures/views/guardunlessrole.blade.php new file mode 100644 index 000000000..00d81bad0 --- /dev/null +++ b/tests/Permission/Fixtures/views/guardunlessrole.blade.php @@ -0,0 +1,5 @@ +@unlessrole($role, $guard) +does not have role +@else +has role +@endunlessrole diff --git a/tests/Permission/Fixtures/views/hasAllRoles.blade.php b/tests/Permission/Fixtures/views/hasAllRoles.blade.php new file mode 100644 index 000000000..1972a5611 --- /dev/null +++ b/tests/Permission/Fixtures/views/hasAllRoles.blade.php @@ -0,0 +1,5 @@ +@hasallroles($roles) +does have all of the given roles +@else +does not have all of the given roles +@endhasallroles diff --git a/tests/Permission/Fixtures/views/hasAnyRole.blade.php b/tests/Permission/Fixtures/views/hasAnyRole.blade.php new file mode 100644 index 000000000..d78348c6a --- /dev/null +++ b/tests/Permission/Fixtures/views/hasAnyRole.blade.php @@ -0,0 +1,5 @@ +@hasanyrole($roles) +does have some of the roles +@else +does not have any of the given roles +@endhasanyrole diff --git a/tests/Permission/Fixtures/views/hasRole.blade.php b/tests/Permission/Fixtures/views/hasRole.blade.php new file mode 100644 index 000000000..6a38eb007 --- /dev/null +++ b/tests/Permission/Fixtures/views/hasRole.blade.php @@ -0,0 +1,5 @@ +@hasrole($role) +has role +@else +does not have role +@endhasrole diff --git a/tests/Permission/Fixtures/views/haspermission.blade.php b/tests/Permission/Fixtures/views/haspermission.blade.php new file mode 100644 index 000000000..7dd4e7903 --- /dev/null +++ b/tests/Permission/Fixtures/views/haspermission.blade.php @@ -0,0 +1,7 @@ +@haspermission($permission, $guard ?? null) +has permission +@elsehaspermission($elsepermission, $guard ?? null) +has else permission +@else +does not have permission +@endhaspermission diff --git a/tests/Permission/Fixtures/views/role.blade.php b/tests/Permission/Fixtures/views/role.blade.php new file mode 100644 index 000000000..022ddfddf --- /dev/null +++ b/tests/Permission/Fixtures/views/role.blade.php @@ -0,0 +1,7 @@ +@role($role) +has role +@elserole($elserole) +has else role +@else +does not have role +@endrole diff --git a/tests/Permission/Fixtures/views/unlessrole.blade.php b/tests/Permission/Fixtures/views/unlessrole.blade.php new file mode 100644 index 000000000..86c118c2c --- /dev/null +++ b/tests/Permission/Fixtures/views/unlessrole.blade.php @@ -0,0 +1,5 @@ +@unlessrole($role) +does not have role +@else +has role +@endunlessrole diff --git a/tests/Permission/ForbiddenPermissionTest.php b/tests/Permission/ForbiddenPermissionTest.php new file mode 100644 index 000000000..a2e83c0e1 --- /dev/null +++ b/tests/Permission/ForbiddenPermissionTest.php @@ -0,0 +1,196 @@ +testUser->giveForbiddenTo('edit-articles'); + + $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + $this->assertTrue((bool) $this->testUser->permissions->firstWhere('name', 'edit-articles')->pivot->getAttribute('is_forbidden')); + } + + public function testDirectForbiddenPermissionOverridesRolePermission(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->testUser->assignRole('testRole'); + $this->testUser->giveForbiddenTo('edit-articles'); + + $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + $this->assertFalse($this->testUser->getAllPermissions()->contains('name', 'edit-articles')); + } + + public function testRoleForbiddenPermissionOverridesDirectPermission(): void + { + $role = $this->app->make(RoleContract::class)::create(['name' => 'restricted']); + $role->giveForbiddenTo('edit-articles'); + + $this->testUser->givePermissionTo('edit-articles'); + $this->testUser->assignRole($role); + + $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertFalse($this->testUser->getAllPermissions()->contains('name', 'edit-articles')); + } + + public function testForbiddenPermissionWinsWhenAllowedAndForbiddenAreSyncedTogether(): void + { + $changes = $this->testUser->syncPermissionsWithForbidden( + allowed: ['edit-articles', 'edit-news'], + forbidden: ['edit-news'], + ); + + $this->assertArrayHasKey('attached', $changes); + $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('edit-news')); + $this->assertTrue($this->testUser->hasForbiddenPermission('edit-news')); + } + + public function testForbiddenSyncReplacesExistingDirectPermissions(): void + { + $this->app->make(PermissionContract::class)::create(['name' => 'delete-articles']); + + $this->testUser->givePermissionTo('edit-articles', 'edit-news'); + $changes = $this->testUser->syncPermissionsWithForbidden( + allowed: ['edit-news'], + forbidden: ['delete-articles'], + ); + + $this->testUser->refresh(); + + $this->assertEqualsCanonicalizing([ + $this->testUserPermission->getKey(), + ], $changes['detached']); + $this->assertEqualsCanonicalizing([ + $this->app->make(PermissionContract::class)::findByName('delete-articles')->getKey(), + ], $changes['attached']); + $this->assertEqualsCanonicalizing([ + $this->app->make(PermissionContract::class)::findByName('edit-news')->getKey(), + ], $changes['updated']); + $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDirectPermission('edit-news')); + $this->assertTrue($this->testUser->hasForbiddenPermission('delete-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('delete-articles')); + } + + public function testForbiddenSyncReportsPivotChangesAsUpdates(): void + { + $this->testUser->givePermissionTo('edit-articles'); + $this->testUser->giveForbiddenTo('edit-news'); + + $changes = $this->testUser->syncPermissionsWithForbidden( + allowed: ['edit-news'], + forbidden: ['edit-articles'], + ); + + $this->testUser->refresh(); + + $this->assertSame([], $changes['attached']); + $this->assertSame([], $changes['detached']); + $this->assertEqualsCanonicalizing([ + $this->testUserPermission->getKey(), + $this->app->make(PermissionContract::class)::findByName('edit-news')->getKey(), + ], $changes['updated']); + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUser->hasForbiddenPermission('edit-articles')); + $this->assertTrue($this->testUser->hasPermissionTo('edit-news')); + $this->assertFalse($this->testUser->hasForbiddenPermission('edit-news')); + } + + public function testRoleForbiddenSyncUsesCustomPermissionPrimaryKeys(): void + { + $this->setUpCustomModels(); + + $allowedPermission = Permission::findOrCreate('custom-allow'); + $forbiddenPermission = Permission::findOrCreate('custom-deny'); + $role = Role::findOrCreate('custom-role'); + + $changes = $role->syncPermissionsWithForbidden( + allowed: [$allowedPermission], + forbidden: [$forbiddenPermission], + ); + + $role = $role->fresh(); + + $this->assertEqualsCanonicalizing([ + $allowedPermission->getKey(), + $forbiddenPermission->getKey(), + ], $changes['attached']); + $this->assertTrue($role->hasPermissionTo($allowedPermission)); + $this->assertTrue($role->hasForbiddenPermission($forbiddenPermission)); + $this->assertFalse($role->hasPermissionTo($forbiddenPermission)); + } + + public function testRoleForbiddenPermissionsAreExcludedFromRolePermissionResults(): void + { + $role = $this->app->make(RoleContract::class)::create(['name' => 'mixed']); + + $this->app->make(PermissionContract::class)::create(['name' => 'delete-articles']); + + $role->givePermissionTo('edit-articles'); + $role->giveForbiddenTo('delete-articles'); + $this->testUser->assignRole($role); + + $permissionNames = $this->testUser->getPermissionsViaRoles()->pluck('name')->all(); + + $this->assertContains('edit-articles', $permissionNames); + $this->assertNotContains('delete-articles', $permissionNames); + $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('delete-articles')); + $this->assertFalse($this->testUser->hasPermissionTo('delete-articles')); + } + + public function testRoleForbiddenSyncAffectsAllUsersWithRoleAfterCachesAreWarm(): void + { + $role = $this->app->make(RoleContract::class)::create(['name' => 'publisher']); + $role->givePermissionTo('edit-articles', 'edit-news'); + + $this->testUser->assignRole($role); + $anotherUser = User::create(['email' => 'another@example.com']); + $anotherUser->assignRole($role); + + $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); + $this->assertTrue($anotherUser->hasPermissionTo('edit-news')); + + $role->syncPermissionsWithForbidden( + allowed: ['edit-blog'], + forbidden: ['edit-articles'], + ); + + $this->testUser->refresh(); + $anotherUser->refresh(); + + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUser->hasForbiddenPermissionViaRoles('edit-articles')); + $this->assertTrue($this->testUser->hasPermissionTo('edit-blog')); + $this->assertFalse($anotherUser->hasPermissionTo('edit-news')); + $this->assertTrue($anotherUser->hasPermissionTo('edit-blog')); + } + + public function testRoleGetAllPermissionsExcludesForbiddenPermissions(): void + { + $role = $this->app->make(RoleContract::class)::create(['name' => 'reviewer']); + + $role->givePermissionTo('edit-articles'); + $role->giveForbiddenTo('edit-news'); + + $permissionNames = $role->getAllPermissions()->pluck('name')->all(); + + $this->assertSame(['edit-articles'], $permissionNames); + $this->assertTrue($role->hasForbiddenPermission('edit-news')); + } +} diff --git a/tests/Permission/GateTest.php b/tests/Permission/GateTest.php new file mode 100644 index 000000000..10d7f4a64 --- /dev/null +++ b/tests/Permission/GateTest.php @@ -0,0 +1,88 @@ +assertFalse($this->testUser->can('edit-articles')); + } + + public function testOtherGateBeforeCallbacksCanGrantMissingPermissions(): void + { + $this->assertFalse($this->testUser->can('edit-articles')); + + $this->app->make(Gate::class)->before(fn (): bool => true); + + $this->assertTrue($this->testUser->can('edit-articles')); + } + + public function testGateAfterCallbackCanGrantDeniedPrivileges(): void + { + $this->assertFalse($this->testUser->can('edit-articles')); + + $this->app->make(Gate::class)->after(fn (): bool => true); + + $this->assertTrue($this->testUser->can('edit-articles')); + } + + public function testItAllowsDirectPermissionsThroughGate(): void + { + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertTrue($this->testUser->can('edit-articles')); + $this->assertFalse($this->testUser->can('non-existing-permission')); + $this->assertFalse($this->testUser->can('admin-permission')); + } + + public function testItAllowsRolePermissionsThroughGate(): void + { + $this->testUserRole->givePermissionTo($this->testUserPermission); + $this->testUser->assignRole($this->testUserRole); + + $this->assertTrue($this->testUser->hasPermissionTo($this->testUserPermission)); + $this->assertTrue($this->testUser->can('edit-articles')); + $this->assertFalse($this->testUser->can('non-existing-permission')); + $this->assertFalse($this->testUser->can('admin-permission')); + } + + public function testItAllowsRolePermissionsForUsersWithDifferentGuardsThroughGate(): void + { + $this->testAdminRole->givePermissionTo($this->testAdminPermission); + $this->testAdmin->assignRole($this->testAdminRole); + + $this->assertTrue($this->testAdmin->hasPermissionTo($this->testAdminPermission)); + $this->assertTrue($this->testAdmin->can('admin-permission')); + $this->assertFalse($this->testAdmin->can('non-existing-permission')); + $this->assertFalse($this->testAdmin->can('edit-articles')); + } + + public function testItAllowsEnumPermissionsThroughGate(): void + { + $this->app->make(Permission::class)::findOrCreate(TestRolePermissionsEnum::ViewArticles); + + $this->assertFalse($this->testUser->can(TestRolePermissionsEnum::ViewArticles->value)); + $this->assertFalse($this->testUser->canAny([TestRolePermissionsEnum::ViewArticles->value, 'missing'])); + + $this->testUser->givePermissionTo(TestRolePermissionsEnum::ViewArticles); + + $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::ViewArticles)); + $this->assertTrue($this->testUser->can(TestRolePermissionsEnum::ViewArticles->value)); + $this->assertTrue($this->testUser->canAny([TestRolePermissionsEnum::ViewArticles->value, 'missing'])); + } + + public function testForbiddenPermissionDeniesGatePermission(): void + { + $this->testUser->givePermissionTo('edit-articles'); + $this->testUser->giveForbiddenTo('edit-articles'); + + $this->assertFalse($this->testUser->can('edit-articles')); + } +} diff --git a/tests/Permission/Integration/BladeTest.php b/tests/Permission/Integration/BladeTest.php new file mode 100644 index 000000000..157ec82ff --- /dev/null +++ b/tests/Permission/Integration/BladeTest.php @@ -0,0 +1,331 @@ +create(['name' => 'member']); + $roleModel->create(['name' => 'writer']); + $roleModel->create(['name' => 'intern']); + $roleModel->create(['name' => 'super-admin', 'guard_name' => 'admin']); + $roleModel->create(['name' => 'moderator', 'guard_name' => 'admin']); + } + + public function testItEvaluatesAllBladeDirectivesAsFalseWhenNobodyIsLoggedIn(): void + { + $permission = 'edit-articles'; + $role = 'writer'; + $roles = [$role]; + $elserole = 'na'; + $elsepermission = 'na'; + + $this->assertSame('does not have permission', $this->renderView('can', compact('permission'))); + $this->assertSame('does not have permission', $this->renderView('haspermission', compact('permission', 'elsepermission'))); + $this->assertSame('does not have role', $this->renderView('role', compact('role', 'elserole'))); + $this->assertSame('does not have role', $this->renderView('hasRole', compact('role', 'elserole'))); + $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', compact('roles'))); + $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', ['roles' => implode('|', $roles)])); + $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', compact('roles'))); + $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', ['roles' => implode('|', $roles)])); + } + + public function testItEvaluatesAllBladeDirectivesAsFalseWhenUserHasNoRolesOrPermissions(): void + { + Auth::setUser($this->testUser); + + $permission = 'edit-articles'; + $role = 'writer'; + $roles = 'writer'; + $elserole = 'na'; + $elsepermission = 'na'; + + $this->assertSame('does not have permission', $this->renderView('can', compact('permission'))); + $this->assertSame('does not have permission', $this->renderView('haspermission', compact('permission', 'elsepermission'))); + $this->assertSame('does not have role', $this->renderView('role', compact('role', 'elserole'))); + $this->assertSame('does not have role', $this->renderView('hasRole', compact('role', 'elserole'))); + $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', compact('roles'))); + $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', compact('roles'))); + } + + public function testItEvaluatesAllBladeDirectivesAsFalseWhenSomebodyWithAnotherGuardIsLoggedIn(): void + { + Auth::guard('admin')->setUser($this->testAdmin); + + $permission = 'edit-articles'; + $role = 'writer'; + $roles = 'writer'; + $elserole = 'na'; + $elsepermission = 'na'; + + $this->assertSame('does not have permission', $this->renderView('can', compact('permission'))); + $this->assertSame('does not have permission', $this->renderView('haspermission', compact('permission', 'elsepermission'))); + $this->assertSame('does not have role', $this->renderView('role', compact('role', 'elserole'))); + $this->assertSame('does not have role', $this->renderView('hasRole', compact('role', 'elserole'))); + $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', compact('roles'))); + $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', compact('roles'))); + } + + public function testItAcceptsAGuardNameInTheCanDirective(): void + { + $user = $this->writer(); + $user->givePermissionTo('edit-articles'); + Auth::setUser($user); + + $permission = 'edit-articles'; + $guard = 'web'; + $this->assertSame('has permission', $this->renderView('can', compact('permission', 'guard'))); + + $guard = 'admin'; + $this->assertSame('does not have permission', $this->renderView('can', compact('permission', 'guard'))); + + Auth::logout(); + + $this->testAdmin->givePermissionTo($this->testAdminPermission); + Auth::setUser($this->testAdmin); + + $permission = 'edit-articles'; + $guard = 'web'; + $this->assertSame('does not have permission', $this->renderView('can', compact('permission', 'guard'))); + + $permission = 'admin-permission'; + $guard = 'admin'; + $this->assertTrue($this->testAdmin->checkPermissionTo($permission, $guard)); + $this->assertSame('has permission', $this->renderView('can', compact('permission', 'guard'))); + } + + public function testCanDirectiveIsTrueWhenUserHasPermission(): void + { + $user = $this->writer(); + $user->givePermissionTo('edit-articles'); + Auth::setUser($user); + + $this->assertSame('has permission', $this->renderView('can', ['permission' => 'edit-articles'])); + } + + public function testHaspermissionDirectiveIsTrueWhenUserHasPermission(): void + { + $user = $this->writer(); + $permission = 'edit-articles'; + $user->givePermissionTo('edit-articles'); + Auth::setUser($user); + + $this->assertSame('has permission', $this->renderView('haspermission', compact('permission'))); + + $guard = 'admin'; + $elsepermission = 'na'; + $this->assertSame('does not have permission', $this->renderView('haspermission', compact('permission', 'elsepermission', 'guard'))); + + $this->testAdminRole->givePermissionTo($this->testAdminPermission); + $this->testAdmin->assignRole($this->testAdminRole); + Auth::guard('admin')->setUser($this->testAdmin); + $permission = 'admin-permission'; + + $this->assertSame('has permission', $this->renderView('haspermission', compact('permission', 'guard', 'elsepermission'))); + } + + public function testRoleDirectiveIsTrueWhenUserHasRole(): void + { + Auth::setUser($this->writer()); + + $this->assertSame('has role', $this->renderView('role', ['role' => 'writer', 'elserole' => 'na'])); + } + + public function testElseroleDirectiveIsTrueWhenUserHasElseRole(): void + { + Auth::setUser($this->member()); + + $this->assertSame('has else role', $this->renderView('role', ['role' => 'writer', 'elserole' => 'member'])); + } + + public function testRoleDirectiveIsTrueForGivenGuard(): void + { + Auth::guard('admin')->setUser($this->superAdmin()); + + $this->assertSame('has role for guard', $this->renderView('guardRole', ['role' => 'super-admin', 'guard' => 'admin'])); + } + + public function testHasroleDirectiveIsTrueWhenUserHasRole(): void + { + Auth::setUser($this->writer()); + + $this->assertSame('has role', $this->renderView('hasRole', ['role' => 'writer'])); + } + + public function testHasroleDirectiveIsTrueForGivenGuard(): void + { + Auth::guard('admin')->setUser($this->superAdmin()); + + $this->assertSame('has role', $this->renderView('guardHasRole', ['role' => 'super-admin', 'guard' => 'admin'])); + } + + public function testUnlessroleDirectiveIsTrueWhenUserDoesNotHaveRole(): void + { + Auth::setUser($this->writer()); + + $this->assertSame('does not have role', $this->renderView('unlessrole', ['role' => 'another'])); + } + + public function testUnlessroleDirectiveIsTrueForGivenGuard(): void + { + Auth::guard('admin')->setUser($this->superAdmin()); + + $this->assertSame('does not have role', $this->renderView('guardunlessrole', ['role' => 'another', 'guard' => 'admin'])); + $this->assertSame('does not have role', $this->renderView('guardunlessrole', ['role' => 'super-admin', 'guard' => 'web'])); + } + + public function testHasanyroleDirectiveIsFalseWhenUserDoesNotHaveAnyRequiredRole(): void + { + $roles = ['writer', 'intern']; + Auth::setUser($this->member()); + + $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', compact('roles'))); + $this->assertSame('does not have any of the given roles', $this->renderView('hasAnyRole', ['roles' => implode('|', $roles)])); + } + + public function testHasanyroleDirectiveIsTrueWhenUserHasSomeRequiredRoles(): void + { + $roles = ['member', 'writer', 'intern']; + Auth::setUser($this->member()); + + $this->assertSame('does have some of the roles', $this->renderView('hasAnyRole', compact('roles'))); + $this->assertSame('does have some of the roles', $this->renderView('hasAnyRole', ['roles' => implode('|', $roles)])); + } + + public function testHasanyroleDirectiveIsTrueForGivenGuard(): void + { + $roles = ['super-admin', 'moderator']; + $guard = 'admin'; + Auth::guard('admin')->setUser($this->superAdmin()); + + $this->assertSame('does have some of the roles', $this->renderView('guardHasAnyRole', compact('roles', 'guard'))); + } + + public function testHasanyroleDirectiveIsTrueForPipeInput(): void + { + $guard = 'admin'; + Auth::guard('admin')->setUser($this->superAdmin()); + + $this->assertSame('does have some of the roles', $this->renderView('guardHasAnyRolePipe', compact('guard'))); + } + + public function testHasanyroleDirectiveIsFalseForPipeInput(): void + { + $guard = ''; + Auth::guard('admin')->setUser($this->member()); + + $this->assertSame('does not have any of the given roles', $this->renderView('guardHasAnyRolePipe', compact('guard'))); + } + + public function testHasallrolesDirectiveIsFalseWhenUserDoesNotHaveAllRequiredRoles(): void + { + $roles = ['member', 'writer']; + Auth::setUser($this->member()); + + $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', compact('roles'))); + $this->assertSame('does not have all of the given roles', $this->renderView('hasAllRoles', ['roles' => implode('|', $roles)])); + } + + public function testHasallrolesDirectiveIsTrueWhenUserHasAllRequiredRoles(): void + { + $roles = ['member', 'writer']; + $user = $this->member(); + $user->assignRole('writer'); + Auth::setUser($user); + + $this->assertSame('does have all of the given roles', $this->renderView('hasAllRoles', compact('roles'))); + $this->assertSame('does have all of the given roles', $this->renderView('hasAllRoles', ['roles' => implode('|', $roles)])); + } + + public function testHasallrolesDirectiveIsTrueForGivenGuard(): void + { + $roles = ['super-admin', 'moderator']; + $guard = 'admin'; + $admin = $this->superAdmin(); + $admin->assignRole('moderator'); + Auth::guard('admin')->setUser($admin); + + $this->assertSame('does have all of the given roles', $this->renderView('guardHasAllRoles', compact('roles', 'guard'))); + } + + public function testHasallrolesDirectiveIsTrueForPipeInput(): void + { + $guard = 'admin'; + $admin = $this->superAdmin(); + $admin->assignRole('moderator'); + Auth::guard('admin')->setUser($admin); + + $this->assertSame('does have all of the given roles', $this->renderView('guardHasAllRolesPipe', compact('guard'))); + } + + public function testHasallrolesDirectiveIsFalseForPipeInput(): void + { + $guard = ''; + $user = $this->member(); + $user->assignRole('writer'); + Auth::setUser($user); + + $this->assertSame('does not have all of the given roles', $this->renderView('guardHasAllRolesPipe', compact('guard'))); + } + + public function testHasallrolesDirectiveIsTrueForArrayInput(): void + { + $guard = 'admin'; + $admin = $this->superAdmin(); + $admin->assignRole('moderator'); + Auth::guard('admin')->setUser($admin); + + $this->assertSame('does have all of the given roles', $this->renderView('guardHasAllRolesArray', compact('guard'))); + } + + public function testHasallrolesDirectiveIsFalseForArrayInput(): void + { + $guard = ''; + $user = $this->member(); + $user->assignRole('writer'); + Auth::setUser($user); + + $this->assertSame('does not have all of the given roles', $this->renderView('guardHasAllRolesArray', compact('guard'))); + } + + protected function renderView(string $view, array $parameters): string + { + Artisan::call('view:clear'); + + return trim((string) view($view)->with($parameters)); + } + + protected function writer(): User + { + $this->testUser->assignRole('writer'); + + return $this->testUser; + } + + protected function member(): User + { + $this->testUser->assignRole('member'); + + return $this->testUser; + } + + protected function superAdmin(): Admin + { + $this->testAdmin->assignRole('super-admin'); + + return $this->testAdmin; + } +} diff --git a/tests/Permission/Integration/CacheTest.php b/tests/Permission/Integration/CacheTest.php new file mode 100644 index 000000000..5dd770945 --- /dev/null +++ b/tests/Permission/Integration/CacheTest.php @@ -0,0 +1,280 @@ +registrar = $this->app->make(PermissionRegistrar::class); + $this->registrar->forgetCachedPermissions(); + + DB::connection()->enableQueryLog(); + } + + public function testItCanCacheThePermissions(): void + { + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItFlushesTheCacheWhenCreatingAPermission(): void + { + $this->app->make(Permission::class)->create(['name' => 'new']); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItFlushesTheCacheWhenUpdatingAPermission(): void + { + $permission = $this->app->make(Permission::class)->create(['name' => 'new']); + + $permission->name = 'other name'; + $permission->save(); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItFlushesTheCacheWhenCreatingARole(): void + { + $this->app->make(Role::class)->create(['name' => 'new']); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItFlushesTheCacheWhenUpdatingARole(): void + { + $role = $this->app->make(Role::class)->create(['name' => 'new']); + + $role->name = 'other name'; + $role->save(); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItShouldNotFlushTheCacheWhenRemovingAPermissionFromAUser(): void + { + $this->testUser->givePermissionTo('edit-articles'); + + $this->registrar->getPermissions(); + + $this->testUser->revokePermissionTo('edit-articles'); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount(0); + } + + public function testItShouldNotFlushTheCacheWhenRemovingARoleFromAUser(): void + { + $this->testUser->assignRole('testRole'); + + $this->registrar->getPermissions(); + + $this->testUser->removeRole('testRole'); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount(0); + } + + public function testItFlushesTheCacheWhenRemovingARoleFromAPermission(): void + { + $this->testUserPermission->assignRole('testRole'); + + $this->registrar->getPermissions(); + + $this->testUserPermission->removeRole('testRole'); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItFlushesTheCacheWhenAssigningAPermissionToARole(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItShouldNotFlushTheCacheOnUserCreation(): void + { + $this->registrar->getPermissions(); + + User::create(['email' => 'new']); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount(0); + } + + public function testItFlushesTheCacheWhenGivingAPermissionToARole(): void + { + $this->testUserRole->givePermissionTo($this->testUserPermission); + + $this->resetQueryCount(); + + $this->registrar->getPermissions(); + + $this->assertQueryCount($this->cacheRunCount); + } + + public function testItUsesTheCacheForHasPermissionTo(): void + { + $this->testUserRole->givePermissionTo(['edit-articles', 'edit-news', 'Edit News']); + $this->testUser->assignRole('testRole'); + $this->testUser->loadMissing('roles', 'permissions'); + + $this->resetQueryCount(); + $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); + $this->assertQueryCount(0); + + $this->resetQueryCount(); + $this->assertTrue($this->testUser->hasPermissionTo('edit-news')); + $this->assertQueryCount(0); + + $this->resetQueryCount(); + $this->assertTrue($this->testUser->hasPermissionTo('edit-articles')); + $this->assertQueryCount(0); + + $this->resetQueryCount(); + $this->assertTrue($this->testUser->hasPermissionTo('Edit News')); + $this->assertQueryCount(0); + } + + public function testItDifferentiatesTheCacheByGuardName(): void + { + $this->app->make(Permission::class)->create(['name' => 'web']); + $this->testUserRole->givePermissionTo(['edit-articles', 'web']); + $this->testUser->assignRole('testRole'); + $this->testUser->loadMissing('roles', 'permissions'); + + $this->resetQueryCount(); + $this->assertTrue($this->testUser->hasPermissionTo('edit-articles', 'web')); + $this->assertQueryCount(0); + + $this->resetQueryCount(); + + $this->expectException(PermissionDoesNotExist::class); + + $this->testUser->hasPermissionTo('edit-articles', 'admin'); + } + + public function testItUsesTheCacheForGetAllPermissions(): void + { + $this->testUserRole->givePermissionTo($expected = ['edit-articles', 'edit-news']); + $this->testUser->assignRole('testRole'); + $this->testUser->loadMissing('roles.permissions', 'permissions'); + + $this->resetQueryCount(); + $this->registrar->getPermissions(); + $this->assertQueryCount(0); + + $this->resetQueryCount(); + $actual = $this->testUser->getAllPermissions()->pluck('name')->sort()->values(); + + $this->assertEquals(collect($expected), $actual); + $this->assertQueryCount(0); + } + + public function testItStoresRoleAttributesOnceWhileHydratingPermissionPivots(): void + { + $this->testUserRole->givePermissionTo(['edit-articles', 'edit-news']); + $permissions = $this->registrar->getPermissions(); + $roles = $permissions->flatMap->roles; + + $this->assertNotSame($roles[0], $roles[1]); + $this->assertSame($roles[0]->getKey(), $roles[1]->getKey()); + $this->assertNotSame( + $roles[0]->pivot->getAttribute($this->registrar->pivotPermission), + $roles[1]->pivot->getAttribute($this->registrar->pivotPermission), + ); + + $payload = $this->registrar->getCacheRepository()->get($this->registrar->cacheKey); + + $matchingRoles = array_filter( + $payload['roles'], + fn (array $role): bool => $role['attributes'][$this->testUserRole->getKeyName()] === $this->testUserRole->getKey(), + ); + + $this->assertCount(1, $matchingRoles); + } + + public function testItCanResetTheCacheWithArtisanCommand(): void + { + Artisan::call('permission:create-permission', ['name' => 'new-permission']); + + $permissionClass = $this->app->make(Permission::class); + + $this->assertCount(1, $permissionClass::where('name', 'new-permission')->get()); + + $this->resetQueryCount(); + $this->registrar->getPermissions(); + $this->assertQueryCount($this->cacheRunCount); + + Artisan::call('permission:cache-reset'); + + $this->resetQueryCount(); + $this->registrar->getPermissions(); + $this->assertQueryCount($this->cacheRunCount); + } + + protected function resetQueryCount(): void + { + DB::flushQueryLog(); + } + + protected function assertQueryCount(int $expected): void + { + $this->assertCount($expected, DB::getQueryLog()); + } +} diff --git a/tests/Permission/Integration/MultipleGuardsTest.php b/tests/Permission/Integration/MultipleGuardsTest.php new file mode 100644 index 000000000..c9cf5083c --- /dev/null +++ b/tests/Permission/Integration/MultipleGuardsTest.php @@ -0,0 +1,78 @@ +testUser->givePermissionTo($this->app->make(Permission::class)::create([ + 'name' => 'do_this', + 'guard_name' => 'web', + ])); + + $this->testUser->givePermissionTo($this->app->make(Permission::class)::create([ + 'name' => 'do_that', + 'guard_name' => 'api', + ])); + + $this->assertTrue($this->testUser->checkPermissionTo('do_this', 'web')); + $this->assertTrue($this->testUser->checkPermissionTo('do_that', 'api')); + $this->assertFalse($this->testUser->checkPermissionTo('do_that', 'web')); + } + + public function testGateCanGrantPermissionByGuardName(): void + { + $this->testUser->givePermissionTo($this->app->make(Permission::class)::create([ + 'name' => 'do_this', + 'guard_name' => 'web', + ])); + + $this->testUser->givePermissionTo($this->app->make(Permission::class)::create([ + 'name' => 'do_that', + 'guard_name' => 'api', + ])); + + $this->assertTrue($this->testUser->can('do_this', 'web')); + $this->assertTrue($this->testUser->can('do_that', 'api')); + $this->assertFalse($this->testUser->can('do_that', 'web')); + $this->assertTrue($this->testUser->cannot('do_that', 'web')); + $this->assertTrue($this->testUser->canAny(['do_this', 'do_that'], 'web')); + + $this->testAdminRole->givePermissionTo($this->testAdminPermission); + $this->testAdmin->assignRole($this->testAdminRole); + + $this->assertTrue($this->testAdmin->hasPermissionTo($this->testAdminPermission)); + $this->assertTrue($this->testAdmin->can('admin-permission')); + $this->assertTrue($this->testAdmin->can('admin-permission', 'admin')); + $this->assertTrue($this->testAdmin->cannot('admin-permission', 'web')); + $this->assertTrue($this->testAdmin->cannot('non-existing-permission')); + $this->assertTrue($this->testAdmin->cannot('non-existing-permission', 'web')); + $this->assertTrue($this->testAdmin->cannot('non-existing-permission', 'admin')); + $this->assertTrue($this->testAdmin->cannot(['admin-permission', 'non-existing-permission'], 'web')); + $this->assertFalse($this->testAdmin->can('edit-articles', 'web')); + $this->assertFalse($this->testAdmin->can('edit-articles', 'admin')); + $this->assertTrue($this->testUser->cannot('edit-articles', 'admin')); + $this->assertTrue($this->testUser->cannot('admin-permission', 'admin')); + $this->assertTrue($this->testUser->cannot('admin-permission', 'web')); + } + + public function testItHonorsGuardNameMethodWhenOverridingGuardNameProperty(): void + { + $user = Manager::create(['email' => 'manager@test.com']); + $user->givePermissionTo($this->app->make(Permission::class)::create([ + 'name' => 'do_jwt', + 'guard_name' => 'jwt', + ])); + + $this->assertTrue($user->checkPermissionTo('do_jwt', 'jwt')); + $this->assertTrue($user->hasPermissionTo('do_jwt', 'jwt')); + $this->assertFalse($user->checkPermissionTo('do_jwt', 'web')); + } +} diff --git a/tests/Permission/Integration/PermissionRegistrarTest.php b/tests/Permission/Integration/PermissionRegistrarTest.php new file mode 100644 index 000000000..3c6241151 --- /dev/null +++ b/tests/Permission/Integration/PermissionRegistrarTest.php @@ -0,0 +1,120 @@ +app->make(PermissionRegistrar::class); + + $registrar->getPermissions(); + + $this->assertTrue(CoroutineContext::has(PermissionRegistrar::PERMISSION_CATALOG_CONTEXT_KEY)); + + $registrar->clearPermissionsCollection(); + + $this->assertFalse(CoroutineContext::has(PermissionRegistrar::PERMISSION_CATALOG_CONTEXT_KEY)); + } + + public function testItCanCheckUids(): void + { + $uids = [ + // UUIDs + '00000000-0000-0000-0000-000000000000', + '9be37b52-e1fa-4e86-b65f-cbfcbedde838', + 'fc458041-fb21-4eea-a04b-b55c87a7224a', + '78144b52-a889-11ed-afa1-0242ac120002', + '78144f4e-a889-11ed-afa1-0242ac120002', + // GUIDs + '4b8590bb-90a2-4f38-8dc9-70e663a5b0e5', + 'A98C5A1E-A742-4808-96FA-6F409E799937', + '1f01164a-98e9-4246-93ec-7941aefb1da6', + '91b73d20-89e6-46b0-b39b-632706cc3ed7', + '0df4a5b8-7c2e-484f-ad1d-787d1b83aacc', + // ULIDs + '01GRVB3DREB63KNN4G2QVV99DF', + '01GRVB3DRECY317SJCJ6DMTFCA', + '01GRVB3DREGGPBXNH1M24GX1DS', + '01GRVB3DRESRM2K9AVQSW1JCKA', + '01GRVB3DRES5CQ31PB24MP4CSV', + ]; + + $notUids = [ + '9be37b52-e1fa', + '9be37b52-e1fa-4e86', + '9be37b52-e1fa-4e86-b65f', + '01GRVB3DREB63KNN4G2', + 'TEST STRING', + '00-00-00-00-00-00', + '91GRVB3DRES5CQ31PB24MP4CSV', + ]; + + foreach ($uids as $uid) { + $this->assertTrue(PermissionRegistrar::isUid($uid)); + } + + foreach ($notUids as $notUid) { + $this->assertFalse(PermissionRegistrar::isUid($notUid)); + } + } + + public function testItCanGetPermissionClass(): void + { + $this->assertSame(HypervelPermission::class, $this->app->make(PermissionRegistrar::class)->getPermissionClass()); + $this->assertInstanceOf(HypervelPermission::class, $this->app->make(PermissionContract::class)); + } + + public function testItCanChangePermissionClass(): void + { + $this->assertSame(HypervelPermission::class, $this->app->make('config')->get('permission.models.permission')); + $this->assertSame(HypervelPermission::class, $this->app->make(PermissionRegistrar::class)->getPermissionClass()); + $this->assertInstanceOf(HypervelPermission::class, $this->app->make(PermissionContract::class)); + + $this->app->make(PermissionRegistrar::class)->setPermissionClass(TestPermission::class); + + $this->assertSame(HypervelPermission::class, $this->app->make('config')->get('permission.models.permission')); + $this->assertSame(TestPermission::class, $this->app->make(PermissionRegistrar::class)->getPermissionClass()); + $this->assertInstanceOf(TestPermission::class, $this->app->make(PermissionContract::class)); + } + + public function testItCanGetRoleClass(): void + { + $this->assertSame(HypervelRole::class, $this->app->make(PermissionRegistrar::class)->getRoleClass()); + $this->assertInstanceOf(HypervelRole::class, $this->app->make(RoleContract::class)); + } + + public function testItCanChangeRoleClass(): void + { + $this->assertSame(HypervelRole::class, $this->app->make('config')->get('permission.models.role')); + $this->assertSame(HypervelRole::class, $this->app->make(PermissionRegistrar::class)->getRoleClass()); + $this->assertInstanceOf(HypervelRole::class, $this->app->make(RoleContract::class)); + + $this->app->make(PermissionRegistrar::class)->setRoleClass(TestRole::class); + + $this->assertSame(HypervelRole::class, $this->app->make('config')->get('permission.models.role')); + $this->assertSame(TestRole::class, $this->app->make(PermissionRegistrar::class)->getRoleClass()); + $this->assertInstanceOf(TestRole::class, $this->app->make(RoleContract::class)); + } + + public function testItCanChangeTeamId(): void + { + $teamId = '00000000-0000-0000-0000-000000000000'; + + $this->app->make(PermissionRegistrar::class)->setPermissionsTeamId($teamId); + + $this->assertSame($teamId, $this->app->make(PermissionRegistrar::class)->getPermissionsTeamId()); + } +} diff --git a/tests/Permission/Integration/PolicyTest.php b/tests/Permission/Integration/PolicyTest.php new file mode 100644 index 000000000..2875f90df --- /dev/null +++ b/tests/Permission/Integration/PolicyTest.php @@ -0,0 +1,33 @@ + 'special admin content']); + $record2 = Content::create(['content' => 'viewable', 'user_id' => $this->testUser->id]); + + $this->app->make(Gate::class)->policy(Content::class, ContentPolicy::class); + + $this->assertFalse($this->testUser->can('view', $record1)); + $this->assertFalse($this->testUser->can('update', $record1)); + + $this->assertTrue($this->testUser->can('update', $record2)); + + $this->assertFalse($this->testAdmin->can('update', $record1)); + + $this->testAdmin->assignRole($this->testAdminRole); + + $this->assertTrue($this->testAdmin->can('update', $record1)); + $this->assertTrue($this->testAdmin->can('update', $record2)); + } +} diff --git a/tests/Permission/Integration/RouteTest.php b/tests/Permission/Integration/RouteTest.php new file mode 100644 index 000000000..a17bee932 --- /dev/null +++ b/tests/Permission/Integration/RouteTest.php @@ -0,0 +1,131 @@ +getRouter(); + + $router->get('role-test', $this->getRouteResponse()) + ->name('role.test') + ->role('superadmin'); + + $this->assertSame(['role:superadmin'], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testPermissionFunction(): void + { + $router = $this->getRouter(); + + $router->get('permission-test', $this->getRouteResponse()) + ->name('permission.test') + ->permission(['edit articles', 'save articles']); + + $this->assertSame(['permission:edit articles|save articles'], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleAndPermissionFunctionTogether(): void + { + $router = $this->getRouter(); + + $router->get('role-permission-test', $this->getRouteResponse()) + ->name('role-permission.test') + ->role('superadmin|admin') + ->permission('create user|edit user'); + + $this->assertSame([ + 'role:superadmin|admin', + 'permission:create user|edit user', + ], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleFunctionWithBackedEnum(): void + { + $router = $this->getRouter(); + + $router->get('role-test.enum', $this->getRouteResponse()) + ->name('role.test.enum') + ->role(TestRolePermissionsEnum::UserManager); + + $this->assertSame(['role:' . TestRolePermissionsEnum::UserManager->value], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testPermissionFunctionWithBackedEnum(): void + { + $router = $this->getRouter(); + + $router->get('permission-test.enum', $this->getRouteResponse()) + ->name('permission.test.enum') + ->permission(TestRolePermissionsEnum::Writer); + + $this->assertSame(['permission:' . TestRolePermissionsEnum::Writer->value], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleAndPermissionFunctionTogetherWithBackedEnum(): void + { + $router = $this->getRouter(); + + $router->get('roles-permissions-test.enum', $this->getRouteResponse()) + ->name('roles-permissions.test.enum') + ->role([TestRolePermissionsEnum::UserManager, TestRolePermissionsEnum::Admin]) + ->permission([TestRolePermissionsEnum::Writer, TestRolePermissionsEnum::Editor]); + + $this->assertSame([ + 'role:' . TestRolePermissionsEnum::UserManager->value . '|' . TestRolePermissionsEnum::Admin->value, + 'permission:' . TestRolePermissionsEnum::Writer->value . '|' . TestRolePermissionsEnum::Editor->value, + ], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleOrPermissionFunction(): void + { + $router = $this->getRouter(); + + $router->get('role-or-permission-test', $this->getRouteResponse()) + ->name('role-or-permission.test') + ->roleOrPermission('admin|edit articles'); + + $this->assertSame(['role_or_permission:admin|edit articles'], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleOrPermissionFunctionWithArray(): void + { + $router = $this->getRouter(); + + $router->get('role-or-permission-array-test', $this->getRouteResponse()) + ->name('role-or-permission-array.test') + ->roleOrPermission(['admin', 'edit articles']); + + $this->assertSame(['role_or_permission:admin|edit articles'], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleOrPermissionFunctionWithBackedEnum(): void + { + $router = $this->getRouter(); + + $router->get('role-or-permission-test.enum', $this->getRouteResponse()) + ->name('role-or-permission.test.enum') + ->roleOrPermission(TestRolePermissionsEnum::UserManager); + + $this->assertSame(['role_or_permission:' . TestRolePermissionsEnum::UserManager->value], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleOrPermissionFunctionWithBackedEnumArray(): void + { + $router = $this->getRouter(); + + $router->get('role-or-permission-array-test.enum', $this->getRouteResponse()) + ->name('role-or-permission-array.test.enum') + ->roleOrPermission([TestRolePermissionsEnum::UserManager, TestRolePermissionsEnum::EditArticles]); + + $this->assertSame([ + 'role_or_permission:' . TestRolePermissionsEnum::UserManager->value . '|' . TestRolePermissionsEnum::EditArticles->value, + ], $this->getLastRouteMiddlewareFromRouter($router)); + } +} diff --git a/tests/Permission/Integration/WildcardRouteTest.php b/tests/Permission/Integration/WildcardRouteTest.php new file mode 100644 index 000000000..0fc294a61 --- /dev/null +++ b/tests/Permission/Integration/WildcardRouteTest.php @@ -0,0 +1,44 @@ +app->make('config')->set('permission.enable_wildcard_permission', true); + $this->flushPermissionState(); + } + + public function testPermissionFunction(): void + { + $router = $this->getRouter(); + + $router->get('permission-test', $this->getRouteResponse()) + ->name('permission.test') + ->permission(['articles.edit', 'articles.save']); + + $this->assertSame(['permission:articles.edit|articles.save'], $this->getLastRouteMiddlewareFromRouter($router)); + } + + public function testRoleAndPermissionFunctionTogether(): void + { + $router = $this->getRouter(); + + $router->get('role-permission-test', $this->getRouteResponse()) + ->name('role-permission.test') + ->role('superadmin|admin') + ->permission('user.create|user.edit'); + + $this->assertSame([ + 'role:superadmin|admin', + 'permission:user.create|user.edit', + ], $this->getLastRouteMiddlewareFromRouter($router)); + } +} diff --git a/tests/Permission/Middleware/PassportClientMiddlewareTest.php b/tests/Permission/Middleware/PassportClientMiddlewareTest.php new file mode 100644 index 000000000..7471ceb9b --- /dev/null +++ b/tests/Permission/Middleware/PassportClientMiddlewareTest.php @@ -0,0 +1,181 @@ +setUpPassportClient(); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(200, $this->runMiddleware(new PermissionMiddleware, 'edit-posts', null, true)); + } + + public function testPassportClientCanAccessPermissionMiddlewareIfItHasOneOfThePermissions(): void + { + $this->setUpPassportClient(); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(200, $this->runMiddleware(new PermissionMiddleware, 'edit-news|edit-posts', null, true)); + $this->assertSame(200, $this->runMiddleware(new PermissionMiddleware, ['edit-news', 'edit-posts'], null, true)); + } + + public function testPassportClientCanAccessPermissionMiddlewareThroughRolePermission(): void + { + $this->setUpPassportClient(); + + $this->assertSame(403, $this->runMiddleware(new PermissionMiddleware, 'edit-posts', null, true)); + + $this->testClientRole->givePermissionTo('edit-posts'); + $this->testClient->assignRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware(new PermissionMiddleware, 'edit-posts', null, true)); + } + + public function testPassportClientCannotAccessPermissionMiddlewareWithADifferentPermission(): void + { + $this->setUpPassportClient(); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware(new PermissionMiddleware, 'edit-news', null, true)); + } + + public function testPassportClientCannotAccessPermissionMiddlewareWithoutPermissions(): void + { + $this->setUpPassportClient(); + + $this->assertSame(403, $this->runMiddleware(new PermissionMiddleware, 'edit-articles|edit-posts', null, true)); + } + + public function testPassportClientCanAccessRoleMiddleware(): void + { + $this->setUpPassportClient(); + + $this->testClient->assignRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware(new RoleMiddleware, 'clientRole', null, true)); + } + + public function testPassportClientCanAccessRoleMiddlewareIfItHasOneOfTheRoles(): void + { + $this->setUpPassportClient(); + + $this->testClient->assignRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware(new RoleMiddleware, 'clientRole|testRole2', null, true)); + $this->assertSame(200, $this->runMiddleware(new RoleMiddleware, ['testRole2', 'clientRole'], null, true)); + } + + public function testPassportClientCannotAccessRoleMiddlewareWithADifferentRole(): void + { + $this->setUpPassportClient(); + + $this->testClient->assignRole('clientRole'); + + $this->assertSame(403, $this->runMiddleware(new RoleMiddleware, 'clientRole2', null, true)); + } + + public function testPassportClientCannotAccessRoleMiddlewareWithoutRoles(): void + { + $this->setUpPassportClient(); + + $this->assertSame(403, $this->runMiddleware(new RoleMiddleware, 'testRole|testRole2', null, true)); + } + + public function testPassportClientCannotAccessRoleMiddlewareWhenRoleIsUndefined(): void + { + $this->setUpPassportClient(); + + $this->assertSame(403, $this->runMiddleware(new RoleMiddleware, '', null, true)); + } + + public function testPassportClientCanAccessRoleOrPermissionMiddleware(): void + { + $this->setUpPassportClient(); + + $this->testClient->assignRole('clientRole'); + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(200, $this->runMiddleware(new RoleOrPermissionMiddleware, 'clientRole|edit-news|edit-posts', null, true)); + + $this->testClient->removeRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware(new RoleOrPermissionMiddleware, 'clientRole|edit-posts', null, true)); + + $this->testClient->revokePermissionTo('edit-posts'); + $this->testClient->assignRole('clientRole'); + + $this->assertSame(200, $this->runMiddleware(new RoleOrPermissionMiddleware, 'clientRole|edit-posts', null, true)); + $this->assertSame(200, $this->runMiddleware(new RoleOrPermissionMiddleware, ['clientRole', 'edit-posts'], null, true)); + } + + public function testPassportClientCannotAccessRoleOrPermissionMiddlewareWithoutTheRoleOrPermission(): void + { + $this->setUpPassportClient(); + + $this->assertSame(403, $this->runMiddleware(new RoleOrPermissionMiddleware, 'clientRole|edit-posts', null, true)); + $this->assertSame(403, $this->runMiddleware(new RoleOrPermissionMiddleware, 'missingRole|missingPermission', null, true)); + } + + public function testPassportClientIsNotUsedWhenFeatureIsDisabled(): void + { + $this->setUpPassportClient(); + + $this->app->make('config')->set('permission.use_passport_client_credentials', false); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware(new PermissionMiddleware, 'edit-posts', 'api', true)); + } + + public function testPassportClientMustMatchRequestedGuard(): void + { + $this->setUpPassportClient(); + + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware(new PermissionMiddleware, 'edit-posts', 'web', true)); + } + + public function testPassportClientCannotAccessRoleMiddlewareWithWrongGuard(): void + { + $this->setUpPassportClient(); + + $this->testClient->assignRole('clientRole'); + + $this->assertSame(403, $this->runMiddleware(new RoleMiddleware, 'clientRole', 'admin', true)); + } + + public function testPassportClientCannotAccessRoleOrPermissionMiddlewareWithWrongGuard(): void + { + $this->setUpPassportClient(); + + $this->testClient->assignRole('clientRole'); + $this->testClient->givePermissionTo('edit-posts'); + + $this->assertSame(403, $this->runMiddleware(new RoleOrPermissionMiddleware, 'edit-posts|clientRole', 'admin', true)); + } + + protected function setUpPassportClient(): void + { + $this->setUpPassport(); + + $client = $this->testClient; + + Auth::extend('passport', fn (): PassportGuard => new PassportGuard($client)); + Auth::forgetGuards(); + } +} diff --git a/tests/Permission/Middleware/PermissionMiddlewareTest.php b/tests/Permission/Middleware/PermissionMiddlewareTest.php index 3597f1b23..96b2cde71 100644 --- a/tests/Permission/Middleware/PermissionMiddlewareTest.php +++ b/tests/Permission/Middleware/PermissionMiddlewareTest.php @@ -4,146 +4,217 @@ namespace Hypervel\Tests\Permission\Middleware; -use Closure; -use Hypervel\Auth\AuthManager; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\Request; -use Hypervel\Permission\Exceptions\PermissionException; +use Hypervel\Http\Response; +use Hypervel\Permission\Contracts\Permission; use Hypervel\Permission\Exceptions\UnauthorizedException; use Hypervel\Permission\Middleware\PermissionMiddleware; -use Hypervel\Permission\Models\Permission; -use Hypervel\Tests\Permission\Enums\Permission as PermissionEnum; -use Hypervel\Tests\Permission\Models\User; -use Hypervel\Tests\Permission\PermissionTestCase; -use Mockery as m; -use Symfony\Component\HttpFoundation\Response; - -class PermissionMiddlewareTest extends PermissionTestCase +use Hypervel\Support\Facades\Auth; +use Hypervel\Support\Facades\Gate; +use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; +use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; +use Hypervel\Tests\Permission\TestCase; +use InvalidArgumentException; + +class PermissionMiddlewareTest extends TestCase { - protected PermissionMiddleware $middleware; + protected PermissionMiddleware $permissionMiddleware; - protected Request $request; + protected function setUp(): void + { + parent::setUp(); - protected Closure $next; + $this->permissionMiddleware = new PermissionMiddleware; + } + + public function testGuestCannotAccessPermissionProtectedRoute(): void + { + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); + } - protected Response $response; + public function testUserCanAccessRouteWithDirectPermission(): void + { + Auth::login($this->testUser); - protected Container $container; + $this->testUser->givePermissionTo('edit-articles'); - protected AuthManager $authManager; + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); + } - protected function setUp(): void + public function testSuperAdminGateBeforeCanAccessPermissionProtectedRoute(): void { - parent::setUp(); + Auth::login($this->testUser); - $this->container = m::mock(Container::class); - $this->authManager = m::mock(AuthManager::class); - $this->container->shouldReceive('make') - ->with('auth') - ->andReturn($this->authManager); + Gate::before(fn ($user): ?bool => $user->getKey() === $this->testUser->getKey() ? true : null); - $this->middleware = new PermissionMiddleware($this->container); - $this->request = Request::create('http://example.com'); - $this->response = new Response; - $this->next = fn () => $this->response; + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); } - protected function tearDown(): void + public function testUserCanAccessRouteWithOneOfSeveralPermissions(): void { - parent::tearDown(); + Auth::login($this->testUser); + + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-news|edit-articles')); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['edit-news', 'edit-articles'])); } - public function testProcessThrowsUnauthorizedExceptionWhenUserNotLoggedIn(): void + public function testUserCanAccessRouteWithWildcardPermission(): void { - $this->authManager->shouldReceive('user')->once()->andReturn(null); + $this->app->make('config')->set('permission.enable_wildcard_permission', true); + $this->flushPermissionState(); + + Auth::login($this->testUser); - $this->expectException(UnauthorizedException::class); + $this->app->make(Permission::class)::create(['name' => 'articles.*.test']); + $this->testUser->givePermissionTo('articles.*.test'); - $this->middleware->handle($this->request, $this->next, 'view'); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'news.edit|articles.create.test')); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['news.edit', 'articles.create.test'])); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.create.other')); } - public function testProcessThrowsUnauthorizedExceptionWhenUserMissingHasAnyPermissionMethod(): void + public function testUserCannotAccessRouteWithoutMatchingPermission(): void { - $user = m::mock(); - $user->shouldReceive('getAuthIdentifier')->andReturn(''); + Auth::login($this->testUser); + + $this->testUser->givePermissionTo('edit-articles'); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); + } - $this->expectException(UnauthorizedException::class); - $this->expectExceptionMessage( - 'User "" does not have the "hasAnyPermissions" method. Cannot check permissions: view' - ); + public function testUserCannotAccessRouteWithoutPermissions(): void + { + Auth::login($this->testUser); - $this->middleware->handle($this->request, $this->next, 'view'); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles|edit-news')); } - public function testProcessThrowsPermissionExceptionWhenUserLacksPermission(): void + public function testUserCanAccessRouteWithPermissionViaRole(): void { - $user = User::create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + Auth::login($this->testUser); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUser->assignRole('testRole'); - $this->expectException(PermissionException::class); - $this->expectExceptionMessage( - 'User "' . $user->getAuthIdentifier() . '" does not have any of the required permissions: view' - ); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'edit-articles')); + } - $this->middleware->handle($this->request, $this->next, 'view'); + public function testUserWithoutHasRolesTraitCannotAccessRoute(): void + { + Auth::login(UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com'])); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-news')); } - public function testProcessSucceedsWhenUserHasPermission(): void + public function testGuardSpecificPermissionIsUsed(): void { - $user = User::create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'web']); + $adminPermission = $this->app->make(Permission::class)->create(['name' => 'admin-permission2', 'guard_name' => 'admin']); - Permission::create([ - 'name' => 'view', - 'guard_name' => 'web', - ]); + Auth::guard('admin')->login($this->testAdmin); - $user->givePermissionTo('view'); + $this->testAdmin->givePermissionTo($adminPermission); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'admin-permission2', 'web')); + } + + public function testUserCannotAccessPermissionWithAdminGuardWhileLoggedInUsingDefaultGuard(): void + { + Auth::login($this->testUser); - $result = $this->middleware->handle($this->request, $this->next, 'view'); + $this->testUser->givePermissionTo('edit-articles'); - $this->assertSame($this->response, $result); + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'edit-articles', 'admin')); } - public function testProcessWithMultiplePermissionsSucceedsWhenUserHasAny(): void + public function testUserCanAccessPermissionWithAdminGuardWhileLoggedInUsingAdminGuard(): void { - $user = User::create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + Auth::guard('admin')->login($this->testAdmin); - Permission::create([ - 'name' => 'view', - 'guard_name' => 'web', - ]); + $this->testAdmin->givePermissionTo('admin-permission'); - $user->givePermissionTo('view'); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'admin-permission', 'admin')); + } + + public function testItCanBeCreatedWithStaticUsingMethod(): void + { + $this->assertSame(PermissionMiddleware::class . ':edit-articles', PermissionMiddleware::using('edit-articles')); + $this->assertSame(PermissionMiddleware::class . ':edit-articles,my-guard', PermissionMiddleware::using('edit-articles', 'my-guard')); + $this->assertSame(PermissionMiddleware::class . ':edit-articles|edit-news', PermissionMiddleware::using(['edit-articles', 'edit-news'])); + } + + public function testItCanHandleEnumPermissionsWithStaticUsingMethod(): void + { + $this->assertSame(PermissionMiddleware::class . ':view articles', PermissionMiddleware::using(TestRolePermissionsEnum::ViewArticles)); + $this->assertSame(PermissionMiddleware::class . ':view articles,my-guard', PermissionMiddleware::using(TestRolePermissionsEnum::ViewArticles, 'my-guard')); + $this->assertSame(PermissionMiddleware::class . ':view articles|edit articles', PermissionMiddleware::using([ + TestRolePermissionsEnum::ViewArticles, + TestRolePermissionsEnum::EditArticles, + ])); + } + + public function testItCanHandleEnumPermissionsWithHandleMethod(): void + { + $this->app->make(Permission::class)->create(['name' => TestRolePermissionsEnum::ViewArticles->value]); + $this->app->make(Permission::class)->create(['name' => TestRolePermissionsEnum::EditArticles->value]); + + Auth::login($this->testUser); + $this->testUser->givePermissionTo(TestRolePermissionsEnum::ViewArticles); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, TestRolePermissionsEnum::ViewArticles)); + + $this->testUser->givePermissionTo(TestRolePermissionsEnum::EditArticles); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, [ + TestRolePermissionsEnum::ViewArticles, + TestRolePermissionsEnum::EditArticles, + ])); + } + + public function testItExposesRequiredPermissionsOnTheUnauthorizedException(): void + { + Auth::login($this->testUser); + + try { + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'permission.some'); + } catch (UnauthorizedException $exception) { + $this->assertSame(['permission.some'], $exception->getRequiredPermissions()); + + return; + } + + $this->fail('Expected unauthorized permission exception was not thrown.'); + } + + public function testItCanDisplayRequiredPermissionsOnTheUnauthorizedException(): void + { + Auth::login($this->testUser); + $this->app->make('config')->set('permission.display_permission_in_exception', true); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + try { + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'some-permission'); + } catch (UnauthorizedException $exception) { + $this->assertStringEndsWith('Necessary permissions are some-permission', $exception->getMessage()); - $result = $this->middleware->handle($this->request, $this->next, 'view', 'edit'); + return; + } - $this->assertSame($this->response, $result); + $this->fail('Expected unauthorized permission exception was not thrown.'); } - public function testParsePermissionsToStringWithMixedArray(): void + public function testItThrowsForMissingCustomGuard(): void { - $result = PermissionMiddleware::parsePermissionsToString([ - 'view', - PermissionEnum::Edit, - 'manage', - ]); + $this->expectException(InvalidArgumentException::class); - $this->assertEquals('view|edit|manage', $result); + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'edit-articles', 'xxx'); } } diff --git a/tests/Permission/Middleware/RoleMiddlewareTest.php b/tests/Permission/Middleware/RoleMiddlewareTest.php index 958a6e524..8f89cb5c9 100644 --- a/tests/Permission/Middleware/RoleMiddlewareTest.php +++ b/tests/Permission/Middleware/RoleMiddlewareTest.php @@ -4,146 +4,186 @@ namespace Hypervel\Tests\Permission\Middleware; -use Closure; -use Hypervel\Auth\AuthManager; -use Hypervel\Contracts\Container\Container; use Hypervel\Http\Request; -use Hypervel\Permission\Exceptions\RoleException; +use Hypervel\Http\Response; +use Hypervel\Permission\Contracts\Role; use Hypervel\Permission\Exceptions\UnauthorizedException; use Hypervel\Permission\Middleware\RoleMiddleware; -use Hypervel\Permission\Models\Role; -use Hypervel\Tests\Permission\Enums\Role as RoleEnum; -use Hypervel\Tests\Permission\Models\User; -use Hypervel\Tests\Permission\PermissionTestCase; -use Mockery as m; -use Symfony\Component\HttpFoundation\Response; - -class RoleMiddlewareTest extends PermissionTestCase +use Hypervel\Support\Facades\Auth; +use Hypervel\Tests\Permission\Fixtures\Models\TestRolePermissionsEnum; +use Hypervel\Tests\Permission\Fixtures\Models\UserWithoutHasRoles; +use Hypervel\Tests\Permission\TestCase; +use InvalidArgumentException; + +class RoleMiddlewareTest extends TestCase { - protected RoleMiddleware $middleware; + protected RoleMiddleware $roleMiddleware; - protected Request $request; + protected function setUp(): void + { + parent::setUp(); - protected Closure $next; + $this->roleMiddleware = new RoleMiddleware; + } - protected Response $response; + public function testGuestCannotAccessRoleProtectedRoute(): void + { + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole')); + } - protected Container $container; + public function testUserCanAccessRouteWithRole(): void + { + Auth::login($this->testUser); - protected AuthManager $authManager; + $this->testUser->assignRole('testRole'); - protected function setUp(): void + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testRole')); + } + + public function testUserCannotAccessRouteWithRoleFromAnotherGuard(): void { - parent::setUp(); + Auth::login($this->testUser); - $this->container = m::mock(Container::class); - $this->authManager = m::mock(AuthManager::class); - $this->container->shouldReceive('make') - ->with('auth') - ->andReturn($this->authManager); + $this->testUser->assignRole('testRole'); - $this->middleware = new RoleMiddleware($this->container); - $this->request = Request::create('http://example.com'); - $this->response = new Response; - $this->next = fn () => $this->response; + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testAdminRole')); } - protected function tearDown(): void + public function testUserCanAccessRouteWithOneOfSeveralRoles(): void { - parent::tearDown(); + Auth::login($this->testUser); + + $this->testUser->assignRole('testRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testRole|testRole2')); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, ['testRole2', 'testRole'])); } - public function testProcessThrowsUnauthorizedExceptionWhenUserNotLoggedIn(): void + public function testUserCannotAccessRouteWithDifferentRole(): void { - $this->authManager->shouldReceive('user')->once()->andReturn(null); + Auth::login($this->testUser); - $this->expectException(UnauthorizedException::class); + $this->testUser->assignRole('testRole'); - $this->middleware->handle($this->request, $this->next, 'admin'); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole2')); } - public function testProcessThrowsUnauthorizedExceptionWhenUserMissingHasAnyRolesMethod(): void + public function testUserCannotAccessRouteWithoutRoles(): void { - $user = m::mock(); - $user->shouldReceive('getAuthIdentifier')->andReturn(''); + Auth::login($this->testUser); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole|testRole2')); + } - $this->expectException(UnauthorizedException::class); - $this->expectExceptionMessage( - 'User "" does not have the "hasAnyRoles" method. Cannot check roles: admin' - ); + public function testUserCannotAccessRouteWithUndefinedRole(): void + { + Auth::login($this->testUser); - $this->middleware->handle($this->request, $this->next, 'admin'); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, '')); } - public function testProcessThrowsRoleExceptionWhenUserLacksRole(): void + public function testUserWithoutHasRolesTraitCannotAccessRoute(): void { - $user = User::create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + Auth::login(UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com'])); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole')); + } - $this->expectException(RoleException::class); - $this->expectExceptionMessage( - 'User "' . $user->getAuthIdentifier() . '" does not have any of the required roles: admin' - ); + public function testUserCanAccessRoleWithMatchingGuard(): void + { + Auth::guard('admin')->login($this->testAdmin); + + $this->testAdmin->assignRole('testAdminRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, 'testAdminRole', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole', 'admin')); + } + + public function testUserCannotAccessRoleWithAdminGuardWhileLoggedInUsingDefaultGuard(): void + { + Auth::login($this->testUser); + + $this->testUser->assignRole('testRole'); + + $this->assertSame(403, $this->runMiddleware($this->roleMiddleware, 'testRole', 'admin')); + } - $this->middleware->handle($this->request, $this->next, 'admin'); + public function testItCanBeCreatedWithStaticUsingMethod(): void + { + $this->assertSame(RoleMiddleware::class . ':testAdminRole', RoleMiddleware::using('testAdminRole')); + $this->assertSame(RoleMiddleware::class . ':testAdminRole,my-guard', RoleMiddleware::using('testAdminRole', 'my-guard')); + $this->assertSame(RoleMiddleware::class . ':testAdminRole|anotherRole', RoleMiddleware::using(['testAdminRole', 'anotherRole'])); } - public function testProcessSucceedsWhenUserHasRole(): void + public function testItCanHandleEnumRolesWithStaticUsingMethod(): void { - $user = User::create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->assertSame(RoleMiddleware::class . ':writer', RoleMiddleware::using(TestRolePermissionsEnum::Writer)); + $this->assertSame(RoleMiddleware::class . ':writer,my-guard', RoleMiddleware::using(TestRolePermissionsEnum::Writer, 'my-guard')); + $this->assertSame(RoleMiddleware::class . ':writer|editor', RoleMiddleware::using([ + TestRolePermissionsEnum::Writer, + TestRolePermissionsEnum::Editor, + ])); + } - Role::create([ - 'name' => 'admin', - 'guard_name' => 'web', - ]); + public function testItCanHandleEnumRolesWithHandleMethod(): void + { + $this->app->make(Role::class)->create(['name' => TestRolePermissionsEnum::Writer->value]); + $this->app->make(Role::class)->create(['name' => TestRolePermissionsEnum::Editor->value]); - $user->assignRole('admin'); + Auth::login($this->testUser); + $this->testUser->assignRole(TestRolePermissionsEnum::Writer); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, TestRolePermissionsEnum::Writer)); - $result = $this->middleware->handle($this->request, $this->next, 'admin'); + $this->testUser->assignRole(TestRolePermissionsEnum::Editor); - $this->assertSame($this->response, $result); + $this->assertSame(200, $this->runMiddleware($this->roleMiddleware, [ + TestRolePermissionsEnum::Writer, + TestRolePermissionsEnum::Editor, + ])); } - public function testProcessWithMultipleRolesSucceedsWhenUserHasAny(): void + public function testItExposesRequiredRolesOnTheUnauthorizedException(): void { - $user = User::create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + Auth::login($this->testUser); - Role::create([ - 'name' => 'admin', - 'guard_name' => 'web', - ]); + try { + $this->roleMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'role.some'); + } catch (UnauthorizedException $exception) { + $this->assertSame(['role.some'], $exception->getRequiredRoles()); - $user->assignRole('admin'); + return; + } + + $this->fail('Expected unauthorized role exception was not thrown.'); + } + + public function testItCanDisplayRequiredRolesOnTheUnauthorizedException(): void + { + Auth::login($this->testUser); + $this->app->make('config')->set('permission.display_role_in_exception', true); - $this->authManager->shouldReceive('user')->once()->andReturn($user); + try { + $this->roleMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'some-role'); + } catch (UnauthorizedException $exception) { + $this->assertStringEndsWith('Necessary roles are some-role', $exception->getMessage()); - $result = $this->middleware->handle($this->request, $this->next, 'admin', 'viewer'); + return; + } - $this->assertSame($this->response, $result); + $this->fail('Expected unauthorized role exception was not thrown.'); } - public function testParseRolesToStringWithMixedArray(): void + public function testItThrowsForMissingCustomGuard(): void { - $result = RoleMiddleware::parseRolesToString([ - 'admin', - RoleEnum::Viewer, - 'manager', - ]); + $this->expectException(InvalidArgumentException::class); - $this->assertEquals('admin|viewer|manager', $result); + $this->roleMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'testRole', 'xxx'); } } diff --git a/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php b/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php new file mode 100644 index 000000000..35f3e7244 --- /dev/null +++ b/tests/Permission/Middleware/RoleOrPermissionMiddlewareTest.php @@ -0,0 +1,151 @@ +roleOrPermissionMiddleware = new RoleOrPermissionMiddleware; + } + + public function testGuestCannotAccessProtectedRoute(): void + { + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole')); + } + + public function testUserCanAccessRouteWithEitherPermissionOrRole(): void + { + Auth::login($this->testUser); + + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-news|edit-articles')); + + $this->testUser->removeRole('testRole'); + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); + + $this->testUser->revokePermissionTo('edit-articles'); + $this->testUser->assignRole('testRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, ['testRole', 'edit-articles'])); + } + + public function testSuperAdminGateBeforeCanAccessProtectedRoute(): void + { + Auth::login($this->testUser); + + Gate::before(fn ($user): ?bool => $user->getKey() === $this->testUser->getKey() ? true : null); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); + } + + public function testUserWithoutHasRolesTraitCannotAccessRoute(): void + { + Auth::login(UserWithoutHasRoles::create(['email' => 'test_not_has_roles@user.com'])); + + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); + } + + public function testUserCannotAccessRouteWithoutMatchingPermissionOrRole(): void + { + Auth::login($this->testUser); + + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|edit-articles')); + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'missingRole|missingPermission')); + } + + public function testUserCanAccessPermissionOrRoleWithMatchingGuard(): void + { + Auth::guard('admin')->login($this->testAdmin); + + $this->testAdmin->assignRole('testAdminRole'); + $this->testAdmin->givePermissionTo('admin-permission'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'admin-permission|testAdminRole', 'admin')); + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-articles|testRole', 'admin')); + } + + public function testUserCannotAccessPermissionOrRoleWithAdminGuardWhileLoggedInUsingDefaultGuard(): void + { + Auth::login($this->testUser); + + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertSame(403, $this->runMiddleware($this->roleOrPermissionMiddleware, 'edit-articles|testRole', 'admin')); + } + + public function testItCanBeCreatedWithStaticUsingMethod(): void + { + $this->assertSame(RoleOrPermissionMiddleware::class . ':edit-articles', RoleOrPermissionMiddleware::using('edit-articles')); + $this->assertSame(RoleOrPermissionMiddleware::class . ':edit-articles,my-guard', RoleOrPermissionMiddleware::using('edit-articles', 'my-guard')); + $this->assertSame(RoleOrPermissionMiddleware::class . ':edit-articles|testAdminRole', RoleOrPermissionMiddleware::using(['edit-articles', 'testAdminRole'])); + } + + public function testItExposesRequiredRolesOrPermissionsOnTheUnauthorizedException(): void + { + Auth::login($this->testUser); + + try { + $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'some-permission|some-role'); + } catch (UnauthorizedException $exception) { + $this->assertSame('User does not have any of the necessary access rights.', $exception->getMessage()); + $this->assertSame(['some-permission', 'some-role'], $exception->getRequiredPermissions()); + + return; + } + + $this->fail('Expected unauthorized role or permission exception was not thrown.'); + } + + public function testItCanDisplayRequiredRolesOrPermissionsOnTheUnauthorizedException(): void + { + Auth::login($this->testUser); + $this->app->make('config')->set([ + 'permission.display_permission_in_exception' => true, + 'permission.display_role_in_exception' => true, + ]); + + try { + $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'some-permission|some-role'); + } catch (UnauthorizedException $exception) { + $this->assertStringEndsWith('Necessary roles or permissions are some-permission, some-role', $exception->getMessage()); + + return; + } + + $this->fail('Expected unauthorized role or permission exception was not thrown.'); + } + + public function testItThrowsForMissingCustomGuard(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->roleOrPermissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'testRole', 'xxx'); + } +} diff --git a/tests/Permission/Middleware/WildcardMiddlewareTest.php b/tests/Permission/Middleware/WildcardMiddlewareTest.php new file mode 100644 index 000000000..b1256352b --- /dev/null +++ b/tests/Permission/Middleware/WildcardMiddlewareTest.php @@ -0,0 +1,117 @@ +roleMiddleware = new RoleMiddleware; + $this->permissionMiddleware = new PermissionMiddleware; + $this->roleOrPermissionMiddleware = new RoleOrPermissionMiddleware; + + $this->app->make('config')->set('permission.enable_wildcard_permission', true); + $this->flushPermissionState(); + } + + public function testGuestCannotAccessPermissionProtectedRoute(): void + { + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.edit')); + } + + public function testUserCanAccessRouteWithWildcardPermission(): void + { + Auth::login($this->testUser); + + Permission::create(['name' => 'articles']); + $this->testUser->givePermissionTo('articles'); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'articles.edit')); + } + + public function testUserCanAccessRouteWithOneOfTheWildcardPermissions(): void + { + Auth::login($this->testUser); + + Permission::create(['name' => 'articles.*.test']); + $this->testUser->givePermissionTo('articles.*.test'); + + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, 'news.edit|articles.create.test')); + $this->assertSame(200, $this->runMiddleware($this->permissionMiddleware, ['news.edit', 'articles.create.test'])); + } + + public function testUserCannotAccessRouteWithDifferentWildcardPermission(): void + { + Auth::login($this->testUser); + + Permission::create(['name' => 'articles.*']); + $this->testUser->givePermissionTo('articles.*'); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'news.edit')); + } + + public function testUserCannotAccessRouteWithNoMatchingPermission(): void + { + Auth::login($this->testUser); + + $this->assertSame(403, $this->runMiddleware($this->permissionMiddleware, 'articles.edit|news.edit')); + } + + public function testUserCanAccessPermissionOrRoleProtectedRouteWithWildcardPermissionOrRole(): void + { + Auth::login($this->testUser); + + Permission::create(['name' => 'articles.*']); + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('articles.*'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|news.edit|articles.create')); + + $this->testUser->removeRole('testRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|articles.edit')); + + $this->testUser->revokePermissionTo('articles.*'); + $this->testUser->assignRole('testRole'); + + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, 'testRole|articles.edit')); + $this->assertSame(200, $this->runMiddleware($this->roleOrPermissionMiddleware, ['testRole', 'articles.edit'])); + } + + public function testItCanFetchRequiredPermissionsFromException(): void + { + Auth::login($this->testUser); + + try { + $this->permissionMiddleware->handle(new Request, function (): Response { + return (new Response)->setContent(''); + }, 'permission.some'); + } catch (UnauthorizedException $exception) { + $this->assertSame(['permission.some'], $exception->getRequiredPermissions()); + + return; + } + + $this->fail('Expected unauthorized permission exception was not thrown.'); + } +} diff --git a/tests/Permission/Models/PermissionTest.php b/tests/Permission/Models/PermissionTest.php new file mode 100644 index 000000000..91ea592ed --- /dev/null +++ b/tests/Permission/Models/PermissionTest.php @@ -0,0 +1,144 @@ +testUser->givePermissionTo($this->testUserPermission); + + $permission = $this->app->make(Permission::class)::with('users') + ->where($this->testUserPermission->getKeyName(), $this->testUserPermission->getKey()) + ->first(); + + $this->assertSame($this->testUserPermission->getKey(), $permission->getKey()); + $this->assertCount(1, $permission->users); + $this->assertSame($this->testUser->id, $permission->users[0]->id); + } + + #[DataProvider('permissionNameProvider')] + public function testItCanBeCreated(string|BackedEnum $name, string $expected): void + { + $permission = $this->app->make(Permission::class)->create(['name' => $name]); + + $this->assertSame($expected, $permission->name); + } + + #[DataProvider('permissionNameProvider')] + public function testItCanFindByName(string|BackedEnum $name, string $expected): void + { + $this->app->make(Permission::class)->create(['name' => $name]); + + $permission = $this->app->make(Permission::class)->findByName($name); + + $this->assertSame($expected, $permission->name); + } + + #[DataProvider('permissionNameProvider')] + public function testItCanFindOrCreateByName(string|BackedEnum $name, string $expected): void + { + $permission = $this->app->make(Permission::class)->findOrCreate($name); + + $this->assertSame($expected, $permission->name); + } + + /** + * Provide permission names. + */ + public static function permissionNameProvider(): array + { + return [ + 'string' => ['test-permission', 'test-permission'], + 'enum' => [TestPermissionEnum::TestPermission, TestPermissionEnum::TestPermission->value], + ]; + } + + #[DataProvider('permissionNameOnlyProvider')] + public function testItThrowsAnExceptionWhenThePermissionAlreadyExists(string|BackedEnum $name): void + { + $this->app->make(Permission::class)->create(['name' => $name]); + + $this->expectException(PermissionAlreadyExists::class); + + $this->app->make(Permission::class)->create(['name' => $name]); + } + + /** + * Provide permission names without expected values. + */ + public static function permissionNameOnlyProvider(): array + { + return [ + 'string' => ['test-permission'], + 'enum' => [TestPermissionEnum::TestPermission], + ]; + } + + public function testItBelongsToAGuard(): void + { + $permission = $this->app->make(Permission::class)->create(['name' => 'can-edit', 'guard_name' => 'admin']); + + $this->assertSame('admin', $permission->guard_name); + } + + public function testItBelongsToTheDefaultGuardByDefault(): void + { + $this->assertSame( + $this->app->make('config')->get('auth.defaults.guard'), + $this->testUserPermission->guard_name, + ); + } + + public function testItHasUserModelsOfTheRightClass(): void + { + $this->testAdmin->givePermissionTo($this->testAdminPermission); + $this->testUser->givePermissionTo($this->testUserPermission); + + $this->assertCount(1, $this->testUserPermission->users); + $this->assertTrue($this->testUserPermission->users->first()->is($this->testUser)); + $this->assertInstanceOf(User::class, $this->testUserPermission->users->first()); + } + + public function testItIsRetrievableById(): void + { + $permission = $this->app->make(Permission::class)->findById($this->testUserPermission->id); + + $this->assertSame($this->testUserPermission->id, $permission->id); + } + + public function testItCanDeleteHydratedPermissions(): void + { + $this->reloadPermissions(); + + $permission = $this->app->make(Permission::class)->findByName($this->testUserPermission->name); + $permission->delete(); + + $this->assertCount(0, $this->app->make(Permission::class) + ->where($this->testUserPermission->getKeyName(), $this->testUserPermission->getKey()) + ->get()); + } + + public function testItDoesNotTreatStringZeroAsEmptyWhenGivingPermission(): void + { + $this->app->make(Permission::class)->create(['name' => '0']); + + $this->testUser->givePermissionTo('0'); + + $this->assertTrue($this->testUser->hasPermissionTo('0')); + } +} diff --git a/tests/Permission/Models/RoleTest.php b/tests/Permission/Models/RoleTest.php new file mode 100644 index 000000000..5ad20b300 --- /dev/null +++ b/tests/Permission/Models/RoleTest.php @@ -0,0 +1,323 @@ + 'other-permission']); + Permission::create(['name' => 'wrong-guard-permission', 'guard_name' => 'admin']); + } + + public function testItGetsUserModelsUsingWith(): void + { + $this->testUser->assignRole($this->testUserRole); + + $role = $this->app->make(Role::class)::with('users') + ->where($this->testUserRole->getKeyName(), $this->testUserRole->getKey()) + ->first(); + + $this->assertSame($this->testUserRole->getKey(), $role->getKey()); + $this->assertCount(1, $role->users); + $this->assertSame($this->testUser->id, $role->users[0]->id); + } + + public function testItHasUserModelsOfTheRightClass(): void + { + $this->testAdmin->assignRole($this->testAdminRole); + $this->testUser->assignRole($this->testUserRole); + + $this->assertCount(1, $this->testUserRole->users); + $this->assertTrue($this->testUserRole->users->first()->is($this->testUser)); + $this->assertInstanceOf(User::class, $this->testUserRole->users->first()); + + $this->assertCount(1, $this->testAdminRole->users); + $this->assertTrue($this->testAdminRole->users->first()->is($this->testAdmin)); + $this->assertInstanceOf(Admin::class, $this->testAdminRole->users->first()); + } + + #[DataProvider('roleNameProvider')] + public function testItCanBeCreated(string|BackedEnum $name, string $expected): void + { + $role = $this->app->make(Role::class)->create(['name' => $name]); + + $this->assertSame($expected, $role->name); + } + + #[DataProvider('roleNameOnlyProvider')] + public function testItThrowsAnExceptionWhenTheRoleAlreadyExists(string|BackedEnum $name): void + { + $this->app->make(Role::class)->create(['name' => $name]); + + $this->expectException(RoleAlreadyExists::class); + + $this->app->make(Role::class)->create(['name' => $name]); + } + + /** + * Provide role names without expected values. + */ + public static function roleNameOnlyProvider(): array + { + return [ + 'string' => ['test-role'], + 'enum' => [TestRoleEnum::TestRole], + ]; + } + + public function testItCanBeGivenAPermission(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-articles')); + } + + public function testItThrowsAnExceptionWhenGivenAPermissionThatDoesNotExist(): void + { + $this->expectException(PermissionDoesNotExist::class); + + $this->testUserRole->givePermissionTo('create-evil-empire'); + } + + public function testItThrowsAnExceptionWhenGivenAPermissionThatBelongsToAnotherGuard(): void + { + $this->expectException(PermissionDoesNotExist::class); + + $this->testUserRole->givePermissionTo('admin-permission'); + } + + public function testItThrowsGuardMismatchWhenGivenAPermissionObjectFromAnotherGuard(): void + { + $this->expectException(GuardDoesNotMatch::class); + + $this->testUserRole->givePermissionTo($this->testAdminPermission); + } + + public function testItCanBeGivenMultiplePermissionsUsingAnArray(): void + { + $this->testUserRole->givePermissionTo(['edit-articles', 'edit-news']); + + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-news')); + } + + public function testItCanBeGivenMultiplePermissionsUsingMultipleArguments(): void + { + $this->testUserRole->givePermissionTo('edit-articles', 'edit-news'); + + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-news')); + } + + public function testItCanSyncPermissions(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->testUserRole->syncPermissions('edit-news'); + + $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-news')); + } + + public function testItThrowsAnExceptionWhenSyncingPermissionsThatDoNotExist(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->expectException(PermissionDoesNotExist::class); + + $this->testUserRole->syncPermissions('permission-does-not-exist'); + } + + public function testItThrowsAnExceptionWhenSyncingPermissionsThatBelongToADifferentGuardByName(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->expectException(PermissionDoesNotExist::class); + + $this->testUserRole->syncPermissions('admin-permission'); + } + + public function testItThrowsGuardMismatchWhenSyncingPermissionObjectsFromAnotherGuard(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->expectException(GuardDoesNotMatch::class); + + $this->testUserRole->syncPermissions($this->testAdminPermission); + } + + public function testSyncPermissionErrorDoesNotDetachPermissions(): void + { + $this->testUserRole->givePermissionTo('edit-news'); + + try { + $this->testUserRole->syncPermissions('edit-articles', 'permission-that-does-not-exist'); + } catch (PermissionDoesNotExist) { + $this->assertTrue($this->testUserRole->fresh()->hasDirectPermission('edit-news')); + + return; + } + + $this->fail('Expected missing permission exception.'); + } + + public function testItCanRevokeAPermission(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->assertTrue($this->testUserRole->hasPermissionTo('edit-articles')); + + $this->testUserRole->revokePermissionTo('edit-articles'); + $this->testUserRole = $this->testUserRole->fresh(); + + $this->assertFalse($this->testUserRole->hasPermissionTo('edit-articles')); + } + + public function testItCanBeGivenAPermissionUsingObjects(): void + { + $this->testUserRole->givePermissionTo($this->testUserPermission); + + $this->assertTrue($this->testUserRole->hasPermissionTo($this->testUserPermission)); + } + + public function testItReturnsFalseIfItDoesNotHaveThePermission(): void + { + $this->assertFalse($this->testUserRole->hasPermissionTo('other-permission')); + } + + public function testItThrowsAnExceptionIfThePermissionDoesNotExist(): void + { + $this->expectException(PermissionDoesNotExist::class); + + $this->testUserRole->hasPermissionTo('doesnt-exist'); + } + + #[DataProvider('roleNameProvider')] + public function testItCanBeFoundByName(string|BackedEnum $name, string $expected): void + { + $this->app->make(Role::class)->create(['name' => $name]); + + $role = $this->app->make(Role::class)->findByName($name); + + $this->assertSame($expected, $role->name); + } + + public function testItReturnsFalseIfItDoesNotHaveAPermissionObject(): void + { + $permission = Permission::findByName('other-permission'); + + $this->assertFalse($this->testUserRole->hasPermissionTo($permission)); + } + + public function testItCreatesPermissionObjectWithFindOrCreateIfItDoesNotHaveAPermissionObject(): void + { + $permission = Permission::findOrCreate('another-permission'); + + $this->assertFalse($this->testUserRole->hasPermissionTo($permission)); + + $this->testUserRole->givePermissionTo($permission); + $this->testUserRole = $this->testUserRole->fresh(); + + $this->assertTrue($this->testUserRole->hasPermissionTo('another-permission')); + } + + #[DataProvider('roleNameProvider')] + public function testItCreatesARoleWithFindOrCreateIfTheNamedRoleDoesNotExist(string|BackedEnum $name, string $expected): void + { + try { + $this->app->make(Role::class)->findByName($name); + } catch (RoleDoesNotExist) { + $role = $this->app->make(Role::class)->findOrCreate($name); + + $this->assertSame($expected, $role->name); + + return; + } + + $this->fail('Expected missing role exception.'); + } + + /** + * Provide role names. + */ + public static function roleNameProvider(): array + { + return [ + 'string' => ['test-role', 'test-role'], + 'enum' => [TestRoleEnum::TestRole, TestRoleEnum::TestRole->value], + ]; + } + + public function testItBelongsToAGuard(): void + { + $role = $this->app->make(Role::class)->create(['name' => 'admin', 'guard_name' => 'admin']); + + $this->assertSame('admin', $role->guard_name); + } + + public function testItBelongsToTheDefaultGuardByDefault(): void + { + $this->assertSame( + $this->app->make('config')->get('auth.defaults.guard'), + $this->testUserRole->guard_name, + ); + } + + public function testItCanChangeRoleClassAtRuntime(): void + { + $role = $this->app->make(Role::class)->create(['name' => 'test-role-old']); + + $this->assertNotInstanceOf(RuntimeRole::class, $role); + + $role->givePermissionTo('edit-articles'); + + $this->app->make('config')->set('permission.models.role', RuntimeRole::class); + $this->app->bind(Role::class, RuntimeRole::class); + $this->app->make(PermissionRegistrar::class)->setRoleClass(RuntimeRole::class); + + $permission = Permission::findByName('edit-articles'); + $this->assertInstanceOf(RuntimeRole::class, $permission->roles[0]); + $this->assertSame('test-role-old', $permission->roles[0]->name); + + $role = $this->app->make(Role::class)->create(['name' => 'test-role']); + $this->assertInstanceOf(RuntimeRole::class, $role); + + $this->testUser->assignRole('test-role'); + $this->assertTrue($this->testUser->hasRole('test-role')); + $this->assertInstanceOf(RuntimeRole::class, $this->testUser->roles[0]); + $this->assertSame('test-role', $this->testUser->roles[0]->name); + } + + public function testItDoesNotTreatStringZeroAsEmptyWhenAssigningRole(): void + { + $this->app->make(Role::class)->create(['name' => '0']); + + $this->testUser->assignRole('0'); + + $this->assertTrue($this->testUser->hasRole('0')); + } +} diff --git a/tests/Permission/Models/RoleWithNestingTest.php b/tests/Permission/Models/RoleWithNestingTest.php new file mode 100644 index 000000000..f03134e97 --- /dev/null +++ b/tests/Permission/Models/RoleWithNestingTest.php @@ -0,0 +1,84 @@ + + */ + protected array $parentRoles = []; + + /** + * @var array + */ + protected array $childRoles = []; + + protected function setUpInCoroutine(): void + { + $this->setUpRoleNesting(); + + $this->parentRoles = [ + 'has_no_children' => Role::create(['name' => 'has_no_children']), + 'has_1_child' => Role::create(['name' => 'has_1_child']), + 'has_3_children' => Role::create(['name' => 'has_3_children']), + ]; + + $this->childRoles = [ + 'has_no_parents' => Role::create(['name' => 'has_no_parents']), + 'has_1_parent' => Role::create(['name' => 'has_1_parent']), + 'has_2_parents' => Role::create(['name' => 'has_2_parents']), + 'third_child' => Role::create(['name' => 'third_child']), + ]; + + $this->parentRoles['has_1_child']->children()->attach($this->childRoles['has_2_parents']); + $this->parentRoles['has_3_children']->children()->attach([ + $this->childRoles['has_2_parents']->getKey(), + $this->childRoles['has_1_parent']->getKey(), + $this->childRoles['third_child']->getKey(), + ]); + } + + #[DataProvider('roleCountProvider')] + public function testItReturnsCorrectWithCountOfNestedRoles( + string $roleGroup, + string $index, + string $relation, + int $expectedCount + ): void { + $roles = $roleGroup === 'parent_roles' ? $this->parentRoles : $this->childRoles; + $role = $roles[$index]; + $countFieldName = sprintf('%s_count', $relation); + + $actualCount = (int) Role::withCount($relation)->find($role->getKey())->{$countFieldName}; + + $this->assertSame($expectedCount, $actualCount, sprintf( + '%s expects %d %s, %d found', + $role->name, + $expectedCount, + $relation, + $actualCount, + )); + } + + /** + * Provide nested role counts. + */ + public static function roleCountProvider(): array + { + return [ + ['parent_roles', 'has_no_children', 'children', 0], + ['parent_roles', 'has_1_child', 'children', 1], + ['parent_roles', 'has_3_children', 'children', 3], + ['child_roles', 'has_no_parents', 'parents', 0], + ['child_roles', 'has_1_parent', 'parents', 1], + ['child_roles', 'has_2_parents', 'parents', 2], + ]; + } +} diff --git a/tests/Permission/Models/WildcardRoleTest.php b/tests/Permission/Models/WildcardRoleTest.php new file mode 100644 index 000000000..8e99c04ff --- /dev/null +++ b/tests/Permission/Models/WildcardRoleTest.php @@ -0,0 +1,95 @@ +app->make('config')->set('permission.enable_wildcard_permission', true); + $this->flushPermissionState(); + + Permission::create(['name' => 'other-permission']); + Permission::create(['name' => 'wrong-guard-permission', 'guard_name' => 'admin']); + } + + public function testItCanBeGivenAPermission(): void + { + Permission::create(['name' => 'posts.*']); + $this->testUserRole->givePermissionTo('posts.*'); + + $this->assertTrue($this->testUserRole->hasPermissionTo('posts.create')); + } + + public function testItCanBeGivenMultiplePermissionsUsingAnArray(): void + { + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'news.*']); + + $this->testUserRole->givePermissionTo(['posts.*', 'news.*']); + + $this->assertTrue($this->testUserRole->hasPermissionTo('posts.create')); + $this->assertTrue($this->testUserRole->hasPermissionTo('news.create')); + } + + public function testItCanBeGivenMultiplePermissionsUsingMultipleArguments(): void + { + Permission::create(['name' => 'posts.*']); + Permission::create(['name' => 'news.*']); + + $this->testUserRole->givePermissionTo('posts.*', 'news.*'); + + $this->assertTrue($this->testUserRole->hasPermissionTo('posts.edit.123')); + $this->assertTrue($this->testUserRole->hasPermissionTo('news.view.1')); + } + + public function testItCanBeGivenAPermissionUsingObjects(): void + { + $this->testUserRole->givePermissionTo($this->testUserPermission); + + $this->assertTrue($this->testUserRole->hasPermissionTo($this->testUserPermission)); + } + + public function testItReturnsFalseIfItDoesNotHaveThePermission(): void + { + $this->assertFalse($this->testUserRole->hasPermissionTo('other-permission')); + } + + public function testItReturnsFalseIfPermissionDoesNotExist(): void + { + $this->assertFalse($this->testUserRole->hasPermissionTo('doesnt-exist')); + } + + public function testItReturnsFalseIfItDoesNotHaveAPermissionObject(): void + { + $permission = Permission::findByName('other-permission'); + + $this->assertFalse($this->testUserRole->hasPermissionTo($permission)); + } + + public function testItCreatesPermissionObjectWithFindOrCreateIfItDoesNotHaveAPermissionObject(): void + { + $permission = Permission::findOrCreate('another-permission'); + + $this->assertFalse($this->testUserRole->hasPermissionTo($permission)); + + $this->testUserRole->givePermissionTo($permission); + $this->testUserRole = $this->testUserRole->fresh(); + + $this->assertTrue($this->testUserRole->hasPermissionTo('another-permission')); + } + + public function testItReturnsFalseWhenAPermissionOfTheWrongGuardIsPassedIn(): void + { + $permission = Permission::findByName('wrong-guard-permission', 'admin'); + + $this->assertFalse($this->testUserRole->hasPermissionTo($permission)); + } +} diff --git a/tests/Permission/PublicApiTest.php b/tests/Permission/PublicApiTest.php new file mode 100644 index 000000000..037d361b2 --- /dev/null +++ b/tests/Permission/PublicApiTest.php @@ -0,0 +1,64 @@ +app->make(Router::class); + + $this->assertSame(RoleMiddleware::class, $router->getMiddleware()['role']); + $this->assertSame(PermissionMiddleware::class, $router->getMiddleware()['permission']); + $this->assertSame(RoleOrPermissionMiddleware::class, $router->getMiddleware()['role_or_permission']); + } + + public function testRouteMacrosAttachPermissionMiddleware(): void + { + $roleRoute = Route::get('/roles', $this->getRouteResponse())->role(['testRole', TestRolePermissionsEnum::Editor]); + $permissionRoute = Route::get('/permissions', $this->getRouteResponse())->permission(['edit-articles', TestRolePermissionsEnum::ViewArticles]); + $roleOrPermissionRoute = Route::get('/either', $this->getRouteResponse())->roleOrPermission(['testRole', 'edit-articles']); + + $this->assertContains('role:testRole|editor', $roleRoute->middleware()); + $this->assertContains('permission:edit-articles|view articles', $permissionRoute->middleware()); + $this->assertContains('role_or_permission:testRole|edit-articles', $roleOrPermissionRoute->middleware()); + } + + public function testBladeConditionsCheckRolesAndPermissions(): void + { + Auth::login($this->testUser); + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertTrue(Blade::check('role', 'testRole')); + $this->assertTrue(Blade::check('hasrole', 'testRole')); + $this->assertTrue(Blade::check('hasanyrole', ['missing', 'testRole'])); + $this->assertTrue(Blade::check('hasallroles', ['testRole'])); + $this->assertTrue(Blade::check('hasexactroles', ['testRole'])); + $this->assertTrue(Blade::check('haspermission', 'edit-articles')); + } + + public function testBladeDirectivesCompile(): void + { + $compiler = $this->app->make(BladeCompiler::class); + + $compiled = $compiler->compileString("@role('testRole') allowed @else missing @endrole @unlessrole('missing') unless @endunlessrole"); + + $this->assertStringContainsString("Blade::check('role', 'testRole')", $compiled); + $this->assertStringContainsString('', $compiled); + $this->assertStringContainsString("Blade::check('role', 'missing')", $compiled); + $this->assertStringContainsString('', $compiled); + } +} diff --git a/tests/Permission/SchemaConfigTest.php b/tests/Permission/SchemaConfigTest.php new file mode 100644 index 000000000..1293e2898 --- /dev/null +++ b/tests/Permission/SchemaConfigTest.php @@ -0,0 +1,40 @@ +assertFalse(Schema::hasColumn('roles', 'team_test_id')); + $this->assertFalse(Schema::hasColumn('model_has_roles', 'team_test_id')); + $this->assertFalse(Schema::hasColumn('model_has_permissions', 'team_test_id')); + } + + public function testCustomMorphAndPivotKeysAreUsedByRelations(): void + { + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-articles'); + $this->testUserRole->givePermissionTo('edit-news'); + + $this->assertDatabaseHas('model_has_roles', [ + 'role_test_id' => $this->testUserRole->getKey(), + 'model_test_id' => $this->testUser->getKey(), + ]); + + $this->assertDatabaseHas('model_has_permissions', [ + 'permission_test_id' => $this->testUserPermission->getKey(), + 'model_test_id' => $this->testUser->getKey(), + ]); + + $this->assertDatabaseHas('role_has_permissions', [ + 'role_test_id' => $this->testUserRole->getKey(), + 'permission_test_id' => $this->app->make(Permission::class)::findByName('edit-news')->getKey(), + ]); + } +} diff --git a/tests/Permission/TeamsTest.php b/tests/Permission/TeamsTest.php new file mode 100644 index 000000000..7fcc8cab5 --- /dev/null +++ b/tests/Permission/TeamsTest.php @@ -0,0 +1,45 @@ +make('config')->set([ + 'permission.teams' => true, + 'permission.models.team' => Team::class, + ]); + } + + public function testTeamEnabledSchemaCreatesTeamColumns(): void + { + $this->assertTrue(Schema::hasColumn('roles', 'team_test_id')); + $this->assertTrue(Schema::hasColumn('model_has_roles', 'team_test_id')); + $this->assertTrue(Schema::hasColumn('model_has_permissions', 'team_test_id')); + } + + public function testRoleAssignmentsAreIsolatedByTeamContext(): void + { + setPermissionsTeamId(1); + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasRole('testRole')); + + setPermissionsTeamId(2); + + $this->assertFalse($this->testUser->hasRole('testRole')); + + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasRole('testRole')); + } +} diff --git a/tests/Permission/TestCase.php b/tests/Permission/TestCase.php new file mode 100644 index 000000000..10992981e --- /dev/null +++ b/tests/Permission/TestCase.php @@ -0,0 +1,390 @@ +make('config')->set([ + 'database.default' => 'testing', + 'permission.storage.database.connection' => 'testing', + 'permission.register_permission_check_method' => true, + 'permission.teams' => false, + 'permission.column_names.model_morph_key' => 'model_test_id', + 'permission.column_names.team_foreign_key' => 'team_test_id', + 'permission.column_names.role_pivot_key' => 'role_test_id', + 'permission.column_names.permission_pivot_key' => 'permission_test_id', + 'permission.cache.store' => 'array', + 'permission.models.default_model' => User::class, + 'auth.guards.web' => ['driver' => 'session', 'provider' => 'users'], + 'auth.guards.api' => ['driver' => 'session', 'provider' => 'users'], + 'auth.guards.admin' => ['driver' => 'session', 'provider' => 'admins'], + 'auth.providers.users' => ['driver' => 'eloquent', 'model' => User::class], + 'auth.providers.admins' => ['driver' => 'eloquent', 'model' => Admin::class], + 'view.paths' => [__DIR__ . '/Fixtures/views'], + 'cache.default' => 'array', + 'cache.stores.array' => ['driver' => 'array'], + 'cache.prefix' => 'permission_tests', + ]); + } + + /** + * Get the migrations to run for the test. + */ + protected function migrateFreshUsing(): array + { + return [ + '--seed' => $this->shouldSeed(), + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => [ + dirname(__DIR__, 2) . '/src/permission/database/migrations', + ], + ]; + } + + /** + * Seed the database after refreshing it. + */ + protected function afterRefreshingDatabase(): void + { + $this->createFixtureTables(); + $this->flushPermissionState(); + $this->setUpBaseTestPermissions(); + $this->setUpRoutes(); + } + + /** + * Create fixture tables. + */ + protected function createFixtureTables(): void + { + Schema::create('users', function (Blueprint $table): void { + $table->increments('id'); + $table->string('email'); + $table->softDeletes(); + }); + + Schema::create('admins', function (Blueprint $table): void { + $table->increments('id'); + $table->string('email'); + }); + + Schema::create('clients', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + }); + + Schema::create('content', function (Blueprint $table): void { + $table->increments('id'); + $table->string('content'); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + }); + + Schema::create('teams', function (Blueprint $table): void { + $table->increments('id'); + $table->string('name'); + }); + } + + /** + * Set up initial roles and permissions. + */ + protected function setUpBaseTestPermissions(): void + { + $this->testUser = User::create(['email' => 'test@user.com']); + $this->testAdmin = Admin::create(['email' => 'admin@user.com']); + + $this->testUserRole = $this->app->make(RoleContract::class)->create(['name' => 'testRole']); + $this->app->make(RoleContract::class)->create(['name' => 'testRole2']); + $this->testAdminRole = $this->app->make(RoleContract::class)->create(['name' => 'testAdminRole', 'guard_name' => 'admin']); + $this->testUserPermission = $this->app->make(PermissionContract::class)->create(['name' => 'edit-articles']); + $this->app->make(PermissionContract::class)->create(['name' => 'edit-news']); + $this->app->make(PermissionContract::class)->create(['name' => 'edit-blog']); + $this->testAdminPermission = $this->app->make(PermissionContract::class)->create([ + 'name' => 'admin-permission', + 'guard_name' => 'admin', + ]); + $this->app->make(PermissionContract::class)->create(['name' => 'Edit News']); + } + + /** + * Set up Passport-style client fixtures. + */ + protected function setUpPassport(): void + { + $this->app->make('config')->set([ + 'permission.use_passport_client_credentials' => true, + 'auth.guards.api' => ['driver' => 'passport', 'provider' => 'users'], + ]); + + $this->testClient = Client::create(['name' => 'Test']); + $this->testClientRole = $this->app->make(RoleContract::class)->create(['name' => 'clientRole', 'guard_name' => 'api']); + $this->testClientPermission = $this->app->make(PermissionContract::class)->create(['name' => 'edit-posts', 'guard_name' => 'api']); + } + + /** + * Set up team-aware permissions. + */ + protected function setUpTeams(): void + { + $this->app->make('config')->set('permission.teams', true); + $this->flushPermissionState(); + setPermissionsTeamId(1); + } + + /** + * Set up custom role and permission models. + */ + protected function setUpCustomModels(): void + { + $this->app->make('config')->set([ + 'permission.models.permission' => Permission::class, + 'permission.models.role' => Role::class, + ]); + + $this->recreateCustomPermissionTables(); + $this->flushPermissionState(); + $this->setUpBaseTestPermissions(); + } + + /** + * Set up nested role hierarchy tables. + */ + protected function setUpRoleNesting(): void + { + $this->setUpCustomModels(); + + Schema::create(Role::HIERARCHY_TABLE, function (Blueprint $table): void { + $table->id(); + $table->uuid('parent_id'); + $table->uuid('child_id'); + $table->foreign('parent_id')->references('role_test_id')->on('roles'); + $table->foreign('child_id')->references('role_test_id')->on('roles'); + }); + } + + /** + * Reload permission cache state. + */ + protected function reloadPermissions(): void + { + $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + /** + * Define test routes. + */ + protected function setUpRoutes(): void + { + Route::middleware('auth:api')->get('/check-api-guard-permission', function (Request $request): array { + return [ + 'status' => $request->user()->hasPermissionTo('do_that'), + ]; + }); + } + + /** + * Run middleware and return its response code. + */ + protected function runMiddleware(object $middleware, mixed $permission, ?string $guard = null, bool $client = false): int + { + $request = new Request; + + if ($client) { + $request->headers->set('Authorization', 'Bearer ' . str()->random(30)); + } + + try { + return $middleware->handle($request, function (): Response { + return (new Response)->setContent(''); + }, $permission, $guard)->status(); + } catch (UnauthorizedException $exception) { + return $exception->getStatusCode(); + } + } + + /** + * Get the last route middleware from the router. + */ + protected function getLastRouteMiddlewareFromRouter(mixed $router): array + { + return last($router->getRoutes()->get())->middleware(); + } + + /** + * Get the router. + */ + protected function getRouter(): mixed + { + return $this->app->make('router'); + } + + /** + * Get a route response callback. + */ + protected function getRouteResponse(): callable + { + return function (): Response { + return (new Response)->setContent(''); + }; + } + + /** + * Flush permission cache and singleton state. + */ + protected function flushPermissionState(): void + { + $this->app->make('cache')->store('array')->clear(); + $this->app->forgetInstance(PermissionRegistrar::class); + $this->app->make(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + /** + * Recreate permission tables for custom UUID role and permission models. + */ + private function recreateCustomPermissionTables(): void + { + $tableNames = (array) $this->app->make('config')->get('permission.table_names'); + $columnNames = (array) $this->app->make('config')->get('permission.column_names'); + $pivotRole = $columnNames['role_pivot_key']; + $pivotPermission = $columnNames['permission_pivot_key']; + $modelMorphKey = $columnNames['model_morph_key']; + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + + Schema::create($tableNames['permissions'], static function (Blueprint $table): void { + $table->uuid('permission_test_id')->primary(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], static function (Blueprint $table): void { + $table->uuid('role_test_id')->primary(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($modelMorphKey, $pivotPermission, $tableNames): void { + $table->uuid($pivotPermission); + $table->string('model_type'); + $table->unsignedBigInteger($modelMorphKey); + $table->boolean('is_forbidden')->default(false); + $table->index([$modelMorphKey, 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('permission_test_id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $modelMorphKey, 'model_type', 'is_forbidden'], 'model_has_permissions_permission_model_type_primary'); + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($modelMorphKey, $pivotRole, $tableNames): void { + $table->uuid($pivotRole); + $table->string('model_type'); + $table->unsignedBigInteger($modelMorphKey); + $table->index([$modelMorphKey, 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('role_test_id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotRole, $modelMorphKey, 'model_type'], 'model_has_roles_role_model_type_primary'); + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($pivotPermission, $pivotRole, $tableNames): void { + $table->uuid($pivotPermission); + $table->uuid($pivotRole); + $table->boolean('is_forbidden')->default(false); + + $table->foreign($pivotPermission) + ->references('permission_test_id') + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('role_test_id') + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole, 'is_forbidden'], 'role_has_permissions_permission_id_role_id_primary'); + }); + } +} diff --git a/tests/Permission/Traits/HasAssignedModelsTest.php b/tests/Permission/Traits/HasAssignedModelsTest.php new file mode 100644 index 000000000..97c1e6d39 --- /dev/null +++ b/tests/Permission/Traits/HasAssignedModelsTest.php @@ -0,0 +1,246 @@ + 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $this->testUserRole->syncModels([$user1, $user2]); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItRemovesPreviousModelsWhenSyncing(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $user1->assignRole($this->testUserRole); + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + + $this->testUserRole->syncModels([$user2]); + + $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItRemovesAllModelsWhenSyncingWithAnEmptyArray(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $user1->assignRole($this->testUserRole); + $user2->assignRole($this->testUserRole); + + $this->testUserRole->syncModels([]); + + $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); + $this->assertFalse($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItDoesNotAddDuplicateModelsWhenSyncing(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->syncModels([$user1, $user1]); + + $count = DB::table(Config::modelHasRolesTable()) + ->where(app(PermissionRegistrar::class)->pivotRole, $this->testUserRole->getKey()) + ->count(); + + $this->assertSame(1, $count); + } + + public function testItCanSyncModelsUsingASingleModelInstance(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->syncModels($user1); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanSyncModelsUsingIds(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $this->testUserRole->syncModels([$user1->getKey(), $user2->getKey()]); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanAssignARoleToModels(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $this->testUserRole->assignToModels([$user1, $user2]); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($this->testUserRole->users->contains($user1)); + $this->assertTrue($this->testUserRole->users->contains($user2)); + } + + public function testItCanAssignARoleToASingleModelInstance(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->assignToModels($user1); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanAssignARoleToModelsUsingIds(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->assignToModels($user1->getKey()); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItDoesNotAssignDuplicateModels(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->assignToModels([$user1, $user1]); + + $count = DB::table(Config::modelHasRolesTable()) + ->where(app(PermissionRegistrar::class)->pivotRole, $this->testUserRole->getKey()) + ->count(); + + $this->assertSame(1, $count); + } + + public function testItDoesNotReAssignModelsAlreadyAssigned(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->assignToModels($user1); + $this->testUserRole->assignToModels($user1); + + $count = DB::table(Config::modelHasRolesTable()) + ->where(app(PermissionRegistrar::class)->pivotRole, $this->testUserRole->getKey()) + ->count(); + + $this->assertSame(1, $count); + } + + public function testItCanAssignAdditionalModelsWithoutRemovingExistingOnes(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $this->testUserRole->assignToModels($user1); + $this->testUserRole->assignToModels($user2); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanRemoveARoleFromModels(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $user1->assignRole($this->testUserRole); + $user2->assignRole($this->testUserRole); + + $this->testUserRole->removeFromModels([$user1]); + + $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanRemoveARoleFromASingleModelInstance(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $user1->assignRole($this->testUserRole); + + $this->testUserRole->removeFromModels($user1); + + $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanRemoveARoleFromModelsUsingIds(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $user1->assignRole($this->testUserRole); + + $this->testUserRole->removeFromModels($user1->getKey()); + + $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItDoesNothingWhenRemovingTheRoleFromModelsThatDoNotHaveIt(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $user1->assignRole($this->testUserRole); + + $this->testUserRole->removeFromModels($user2); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanSyncModelsUsingIdsWithExplicitModelClass(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + $this->testUserRole->syncModels([$user1->getKey(), $user2->getKey()], User::class); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + $this->assertTrue($user2->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanAssignARoleToModelsUsingIdsWithExplicitModelClass(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->assignToModels($user1->getKey(), User::class); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItCanRemoveARoleFromModelsUsingIdsWithExplicitModelClass(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + + $user1->assignRole($this->testUserRole); + + $this->testUserRole->removeFromModels($user1->getKey(), User::class); + + $this->assertFalse($user1->fresh()->hasRole($this->testUserRole)); + } + + public function testItUsesConfigDefaultModelWhenResolvingIds(): void + { + config()->set('permission.models.default_model', User::class); + + $user1 = User::create(['email' => 'user1@test.com']); + + $this->testUserRole->syncModels([$user1->getKey()]); + + $this->assertTrue($user1->fresh()->hasRole($this->testUserRole)); + } +} diff --git a/tests/Permission/Traits/HasPermissionsTest.php b/tests/Permission/Traits/HasPermissionsTest.php new file mode 100644 index 000000000..c120f9cc5 --- /dev/null +++ b/tests/Permission/Traits/HasPermissionsTest.php @@ -0,0 +1,754 @@ +testUser->givePermissionTo($this->testUserPermission); + + $this->assertTrue($this->testUser->hasPermissionTo($this->testUserPermission)); + } + + public function testItCanAssignAPermissionToAUserWithANonDefaultGuard(): void + { + $testUserPermission = app(Permission::class)->create([ + 'name' => 'edit-articles', + 'guard_name' => 'api', + ]); + + $this->testUser->givePermissionTo($testUserPermission); + + $this->assertTrue($this->testUser->hasPermissionTo($testUserPermission)); + } + + public function testItThrowsAnExceptionWhenAssigningAPermissionThatDoesNotExist(): void + { + $this->expectException(PermissionDoesNotExist::class); + + $this->testUser->givePermissionTo('permission-does-not-exist'); + } + + public function testItThrowsAnExceptionWhenAssigningAPermissionToAUserFromADifferentGuard(): void + { + try { + $this->testUser->givePermissionTo($this->testAdminPermission); + $this->fail('Expected guard mismatch exception was not thrown.'); + } catch (GuardDoesNotMatch) { + $this->assertTrue(true); + } + + $this->expectException(PermissionDoesNotExist::class); + + $this->testUser->givePermissionTo('admin-permission'); + } + + public function testItCanRevokeAPermissionFromAUser(): void + { + $this->testUser->givePermissionTo($this->testUserPermission); + + $this->assertTrue($this->testUser->hasPermissionTo($this->testUserPermission)); + + $this->testUser->revokePermissionTo($this->testUserPermission); + + $this->assertFalse($this->testUser->hasPermissionTo($this->testUserPermission)); + } + + public function testItCanAssignAndRemoveAPermissionUsingEnums(): void + { + $enum = TestRolePermissionsEnum::ViewArticles; + + app(Permission::class)->findOrCreate($enum->value, 'web'); + + $this->testUser->givePermissionTo($enum); + + $this->assertTrue($this->testUser->hasPermissionTo($enum)); + $this->assertTrue($this->testUser->hasAnyPermission($enum)); + $this->assertTrue($this->testUser->hasDirectPermission($enum)); + + $this->testUser->revokePermissionTo($enum); + + $this->assertFalse($this->testUser->hasPermissionTo($enum)); + $this->assertFalse($this->testUser->hasAnyPermission($enum)); + $this->assertFalse($this->testUser->hasDirectPermission($enum)); + } + + public function testItCanScopeUsersUsingEnums(): void + { + $enum1 = TestRolePermissionsEnum::ViewArticles; + $enum2 = TestRolePermissionsEnum::EditArticles; + app(Permission::class)->findOrCreate($enum1->value, 'web'); + app(Permission::class)->findOrCreate($enum2->value, 'web'); + + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + $user1->givePermissionTo([$enum1, $enum2]); + $this->testUserRole->givePermissionTo($enum2); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::permission($enum2)->get()); + $this->assertCount(1, User::permission([$enum1])->get()); + $this->assertCount(2, User::withoutPermission([$enum1])->get()); + $this->assertCount(1, User::withoutPermission([$enum2])->get()); + } + + public function testItCanScopeUsersUsingAString(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + $user1->givePermissionTo(['edit-articles', 'edit-news']); + $this->testUserRole->givePermissionTo('edit-articles'); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::permission('edit-articles')->get()); + $this->assertCount(1, User::permission(['edit-news'])->get()); + $this->assertCount(2, User::withoutPermission('edit-news')->get()); + } + + public function testItCanScopeUsersUsingAnInt(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + $user1->givePermissionTo([1, 2]); + $this->testUserRole->givePermissionTo(1); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::permission(1)->get()); + $this->assertCount(1, User::permission([2])->get()); + $this->assertCount(2, User::withoutPermission([2])->get()); + } + + public function testItCanScopeUsersUsingAnArray(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + $user1->givePermissionTo(['edit-articles', 'edit-news']); + $this->testUserRole->givePermissionTo('edit-articles'); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::permission(['edit-articles', 'edit-news'])->get()); + $this->assertCount(1, User::permission(['edit-news'])->get()); + $this->assertCount(2, User::withoutPermission(['edit-news'])->get()); + } + + public function testItCanScopeUsersUsingACollection(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + $user1->givePermissionTo(['edit-articles', 'edit-news']); + $this->testUserRole->givePermissionTo('edit-articles'); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::permission(collect(['edit-articles', 'edit-news']))->get()); + $this->assertCount(1, User::permission(collect(['edit-news']))->get()); + $this->assertCount(2, User::withoutPermission(collect(['edit-news']))->get()); + } + + public function testItCanScopeUsersUsingAnObject(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user1->givePermissionTo($this->testUserPermission->name); + + $this->assertCount(1, User::permission($this->testUserPermission)->get()); + $this->assertCount(1, User::permission([$this->testUserPermission])->get()); + $this->assertCount(1, User::permission(collect([$this->testUserPermission]))->get()); + $this->assertCount(0, User::withoutPermission(collect([$this->testUserPermission]))->get()); + } + + public function testItCanScopeUsersWithoutDirectPermissionsOnlyRole(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $this->testUserRole->givePermissionTo('edit-articles'); + $user1->assignRole('testRole'); + $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); + + $this->assertCount(2, User::permission('edit-articles')->get()); + $this->assertCount(1, User::withoutPermission('edit-articles')->get()); + } + + public function testItCanScopeUsersWithOnlyDirectPermission(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + $user1->givePermissionTo(['edit-news']); + $user2->givePermissionTo(['edit-articles', 'edit-news']); + + $this->assertCount(2, User::permission('edit-news')->get()); + $this->assertCount(1, User::withoutPermission('edit-news')->get()); + } + + public function testItThrowsAnExceptionWhenCallingHasPermissionToWithAnInvalidType(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $this->expectException(PermissionDoesNotExist::class); + + $user->hasPermissionTo(new stdClass); + } + + public function testItThrowsAnExceptionWhenCallingHasPermissionToWithNull(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $this->expectException(PermissionDoesNotExist::class); + + $user->hasPermissionTo(null); + } + + public function testItThrowsAnExceptionWhenCallingHasDirectPermissionWithAnInvalidType(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $this->expectException(PermissionDoesNotExist::class); + + $user->hasDirectPermission(new stdClass); + } + + public function testItThrowsAnExceptionWhenCallingHasDirectPermissionWithNull(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $this->expectException(PermissionDoesNotExist::class); + + $user->hasDirectPermission(null); + } + + public function testItThrowsAnExceptionWhenTryingToScopeANonExistingPermission(): void + { + try { + User::permission('not defined permission')->get(); + $this->fail('Expected permission does not exist exception was not thrown.'); + } catch (PermissionDoesNotExist) { + $this->assertTrue(true); + } + + $this->expectException(PermissionDoesNotExist::class); + + User::withoutPermission('not defined permission')->get(); + } + + public function testItThrowsAnExceptionWhenTryingToScopeAPermissionFromAnotherGuard(): void + { + try { + User::permission('testAdminPermission')->get(); + $this->fail('Expected permission does not exist exception was not thrown.'); + } catch (PermissionDoesNotExist) { + $this->assertTrue(true); + } + + $this->expectException(PermissionDoesNotExist::class); + + User::withoutPermission('testAdminPermission')->get(); + } + + public function testItDoesNotDetachPermissionsWhenUserSoftDeleting(): void + { + $user = SoftDeletingUser::create(['email' => 'test@example.com']); + $user->givePermissionTo(['edit-news']); + $user->delete(); + + $user = SoftDeletingUser::withTrashed()->find($user->id); + + $this->assertTrue($user->hasPermissionTo('edit-news')); + } + + public function testItCanGiveAndRevokeMultiplePermissions(): void + { + $this->testUserRole->givePermissionTo(['edit-articles', 'edit-news']); + + $this->assertSame(2, $this->testUserRole->permissions()->count()); + + $this->testUserRole->revokePermissionTo(['edit-articles', 'edit-news']); + + $this->assertSame(0, $this->testUserRole->permissions()->count()); + } + + public function testItCanGiveAndRevokePermissionsModelsArray(): void + { + $models = [ + app(Permission::class)::where('name', 'edit-articles')->first(), + app(Permission::class)::where('name', 'edit-news')->first(), + ]; + + $this->testUserRole->givePermissionTo($models); + + $this->assertSame(2, $this->testUserRole->permissions()->count()); + + $this->testUserRole->revokePermissionTo($models); + + $this->assertSame(0, $this->testUserRole->permissions()->count()); + } + + public function testItCanGiveAndRevokePermissionsModelsCollection(): void + { + $models = app(Permission::class)::whereIn('name', ['edit-articles', 'edit-news'])->get(); + + $this->testUserRole->givePermissionTo($models); + + $this->assertSame(2, $this->testUserRole->permissions()->count()); + + $this->testUserRole->revokePermissionTo($models); + + $this->assertSame(0, $this->testUserRole->permissions()->count()); + } + + public function testItCanDetermineThatTheUserDoesNotHaveAPermission(): void + { + $this->assertFalse($this->testUser->hasPermissionTo('edit-articles')); + } + + public function testItThrowsAnExceptionWhenThePermissionDoesNotExist(): void + { + $this->expectException(PermissionDoesNotExist::class); + + $this->testUser->hasPermissionTo('does-not-exist'); + } + + public function testItThrowsAnExceptionWhenThePermissionDoesNotExistForThisGuard(): void + { + $this->expectException(PermissionDoesNotExist::class); + + $this->testUser->hasPermissionTo('does-not-exist', 'web'); + } + + public function testItCanRejectAUserThatDoesNotHaveAnyPermissionsAtAll(): void + { + $user = new User; + + $this->assertFalse($user->hasPermissionTo('edit-articles')); + } + + public function testItCanDetermineThatTheUserHasAnyOfThePermissionsDirectly(): void + { + $this->assertFalse($this->testUser->hasAnyPermission('edit-articles')); + + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertTrue($this->testUser->hasAnyPermission('edit-news', 'edit-articles')); + + $this->testUser->givePermissionTo('edit-news'); + $this->testUser->revokePermissionTo($this->testUserPermission); + + $this->assertTrue($this->testUser->hasAnyPermission('edit-articles', 'edit-news')); + $this->assertFalse($this->testUser->hasAnyPermission('edit-blog', 'Edit News', ['Edit News'])); + } + + public function testItCanDetermineThatTheUserHasAnyOfThePermissionsDirectlyUsingAnArray(): void + { + $this->assertFalse($this->testUser->hasAnyPermission(['edit-articles'])); + + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertTrue($this->testUser->hasAnyPermission(['edit-news', 'edit-articles'])); + + $this->testUser->givePermissionTo('edit-news'); + $this->testUser->revokePermissionTo($this->testUserPermission); + + $this->assertTrue($this->testUser->hasAnyPermission(['edit-articles', 'edit-news'])); + } + + public function testItCanDetermineThatTheUserHasAnyOfThePermissionsViaRole(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasAnyPermission('edit-news', 'edit-articles')); + $this->assertFalse($this->testUser->hasAnyPermission('edit-blog', 'Edit News', ['Edit News'])); + } + + public function testItCanDetermineThatTheUserHasAllOfThePermissionsDirectly(): void + { + $this->testUser->givePermissionTo('edit-articles', 'edit-news'); + + $this->assertTrue($this->testUser->hasAllPermissions('edit-articles', 'edit-news')); + + $this->testUser->revokePermissionTo('edit-articles'); + + $this->assertFalse($this->testUser->hasAllPermissions('edit-articles', 'edit-news')); + $this->assertFalse($this->testUser->hasAllPermissions(['edit-articles', 'edit-news'], 'edit-blog')); + } + + public function testItCanDetermineThatTheUserHasAllOfThePermissionsDirectlyUsingAnArray(): void + { + $this->assertFalse($this->testUser->hasAllPermissions(['edit-articles', 'edit-news'])); + + $this->testUser->revokePermissionTo('edit-articles'); + + $this->assertFalse($this->testUser->hasAllPermissions(['edit-news', 'edit-articles'])); + + $this->testUser->givePermissionTo('edit-news'); + $this->testUser->revokePermissionTo($this->testUserPermission); + + $this->assertFalse($this->testUser->hasAllPermissions(['edit-articles', 'edit-news'])); + } + + public function testItCanDetermineThatTheUserHasAllOfThePermissionsViaRole(): void + { + $this->testUserRole->givePermissionTo('edit-articles', 'edit-news'); + + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasAllPermissions('edit-articles', 'edit-news')); + } + + public function testItCanDetermineThatUserHasDirectPermission(): void + { + $this->testUser->givePermissionTo('edit-articles'); + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertSame(['edit-articles'], $this->testUser->getDirectPermissions()->pluck('name')->all()); + + $this->testUser->revokePermissionTo('edit-articles'); + $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); + + $this->testUser->assignRole('testRole'); + $this->testUserRole->givePermissionTo('edit-articles'); + $this->assertFalse($this->testUser->hasDirectPermission('edit-articles')); + } + + public function testItCanListAllThePermissionsViaRolesOfUser(): void + { + app(Role::class)->findByName('testRole2')->givePermissionTo('edit-news'); + + $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUser->assignRole('testRole', 'testRole2'); + + $this->assertSame( + ['edit-articles', 'edit-news'], + $this->testUser->getPermissionsViaRoles()->pluck('name')->sort()->values()->all(), + ); + } + + public function testItCanListAllTheCoupledPermissionsBothDirectlyAndViaRoles(): void + { + $this->testUser->givePermissionTo('edit-news'); + + $this->testUserRole->givePermissionTo('edit-articles'); + $this->testUser->assignRole('testRole'); + + $this->assertSame( + ['edit-articles', 'edit-news'], + $this->testUser->getAllPermissions()->pluck('name')->sort()->values()->all(), + ); + } + + public function testItCanSyncMultiplePermissions(): void + { + $this->testUser->givePermissionTo('edit-news'); + + $this->testUser->syncPermissions('edit-articles', 'edit-blog'); + + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDirectPermission('edit-blog')); + $this->assertFalse($this->testUser->hasDirectPermission('edit-news')); + } + + public function testItCanAvoidSyncDuplicatedPermissions(): void + { + $this->testUser->syncPermissions('edit-articles', 'edit-blog', 'edit-blog'); + + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDirectPermission('edit-blog')); + } + + public function testItCanAvoidDetachOnPermissionThatDoesNotExistSync(): void + { + $this->testUser->syncPermissions('edit-articles'); + + try { + $this->testUser->syncPermissions('permission-does-not-exist'); + $this->fail('Expected permission does not exist exception was not thrown.'); + } catch (PermissionDoesNotExist) { + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertFalse($this->testUser->checkPermissionTo('permission-does-not-exist')); + } + } + + public function testItCanSyncMultiplePermissionsById(): void + { + $this->testUser->givePermissionTo('edit-news'); + + $ids = app(Permission::class)::whereIn('name', ['edit-articles', 'edit-blog'])->pluck($this->testUserPermission->getKeyName()); + + $this->testUser->syncPermissions($ids); + + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDirectPermission('edit-blog')); + $this->assertFalse($this->testUser->hasDirectPermission('edit-news')); + } + + public function testItSyncPermissionIgnoresNullInputs(): void + { + $this->testUser->givePermissionTo('edit-news'); + + $ids = app(Permission::class)::whereIn('name', ['edit-articles', 'edit-blog'])->pluck($this->testUserPermission->getKeyName()); + + $ids->push(null); + + $this->testUser->syncPermissions($ids); + + $this->assertTrue($this->testUser->hasDirectPermission('edit-articles')); + $this->assertTrue($this->testUser->hasDirectPermission('edit-blog')); + $this->assertFalse($this->testUser->hasDirectPermission('edit-news')); + } + + public function testItDoesNotDetachPermissionsWhenSyncPermissionErrors(): void + { + $this->testUser->givePermissionTo('edit-news'); + + try { + $this->testUser->syncPermissions('edit-articles', 'permission-that-does-not-exist'); + $this->fail('Expected permission does not exist exception was not thrown.'); + } catch (PermissionDoesNotExist) { + $this->assertTrue($this->testUser->fresh()->hasDirectPermission('edit-news')); + } + } + + public function testItDoesNotRemoveAlreadyAssociatedPermissionsWhenAssigningNewPermissions(): void + { + $this->testUser->givePermissionTo('edit-news'); + $this->testUser->givePermissionTo('edit-articles'); + + $this->assertTrue($this->testUser->fresh()->hasDirectPermission('edit-news')); + } + + public function testItDoesNotThrowAnExceptionWhenAssigningAPermissionThatIsAlreadyAssigned(): void + { + $this->testUser->givePermissionTo('edit-news'); + $this->testUser->givePermissionTo('edit-news'); + + $this->assertTrue($this->testUser->fresh()->hasDirectPermission('edit-news')); + } + + public function testItCanSyncPermissionsToAModelThatIsNotPersisted(): void + { + $user = new User(['email' => 'test@user.com']); + $user->syncPermissions('edit-articles'); + $user->save(); + $user->save(); + + $this->assertTrue($user->hasPermissionTo('edit-articles')); + + $user->syncPermissions('edit-articles'); + $this->assertTrue($user->hasPermissionTo('edit-articles')); + $this->assertTrue($user->fresh()->hasPermissionTo('edit-articles')); + } + + public function testItDoesNotRunUnnecessarySqlWhenAssigningNewPermissions(): void + { + $permission2 = app(Permission::class)->where('name', 'edit-news')->first(); + + DB::enableQueryLog(); + $this->testUser->syncPermissions($this->testUserPermission, $permission2); + DB::disableQueryLog(); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItDoesNotLetQueuedGivePermissionToInterfereWithOtherObjects(): void + { + $user = new User(['email' => 'test@user.com']); + $user->givePermissionTo('edit-news'); + $user->save(); + + $user2 = new User(['email' => 'test2@user.com']); + $user2->givePermissionTo('edit-articles'); + + DB::enableQueryLog(); + $user2->save(); + DB::disableQueryLog(); + + $this->assertTrue($user->fresh()->hasPermissionTo('edit-news')); + $this->assertFalse($user->fresh()->hasPermissionTo('edit-articles')); + + $this->assertTrue($user2->fresh()->hasPermissionTo('edit-articles')); + $this->assertFalse($user2->fresh()->hasPermissionTo('edit-news')); + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItDoesNotLetQueuedSyncPermissionsInterfereWithOtherObjects(): void + { + $user = new User(['email' => 'test@user.com']); + $user->syncPermissions('edit-news'); + $user->save(); + + $user2 = new User(['email' => 'test2@user.com']); + $user2->syncPermissions('edit-articles'); + + DB::enableQueryLog(); + $user2->save(); + DB::disableQueryLog(); + + $this->assertTrue($user->fresh()->hasPermissionTo('edit-news')); + $this->assertFalse($user->fresh()->hasPermissionTo('edit-articles')); + + $this->assertTrue($user2->fresh()->hasPermissionTo('edit-articles')); + $this->assertFalse($user2->fresh()->hasPermissionTo('edit-news')); + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItCanRetrievePermissionNames(): void + { + $this->testUser->givePermissionTo('edit-news', 'edit-articles'); + + $this->assertSame(['edit-articles', 'edit-news'], $this->testUser->getPermissionNames()->sort()->values()->all()); + } + + public function testItCanCheckManyDirectPermissions(): void + { + $this->testUser->givePermissionTo(['edit-articles', 'edit-news']); + + $this->assertTrue($this->testUser->hasAllDirectPermissions(['edit-news', 'edit-articles'])); + $this->assertTrue($this->testUser->hasAllDirectPermissions('edit-news', 'edit-articles')); + $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news', 'edit-blog'])); + $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news'], 'edit-blog')); + } + + public function testItCanCheckIfThereIsAnyOfTheDirectPermissionsGiven(): void + { + $this->testUser->givePermissionTo(['edit-articles', 'edit-news']); + + $this->assertTrue($this->testUser->hasAnyDirectPermission(['edit-news', 'edit-blog'])); + $this->assertTrue($this->testUser->hasAnyDirectPermission('edit-news', 'edit-blog')); + $this->assertFalse($this->testUser->hasAnyDirectPermission('edit-blog', 'Edit News', ['Edit News'])); + } + + public function testItCanCheckPermissionBasedOnLoggedInUserGuard(): void + { + $this->testUser->givePermissionTo(app(Permission::class)::create([ + 'name' => 'do_that', + 'guard_name' => 'api', + ])); + + $response = $this->actingAs($this->testUser, 'api') + ->json('GET', '/check-api-guard-permission'); + + $response->assertJson(['status' => true]); + } + + public function testItCanRejectPermissionBasedOnLoggedInUserGuard(): void + { + app(Permission::class)::create([ + 'name' => 'do_that', + 'guard_name' => 'api', + ]); + + $assignedPermission = app(Permission::class)::create([ + 'name' => 'do_that', + 'guard_name' => 'web', + ]); + + $this->testUser->givePermissionTo($assignedPermission); + + $response = $this->withExceptionHandling() + ->actingAs($this->testUser, 'api') + ->json('GET', '/check-api-guard-permission'); + + $response->assertJson(['status' => false]); + } + + public function testItFiresAnEventWhenAPermissionIsAdded(): void + { + Event::fake(); + app('config')->set('permission.events_enabled', true); + + $this->testUser->givePermissionTo(['edit-articles', 'edit-news']); + + $ids = app(Permission::class)::whereIn('name', ['edit-articles', 'edit-news']) + ->pluck($this->testUserPermission->getKeyName()) + ->toArray(); + + Event::assertDispatched(PermissionAttachedEvent::class, function (PermissionAttachedEvent $event) use ($ids): bool { + return $event->model instanceof User + && $event->model->hasPermissionTo('edit-news') + && $event->model->hasPermissionTo('edit-articles') + && $ids === $event->permissionsOrIds; + }); + } + + public function testItDoesNotFireAnEventWhenEventsAreNotEnabled(): void + { + Event::fake(); + app('config')->set('permission.events_enabled', false); + + $this->testUser->givePermissionTo(['edit-articles', 'edit-news']); + + Event::assertNotDispatched(PermissionAttachedEvent::class); + } + + public function testItFiresAnEventWhenAPermissionIsRemoved(): void + { + Event::fake(); + app('config')->set('permission.events_enabled', true); + + $permissions = app(Permission::class)::whereIn('name', ['edit-articles', 'edit-news'])->get(); + + $this->testUser->givePermissionTo($permissions); + $this->testUser->revokePermissionTo($permissions); + + Event::assertDispatched(PermissionDetachedEvent::class, function (PermissionDetachedEvent $event) use ($permissions): bool { + return $event->model instanceof User + && ! $event->model->hasPermissionTo('edit-news') + && ! $event->model->hasPermissionTo('edit-articles') + && $event->permissionsOrIds === $permissions; + }); + } + + public function testItCanBeGivenAPermissionOnRoleWhenLazyLoadingIsRestricted(): void + { + $this->assertTrue(Model::preventsLazyLoading()); + + $testRole = app(Role::class)->with('permissions')->get()->first(); + + $testRole->givePermissionTo('edit-articles'); + + $this->assertTrue($testRole->hasPermissionTo('edit-articles')); + } + + public function testItCanBeGivenAPermissionOnUserWhenLazyLoadingIsRestricted(): void + { + $this->assertTrue(Model::preventsLazyLoading()); + + User::create(['email' => 'other@user.com']); + $testUser = User::with('permissions')->get()->first(); + + $testUser->givePermissionTo('edit-articles'); + + $this->assertTrue($testUser->hasPermissionTo('edit-articles')); + } +} diff --git a/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php b/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php new file mode 100644 index 000000000..4c8f72d04 --- /dev/null +++ b/tests/Permission/Traits/HasPermissionsWithCustomModelsTest.php @@ -0,0 +1,153 @@ +setUpCustomModels(); + } + + public function testItCanScopeUsersUsingAnInt(): void + { + // Skipped because custom model uses uuid, replacement "testItCanScopeUsersUsingAUuid". + $this->assertTrue(true); + } + + public function testItCanUseCustomModelPermission(): void + { + $this->assertSame(Permission::class, $this->testUserPermission::class); + } + + public function testItCanUseCustomFieldsFromCache(): void + { + DB::connection()->getSchemaBuilder()->table(config('permission.table_names.roles'), function ($table): void { + $table->string('type')->default('R'); + }); + DB::connection()->getSchemaBuilder()->table(config('permission.table_names.permissions'), function ($table): void { + $table->string('type')->default('P'); + }); + + $this->testUserRole->givePermissionTo($this->testUserPermission); + app(PermissionRegistrar::class)->getPermissions(); + + DB::enableQueryLog(); + $this->assertSame('P', Permission::findByName('edit-articles')->type); + $this->assertSame('R', Permission::findByName('edit-articles')->roles[0]->type); + DB::disableQueryLog(); + + $this->assertCount(0, DB::getQueryLog()); + } + + public function testItCanScopeUsersUsingAUuid(): void + { + $uuid1 = $this->testUserPermission->getKey(); + $uuid2 = app(Permission::class)::where('name', 'edit-news')->first()->getKey(); + + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user1->givePermissionTo([$uuid1, $uuid2]); + $this->testUserRole->givePermissionTo($uuid1); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::permission($uuid1)->get()); + $this->assertCount(1, User::permission([$uuid2])->get()); + } + + public function testItDoesNotDetachRolesWhenSoftDeleting(): void + { + $this->testUserRole->givePermissionTo($this->testUserPermission); + + DB::enableQueryLog(); + $this->testUserPermission->delete(); + DB::disableQueryLog(); + + $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + + $permission = Permission::onlyTrashed()->find($this->testUserPermission->getKey()); + + $this->assertSame( + 1, + DB::table(config('permission.table_names.role_has_permissions')) + ->where('permission_test_id', $permission->getKey()) + ->count(), + ); + } + + public function testItDoesNotDetachUsersWhenSoftDeleting(): void + { + $this->testUser->givePermissionTo($this->testUserPermission); + + DB::enableQueryLog(); + $this->testUserPermission->delete(); + DB::disableQueryLog(); + + $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + + $permission = Permission::onlyTrashed()->find($this->testUserPermission->getKey()); + + $this->assertSame( + 1, + DB::table(config('permission.table_names.model_has_permissions')) + ->where('permission_test_id', $permission->getKey()) + ->count(), + ); + } + + public function testItDoesDetachRolesAndUsersWhenForceDeleting(): void + { + $permissionId = $this->testUserPermission->getKey(); + $this->testUserRole->givePermissionTo($permissionId); + $this->testUser->givePermissionTo($permissionId); + + DB::enableQueryLog(); + $this->testUserPermission->forceDelete(); + DB::disableQueryLog(); + + $this->assertCount(3 + $this->resetDatabaseQuery, DB::getQueryLog()); + + $this->assertNull(Permission::withTrashed()->find($permissionId)); + $this->assertSame( + 0, + DB::table(config('permission.table_names.role_has_permissions')) + ->where('permission_test_id', $permissionId) + ->count(), + ); + $this->assertSame( + 0, + DB::table(config('permission.table_names.model_has_permissions')) + ->where('permission_test_id', $permissionId) + ->count(), + ); + } + + public function testItTouchesWhenAssigningNewPermissions(): void + { + CarbonImmutable::setTestNow('2021-07-19 10:13:14'); + + $user = Admin::create(['email' => 'user1@test.com']); + $permission1 = Permission::create(['name' => 'edit-news', 'guard_name' => 'admin']); + $permission2 = Permission::create(['name' => 'edit-blog', 'guard_name' => 'admin']); + + $this->assertSame('2021-07-19 10:13:14', $permission1->updated_at->format('Y-m-d H:i:s')); + + CarbonImmutable::setTestNow('2021-07-20 19:13:14'); + + $user->syncPermissions([$permission1->getKey(), $permission2->getKey()]); + + $this->assertSame('2021-07-20 19:13:14', $permission1->refresh()->updated_at->format('Y-m-d H:i:s')); + $this->assertSame('2021-07-20 19:13:14', $permission2->refresh()->updated_at->format('Y-m-d H:i:s')); + } +} diff --git a/tests/Permission/Traits/HasRolesTest.php b/tests/Permission/Traits/HasRolesTest.php new file mode 100644 index 000000000..a6112bbf2 --- /dev/null +++ b/tests/Permission/Traits/HasRolesTest.php @@ -0,0 +1,859 @@ +assertFalse($this->testUser->hasRole('testRole')); + + $role = app(Role::class)->findOrCreate('testRoleInWebGuard', 'web'); + + $this->assertFalse($this->testUser->hasRole($role)); + + $this->testUser->assignRole($role); + $this->assertTrue($this->testUser->hasRole($role)); + $this->assertTrue($this->testUser->hasRole($role->name)); + $this->assertTrue($this->testUser->hasRole($role->name, $role->guard_name)); + $this->assertTrue($this->testUser->hasRole([$role->name, 'fakeRole'], $role->guard_name)); + $this->assertTrue($this->testUser->hasRole($role->getKey(), $role->guard_name)); + $this->assertTrue($this->testUser->hasRole([$role->getKey(), 'fakeRole'], $role->guard_name)); + + $this->assertFalse($this->testUser->hasRole($role->name, 'fakeGuard')); + $this->assertFalse($this->testUser->hasRole([$role->name, 'fakeRole'], 'fakeGuard')); + $this->assertFalse($this->testUser->hasRole($role->getKey(), 'fakeGuard')); + $this->assertFalse($this->testUser->hasRole([$role->getKey(), 'fakeRole'], 'fakeGuard')); + + $role = app(Role::class)->findOrCreate('testRoleInWebGuard2', 'web'); + $this->assertFalse($this->testUser->hasRole($role)); + } + + public function testItCanAssignAndRemoveARoleUsingEnums(): void + { + $enum1 = TestRolePermissionsEnum::UserManager; + $enum2 = TestRolePermissionsEnum::Writer; + $enum3 = TestRolePermissionsEnum::CastedEnum1; + $enum4 = TestRolePermissionsEnum::CastedEnum2; + + app(Role::class)->findOrCreate($enum1->value, 'web'); + app(Role::class)->findOrCreate($enum2->value, 'web'); + app(Role::class)->findOrCreate($enum3->value, 'web'); + app(Role::class)->findOrCreate($enum4->value, 'web'); + + $this->assertFalse($this->testUser->hasRole($enum1)); + $this->assertFalse($this->testUser->hasRole($enum2)); + $this->assertFalse($this->testUser->hasRole($enum3)); + $this->assertFalse($this->testUser->hasRole($enum4)); + $this->assertFalse($this->testUser->hasRole('user-manager')); + $this->assertFalse($this->testUser->hasRole('writer')); + $this->assertFalse($this->testUser->hasRole('casted_enum-1')); + $this->assertFalse($this->testUser->hasRole('casted_enum-2')); + + $this->testUser->assignRole($enum1); + $this->testUser->assignRole($enum2); + $this->testUser->assignRole($enum3); + $this->testUser->assignRole($enum4); + + $this->assertTrue($this->testUser->hasRole($enum1)); + $this->assertTrue($this->testUser->hasRole($enum2)); + $this->assertTrue($this->testUser->hasRole($enum3)); + $this->assertTrue($this->testUser->hasRole($enum4)); + + $this->assertTrue($this->testUser->hasRole([$enum1, 'writer'])); + $this->assertTrue($this->testUser->hasRole([$enum3, 'casted_enum-2'])); + + $this->assertTrue($this->testUser->hasAllRoles([$enum1, $enum2, $enum3, $enum4])); + $this->assertTrue($this->testUser->hasAllRoles(['user-manager', 'writer', 'casted_enum-1', 'casted_enum-2'])); + $this->assertFalse($this->testUser->hasAllRoles([$enum1, $enum2, $enum3, $enum4, 'not exist'])); + $this->assertFalse($this->testUser->hasAllRoles(['user-manager', 'writer', 'casted_enum-1', 'casted_enum-2', 'not exist'])); + + $this->assertTrue($this->testUser->hasExactRoles([$enum4, $enum3, $enum2, $enum1])); + $this->assertTrue($this->testUser->hasExactRoles(['user-manager', 'writer', 'casted_enum-1', 'casted_enum-2'])); + + $this->testUser->removeRole($enum1); + + $this->assertFalse($this->testUser->hasRole($enum1)); + } + + public function testItCanScopeARoleUsingEnums(): void + { + $enum1 = TestRolePermissionsEnum::UserManager; + $enum2 = TestRolePermissionsEnum::Writer; + app(Role::class)->findOrCreate($enum1->value, 'web'); + app(Role::class)->findOrCreate($enum2->value, 'web'); + + User::all()->each(fn ($item) => $item->delete()); + User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + User::create(['email' => 'user3@test.com']); + + $user2->assignRole($enum1); + $this->assertTrue($user2->hasRole($enum1)); + $this->assertFalse($user2->hasRole($enum2)); + + $this->assertCount(1, User::role($enum1)->get()); + $this->assertCount(0, User::role($enum2)->get()); + $this->assertCount(3, User::withoutRole($enum2)->get()); + } + + public function testItCanAssignAndRemoveARole(): void + { + $this->assertFalse($this->testUser->hasRole('testRole')); + + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasRole('testRole')); + + $this->testUser->removeRole('testRole'); + + $this->assertFalse($this->testUser->hasRole('testRole')); + } + + public function testItRemovesARoleAndReturnsRoles(): void + { + $this->testUser->assignRole('testRole'); + $this->testUser->assignRole('testRole2'); + + $this->assertTrue($this->testUser->hasRole(['testRole', 'testRole2'])); + + $roles = $this->testUser->removeRole('testRole'); + + $this->assertFalse($roles->hasRole('testRole')); + $this->assertTrue($roles->hasRole('testRole2')); + } + + public function testItCanAssignAndRemoveARoleOnAPermission(): void + { + $this->testUserPermission->assignRole('testRole'); + + $this->assertTrue($this->testUserPermission->hasRole('testRole')); + + $this->testUserPermission->removeRole('testRole'); + + $this->assertFalse($this->testUserPermission->hasRole('testRole')); + } + + public function testItCanAssignAndRemoveARoleUsingAnObject(): void + { + $this->testUser->assignRole($this->testUserRole); + + $this->assertTrue($this->testUser->hasRole($this->testUserRole)); + + $this->testUser->removeRole($this->testUserRole); + + $this->assertFalse($this->testUser->hasRole($this->testUserRole)); + } + + public function testItCanAssignAndRemoveARoleUsingAnId(): void + { + $this->testUser->assignRole($this->testUserRole->getKey()); + + $this->assertTrue($this->testUser->hasRole($this->testUserRole)); + + $this->testUser->removeRole($this->testUserRole->getKey()); + + $this->assertFalse($this->testUser->hasRole($this->testUserRole)); + } + + public function testItCanAssignAndRemoveMultipleRolesAtOnce(): void + { + $this->testUser->assignRole($this->testUserRole->getKey(), 'testRole2'); + + $this->assertTrue($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + + $this->testUser->removeRole($this->testUserRole->getKey(), 'testRole2'); + + $this->assertFalse($this->testUser->hasRole('testRole')); + $this->assertFalse($this->testUser->hasRole('testRole2')); + } + + public function testItCanAssignAndRemoveMultipleRolesUsingAnArray(): void + { + $this->testUser->assignRole([$this->testUserRole->getKey(), 'testRole2']); + + $this->assertTrue($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + + $this->testUser->removeRole([$this->testUserRole->getKey(), 'testRole2']); + + $this->assertFalse($this->testUser->hasRole('testRole')); + $this->assertFalse($this->testUser->hasRole('testRole2')); + } + + public function testItDoesNotRemoveAlreadyAssociatedRolesWhenAssigningNewRoles(): void + { + $this->testUser->assignRole($this->testUserRole->getKey()); + $this->testUser->assignRole('testRole2'); + + $this->assertTrue($this->testUser->fresh()->hasRole('testRole')); + } + + public function testItDoesNotThrowAnExceptionWhenAssigningARoleThatIsAlreadyAssigned(): void + { + $this->testUser->assignRole($this->testUserRole->getKey()); + $this->testUser->assignRole($this->testUserRole->getKey()); + + $this->assertTrue($this->testUser->fresh()->hasRole('testRole')); + } + + public function testItThrowsAnExceptionWhenAssigningARoleThatDoesNotExist(): void + { + $this->expectException(RoleDoesNotExist::class); + + $this->testUser->assignRole('evil-emperor'); + } + + public function testItCanOnlyAssignRolesFromTheCorrectGuard(): void + { + $this->expectException(RoleDoesNotExist::class); + + $this->testUser->assignRole('testAdminRole'); + } + + public function testItThrowsAnExceptionWhenAssigningARoleFromADifferentGuard(): void + { + $this->expectException(GuardDoesNotMatch::class); + + $this->testUser->assignRole($this->testAdminRole); + } + + public function testItIgnoresNullRolesWhenSyncing(): void + { + $this->testUser->assignRole('testRole'); + + $this->testUser->syncRoles('testRole2', null); + + $this->assertFalse($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + } + + public function testItCanSyncRolesFromAString(): void + { + $this->testUser->assignRole('testRole'); + + $this->testUser->syncRoles('testRole2'); + + $this->assertFalse($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + } + + public function testItCanSyncRolesFromAStringOnAPermission(): void + { + $this->testUserPermission->assignRole('testRole'); + + $this->testUserPermission->syncRoles('testRole2'); + + $this->assertFalse($this->testUserPermission->hasRole('testRole')); + $this->assertTrue($this->testUserPermission->hasRole('testRole2')); + } + + public function testItCanAvoidSyncDuplicatedRoles(): void + { + $this->testUser->syncRoles('testRole', 'testRole', 'testRole2'); + + $this->assertTrue($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + } + + public function testItCanAvoidDetachOnRoleThatDoesNotExistSync(): void + { + $this->testUser->syncRoles('testRole'); + + try { + $this->testUser->syncRoles('role-does-not-exist'); + $this->fail('Expected role does not exist exception was not thrown.'); + } catch (RoleDoesNotExist) { + $this->assertTrue($this->testUser->hasRole('testRole')); + $this->assertFalse($this->testUser->hasRole('role-does-not-exist')); + } + } + + public function testItCanSyncMultipleRoles(): void + { + $this->testUser->syncRoles('testRole', 'testRole2'); + + $this->assertTrue($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + } + + public function testItCanSyncMultipleRolesFromAnArray(): void + { + $this->testUser->syncRoles(['testRole', 'testRole2']); + + $this->assertTrue($this->testUser->hasRole('testRole')); + $this->assertTrue($this->testUser->hasRole('testRole2')); + } + + public function testItWillRemoveAllRolesWhenAnEmptyArrayIsPassedToSyncRoles(): void + { + $this->testUser->assignRole('testRole'); + $this->testUser->assignRole('testRole2'); + + $this->testUser->syncRoles([]); + + $this->assertFalse($this->testUser->hasRole('testRole')); + $this->assertFalse($this->testUser->hasRole('testRole2')); + } + + public function testItDoesNotDetachRolesWhenSyncRolesErrors(): void + { + $this->testUser->assignRole('testRole'); + + try { + $this->testUser->syncRoles('testRole2', 'role-that-does-not-exist'); + $this->fail('Expected role does not exist exception was not thrown.'); + } catch (RoleDoesNotExist) { + $this->assertTrue($this->testUser->fresh()->hasRole('testRole')); + } + } + + public function testItWillSyncRolesToAModelThatIsNotPersisted(): void + { + $user = new User(['email' => 'test@user.com']); + $user->syncRoles([$this->testUserRole]); + $user->save(); + $user->save(); + + $this->assertTrue($user->hasRole($this->testUserRole)); + + $user->syncRoles([$this->testUserRole]); + $this->assertTrue($user->hasRole($this->testUserRole)); + $this->assertTrue($user->fresh()->hasRole($this->testUserRole)); + } + + public function testItDoesNotRunUnnecessarySqlWhenAssigningNewRoles(): void + { + $role2 = app(Role::class)->where('name', 'testRole2')->first(); + + DB::enableQueryLog(); + $this->testUser->syncRoles($this->testUserRole, $role2); + DB::disableQueryLog(); + + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItDoesNotLetQueuedSyncRolesInterfereWithOtherObjects(): void + { + $user = new User(['email' => 'test@user.com']); + $user->syncRoles('testRole'); + $user->save(); + + $user2 = new User(['email' => 'admin@user.com']); + $user2->syncRoles('testRole2'); + + DB::enableQueryLog(); + $user2->save(); + DB::disableQueryLog(); + + $this->assertTrue($user->fresh()->hasRole('testRole')); + $this->assertFalse($user->fresh()->hasRole('testRole2')); + + $this->assertTrue($user2->fresh()->hasRole('testRole2')); + $this->assertFalse($user2->fresh()->hasRole('testRole')); + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItDoesNotLetQueuedAssignRoleInterfereWithOtherObjects(): void + { + $user = new User(['email' => 'test@user.com']); + $user->assignRole('testRole'); + $user->save(); + + $adminUser = new User(['email' => 'admin@user.com']); + $adminUser->assignRole('testRole2'); + + DB::enableQueryLog(); + $adminUser->save(); + DB::disableQueryLog(); + + $this->assertTrue($user->fresh()->hasRole('testRole')); + $this->assertFalse($user->fresh()->hasRole('testRole2')); + + $this->assertTrue($adminUser->fresh()->hasRole('testRole2')); + $this->assertFalse($adminUser->fresh()->hasRole('testRole')); + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItThrowsAnExceptionWhenSyncingARoleFromAnotherGuard(): void + { + try { + $this->testUser->syncRoles('testRole', 'testAdminRole'); + $this->fail('Expected role does not exist exception was not thrown.'); + } catch (RoleDoesNotExist) { + $this->assertTrue(true); + } + + $this->expectException(GuardDoesNotMatch::class); + + $this->testUser->syncRoles('testRole', $this->testAdminRole); + } + + public function testItDeletesPivotTableEntriesWhenDeletingModels(): void + { + $user = User::create(['email' => 'user@test.com']); + + $user->assignRole('testRole'); + $user->givePermissionTo('edit-articles'); + + $this->assertDatabaseHas('model_has_permissions', [config('permission.column_names.model_morph_key') => $user->id]); + $this->assertDatabaseHas('model_has_roles', [config('permission.column_names.model_morph_key') => $user->id]); + + $user->delete(); + + $this->assertDatabaseMissing('model_has_permissions', [config('permission.column_names.model_morph_key') => $user->id]); + $this->assertDatabaseMissing('model_has_roles', [config('permission.column_names.model_morph_key') => $user->id]); + } + + public function testItCanScopeUsersUsingAString(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + User::create(['email' => 'user2@test.com']); + $user1->assignRole('testRole'); + + $this->assertCount(1, User::role('testRole')->get()); + } + + public function testItCanWithoutScopeUsersUsingAString(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $this->assertCount(1, User::withoutRole('testRole2')->get()); + } + + public function testItCanScopeUsersUsingAnArray(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + + $this->assertCount(1, User::role([$this->testUserRole])->get()); + $this->assertCount(2, User::role(['testRole', 'testRole2'])->get()); + } + + public function testItCanWithoutScopeUsersUsingAnArray(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $this->assertCount(2, User::withoutRole([$this->testUserRole])->get()); + $this->assertCount(0, User::withoutRole([$this->testUserRole->name, 'testRole2'])->get()); + } + + public function testItCanScopeUsersUsingAnArrayOfIdsAndNames(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + + $firstAssignedRoleName = $this->testUserRole->name; + $secondAssignedRoleId = app(Role::class)->findByName('testRole2')->getKey(); + + $this->assertCount(2, User::role([$firstAssignedRoleName, $secondAssignedRoleId])->get()); + } + + public function testItCanWithoutScopeUsersUsingAnArrayOfIdsAndNames(): void + { + app(Role::class)->create(['name' => 'testRole3']); + + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $firstAssignedRoleName = $this->testUserRole->name; + $unassignedRoleId = app(Role::class)->findByName('testRole3')->getKey(); + + $this->assertCount(2, User::withoutRole([$firstAssignedRoleName, $unassignedRoleId])->get()); + } + + public function testItCanScopeUsersUsingACollection(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + + $this->assertCount(1, User::role([$this->testUserRole])->get()); + $this->assertCount(2, User::role(collect(['testRole', 'testRole2']))->get()); + } + + public function testItCanWithoutScopeUsersUsingACollection(): void + { + app(Role::class)->create(['name' => 'testRole3']); + + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); + + $this->assertCount(1, User::withoutRole([$this->testUserRole])->get()); + $this->assertCount(1, User::withoutRole(collect(['testRole', 'testRole3']))->get()); + } + + public function testItCanScopeUsersUsingAnObject(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + User::create(['email' => 'user2@test.com'])->assignRole('testRole2'); + $user1->assignRole($this->testUserRole); + + $this->assertCount(1, User::role($this->testUserRole)->get()); + $this->assertCount(1, User::role([$this->testUserRole])->get()); + $this->assertCount(1, User::role(collect([$this->testUserRole]))->get()); + } + + public function testItCanWithoutScopeUsersUsingAnObject(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $this->assertCount(2, User::withoutRole($this->testUserRole)->get()); + $this->assertCount(2, User::withoutRole([$this->testUserRole])->get()); + $this->assertCount(2, User::withoutRole(collect([$this->testUserRole]))->get()); + } + + public function testItCanScopeAgainstASpecificGuard(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); + + $this->assertCount(1, User::role('testRole', 'web')->get()); + + $user3 = Admin::create(['email' => 'user3@test.com']); + $user4 = Admin::create(['email' => 'user4@test.com']); + $user5 = Admin::create(['email' => 'user5@test.com']); + $testAdminRole2 = app(Role::class)->create(['name' => 'testAdminRole2', 'guard_name' => 'admin']); + $user3->assignRole($this->testAdminRole); + $user4->assignRole($this->testAdminRole); + $user5->assignRole($testAdminRole2); + + $this->assertCount(2, Admin::role('testAdminRole', 'admin')->get()); + $this->assertCount(1, Admin::role('testAdminRole2', 'admin')->get()); + } + + public function testItCanWithoutScopeAgainstASpecificGuard(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $this->assertCount(2, User::withoutRole('testRole', 'web')->get()); + + Admin::with(['roles', 'permissions'])->get()->each(fn ($item) => $item->delete()); + $user4 = Admin::create(['email' => 'user4@test.com']); + $user5 = Admin::create(['email' => 'user5@test.com']); + $user6 = Admin::create(['email' => 'user6@test.com']); + $testAdminRole2 = app(Role::class)->create(['name' => 'testAdminRole2', 'guard_name' => 'admin']); + $user4->assignRole($this->testAdminRole); + $user5->assignRole($this->testAdminRole); + $user6->assignRole($testAdminRole2); + + $this->assertCount(1, Admin::withoutRole('testAdminRole', 'admin')->get()); + $this->assertCount(2, Admin::withoutRole('testAdminRole2', 'admin')->get()); + } + + public function testItThrowsAnExceptionWhenTryingToScopeARoleFromAnotherGuard(): void + { + $this->expectException(RoleDoesNotExist::class); + + User::role('testAdminRole')->get(); + } + + public function testItThrowsAnExceptionWhenTryingToCallWithoutScopeOnARoleFromAnotherGuard(): void + { + $this->expectException(RoleDoesNotExist::class); + + User::withoutRole('testAdminRole')->get(); + } + + public function testItThrowsAnExceptionWhenTryingToScopeANonExistingRole(): void + { + $this->expectException(RoleDoesNotExist::class); + + User::role('role not defined')->get(); + } + + public function testItThrowsAnExceptionWhenTryingToUseWithoutScopeOnANonExistingRole(): void + { + $this->expectException(RoleDoesNotExist::class); + + User::withoutRole('role not defined')->get(); + } + + public function testItCanDetermineThatAUserHasOneOfTheGivenRoles(): void + { + $roleModel = app(Role::class); + + $roleModel->create(['name' => 'second role']); + + $this->assertFalse($this->testUser->hasRole($roleModel->all())); + + $this->testUser->assignRole($this->testUserRole); + + $this->assertTrue($this->testUser->hasRole($roleModel->all())); + $this->assertTrue($this->testUser->hasAnyRole($roleModel->all())); + $this->assertTrue($this->testUser->hasAnyRole('testRole')); + $this->assertFalse($this->testUser->hasAnyRole('role does not exist')); + $this->assertTrue($this->testUser->hasAnyRole(['testRole'])); + $this->assertTrue($this->testUser->hasAnyRole(['testRole', 'role does not exist'])); + $this->assertFalse($this->testUser->hasAnyRole(['role does not exist'])); + $this->assertTrue($this->testUser->hasAnyRole('testRole', 'role does not exist')); + } + + public function testItCanDetermineThatAUserHasAllOfTheGivenRoles(): void + { + $roleModel = app(Role::class); + + $this->assertFalse($this->testUser->hasAllRoles($roleModel->first())); + $this->assertFalse($this->testUser->hasAllRoles('testRole')); + $this->assertFalse($this->testUser->hasAllRoles($roleModel->all())); + + $roleModel->create(['name' => 'second role']); + + $this->testUser->assignRole($this->testUserRole); + + $this->assertTrue($this->testUser->hasAllRoles('testRole')); + $this->assertTrue($this->testUser->hasAllRoles('testRole', 'web')); + $this->assertFalse($this->testUser->hasAllRoles('testRole', 'fakeGuard')); + + $this->assertFalse($this->testUser->hasAllRoles(['testRole', 'second role'])); + $this->assertFalse($this->testUser->hasAllRoles(['testRole', 'second role'], 'web')); + + $this->testUser->assignRole('second role'); + + $this->assertTrue($this->testUser->hasAllRoles(['testRole', 'second role'])); + $this->assertTrue($this->testUser->hasAllRoles(['testRole', 'second role'], 'web')); + $this->assertFalse($this->testUser->hasAllRoles(['testRole', 'second role'], 'fakeGuard')); + } + + public function testItCanDetermineThatAUserHasExactlyAllOfTheGivenRoles(): void + { + $roleModel = app(Role::class); + + $this->assertFalse($this->testUser->hasExactRoles($roleModel->first())); + $this->assertFalse($this->testUser->hasExactRoles('testRole')); + $this->assertFalse($this->testUser->hasExactRoles($roleModel->all())); + + $roleModel->create(['name' => 'second role']); + + $this->testUser->assignRole($this->testUserRole); + + $this->assertTrue($this->testUser->hasExactRoles('testRole')); + $this->assertTrue($this->testUser->hasExactRoles('testRole', 'web')); + $this->assertFalse($this->testUser->hasExactRoles('testRole', 'fakeGuard')); + + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role'])); + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role'], 'web')); + + $this->testUser->assignRole('second role'); + + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'second role'])); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'second role'], 'web')); + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role'], 'fakeGuard')); + + $roleModel->create(['name' => 'third role']); + $this->testUser->assignRole('third role'); + + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role'])); + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role'], 'web')); + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role'], 'fakeGuard')); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'second role', 'third role'])); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'second role', 'third role'], 'web')); + $this->assertFalse($this->testUser->hasExactRoles(['testRole', 'second role', 'third role'], 'fakeGuard')); + } + + public function testItCanDetermineThatAUserDoesNotHaveARoleFromAnotherGuard(): void + { + $this->assertFalse($this->testUser->hasRole('testAdminRole')); + $this->assertFalse($this->testUser->hasRole($this->testAdminRole)); + + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasAnyRole(['testRole', 'testAdminRole'])); + $this->assertFalse($this->testUser->hasAnyRole('testAdminRole', $this->testAdminRole)); + } + + public function testItCanCheckAgainstAnyMultipleRolesUsingMultipleArguments(): void + { + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasAnyRole($this->testAdminRole, ['testRole'], 'This Role Does Not Even Exist')); + } + + public function testItReturnsFalseInsteadOfAnExceptionWhenCheckingAgainstAnyUndefinedRolesUsingMultipleArguments(): void + { + $this->assertFalse($this->testUser->hasAnyRole('This Role Does Not Even Exist', $this->testAdminRole)); + } + + public function testItThrowsAnExceptionIfAnUnsupportedTypeIsPassedToHasRoles(): void + { + $this->expectException(TypeError::class); + + $this->testUser->hasRole(new class {}); + } + + public function testItCanRetrieveRoleNames(): void + { + $this->testUser->assignRole('testRole', 'testRole2'); + + $this->assertSame(['testRole', 'testRole2'], $this->testUser->getRoleNames()->sort()->values()->all()); + } + + public function testItDoesNotDetachRolesWhenUserSoftDeleting(): void + { + $user = SoftDeletingUser::create(['email' => 'test@example.com']); + $user->assignRole('testRole'); + $user->delete(); + + $user = SoftDeletingUser::withTrashed()->find($user->id); + + $this->assertTrue($user->hasRole('testRole')); + } + + public function testItFiresAnEventWhenARoleIsAdded(): void + { + Event::fake(); + app('config')->set('permission.events_enabled', true); + + $this->testUser->assignRole(['testRole', 'testRole2']); + + $roleIds = app(Role::class)::whereIn('name', ['testRole', 'testRole2']) + ->pluck($this->testUserRole->getKeyName()) + ->toArray(); + + Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event) use ($roleIds): bool { + return $event->model instanceof User + && $event->model->hasRole('testRole') + && $event->model->hasRole('testRole2') + && $event->rolesOrIds === $roleIds; + }); + } + + public function testItFiresAnEventWhenARoleIsRemoved(): void + { + Event::fake(); + app('config')->set('permission.events_enabled', true); + + $this->testUser->assignRole('testRole', 'testRole2'); + $this->testUser->removeRole('testRole', 'testRole2'); + + $roleIds = app(Role::class)::whereIn('name', ['testRole', 'testRole2']) + ->pluck($this->testUserRole->getKeyName()) + ->toArray(); + + Event::assertDispatched(RoleDetachedEvent::class, function (RoleDetachedEvent $event) use ($roleIds): bool { + return $event->model instanceof User + && ! $event->model->hasRole('testRole') + && ! $event->model->hasRole('testRole2') + && $event->rolesOrIds === $roleIds; + }); + } + + public function testItCanBeGivenARoleOnPermissionWhenLazyLoadingIsRestricted(): void + { + $this->assertTrue(Model::preventsLazyLoading()); + + $testPermission = app(Permission::class)->with('roles')->get()->first(); + + $testPermission->assignRole('testRole'); + + $this->assertTrue($testPermission->hasRole('testRole')); + } + + public function testItCanBeGivenARoleOnUserWhenLazyLoadingIsRestricted(): void + { + $this->assertTrue(Model::preventsLazyLoading()); + + User::create(['email' => 'other@user.com']); + $user = User::with('roles')->get()->first(); + $user->assignRole('testRole'); + + $this->assertTrue($user->hasRole('testRole')); + } + + public function testItFiresDetachEventWhenSyncingRoles(): void + { + Event::fake([RoleDetachedEvent::class, RoleAttachedEvent::class]); + app('config')->set('permission.events_enabled', true); + + $this->testUser->assignRole('testRole', 'testRole2'); + + app(Role::class)->create(['name' => 'testRole3']); + + $this->testUser->syncRoles('testRole3'); + + $this->assertFalse($this->testUser->hasRole('testRole')); + $this->assertFalse($this->testUser->hasRole('testRole2')); + $this->assertTrue($this->testUser->hasRole('testRole3')); + + $removedRoleIds = app(Role::class)::whereIn('name', ['testRole', 'testRole2']) + ->pluck($this->testUserRole->getKeyName()) + ->toArray(); + + Event::assertDispatched(RoleDetachedEvent::class, function (RoleDetachedEvent $event) use ($removedRoleIds): bool { + return $event->model instanceof User + && ! $event->model->hasRole('testRole') + && ! $event->model->hasRole('testRole2') + && $event->rolesOrIds === $removedRoleIds; + }); + + $attachedRoleIds = app(Role::class)::whereIn('name', ['testRole3']) + ->pluck($this->testUserRole->getKeyName()) + ->toArray(); + + Event::assertDispatched(RoleAttachedEvent::class, function (RoleAttachedEvent $event) use ($attachedRoleIds): bool { + return $event->model instanceof User + && $event->model->hasRole('testRole3') + && $event->rolesOrIds === $attachedRoleIds; + }); + } +} diff --git a/tests/Permission/Traits/HasRolesWithCustomModelsTest.php b/tests/Permission/Traits/HasRolesWithCustomModelsTest.php new file mode 100644 index 000000000..9bc821ea6 --- /dev/null +++ b/tests/Permission/Traits/HasRolesWithCustomModelsTest.php @@ -0,0 +1,110 @@ +setUpCustomModels(); + } + + public function testItCanUseCustomModelRole(): void + { + $this->assertSame(Role::class, $this->testUserRole::class); + } + + public function testItDoesNotDetachPermissionsWhenSoftDeleting(): void + { + $this->testUserRole->givePermissionTo($this->testUserPermission); + + DB::enableQueryLog(); + $this->testUserRole->delete(); + DB::disableQueryLog(); + + $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + + $role = Role::onlyTrashed()->find($this->testUserRole->getKey()); + + $this->assertSame( + 1, + DB::table(config('permission.table_names.role_has_permissions')) + ->where('role_test_id', $role->getKey()) + ->count(), + ); + } + + public function testItDoesNotDetachUsersWhenSoftDeleting(): void + { + $this->testUser->assignRole($this->testUserRole); + + DB::enableQueryLog(); + $this->testUserRole->delete(); + DB::disableQueryLog(); + + $this->assertCount(1 + $this->resetDatabaseQuery, DB::getQueryLog()); + + $role = Role::onlyTrashed()->find($this->testUserRole->getKey()); + + $this->assertSame( + 1, + DB::table(config('permission.table_names.model_has_roles')) + ->where('role_test_id', $role->getKey()) + ->count(), + ); + } + + public function testItDoesDetachPermissionsAndUsersWhenForceDeleting(): void + { + $roleId = $this->testUserRole->getKey(); + $this->testUserPermission->assignRole($roleId); + $this->testUser->assignRole($roleId); + + DB::enableQueryLog(); + $this->testUserRole->forceDelete(); + DB::disableQueryLog(); + + $this->assertCount(3 + $this->resetDatabaseQuery, DB::getQueryLog()); + + $this->assertNull(Role::withTrashed()->find($roleId)); + $this->assertSame( + 0, + DB::table(config('permission.table_names.role_has_permissions')) + ->where('role_test_id', $roleId) + ->count(), + ); + $this->assertSame( + 0, + DB::table(config('permission.table_names.model_has_roles')) + ->where('role_test_id', $roleId) + ->count(), + ); + } + + public function testItTouchesWhenAssigningNewRoles(): void + { + CarbonImmutable::setTestNow('2021-07-19 10:13:14'); + + $user = Admin::create(['email' => 'user1@test.com']); + $role1 = Role::create(['name' => 'testRoleInWebGuard', 'guard_name' => 'admin']); + $role2 = Role::create(['name' => 'testRoleInWebGuard1', 'guard_name' => 'admin']); + + $this->assertSame('2021-07-19 10:13:14', $role1->updated_at->format('Y-m-d H:i:s')); + + CarbonImmutable::setTestNow('2021-07-20 19:13:14'); + + $user->syncRoles([$role1->getKey(), $role2->getKey()]); + + $this->assertSame('2021-07-20 19:13:14', $role1->refresh()->updated_at->format('Y-m-d H:i:s')); + $this->assertSame('2021-07-20 19:13:14', $role2->refresh()->updated_at->format('Y-m-d H:i:s')); + } +} diff --git a/tests/Permission/Traits/TeamHasPermissionsTest.php b/tests/Permission/Traits/TeamHasPermissionsTest.php new file mode 100644 index 000000000..fc9905fd1 --- /dev/null +++ b/tests/Permission/Traits/TeamHasPermissionsTest.php @@ -0,0 +1,107 @@ +make('config')->set('permission.teams', true); + } + + protected function setUpInCoroutine(): void + { + $this->setUpTeams(); + } + + public function testItCanAssignSameAndDifferentPermissionsOnSameUserOnDifferentTeams(): void + { + setPermissionsTeamId(1); + $this->testUser->givePermissionTo('edit-articles', 'edit-news'); + + setPermissionsTeamId(2); + $this->testUser->givePermissionTo('edit-articles', 'edit-blog'); + + setPermissionsTeamId(1); + $this->testUser->load('permissions'); + $this->assertSame(['edit-articles', 'edit-news'], $this->testUser->getPermissionNames()->sort()->values()->all()); + $this->assertTrue($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news'])); + $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-blog'])); + + setPermissionsTeamId(2); + $this->testUser->load('permissions'); + $this->assertSame(['edit-articles', 'edit-blog'], $this->testUser->getPermissionNames()->sort()->values()->all()); + $this->assertTrue($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-blog'])); + $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news'])); + } + + public function testItCanListAllCoupledPermissionsDirectlyAndViaRolesOnSameUserOnDifferentTeams(): void + { + $this->testUserRole->givePermissionTo('edit-articles'); + + setPermissionsTeamId(1); + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-news'); + + setPermissionsTeamId(2); + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-blog'); + + setPermissionsTeamId(1); + $this->testUser->load('roles', 'permissions'); + $this->assertSame(['edit-articles', 'edit-news'], $this->testUser->getAllPermissions()->pluck('name')->sort()->values()->all()); + + setPermissionsTeamId(2); + $this->testUser->load('roles', 'permissions'); + $this->assertSame(['edit-articles', 'edit-blog'], $this->testUser->getAllPermissions()->pluck('name')->sort()->values()->all()); + } + + public function testItCanSyncOrRemovePermissionsWithoutDetachingDifferentTeams(): void + { + setPermissionsTeamId(1); + $this->testUser->syncPermissions('edit-articles', 'edit-news'); + + setPermissionsTeamId(2); + $this->testUser->syncPermissions('edit-articles', 'edit-blog'); + + setPermissionsTeamId(1); + $this->testUser->load('permissions'); + $this->assertSame(['edit-articles', 'edit-news'], $this->testUser->getPermissionNames()->sort()->values()->all()); + + $this->testUser->revokePermissionTo('edit-articles'); + $this->assertSame(['edit-news'], $this->testUser->getPermissionNames()->sort()->values()->all()); + + setPermissionsTeamId(2); + $this->testUser->load('permissions'); + $this->assertSame(['edit-articles', 'edit-blog'], $this->testUser->getPermissionNames()->sort()->values()->all()); + } + + public function testItCanScopeUsersOnDifferentTeams(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(2); + $user1->givePermissionTo(['edit-articles', 'edit-news']); + $this->testUserRole->givePermissionTo('edit-articles'); + $user2->assignRole('testRole'); + + setPermissionsTeamId(1); + $user1->givePermissionTo(['edit-articles']); + + setPermissionsTeamId(2); + $this->assertCount(2, User::permission(['edit-articles', 'edit-news'])->get()); + $this->assertCount(1, User::permission('edit-news')->get()); + + setPermissionsTeamId(1); + $this->assertCount(1, User::permission(['edit-articles', 'edit-news'])->get()); + $this->assertCount(0, User::permission('edit-news')->get()); + } +} diff --git a/tests/Permission/Traits/TeamHasRolesTest.php b/tests/Permission/Traits/TeamHasRolesTest.php new file mode 100644 index 000000000..95693fcb2 --- /dev/null +++ b/tests/Permission/Traits/TeamHasRolesTest.php @@ -0,0 +1,156 @@ +make('config')->set('permission.teams', true); + } + + protected function setUpInCoroutine(): void + { + $this->setUpTeams(); + } + + public function testItDoesNotRunUnnecessarySqlWhenAssigningNewRoles(): void + { + $role2 = app(Role::class)->where('name', 'testRole2')->first(); + + DB::enableQueryLog(); + $this->testUser->syncRoles($this->testUserRole, $role2); + DB::disableQueryLog(); + + // Hypervel's team-aware sync path writes the current team pivot directly, + // so it avoids the extra relation reload that Spatie needs under Laravel. + $this->assertCount(2, DB::getQueryLog()); + } + + public function testItDeletesPivotTableEntriesWhenDeletingModelsAcrossTeams(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + $user1->givePermissionTo('edit-articles'); + $user2->assignRole('testRole'); + $user2->givePermissionTo('edit-articles'); + + setPermissionsTeamId(2); + $user1->givePermissionTo('edit-news'); + + $this->assertDatabaseHas('model_has_permissions', ['model_test_id' => $user1->getKey()]); + $this->assertDatabaseHas('model_has_roles', ['model_test_id' => $user1->getKey()]); + + $user1->delete(); + + setPermissionsTeamId(1); + $this->assertDatabaseMissing('model_has_permissions', ['model_test_id' => $user1->getKey()]); + $this->assertDatabaseMissing('model_has_roles', ['model_test_id' => $user1->getKey()]); + $this->assertDatabaseHas('model_has_permissions', ['model_test_id' => $user2->getKey()]); + $this->assertDatabaseHas('model_has_roles', ['model_test_id' => $user2->getKey()]); + } + + public function testItCanAssignSameAndDifferentRolesOnSameUserDifferentTeams(): void + { + app(Role::class)->create(['name' => 'testRole3']); + app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); + app(Role::class)->create(['name' => 'testRole4', 'team_test_id' => null]); + + $testRole3Team1 = app(Role::class)->where(['name' => 'testRole3', 'team_test_id' => 1])->first(); + $testRole3Team2 = app(Role::class)->where(['name' => 'testRole3', 'team_test_id' => 2])->first(); + $testRole4NoTeam = app(Role::class)->where(['name' => 'testRole4', 'team_test_id' => null])->first(); + + $this->assertNotNull($testRole3Team1); + $this->assertNotNull($testRole3Team2); + $this->assertNotNull($testRole4NoTeam); + + setPermissionsTeamId(1); + $this->testUser->assignRole('testRole', 'testRole2'); + $this->testUser->load('roles'); + + setPermissionsTeamId(2); + $this->testUser->assignRole('testRole', 'testRole3'); + + setPermissionsTeamId(1); + $this->testUser->load('roles'); + + $this->assertSame(['testRole', 'testRole2'], $this->testUser->getRoleNames()->sort()->values()->all()); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole2'])); + + $this->testUser->assignRole('testRole3', 'testRole4'); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole2', 'testRole3', 'testRole4'])); + $this->assertTrue($this->testUser->hasRole($testRole3Team1)); + $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); + + setPermissionsTeamId(2); + $this->testUser->load('roles'); + + $this->assertSame(['testRole', 'testRole3'], $this->testUser->getRoleNames()->sort()->values()->all()); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole3'])); + $this->assertTrue($this->testUser->hasRole($testRole3Team2)); + + $this->testUser->assignRole('testRole4'); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole3', 'testRole4'])); + $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); + } + + public function testItCanSyncOrRemoveRolesWithoutDetachingDifferentTeams(): void + { + app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); + + setPermissionsTeamId(1); + $this->testUser->syncRoles('testRole', 'testRole2'); + + setPermissionsTeamId(2); + $this->testUser->syncRoles('testRole', 'testRole3'); + + setPermissionsTeamId(1); + $this->testUser->load('roles'); + + $this->assertSame(['testRole', 'testRole2'], $this->testUser->getRoleNames()->sort()->values()->all()); + + $this->testUser->removeRole('testRole'); + $this->assertSame(['testRole2'], $this->testUser->getRoleNames()->sort()->values()->all()); + + setPermissionsTeamId(2); + $this->testUser->load('roles'); + + $this->assertSame(['testRole', 'testRole3'], $this->testUser->getRoleNames()->sort()->values()->all()); + } + + public function testItCanScopeUsersOnDifferentTeams(): void + { + User::all()->each(fn ($item) => $item->delete()); + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(2); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + + setPermissionsTeamId(2); + $this->assertCount(1, User::role($this->testUserRole)->get()); + $this->assertCount(2, User::role(['testRole', 'testRole2'])->get()); + $this->assertCount(1, User::withoutRole('testRole')->get()); + + setPermissionsTeamId(1); + $this->assertCount(1, User::role($this->testUserRole)->get()); + $this->assertCount(0, User::role('testRole2')->get()); + $this->assertCount(1, User::withoutRole('testRole')->get()); + } +} diff --git a/tests/Permission/Traits/TeamScopeTest.php b/tests/Permission/Traits/TeamScopeTest.php new file mode 100644 index 000000000..21290ff56 --- /dev/null +++ b/tests/Permission/Traits/TeamScopeTest.php @@ -0,0 +1,256 @@ +make('config')->set([ + 'permission.teams' => true, + 'permission.models.team' => Team::class, + ]); + } + + protected function setUpInCoroutine(): void + { + $this->setUpTeams(); + app(PermissionRegistrar::class)->setTeamClass(Team::class); + User::query()->delete(); + } + + public function testItThrowsAnExceptionWhenTeamScopesAreQueriedWhileTeamsAreNotEnabled(): void + { + config()->set('permission.teams', false); + app(PermissionRegistrar::class)->teams = false; + + $this->expectException(TeamsNotEnabled::class); + User::team(1)->get(); + } + + public function testItThrowsAnExceptionWhenWithoutTeamScopeIsQueriedWhileTeamsAreNotEnabled(): void + { + config()->set('permission.teams', false); + app(PermissionRegistrar::class)->teams = false; + + $this->expectException(TeamsNotEnabled::class); + User::withoutTeam(1)->get(); + } + + public function testItReturnsAnEmptyTeamsRelationWhenTeamsAreNotEnabled(): void + { + config()->set('permission.teams', false); + app(PermissionRegistrar::class)->teams = false; + + $relation = $this->testUser->teams(); + + $this->assertInstanceOf(BelongsToMany::class, $relation); + $this->assertCount(0, $relation->get()); + } + + public function testItThrowsAnExceptionWhenTeamModelIsNotConfiguredForTeamScope(): void + { + app(PermissionRegistrar::class)->setTeamClass(null); + config()->set('permission.models.team', null); + + $this->expectException(TeamModelNotConfigured::class); + User::team(1)->get(); + } + + public function testItThrowsAnExceptionWhenTeamModelIsNotConfiguredForWithoutTeamScope(): void + { + app(PermissionRegistrar::class)->setTeamClass(null); + config()->set('permission.models.team', null); + + $this->expectException(TeamModelNotConfigured::class); + User::withoutTeam(1)->get(); + } + + public function testItThrowsAnExceptionWhenTeamModelIsNotConfiguredForTeamsRelation(): void + { + app(PermissionRegistrar::class)->setTeamClass(null); + config()->set('permission.models.team', null); + + $this->expectException(TeamModelNotConfigured::class); + $this->testUser->teams()->get(); + } + + public function testItReturnsTheTeamsAUserBelongsToViaTheTeamsRelation(): void + { + Team::create(['id' => 1, 'name' => 'Team One']); + Team::create(['id' => 2, 'name' => 'Team Two']); + + $user = User::create(['email' => 'user1@test.com']); + + setPermissionsTeamId(1); + $user->assignRole('testRole'); + + setPermissionsTeamId(2); + $user->assignRole('testRole'); + + $teams = $user->teams()->get(); + + $this->assertCount(2, $teams); + $this->assertEqualsCanonicalizing([1, 2], $teams->pluck('id')->all()); + } + + public function testItCanScopeUsersByTeamUsingAnId(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + $user2->assignRole('testRole'); + + setPermissionsTeamId(2); + $user3->assignRole('testRole'); + + $this->assertCount(2, User::team(1)->get()); + $this->assertCount(1, User::team(2)->get()); + } + + public function testItCanScopeUsersByTeamUsingAnArrayOfIds(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + + setPermissionsTeamId(2); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::team([1, 2])->get()); + $this->assertCount(1, User::team([1])->get()); + $this->assertCount(1, User::team([2])->get()); + } + + public function testItCanScopeUsersByTeamUsingACollection(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + + setPermissionsTeamId(2); + $user2->assignRole('testRole'); + + $this->assertCount(2, User::team(collect([1, 2]))->get()); + $this->assertCount(1, User::team(collect([1]))->get()); + } + + public function testItCanScopeUsersByTeamUsingAModelInstance(): void + { + $teamOne = Team::create(['id' => 1, 'name' => 'Team One']); + $teamTwo = Team::create(['id' => 2, 'name' => 'Team Two']); + + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + + setPermissionsTeamId(2); + $user2->assignRole('testRole'); + + $this->assertCount(1, User::team($teamOne)->get()); + $this->assertCount(2, User::team([$teamOne, $teamTwo])->get()); + } + + public function testItReturnsUniqueUsersWhenUserHasMultipleRolesInTheSameTeam(): void + { + $user = User::create(['email' => 'user1@test.com']); + + setPermissionsTeamId(1); + $user->assignRole('testRole'); + $user->assignRole('testRole2'); + + $this->assertCount(1, User::team(1)->get()); + } + + public function testItCanScopeUsersWithoutATeamUsingAnId(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + $user2->assignRole('testRole'); + + setPermissionsTeamId(2); + $user3->assignRole('testRole'); + + $this->assertCount(1, User::withoutTeam(1)->get()); + $this->assertCount(2, User::withoutTeam(2)->get()); + } + + public function testItCanScopeUsersWithoutATeamUsingAnArrayOfIds(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + + setPermissionsTeamId(2); + $user2->assignRole('testRole'); + + $this->assertCount(1, User::withoutTeam([1, 2])->get()); + $this->assertCount(2, User::withoutTeam([1])->get()); + } + + public function testItCanScopeUsersWithoutATeamUsingACollection(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + + setPermissionsTeamId(2); + $user2->assignRole('testRole'); + + $this->assertCount(1, User::withoutTeam(collect([1, 2]))->get()); + $this->assertCount(2, User::withoutTeam(collect([1]))->get()); + } + + public function testItDoesNotMixUpUsersFromDifferentTeams(): void + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + + setPermissionsTeamId(1); + $user1->assignRole('testRole'); + $user1->assignRole('testRole2'); + + setPermissionsTeamId(2); + $user2->assignRole('testRole'); + + $inTeam1 = User::team(1)->get(); + $inTeam2 = User::team(2)->get(); + + $this->assertCount(1, $inTeam1); + $this->assertSame($user1->id, $inTeam1->first()->id); + $this->assertCount(1, $inTeam2); + $this->assertSame($user2->id, $inTeam2->first()->id); + } +} diff --git a/tests/Permission/Traits/WildcardHasPermissionsTest.php b/tests/Permission/Traits/WildcardHasPermissionsTest.php new file mode 100644 index 000000000..0c3b2494f --- /dev/null +++ b/tests/Permission/Traits/WildcardHasPermissionsTest.php @@ -0,0 +1,264 @@ +app->make('config')->set('permission.enable_wildcard_permission', true); + $this->flushPermissionState(); + } + + public function testItCanCheckWildcardPermission(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $user->givePermissionTo([ + Permission::create(['name' => 'articles.edit,view,create']), + Permission::create(['name' => 'news.*']), + Permission::create(['name' => 'posts.*']), + ]); + + $this->assertTrue($user->hasPermissionTo('posts.create')); + $this->assertTrue($user->hasPermissionTo('posts.create.123')); + $this->assertTrue($user->hasPermissionTo('posts.*')); + $this->assertTrue($user->hasPermissionTo('articles.view')); + $this->assertFalse($user->hasPermissionTo('projects.view')); + } + + public function testItCanCheckWildcardPermissionForANonDefaultGuard(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $user->givePermissionTo([ + Permission::create(['name' => 'articles.edit,view,create', 'guard_name' => 'api']), + Permission::create(['name' => 'news.*', 'guard_name' => 'api']), + Permission::create(['name' => 'posts.*', 'guard_name' => 'api']), + ]); + + $this->assertTrue($user->hasPermissionTo('posts.create', 'api')); + $this->assertTrue($user->hasPermissionTo('posts.create.123', 'api')); + $this->assertTrue($user->hasPermissionTo('posts.*', 'api')); + $this->assertTrue($user->hasPermissionTo('articles.view', 'api')); + $this->assertFalse($user->hasPermissionTo('projects.view', 'api')); + } + + public function testItCanCheckWildcardPermissionFromInstanceWithoutExplicitGuardArgument(): void + { + $user = User::create(['email' => 'user1@test.com']); + + $permission1 = Permission::create(['name' => 'articles.edit', 'guard_name' => 'api']); + $permission2 = Permission::create(['name' => 'articles.view']); + $permission3 = Permission::create(['name' => 'news.*', 'guard_name' => 'api']); + $permission4 = Permission::create(['name' => 'posts.*', 'guard_name' => 'api']); + + $user->givePermissionTo([$permission1, $permission2, $permission3]); + + $this->assertTrue($user->hasPermissionTo($permission1)); + $this->assertTrue($user->hasPermissionTo($permission2)); + $this->assertTrue($user->hasPermissionTo($permission3)); + $this->assertFalse($user->hasPermissionTo($permission4)); + $this->assertFalse($user->hasPermissionTo('articles.edit')); + } + + public function testItCanAssignWildcardPermissionsUsingEnums(): void + { + $articlesCreator = TestRolePermissionsEnum::WildcardArticlesCreator; + $newsEverything = TestRolePermissionsEnum::WildcardNewsEverything; + $postsEverything = TestRolePermissionsEnum::WildcardPostsEverything; + $postsCreate = TestRolePermissionsEnum::WildcardPostsCreate; + + $user = User::create(['email' => 'user1@test.com']); + $user->givePermissionTo([ + Permission::findOrCreate($articlesCreator), + Permission::findOrCreate($newsEverything), + Permission::findOrCreate($postsEverything), + ]); + + $this->assertTrue($user->hasPermissionTo($postsCreate)); + $this->assertTrue($user->hasPermissionTo($postsCreate->value . '.123')); + $this->assertTrue($user->hasPermissionTo($postsEverything)); + $this->assertTrue($user->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); + $this->assertTrue($user->hasAnyPermission(TestRolePermissionsEnum::WildcardArticlesView)); + $this->assertFalse($user->hasPermissionTo(TestRolePermissionsEnum::WildcardProjectsView)); + + $user->revokePermissionTo([$articlesCreator, $newsEverything, $postsEverything]); + + $this->assertFalse($user->hasPermissionTo($postsCreate)); + $this->assertFalse($user->hasPermissionTo($postsCreate->value . '.123')); + $this->assertFalse($user->hasPermissionTo($postsEverything)); + $this->assertFalse($user->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); + $this->assertFalse($user->hasAnyPermission(TestRolePermissionsEnum::WildcardArticlesView)); + } + + public function testItCanCheckWildcardPermissionsViaRoles(): void + { + $user = User::create(['email' => 'user1@test.com']); + $user->assignRole('testRole'); + + $this->testUserRole->givePermissionTo([ + Permission::create(['name' => 'articles,projects.edit,view,create']), + Permission::create(['name' => 'news.*.456']), + Permission::create(['name' => 'posts']), + ]); + + $this->assertTrue($user->hasPermissionTo('posts.create')); + $this->assertTrue($user->hasPermissionTo('news.create.456')); + $this->assertTrue($user->hasPermissionTo('projects.create')); + $this->assertTrue($user->hasPermissionTo('articles.view')); + $this->assertFalse($user->hasPermissionTo('articles.list')); + $this->assertFalse($user->hasPermissionTo('projects.list')); + } + + public function testItCanCheckCustomWildcardPermission(): void + { + $this->app->make('config')->set('permission.wildcard_permission', WildcardPermission::class); + $this->flushPermissionState(); + + $user = User::create(['email' => 'user1@test.com']); + $user->givePermissionTo([ + Permission::create(['name' => 'articles:edit;view;create']), + Permission::create(['name' => 'news:@']), + Permission::create(['name' => 'posts:@']), + ]); + + $this->assertTrue($user->hasPermissionTo('posts:create')); + $this->assertTrue($user->hasPermissionTo('posts:create:123')); + $this->assertTrue($user->hasPermissionTo('posts:@')); + $this->assertTrue($user->hasPermissionTo('articles:view')); + $this->assertFalse($user->hasPermissionTo('posts.*')); + $this->assertFalse($user->hasPermissionTo('articles.view')); + $this->assertFalse($user->hasPermissionTo('projects:view')); + } + + public function testItCanCheckCustomWildcardPermissionsViaRoles(): void + { + $this->app->make('config')->set('permission.wildcard_permission', WildcardPermission::class); + $this->flushPermissionState(); + + $user = User::create(['email' => 'user1@test.com']); + $user->assignRole('testRole'); + + $this->testUserRole->givePermissionTo([ + Permission::create(['name' => 'articles;projects:edit;view;create']), + Permission::create(['name' => 'news:@:456']), + Permission::create(['name' => 'posts']), + ]); + + $this->assertTrue($user->hasPermissionTo('posts:create')); + $this->assertTrue($user->hasPermissionTo('news:create:456')); + $this->assertTrue($user->hasPermissionTo('projects:create')); + $this->assertTrue($user->hasPermissionTo('articles:view')); + $this->assertFalse($user->hasPermissionTo('news.create.456')); + $this->assertFalse($user->hasPermissionTo('projects.create')); + $this->assertFalse($user->hasPermissionTo('articles:list')); + $this->assertFalse($user->hasPermissionTo('projects:list')); + } + + public function testItCanCheckNonWildcardPermissions(): void + { + $user = User::create(['email' => 'user1@test.com']); + $user->givePermissionTo([ + Permission::create(['name' => 'edit articles']), + Permission::create(['name' => 'create news']), + Permission::create(['name' => 'update comments']), + ]); + + $this->assertTrue($user->hasPermissionTo('edit articles')); + $this->assertTrue($user->hasPermissionTo('create news')); + $this->assertTrue($user->hasPermissionTo('update comments')); + } + + public function testItCanVerifyComplexWildcardPermissions(): void + { + $user = User::create(['email' => 'user1@test.com']); + $user->givePermissionTo([ + Permission::create(['name' => '*.create,update,delete.*.test,course,finance']), + Permission::create(['name' => 'papers,posts,projects,orders.*.test,test1,test2.*']), + Permission::create(['name' => 'User::class.create,edit,view']), + ]); + + $this->assertTrue($user->hasPermissionTo('invoices.delete.367463.finance')); + $this->assertTrue($user->hasPermissionTo('projects.update.test2.test3')); + $this->assertTrue($user->hasPermissionTo('User::class.edit')); + $this->assertFalse($user->hasPermissionTo('User::class.delete')); + $this->assertFalse($user->hasPermissionTo('User::class.*')); + } + + public function testItThrowsExceptionWhenWildcardPermissionIsNotProperlyFormatted(): void + { + $user = User::create(['email' => 'user1@test.com']); + $user->givePermissionTo(Permission::create(['name' => '*..'])); + + $this->expectException(WildcardPermissionNotProperlyFormatted::class); + $user->hasPermissionTo('invoices.*'); + } + + public function testItCanVerifyPermissionInstancesNotAssignedToUser(): void + { + $user = User::create(['email' => 'user@test.com']); + + $userPermission = Permission::create(['name' => 'posts.*']); + $permissionToVerify = Permission::create(['name' => 'posts.create']); + + $user->givePermissionTo($userPermission); + + $this->assertTrue($user->hasPermissionTo('posts.create')); + $this->assertTrue($user->hasPermissionTo('posts.create.123')); + $this->assertTrue($user->hasPermissionTo($permissionToVerify->id)); + $this->assertTrue($user->hasPermissionTo($permissionToVerify)); + } + + public function testItCanVerifyPermissionInstancesAssignedToUser(): void + { + $user = User::create(['email' => 'user@test.com']); + + $userPermission = Permission::create(['name' => 'posts.*']); + $permissionToVerify = Permission::create(['name' => 'posts.create']); + + $user->givePermissionTo([$userPermission, $permissionToVerify]); + + $this->assertTrue($user->hasPermissionTo('posts.create')); + $this->assertTrue($user->hasPermissionTo('posts.create.123')); + $this->assertTrue($user->hasPermissionTo($permissionToVerify)); + $this->assertTrue($user->hasPermissionTo($userPermission)); + } + + public function testItCanVerifyIntegersAsStrings(): void + { + $user = User::create(['email' => 'user@test.com']); + $user->givePermissionTo(Permission::create(['name' => '8'])); + + $this->assertTrue($user->hasPermissionTo('8')); + } + + public function testItThrowsExceptionWhenPermissionHasInvalidArguments(): void + { + $user = User::create(['email' => 'user@test.com']); + + $this->expectException(WildcardPermissionInvalidArgument::class); + $user->hasPermissionTo(['posts.create']); + } + + public function testItThrowsExceptionWhenPermissionIdDoesNotExist(): void + { + $user = User::create(['email' => 'user@test.com']); + + $this->expectException(PermissionDoesNotExist::class); + $user->hasPermissionTo(6); + } +} diff --git a/tests/Permission/UnitEnumTest.php b/tests/Permission/UnitEnumTest.php new file mode 100644 index 000000000..356441893 --- /dev/null +++ b/tests/Permission/UnitEnumTest.php @@ -0,0 +1,35 @@ +app->make(RoleContract::class)::create(['name' => PureRole::StaffWriter->name]); + $permission = $this->app->make(PermissionContract::class)::create(['name' => PurePermission::PublishArticles->name]); + + $role->givePermissionTo(PurePermission::PublishArticles); + $this->testUser->assignRole(PureRole::StaffWriter); + + $this->assertTrue($this->testUser->hasRole(PureRole::StaffWriter)); + $this->assertTrue($this->testUser->hasPermissionTo(PurePermission::PublishArticles)); + $this->assertTrue($role->hasPermissionTo(PurePermission::PublishArticles)); + $this->assertTrue($permission->roles->contains($role)); + } +} diff --git a/tests/Permission/WildcardPermissionTest.php b/tests/Permission/WildcardPermissionTest.php new file mode 100644 index 000000000..951a24a85 --- /dev/null +++ b/tests/Permission/WildcardPermissionTest.php @@ -0,0 +1,122 @@ +app->make('config')->set('permission.enable_wildcard_permission', true); + $this->flushPermissionState(); + } + + public function testItCanCheckWildcardPermissions(): void + { + $this->testUser->givePermissionTo([ + Permission::create(['name' => 'articles.edit,view,create']), + Permission::create(['name' => 'news.*']), + Permission::create(['name' => 'posts.*']), + ]); + + $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); + $this->assertTrue($this->testUser->hasPermissionTo('posts.create.123')); + $this->assertTrue($this->testUser->hasPermissionTo('posts.*')); + $this->assertTrue($this->testUser->hasPermissionTo('articles.view')); + $this->assertFalse($this->testUser->hasPermissionTo('projects.view')); + } + + public function testItCanCheckWildcardPermissionsViaRoles(): void + { + $this->testUser->assignRole('testRole'); + + $this->testUserRole->givePermissionTo([ + Permission::create(['name' => 'articles,projects.edit,view,create']), + Permission::create(['name' => 'news.*.456']), + Permission::create(['name' => 'posts']), + ]); + + $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); + $this->assertTrue($this->testUser->hasPermissionTo('news.create.456')); + $this->assertTrue($this->testUser->hasPermissionTo('projects.create')); + $this->assertTrue($this->testUser->hasPermissionTo('articles.view')); + $this->assertFalse($this->testUser->hasPermissionTo('articles.list')); + $this->assertFalse($this->testUser->hasPermissionTo('projects.list')); + } + + public function testItCanAssignWildcardPermissionsUsingEnums(): void + { + $this->testUser->givePermissionTo([ + Permission::findOrCreate(TestRolePermissionsEnum::WildcardArticlesCreator), + Permission::findOrCreate(TestRolePermissionsEnum::WildcardNewsEverything), + Permission::findOrCreate(TestRolePermissionsEnum::WildcardPostsEverything), + ]); + + $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsCreate)); + $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsCreate->value . '.123')); + $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardPostsEverything)); + $this->assertTrue($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardArticlesView)); + $this->assertFalse($this->testUser->hasPermissionTo(TestRolePermissionsEnum::WildcardProjectsView)); + } + + public function testItClearsWildcardIndexWhenAssignmentsChange(): void + { + $this->testUserRole->givePermissionTo(Permission::create(['name' => 'posts.*'])); + + $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); + + $this->testUser->assignRole('testRole'); + + $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); + + $this->testUser->removeRole('testRole'); + + $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); + } + + public function testWildcardIndexUsesCurrentAssignmentCacheVersion(): void + { + $this->testUser->givePermissionTo(Permission::create(['name' => 'posts.*'])); + $registrar = $this->app->make(PermissionRegistrar::class); + + $this->assertTrue($this->testUser->hasPermissionTo('posts.create')); + + $this->testUser->getConnection() + ->table(Config::modelHasPermissionsTable()) + ->where(Config::morphKey(), $this->testUser->getKey()) + ->where('model_type', $this->testUser->getMorphClass()) + ->delete(); + $registrar->bumpModelAssignmentCacheVersion(); + + $this->assertFalse($this->testUser->hasPermissionTo('posts.create')); + } + + public function testItCanUseACustomWildcardPermissionClass(): void + { + $this->app->make('config')->set('permission.wildcard_permission', WildcardPermission::class); + $this->flushPermissionState(); + + $this->testUser->givePermissionTo([ + Permission::create(['name' => 'articles:edit;view;create']), + Permission::create(['name' => 'news:@']), + Permission::create(['name' => 'posts:@']), + ]); + + $this->assertTrue($this->testUser->hasPermissionTo('posts:create')); + $this->assertTrue($this->testUser->hasPermissionTo('posts:create:123')); + $this->assertTrue($this->testUser->hasPermissionTo('posts:@')); + $this->assertTrue($this->testUser->hasPermissionTo('articles:view')); + $this->assertFalse($this->testUser->hasPermissionTo('posts.*')); + $this->assertFalse($this->testUser->hasPermissionTo('articles.view')); + $this->assertFalse($this->testUser->hasPermissionTo('projects:view')); + } +} diff --git a/tests/Testbench/Foundation/Console/InstallCommandTest.php b/tests/Testbench/Foundation/Console/InstallCommandTest.php new file mode 100644 index 000000000..5e3dda1da --- /dev/null +++ b/tests/Testbench/Foundation/Console/InstallCommandTest.php @@ -0,0 +1,450 @@ +filesystem = new Filesystem; + $this->workingPath = ParallelTesting::tempDir('InstallCommandTest-' . uniqid()); + + $this->filesystem->deleteDirectory($this->workingPath); + $this->filesystem->ensureDirectoryExists($this->workingPath); + + $this->writeComposerJson(); + } + + #[Override] + protected function tearDown(): void + { + $this->filesystem->deleteDirectory($this->workingPath); + + parent::tearDown(); + } + + #[Test] + public function itInstallsTheDefaultWorkbenchScaffold(): void + { + $runtimeBasePath = $this->path('runtime-hypervel'); + + $this->filesystem->copyDirectory($this->componentPath('src/testbench/hypervel'), $runtimeBasePath); + + $this->runInstallCommand(['--no-interaction'], [ + 'TESTBENCH_BASE_PATH' => $runtimeBasePath, + 'TESTBENCH_PACKAGE_REMOTE' => '(true)', + ]); + + $this->assertFileExists($this->path('testbench.yaml')); + $this->assertFileExists($this->path('workbench/.gitignore')); + $this->assertFileExists($this->path('workbench/app/Providers/WorkbenchServiceProvider.php')); + $this->assertFileExists($this->path('workbench/app/Models/User.php')); + $this->assertFileExists($this->path('workbench/database/factories/UserFactory.php')); + $this->assertFileExists($this->path('workbench/database/migrations/.gitkeep')); + $this->assertFileExists($this->path('workbench/database/seeders/DatabaseSeeder.php')); + $this->assertFileExists($this->path('workbench/routes/web.php')); + $this->assertFileExists($this->path('workbench/routes/api.php')); + $this->assertFileExists($this->path('workbench/routes/console.php')); + $this->assertFileDoesNotExist($this->path('workbench/.env')); + $this->assertFileExists(join_paths($runtimeBasePath, 'database', 'database.sqlite')); + + $this->assertSame([ + 'Tests\\\\' => 'tests/', + 'Workbench\App\\' => 'workbench/app/', + 'Workbench\Database\Factories\\' => 'workbench/database/factories/', + 'Workbench\Database\Seeders\\' => 'workbench/database/seeders/', + ], $this->composerAutoloadDevNamespaces()); + + $config = Config::loadFromYaml($this->workingPath); + + $this->assertSame([ + 'Workbench\App\Providers\WorkbenchServiceProvider', + ], $config->getExtraAttributes()['providers']); + $this->assertSame(['workbench/database/migrations'], $config['migrations']); + $this->assertSame(['Workbench\Database\Seeders\DatabaseSeeder'], $config['seeders']); + $this->assertSame([ + 'install' => true, + 'auth' => true, + 'health' => true, + 'sync' => [ + [ + 'from' => 'storage', + 'to' => 'workbench/storage', + 'reverse' => true, + ], + ], + 'discovers' => [ + 'config' => true, + 'factories' => true, + 'web' => true, + 'api' => true, + 'commands' => true, + 'components' => false, + 'views' => true, + ], + ], $config->getWorkbenchAttributes()); + } + + #[Test] + public function itInstallsTheBasicWorkbenchScaffold(): void + { + $this->runInstallCommand(['--basic', '--no-interaction']); + + $this->assertFileExists($this->path('testbench.yaml')); + $this->assertFileExists($this->path('workbench/app/Providers/WorkbenchServiceProvider.php')); + $this->assertFileExists($this->path('workbench/app/Models/User.php')); + $this->assertFileExists($this->path('workbench/database/factories/UserFactory.php')); + $this->assertFileExists($this->path('workbench/database/seeders/DatabaseSeeder.php')); + $this->assertDirectoryDoesNotExist($this->path('workbench/config')); + $this->assertDirectoryDoesNotExist($this->path('workbench/resources')); + $this->assertDirectoryDoesNotExist($this->path('workbench/routes')); + + $config = Config::loadFromYaml($this->workingPath); + + $this->assertSame([ + 'install' => true, + 'auth' => true, + 'health' => null, + 'sync' => [], + 'discovers' => [ + 'config' => false, + 'factories' => false, + 'web' => false, + 'api' => false, + 'commands' => false, + 'components' => false, + 'views' => false, + ], + ], $config->getWorkbenchAttributes()); + } + + #[Test] + public function itDoesNotOverwriteExistingFilesUnlessForced(): void + { + $this->filesystem->put($this->path('testbench.yaml'), "workbench:\n install: false\n"); + $this->filesystem->ensureDirectoryExists($this->path('workbench/routes')); + $this->filesystem->put($this->path('workbench/routes/web.php'), 'existing route file'); + + $this->runInstallCommand(['--no-interaction']); + + $this->assertSame("workbench:\n install: false\n", $this->filesystem->get($this->path('testbench.yaml'))); + $this->assertSame('existing route file', $this->filesystem->get($this->path('workbench/routes/web.php'))); + + $autoloadNamespaces = $this->composerAutoloadDevNamespaces(); + + $this->runInstallCommand(['--no-interaction']); + + $this->assertSame($autoloadNamespaces, $this->composerAutoloadDevNamespaces()); + + $this->runInstallCommand(['--force', '--no-interaction']); + + $this->assertStringContainsString('Workbench\App\Providers\WorkbenchServiceProvider', $this->filesystem->get($this->path('testbench.yaml'))); + $this->assertStringContainsString('Workbench', $this->filesystem->get($this->path('workbench/routes/web.php'))); + } + + #[Test] + public function itUsesExistingWorkbenchAutoloadNamespaces(): void + { + $this->writeComposerJson([ + 'autoload-dev' => [ + 'psr-4' => [ + 'Tests\\\\' => 'tests/', + 'App\\' => 'workbench/app/', + 'Database\Factories\\' => 'workbench/database/factories/', + 'Database\Seeders\\' => 'workbench/database/seeders/', + ], + ], + ]); + + $this->runInstallCommand(['--no-interaction']); + + $this->assertSame([ + 'Tests\\\\' => 'tests/', + 'App\\' => 'workbench/app/', + 'Database\Factories\\' => 'workbench/database/factories/', + 'Database\Seeders\\' => 'workbench/database/seeders/', + ], $this->composerAutoloadDevNamespaces()); + $this->assertStringContainsString('namespace App\Providers;', $this->filesystem->get($this->path('workbench/app/Providers/WorkbenchServiceProvider.php'))); + $this->assertStringContainsString('App\Providers\WorkbenchServiceProvider', $this->filesystem->get($this->path('testbench.yaml'))); + $this->assertStringContainsString('use Database\Factories\UserFactory;', $this->filesystem->get($this->path('workbench/database/seeders/DatabaseSeeder.php'))); + } + + #[Test] + public function itFailsWhenDefaultWorkbenchAutoloadNamespaceUsesADifferentPath(): void + { + $this->writeComposerJson([ + 'autoload-dev' => [ + 'psr-4' => [ + 'Tests\\\\' => 'tests/', + 'Workbench\App\\' => 'app/', + ], + ], + ]); + + $process = $this->runInstallCommand(['--no-interaction'], mustRun: false); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString('Unable to add Workbench autoload mapping [Workbench\App\ => workbench/app/]', $process->getOutput()); + $this->assertStringContainsString('because [Workbench\App\] is already mapped to a different path.', $process->getOutput()); + } + + #[Test] + public function itCanSkipExportingTheWorkbenchEnvironmentFile(): void + { + $this->filesystem->put($this->path('.env.example'), 'APP_NAME=Workbench'); + + $this->runEnvironmentFileCopyCommand( + [], + fn (Factory $components) => $components + ->expects('choice') + ->with("Export '.env' file as?", [ + 'skip' => 'Skip exporting .env', + '.env' => '.env', + '.env.example' => '.env.example', + '.env.dist' => '.env.dist', + ], null) + ->andReturn('skip') + ); + + $this->assertFileDoesNotExist($this->path('workbench/.env')); + $this->assertFileDoesNotExist($this->path('workbench/.env.example')); + $this->assertFileDoesNotExist($this->path('workbench/.env.dist')); + } + + #[Test] + public function itExportsTheSelectedWorkbenchEnvironmentFile(): void + { + $this->filesystem->put($this->path('.env.example'), 'APP_NAME=Workbench'); + + $this->runEnvironmentFileCopyCommand( + [], + fn (Factory $components) => $components + ->expects('choice') + ->with("Export '.env' file as?", [ + 'skip' => 'Skip exporting .env', + '.env' => '.env', + '.env.example' => '.env.example', + '.env.dist' => '.env.dist', + ], null) + ->andReturn('.env') + ); + + $this->assertSame('APP_NAME=Workbench', $this->filesystem->get($this->path('workbench/.env'))); + } + + #[Test] + public function itSkipsEnvironmentExportWhenEveryEnvironmentFileAlreadyExists(): void + { + $this->filesystem->put($this->path('.env.example'), 'APP_NAME=Workbench'); + $this->filesystem->ensureDirectoryExists($this->path('workbench')); + $this->filesystem->put($this->path('workbench/.env'), 'existing .env'); + $this->filesystem->put($this->path('workbench/.env.example'), 'existing .env.example'); + $this->filesystem->put($this->path('workbench/.env.dist'), 'existing .env.dist'); + + $this->runEnvironmentFileCopyCommand( + [], + fn (Factory $components) => $components + ->expects('twoColumnDetail') + ->with('File [.env] already exists', 'SKIPPED') + ); + + $this->assertSame('existing .env', $this->filesystem->get($this->path('workbench/.env'))); + $this->assertSame('existing .env.example', $this->filesystem->get($this->path('workbench/.env.example'))); + $this->assertSame('existing .env.dist', $this->filesystem->get($this->path('workbench/.env.dist'))); + } + + #[Test] + public function itOffersExistingEnvironmentFilesWhenForced(): void + { + $this->filesystem->put($this->path('.env.example'), 'APP_NAME=Workbench'); + $this->filesystem->ensureDirectoryExists($this->path('workbench')); + $this->filesystem->put($this->path('workbench/.env.dist'), 'existing .env.dist'); + + $this->runEnvironmentFileCopyCommand( + ['--force' => true], + fn (Factory $components) => $components + ->expects('choice') + ->with("Export '.env' file as?", [ + 'skip' => 'Skip exporting .env', + '.env' => '.env', + '.env.example' => '.env.example', + '.env.dist' => '.env.dist', + ], null) + ->andReturn('.env.dist') + ); + + $this->assertSame('APP_NAME=Workbench', $this->filesystem->get($this->path('workbench/.env.dist'))); + } + + /** + * Run the package install command against the temporary package. + * + * @param array $arguments + * @param array $environment + */ + private function runInstallCommand(array $arguments = [], array $environment = [], bool $mustRun = true): Process + { + $process = new Process( + [ + php_binary(), + $this->componentPath('src/testbench/bin/testbench'), + 'package:install', + ...$arguments, + ], + $this->workingPath, + [ + 'TESTBENCH_WORKING_PATH' => $this->workingPath, + ...$environment, + ], + ); + + $process->setTimeout(null); + + if ($mustRun) { + $process->mustRun(); + } else { + $process->run(); + } + + return $process; + } + + /** + * Run the command branch that copies the Workbench environment file. + * + * @param array $input + * @param Closure(Factory): void $expectations + */ + private function runEnvironmentFileCopyCommand(array $input, Closure $expectations): void + { + $command = new class($this->filesystem, $this->workingPath) extends InstallCommand { + public function __construct( + private readonly Filesystem $filesystem, + private readonly string $workingPath + ) { + parent::__construct(); + } + + /** + * Execute the console command. + */ + public function handle(Filesystem $filesystem, Composer $composer): int + { + $this->copyWorkbenchDotEnvFile($this->filesystem, $this->workingPath); + + return self::SUCCESS; + } + }; + + $application = m::mock(Application::class); + $outputStyle = m::mock(OutputStyle::class); + $components = m::mock(Factory::class); + + $this->filesystem->ensureDirectoryExists($this->path('workbench')); + + $command->setHypervel($application); + + $application->shouldReceive('make')->withArgs(fn (string $abstract): bool => $abstract === OutputStyle::class)->andReturn($outputStyle); + $application->shouldReceive('make')->withArgs(fn (string $abstract): bool => $abstract === Factory::class)->andReturn($components); + $application->shouldReceive('bound')->andReturn(false); + $application->shouldReceive('basePath')->with('.env.example')->andReturn($this->path('.env.example')); + $application->shouldReceive('runningUnitTests')->andReturn(true); + $application->shouldReceive('call')->with([$command, 'handle'])->andReturnUsing(fn (array $callback): int => $callback[0]->handle($this->filesystem, m::mock(Composer::class))); + $outputStyle->shouldReceive('newLinesWritten')->andReturn(1); + $components->shouldReceive('task')->zeroOrMoreTimes(); + + $expectations($components); + + $status = $command->run(new ArrayInput($input), new NullOutput); + + $this->assertSame(InstallCommand::SUCCESS, $status); + } + + /** + * Write composer.json into the temporary package. + * + * @param array $overrides + */ + private function writeComposerJson(array $overrides = []): void + { + $composer = array_replace_recursive([ + 'name' => 'hypervel/install-command-test', + 'description' => 'Test package', + 'autoload' => [ + 'psr-4' => [ + 'Package\\\\' => 'src/', + ], + ], + 'autoload-dev' => [ + 'psr-4' => [ + 'Tests\\\\' => 'tests/', + ], + ], + ], $overrides); + + $this->filesystem->put( + $this->path('composer.json'), + json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) + ); + } + + /** + * Get composer autoload-dev namespaces. + * + * @return array|string> + */ + private function composerAutoloadDevNamespaces(): array + { + /** @var array{autoload-dev: array{psr-4: array|string>}} $composer */ + $composer = json_decode($this->filesystem->get($this->path('composer.json')), true, 512, JSON_THROW_ON_ERROR); + + return $composer['autoload-dev']['psr-4']; + } + + /** + * Get a temporary package path. + */ + private function path(string $path): string + { + return join_paths($this->workingPath, $path); + } + + /** + * Get a component repository path. + */ + private function componentPath(string $path): string + { + return join_paths(dirname(__DIR__, 4), $path); + } +} diff --git a/tests/Testbench/Foundation/TestbenchServiceProviderTest.php b/tests/Testbench/Foundation/TestbenchServiceProviderTest.php index 90b83380a..366b8bbc2 100644 --- a/tests/Testbench/Foundation/TestbenchServiceProviderTest.php +++ b/tests/Testbench/Foundation/TestbenchServiceProviderTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Console\Kernel as ConsoleKernel; use Hypervel\Testbench\Foundation\Console\CreateSqliteDbCommand; use Hypervel\Testbench\Foundation\Console\DropSqliteDbCommand; +use Hypervel\Testbench\Foundation\Console\InstallCommand; use Hypervel\Testbench\Foundation\Console\PurgeSkeletonCommand; use Hypervel\Testbench\Foundation\Console\ServeCommand; use Hypervel\Testbench\Foundation\Console\SyncSkeletonCommand; @@ -47,6 +48,8 @@ public function itRegistersTheExpectedConsoleCommands(): void $this->assertInstanceOf(CreateSqliteDbCommand::class, $commands['package:create-sqlite-db']); $this->assertArrayHasKey('package:drop-sqlite-db', $commands); $this->assertInstanceOf(DropSqliteDbCommand::class, $commands['package:drop-sqlite-db']); + $this->assertArrayHasKey('package:install', $commands); + $this->assertInstanceOf(InstallCommand::class, $commands['package:install']); $this->assertArrayHasKey('package:purge-skeleton', $commands); $this->assertInstanceOf(PurgeSkeletonCommand::class, $commands['package:purge-skeleton']); $this->assertArrayHasKey('serve', $commands);