From 80499095e3220d78eec2008f6ccfdf9bb72ca825 Mon Sep 17 00:00:00 2001 From: Erik Niebla Date: Tue, 31 Aug 2021 09:14:36 -0500 Subject: [PATCH] Teams support --- config/permission.php | 17 +++ database/migrations/add_teams_fields.php.stub | 73 ++++++++++ .../create_permission_tables.php.stub | 40 +++++- docs/basic-usage/artisan.md | 7 + docs/basic-usage/teams-permissions.md | 68 ++++++++++ docs/installation-laravel.md | 1 + docs/installation-lumen.md | 2 + src/Commands/CreatePermission.php | 2 +- src/Commands/CreateRole.php | 20 ++- src/Commands/Show.php | 35 +++-- src/Commands/UpgradeForTeams.php | 125 ++++++++++++++++++ src/Models/Role.php | 33 ++++- src/PermissionRegistrar.php | 27 ++++ src/PermissionServiceProvider.php | 1 + src/Traits/HasPermissions.php | 22 ++- src/Traits/HasRoles.php | 30 ++++- src/helpers.php | 11 ++ tests/CommandTest.php | 50 +++++++ tests/HasPermissionsTest.php | 4 +- tests/HasRolesTest.php | 2 +- tests/TeamHasPermissionsTest.php | 74 +++++++++++ tests/TeamHasRolesTest.php | 62 +++++++++ tests/TestCase.php | 20 ++- 23 files changed, 689 insertions(+), 37 deletions(-) create mode 100644 database/migrations/add_teams_fields.php.stub create mode 100644 docs/basic-usage/teams-permissions.md create mode 100644 src/Commands/UpgradeForTeams.php create mode 100644 tests/TeamHasPermissionsTest.php create mode 100644 tests/TeamHasRolesTest.php diff --git a/config/permission.php b/config/permission.php index 7e31a6c2a..c7029fa5b 100644 --- a/config/permission.php +++ b/config/permission.php @@ -87,8 +87,25 @@ */ 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', ], + /* + * When set to true the package implements teams using the 'team_foreign_key'. If you want + * the migrations to register the 'team_foreign_key', you must set this to true + * before doing the migration. If you already did the migration then you must make a new + * migration to also add 'team_foreign_key' to 'roles', 'model_has_roles', and + * 'model_has_permissions'(view the latest version of package's migration file) + */ + + 'teams' => false, + /* * When set to true, the required permission names are added to the exception * message. This could be considered an information leak in some contexts, so diff --git a/database/migrations/add_teams_fields.php.stub b/database/migrations/add_teams_fields.php.stub new file mode 100644 index 000000000..727104f17 --- /dev/null +++ b/database/migrations/add_teams_fields.php.stub @@ -0,0 +1,73 @@ +unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + }); + + Schema::table($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames) { + if (! Schema::hasColumn($tableNames['model_has_permissions'], $columnNames['team_foreign_key'])) { + $table->unsignedBigInteger($columnNames['team_foreign_key'])->default('1');; + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->dropPrimary(); + $table->primary([$columnNames['team_foreign_key'], 'permission_id', $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); + + Schema::table($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames) { + if (! Schema::hasColumn($tableNames['model_has_roles'], $columnNames['team_foreign_key'])) { + $table->unsignedBigInteger($columnNames['team_foreign_key'])->default('1');; + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->dropPrimary(); + $table->primary([$columnNames['team_foreign_key'], 'role_id', $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + + } +} diff --git a/database/migrations/create_permission_tables.php.stub b/database/migrations/create_permission_tables.php.stub index 5ad2571a3..f20ef752b 100644 --- a/database/migrations/create_permission_tables.php.stub +++ b/database/migrations/create_permission_tables.php.stub @@ -16,10 +16,14 @@ class CreatePermissionTables extends Migration { $tableNames = config('permission.table_names'); $columnNames = config('permission.column_names'); + $teams = config('permission.teams'); if (empty($tableNames)) { throw new \Exception('Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.'); } + if ($teams && empty($columnNames['team_foreign_key'] ?? null)) { + throw new \Exception('Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.'); + } Schema::create($tableNames['permissions'], function (Blueprint $table) { $table->bigIncrements('id'); @@ -30,16 +34,23 @@ class CreatePermissionTables extends Migration $table->unique(['name', 'guard_name']); }); - Schema::create($tableNames['roles'], function (Blueprint $table) { + Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames) { $table->bigIncrements('id'); + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } $table->string('name'); // For MySQL 8.0 use string('name', 125); $table->string('guard_name'); // For MySQL 8.0 use string('guard_name', 125); $table->timestamps(); - - $table->unique(['name', 'guard_name']); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } }); - Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames) { + Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $teams) { $table->unsignedBigInteger(PermissionRegistrar::$pivotPermission); $table->string('model_type'); @@ -50,12 +61,20 @@ class CreatePermissionTables extends Migration ->references('id') ->on($tableNames['permissions']) ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); - $table->primary([PermissionRegistrar::$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + $table->primary([$columnNames['team_foreign_key'], PermissionRegistrar::$pivotPermission, $columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([PermissionRegistrar::$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + }); - Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames) { + Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $teams) { $table->unsignedBigInteger(PermissionRegistrar::$pivotRole); $table->string('model_type'); @@ -66,9 +85,16 @@ class CreatePermissionTables extends Migration ->references('id') ->on($tableNames['roles']) ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); - $table->primary([PermissionRegistrar::$pivotRole, $columnNames['model_morph_key'], 'model_type'], + $table->primary([$columnNames['team_foreign_key'], PermissionRegistrar::$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([PermissionRegistrar::$pivotRole, $columnNames['model_morph_key'], 'model_type'], 'model_has_roles_role_model_type_primary'); + } }); Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames) { diff --git a/docs/basic-usage/artisan.md b/docs/basic-usage/artisan.md index b8c98dd53..8cf340d1b 100644 --- a/docs/basic-usage/artisan.md +++ b/docs/basic-usage/artisan.md @@ -31,6 +31,13 @@ When creating roles you can also create and link permissions at the same time: php artisan permission:create-role writer web "create articles|edit articles" ``` +When creating roles with teams enabled you can set the team id by adding the `--team-id` parameter: + +```bash +php artisan permission:create-role --team-id=1 writer +php artisan permission:create-role writer api --team-id=1 +``` + ## Displaying roles and permissions in the console There is also a `show` command to show a table of roles and permissions per guard: diff --git a/docs/basic-usage/teams-permissions.md b/docs/basic-usage/teams-permissions.md new file mode 100644 index 000000000..58e0055b7 --- /dev/null +++ b/docs/basic-usage/teams-permissions.md @@ -0,0 +1,68 @@ +--- +title: Teams permissions +weight: 3 +--- + +NOTE: Those changes must be made before performing the migration. If you have already run the migration and want to upgrade your solution, you can run the artisan console command `php artisan permission:setup-teams`, to create a new migration file named [xxxx_xx_xx_xx_add_teams_fields.php](https://github.com/spatie/laravel-permission/blob/master/database/migrations/add_teams_fields.php.stub) and then run `php artisan migrate` to upgrade your database tables. + +When enabled, teams permissions offers you a flexible control for a variety of scenarios. The idea behind teams permissions is inspired by the default permission implementation of [Laratrust](https://laratrust.santigarcor.me/). + + +Teams permissions can be enabled in the permission config file: + +```php +// config/permission.php +'teams' => true, +``` + +Also, if you want to use a custom foreign key for teams you must change in the permission config file: +```php +// config/permission.php +'team_foreign_key' => 'custom_team_id', +``` + +## Working with Teams Permissions + +After implements on login a solution for select a team on authentication (for example set `team_id` of the current selected team on **session**: `session(['team_id' => $team->team_id]);` ), +we can set global `team_id` from anywhere, but works better if you create a `Middleware`, example: + +```php +namespace App\Http\Middleware; + +class TeamsPermission{ + + public function handle($request, \Closure $next){ + if(!empty(auth()->user())){ + // session value set on login + app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId(session('team_id')); + } + // other custom ways to get team_id + /*if(!empty(auth('api')->user())){ + // `getTeamIdFromToken()` example of custom method for getting the set team_id + app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId(auth('api')->user()->getTeamIdFromToken()); + }*/ + + return $next($request); + } +} +``` +NOTE: You must add your custom `Middleware` to `$middlewarePriority` on `app/Http/Kernel.php`. + +## Roles Creating + +When creating a role you can pass the `team_id` as an optional parameter + +```php +// with null team_id it creates a global role, global roles can be used from any team and they are unique +Role::create(['name' => 'writer', 'team_id' => null]); + +// creates a role with team_id = 1, team roles can have the same name on different teams +Role::create(['name' => 'reader', 'team_id' => 1]); + +// creating a role without team_id makes the role take the default global team_id +Role::create(['name' => 'reviewer']); +``` + +## Roles/Permissions Assignment & Removal + +The role/permission assignment and removal are the same, but they take the global `team_id` set on login for sync. diff --git a/docs/installation-laravel.md b/docs/installation-laravel.md index 5d6baf281..c87a5b29f 100644 --- a/docs/installation-laravel.md +++ b/docs/installation-laravel.md @@ -33,6 +33,7 @@ This package can be used with Laravel 6.0 or higher. ``` 6. NOTE: If you are using UUIDs, see the Advanced section of the docs on UUID steps, before you continue. It explains some changes you may want to make to the migrations and config file before continuing. It also mentions important considerations after extending this package's models for UUID capability. + If you are going to use teams feature, you have to update your [`config/permission.php` config file](https://github.com/spatie/laravel-permission/blob/master/config/permission.php) and set `'teams' => true,`, if you want to use a custom foreign key for teams you must change `team_foreign_key`. 7. Clear your config cache. This package requires access to the `permission` config. Generally it's bad practice to do config-caching in a development environment. If you've been caching configurations locally, clear your config cache with either of these commands: diff --git a/docs/installation-lumen.md b/docs/installation-lumen.md index be484756b..e1a825170 100644 --- a/docs/installation-lumen.md +++ b/docs/installation-lumen.md @@ -52,6 +52,8 @@ $app->register(App\Providers\AuthServiceProvider::class); Ensure the application's database name/credentials are set in your `.env` (or `config/database.php` if you have one), and that the database exists. +NOTE: If you are going to use teams feature, you have to update your [`config/permission.php` config file](https://github.com/spatie/laravel-permission/blob/master/config/permission.php) and set `'teams' => true,`, if you want to use a custom foreign key for teams you must change `team_foreign_key`. + Run the migrations to create the tables for this package: ```bash diff --git a/src/Commands/CreatePermission.php b/src/Commands/CreatePermission.php index f71a63240..c3bc20693 100644 --- a/src/Commands/CreatePermission.php +++ b/src/Commands/CreatePermission.php @@ -19,6 +19,6 @@ public function handle() $permission = $permissionClass::findOrCreate($this->argument('name'), $this->argument('guard')); - $this->info("Permission `{$permission->name}` created"); + $this->info("Permission `{$permission->name}` ".($permission->wasRecentlyCreated ? 'created' : 'already exists')); } } diff --git a/src/Commands/CreateRole.php b/src/Commands/CreateRole.php index 426d6f0c7..805d5eeb3 100644 --- a/src/Commands/CreateRole.php +++ b/src/Commands/CreateRole.php @@ -5,13 +5,15 @@ use Illuminate\Console\Command; use Spatie\Permission\Contracts\Permission as PermissionContract; use Spatie\Permission\Contracts\Role as RoleContract; +use Spatie\Permission\PermissionRegistrar; class CreateRole extends Command { protected $signature = 'permission:create-role {name : The name of the role} {guard? : The name of the guard} - {permissions? : A list of permissions to assign to the role, separated by | }'; + {permissions? : A list of permissions to assign to the role, separated by | } + {--team-id=}'; protected $description = 'Create a role'; @@ -19,11 +21,25 @@ public function handle() { $roleClass = app(RoleContract::class); + $teamIdAux = app(PermissionRegistrar::class)->getPermissionsTeamId(); + app(PermissionRegistrar::class)->setPermissionsTeamId($this->option('team-id') ?: null); + + 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; + } + $role = $roleClass::findOrCreate($this->argument('name'), $this->argument('guard')); + app(PermissionRegistrar::class)->setPermissionsTeamId($teamIdAux); + + $teams_key = PermissionRegistrar::$teamsKey; + if (PermissionRegistrar::$teams && $this->option('team-id') && is_null($role->$teams_key)) { + $this->warn("Role `{$role->name}` already exists on the global team; argument --team-id has no effect"); + } $role->givePermissionTo($this->makePermissions($this->argument('permissions'))); - $this->info("Role `{$role->name}` created"); + $this->info("Role `{$role->name}` ".($role->wasRecentlyCreated ? 'created' : 'updated')); } /** diff --git a/src/Commands/Show.php b/src/Commands/Show.php index c0aa0e33d..3aded0491 100644 --- a/src/Commands/Show.php +++ b/src/Commands/Show.php @@ -6,6 +6,7 @@ use Illuminate\Support\Collection; use Spatie\Permission\Contracts\Permission as PermissionContract; use Spatie\Permission\Contracts\Role as RoleContract; +use Symfony\Component\Console\Helper\TableCell; class Show extends Command { @@ -19,6 +20,7 @@ public function handle() { $permissionClass = app(PermissionContract::class); $roleClass = app(RoleContract::class); + $team_key = config('permission.column_names.team_foreign_key'); $style = $this->argument('style') ?? 'default'; $guard = $this->argument('guard'); @@ -32,20 +34,37 @@ public function handle() foreach ($guards as $guard) { $this->info("Guard: $guard"); - $roles = $roleClass::whereGuardName($guard)->orderBy('name')->get()->mapWithKeys(function ($role) { - return [$role->name => $role->permissions->pluck('name')]; - }); + $roles = $roleClass::whereGuardName($guard) + ->when(config('permission.teams'), function ($q) use ($team_key) { + $q->orderBy($team_key); + }) + ->orderBy('name')->get()->mapWithKeys(function ($role) use ($team_key) { + return [$role->name.'_'.($role->$team_key ?: '') => ['permissions' => $role->permissions->pluck('id'), $team_key => $role->$team_key ]]; + }); - $permissions = $permissionClass::whereGuardName($guard)->orderBy('name')->pluck('name'); + $permissions = $permissionClass::whereGuardName($guard)->orderBy('name')->pluck('name', 'id'); - $body = $permissions->map(function ($permission) use ($roles) { - return $roles->map(function (Collection $role_permissions) use ($permission) { - return $role_permissions->contains($permission) ? ' ✔' : ' ·'; + $body = $permissions->map(function ($permission, $id) use ($roles) { + return $roles->map(function (array $role_data) use ($id) { + return $role_data['permissions']->contains($id) ? ' ✔' : ' ·'; })->prepend($permission); }); + if (config('permission.teams')) { + $teams = $roles->groupBy($team_key)->values()->map(function ($group, $id) { + return new TableCell('Team ID: '.($id ?: 'NULL'), ['colspan' => $group->count()]); + }); + } + $this->table( - $roles->keys()->prepend('')->toArray(), + array_merge([ + config('permission.teams') ? $teams->prepend('')->toArray() : [], + $roles->keys()->map(function ($val) { + $name = explode('_', $val); + return $name[0]; + }) + ->prepend('')->toArray() + ]), $body->toArray(), $style ); diff --git a/src/Commands/UpgradeForTeams.php b/src/Commands/UpgradeForTeams.php new file mode 100644 index 000000000..7e70ef5f8 --- /dev/null +++ b/src/Commands/UpgradeForTeams.php @@ -0,0 +1,125 @@ +error('Teams feature is disabled in your permission.php file.'); + $this->warn('Please enable the teams setting in your configuration.'); + return; + } + + $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?", "yes")) { + return; + } + + $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(''); + } + + /** + * Create the migration. + * + * @return bool + */ + protected function createMigration() + { + try { + $migrationStub = __DIR__."/../../database/migrations/{$this->migrationSuffix}.stub"; + copy($migrationStub, $this->getMigrationPath()); + return true; + } catch (\Throwable $e) { + $this->error($e->getMessage()); + return false; + } + } + + /** + * Build a warning regarding possible duplication + * due to already existing migrations. + * + * @param array $existingMigrations + * @return string + */ + protected function getExistingMigrationsWarning(array $existingMigrations) + { + 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, function ($carry, $fileName) { + return $carry . "\n - " . $fileName; + }); + } + + /** + * Check if there is another migration + * with the same suffix. + * + * @return array + */ + protected function alreadyExistingMigrations() + { + $matchingFiles = glob($this->getMigrationPath('*')); + + return array_map(function ($path) { + return basename($path); + }, $matchingFiles); + } + + /** + * Get the migration path. + * + * The date parameter is optional for ability + * to provide a custom value or a wildcard. + * + * @param string|null $date + * @return string + */ + protected function getMigrationPath($date = null) + { + $date = $date ?: date('Y_m_d_His'); + + return database_path("migrations/${date}_{$this->migrationSuffix}"); + } +} diff --git a/src/Models/Role.php b/src/Models/Role.php index 11c497280..3ef1a8313 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -36,7 +36,15 @@ public static function create(array $attributes = []) { $attributes['guard_name'] = $attributes['guard_name'] ?? Guard::getDefaultName(static::class); - if (static::where('name', $attributes['name'])->where('guard_name', $attributes['guard_name'])->first()) { + $params = ['name' => $attributes['name'], 'guard_name' => $attributes['guard_name']]; + if (PermissionRegistrar::$teams) { + if (array_key_exists(PermissionRegistrar::$teamsKey, $attributes)) { + $params[PermissionRegistrar::$teamsKey] = $attributes[PermissionRegistrar::$teamsKey]; + } else { + $attributes[PermissionRegistrar::$teamsKey] = app(PermissionRegistrar::class)->getPermissionsTeamId(); + } + } + if (static::findByParam($params)) { throw RoleAlreadyExists::create($attributes['name'], $attributes['guard_name']); } @@ -84,7 +92,7 @@ public static function findByName(string $name, $guardName = null): RoleContract { $guardName = $guardName ?? Guard::getDefaultName(static::class); - $role = static::where('name', $name)->where('guard_name', $guardName)->first(); + $role = static::findByParam(['name' => $name, 'guard_name' => $guardName]); if (! $role) { throw RoleDoesNotExist::named($name); @@ -97,7 +105,7 @@ public static function findById(int $id, $guardName = null): RoleContract { $guardName = $guardName ?? Guard::getDefaultName(static::class); - $role = static::where('id', $id)->where('guard_name', $guardName)->first(); + $role = static::findByParam(['id' => $id, 'guard_name' => $guardName]); if (! $role) { throw RoleDoesNotExist::withId($id); @@ -118,15 +126,30 @@ public static function findOrCreate(string $name, $guardName = null): RoleContra { $guardName = $guardName ?? Guard::getDefaultName(static::class); - $role = static::where('name', $name)->where('guard_name', $guardName)->first(); + $role = static::findByParam(['name' => $name, 'guard_name' => $guardName]); if (! $role) { - return static::query()->create(['name' => $name, 'guard_name' => $guardName]); + return static::query()->create(['name' => $name, 'guard_name' => $guardName] + (PermissionRegistrar::$teams ? [PermissionRegistrar::$teamsKey => app(PermissionRegistrar::class)->getPermissionsTeamId()] : [])); } return $role; } + protected static function findByParam(array $params = []) + { + $query = static::when(PermissionRegistrar::$teams, function ($q) use ($params) { + $q->where(function ($q) use ($params) { + $q->whereNull(PermissionRegistrar::$teamsKey) + ->orWhere(PermissionRegistrar::$teamsKey, $params[PermissionRegistrar::$teamsKey] ?? app(PermissionRegistrar::class)->getPermissionsTeamId()); + }); + }); + unset($params[PermissionRegistrar::$teamsKey]); + foreach ($params as $key => $value) { + $query->where($key, $value); + } + return $query->first(); + } + /** * Determine if the user may perform the given permission. * diff --git a/src/PermissionRegistrar.php b/src/PermissionRegistrar.php index 4f23c9f7f..b3ae4a7b5 100644 --- a/src/PermissionRegistrar.php +++ b/src/PermissionRegistrar.php @@ -35,6 +35,15 @@ class PermissionRegistrar /** @var \DateInterval|int */ public static $cacheExpirationTime; + /** @var bool */ + public static $teams; + + /** @var string */ + public static $teamsKey; + + /** @var int */ + protected $teamId = null; + /** @var string */ public static $cacheKey; @@ -56,6 +65,9 @@ public function initializeCache() { self::$cacheExpirationTime = config('permission.cache.expiration_time') ?: \DateInterval::createFromDateString('24 hours'); + self::$teams = config('permission.teams', false); + self::$teamsKey = config('permission.column_names.team_foreign_key'); + self::$cacheKey = config('permission.cache.key'); self::$pivotRole = config('permission.column_names.role_pivot_key') ?: 'role_id'; @@ -83,6 +95,21 @@ protected function getCacheStoreFromConfig(): \Illuminate\Contracts\Cache\Reposi return $this->cacheManager->store($cacheDriver); } + /** + * Set the team id for teams/groups support, this id is used when querying permissions/roles + * + * @param int $id + */ + public function setPermissionsTeamId(?int $id) + { + $this->teamId = $id; + } + + public function getPermissionsTeamId(): ?int + { + return $this->teamId; + } + /** * Register the permission check method on the gate. * We resolve the Gate fresh here, for benefit of long-running instances. diff --git a/src/PermissionServiceProvider.php b/src/PermissionServiceProvider.php index 6a237fece..c0ae0ed15 100644 --- a/src/PermissionServiceProvider.php +++ b/src/PermissionServiceProvider.php @@ -63,6 +63,7 @@ protected function registerCommands() Commands\CreateRole::class, Commands\CreatePermission::class, Commands\Show::class, + Commands\UpgradeForTeams::class, ]); } diff --git a/src/Traits/HasPermissions.php b/src/Traits/HasPermissions.php index b0776f089..7d3451db4 100644 --- a/src/Traits/HasPermissions.php +++ b/src/Traits/HasPermissions.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Collection; use Spatie\Permission\Contracts\Permission; +use Spatie\Permission\Contracts\Role; use Spatie\Permission\Exceptions\GuardDoesNotMatch; use Spatie\Permission\Exceptions\PermissionDoesNotExist; use Spatie\Permission\Exceptions\WildcardPermissionInvalidArgument; @@ -48,7 +49,12 @@ public function permissions(): BelongsToMany config('permission.table_names.model_has_permissions'), config('permission.column_names.model_morph_key'), PermissionRegistrar::$pivotPermission - ); + ) + ->where(function ($q) { + $q->when(PermissionRegistrar::$teams, function ($q) { + $q->where(PermissionRegistrar::$teamsKey, app(PermissionRegistrar::class)->getPermissionsTeamId()); + }); + }); } /** @@ -335,13 +341,21 @@ public function givePermissionTo(...$permissions) ->each(function ($permission) { $this->ensureModelSharesGuard($permission); }) - ->map->id - ->all(); + ->map(function ($permission) { + return ['id' => $permission->id, 'values' => PermissionRegistrar::$teams && !is_a($this, Role::class) ? + [PermissionRegistrar::$teamsKey => app(PermissionRegistrar::class)->getPermissionsTeamId()] : [] + ]; + }) + ->pluck('values', 'id')->toArray(); $model = $this->getModel(); if ($model->exists) { - $this->permissions()->sync($permissions, false); + if (PermissionRegistrar::$teams && !is_a($this, Role::class)) { + $this->permissions()->wherePivot(PermissionRegistrar::$teamsKey, app(PermissionRegistrar::class)->getPermissionsTeamId())->sync($permissions, false); + } else { + $this->permissions()->sync($permissions, false); + } $model->load('permissions'); } else { $class = \get_class($model); diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index e5470322d..b0003e5d3 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Collection; +use Spatie\Permission\Contracts\Permission; use Spatie\Permission\Contracts\Role; use Spatie\Permission\PermissionRegistrar; @@ -39,13 +40,24 @@ public function getRoleClass() */ public function roles(): BelongsToMany { + $model_has_roles = config('permission.table_names.model_has_roles'); return $this->morphToMany( config('permission.models.role'), 'model', - config('permission.table_names.model_has_roles'), + $model_has_roles, config('permission.column_names.model_morph_key'), PermissionRegistrar::$pivotRole - ); + ) + ->where(function ($q) use ($model_has_roles) { + $q->when(PermissionRegistrar::$teams, function ($q) use ($model_has_roles) { + $teamId = app(PermissionRegistrar::class)->getPermissionsTeamId(); + $q->where($model_has_roles.'.'.PermissionRegistrar::$teamsKey, $teamId) + ->where(function ($q) use ($teamId) { + $teamField = config('permission.table_names.roles').'.'.PermissionRegistrar::$teamsKey; + $q->whereNull($teamField)->orWhere($teamField, $teamId); + }); + }); + }); } /** @@ -107,13 +119,21 @@ public function assignRole(...$roles) ->each(function ($role) { $this->ensureModelSharesGuard($role); }) - ->map->id - ->all(); + ->map(function ($role) { + return ['id' => $role->id, 'values' => PermissionRegistrar::$teams && !is_a($this, Permission::class) ? + [PermissionRegistrar::$teamsKey => app(PermissionRegistrar::class)->getPermissionsTeamId()] : [] + ]; + }) + ->pluck('values', 'id')->toArray(); $model = $this->getModel(); if ($model->exists) { - $this->roles()->sync($roles, false); + if (PermissionRegistrar::$teams && !is_a($this, Permission::class)) { + $this->roles()->wherePivot(PermissionRegistrar::$teamsKey, app(PermissionRegistrar::class)->getPermissionsTeamId())->sync($roles, false); + } else { + $this->roles()->sync($roles, false); + } $model->load('roles'); } else { $class = \get_class($model); diff --git a/src/helpers.php b/src/helpers.php index 7f79e5764..2b80ea417 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -18,3 +18,14 @@ function getModelForGuard(string $guard) })->get($guard); } } + +if (! function_exists('setPermissionsTeamId')) { + /** + * @param int $id + * + */ + function setPermissionsTeamId(int $id) + { + app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId($id); + } +} diff --git a/tests/CommandTest.php b/tests/CommandTest.php index 2071e45a9..603af80d2 100644 --- a/tests/CommandTest.php +++ b/tests/CommandTest.php @@ -130,4 +130,54 @@ public function it_can_show_permissions_for_guard() $this->assertTrue(strpos($output, 'Guard: web') !== false); $this->assertTrue(strpos($output, 'Guard: admin') === false); } + + /** @test */ + public function it_can_setup_teams_upgrade() + { + config()->set('permission.teams', true); + + $this->artisan('permission:setup-teams') + ->expectsQuestion('Proceed with the migration creation?', 'yes') + ->assertExitCode(0); + + $matchingFiles = glob(database_path('migrations/*_add_teams_fields.php')); + $this->assertTrue(count($matchingFiles) > 0); + + include_once $matchingFiles[count($matchingFiles)-1]; + (new \AddTeamsFields())->up(); + (new \AddTeamsFields())->up(); //test upgrade teams migration fresh + + Role::create(['name' => 'new-role', 'team_test_id' => 1]); + $role = Role::where('name', 'new-role')->first(); + $this->assertNotNull($role); + $this->assertSame(1, (int) $role->team_test_id); + + // remove migration + foreach ($matchingFiles as $file) { + unlink($file); + } + } + + /** @test */ + public function it_can_show_roles_by_teams() + { + config()->set('permission.teams', true); + app(\Spatie\Permission\PermissionRegistrar::class)->initializeCache(); + + Role::create(['name' => 'testRoleTeam', 'team_test_id' => 1]); + Role::create(['name' => 'testRoleTeam', 'team_test_id' => 2]); // same name different team + Artisan::call('permission:show'); + + $output = Artisan::output(); + + // | | Team ID: NULL | Team ID: 1 | Team ID: 2 | + // | | testRole | testRole2 | testRoleTeam | testRoleTeam | + if (method_exists($this, 'assertMatchesRegularExpression')) { + $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+testRole2\s+\|\s+testRoleTeam\s+\|\s+testRoleTeam\s+\|/', $output); + } else { // phpUnit 9/8 + $this->assertRegExp('/\|\s+\|\s+Team ID: NULL\s+\|\s+Team ID: 1\s+\|\s+Team ID: 2\s+\|/', $output); + $this->assertRegExp('/\|\s+\|\s+testRole\s+\|\s+testRole2\s+\|\s+testRoleTeam\s+\|\s+testRoleTeam\s+\|/', $output); + } + } } diff --git a/tests/HasPermissionsTest.php b/tests/HasPermissionsTest.php index c67c20c68..7507837b6 100644 --- a/tests/HasPermissionsTest.php +++ b/tests/HasPermissionsTest.php @@ -516,8 +516,8 @@ public function it_can_retrieve_permission_names() { $this->testUser->givePermissionTo('edit-news', 'edit-articles'); $this->assertEquals( - collect(['edit-news', 'edit-articles']), - $this->testUser->getPermissionNames() + collect(['edit-articles', 'edit-news']), + $this->testUser->getPermissionNames()->sort()->values() ); } diff --git a/tests/HasRolesTest.php b/tests/HasRolesTest.php index 405e6eac1..daa556444 100644 --- a/tests/HasRolesTest.php +++ b/tests/HasRolesTest.php @@ -568,7 +568,7 @@ public function it_can_retrieve_role_names() $this->assertEquals( collect(['testRole', 'testRole2']), - $this->testUser->getRoleNames() + $this->testUser->getRoleNames()->sort()->values() ); } diff --git a/tests/TeamHasPermissionsTest.php b/tests/TeamHasPermissionsTest.php new file mode 100644 index 000000000..9170166ae --- /dev/null +++ b/tests/TeamHasPermissionsTest.php @@ -0,0 +1,74 @@ +setPermissionsTeamId(1); + $this->testUser->load('permissions'); + $this->testUser->givePermissionTo('edit-articles', 'edit-news'); + + $this->setPermissionsTeamId(2); + $this->testUser->load('permissions'); + $this->testUser->givePermissionTo('edit-articles', 'edit-blog'); + + $this->setPermissionsTeamId(1); + $this->testUser->load('permissions'); + $this->assertEquals( + collect(['edit-articles', 'edit-news']), + $this->testUser->getPermissionNames()->sort()->values() + ); + $this->assertTrue($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news'])); + $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-blog'])); + + $this->setPermissionsTeamId(2); + $this->testUser->load('permissions'); + $this->assertEquals( + collect(['edit-articles', 'edit-blog']), + $this->testUser->getPermissionNames()->sort()->values() + ); + $this->assertTrue($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-blog'])); + $this->assertFalse($this->testUser->hasAllDirectPermissions(['edit-articles', 'edit-news'])); + } + + /** @test */ + public function it_can_list_all_the_coupled_permissions_both_directly_and_via_roles_on_same_user_on_different_teams() + { + $this->testUserRole->givePermissionTo('edit-articles'); + + $this->setPermissionsTeamId(1); + $this->testUser->load('permissions'); + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-news'); + + $this->setPermissionsTeamId(2); + $this->testUser->load('permissions'); + $this->testUser->assignRole('testRole'); + $this->testUser->givePermissionTo('edit-blog'); + + $this->setPermissionsTeamId(1); + $this->testUser->load('roles'); + $this->testUser->load('permissions'); + + $this->assertEquals( + collect(['edit-articles', 'edit-news']), + $this->testUser->getAllPermissions()->pluck('name')->sort()->values() + ); + + $this->setPermissionsTeamId(2); + $this->testUser->load('roles'); + $this->testUser->load('permissions'); + + $this->assertEquals( + collect(['edit-articles', 'edit-blog']), + $this->testUser->getAllPermissions()->pluck('name')->sort()->values() + ); + } +} diff --git a/tests/TeamHasRolesTest.php b/tests/TeamHasRolesTest.php new file mode 100644 index 000000000..cdf120513 --- /dev/null +++ b/tests/TeamHasRolesTest.php @@ -0,0 +1,62 @@ +create(['name' => 'testRole3']); //team_test_id = 1 by main class + app(Role::class)->create(['name' => 'testRole3', 'team_test_id' => 2]); + app(Role::class)->create(['name' => 'testRole4', 'team_test_id' => null]); //global role + + $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($testRole4NoTeam); + + $this->setPermissionsTeamId(1); + $this->testUser->load('roles'); + + $this->testUser->assignRole('testRole', 'testRole2'); + + $this->setPermissionsTeamId(2); + $this->testUser->load('roles'); + + $this->testUser->assignRole('testRole', 'testRole3'); + + $this->setPermissionsTeamId(1); + $this->testUser->load('roles'); + + $this->assertEquals( + collect(['testRole', 'testRole2']), + $this->testUser->getRoleNames()->sort()->values() + ); + $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)); //testRole3 team=1 + $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); // global role team=null + + $this->setPermissionsTeamId(2); + $this->testUser->load('roles'); + + $this->assertEquals( + collect(['testRole', 'testRole3']), + $this->testUser->getRoleNames()->sort()->values() + ); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole3'])); + $this->assertTrue($this->testUser->hasRole($testRole3Team2)); //testRole3 team=2 + $this->testUser->assignRole('testRole4'); + $this->assertTrue($this->testUser->hasExactRoles(['testRole', 'testRole3', 'testRole4'])); + $this->assertTrue($this->testUser->hasRole($testRole4NoTeam)); // global role team=null + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fad8cd6f..1ea141566 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -36,12 +36,18 @@ abstract class TestCase extends Orchestra /** @var bool */ protected $useCustomModels = false; + /** @var bool */ + protected $hasTeams=false; + public function setUp(): void { parent::setUp(); // Note: this also flushes the cache from within the migration $this->setUpDatabase($this->app); + if ($this->hasTeams) { + $this->setPermissionsTeamId(1); + } $this->testUser = User::first(); $this->testUserRole = app(Role::class)->find(1); @@ -73,6 +79,10 @@ protected function getPackageProviders($app) */ protected function getEnvironmentSetUp($app) { + $app['config']->set('permission.teams', $this->hasTeams); + $app['config']->set('permission.testing', true); //fix sqlite + $app['config']->set('permission.column_names.model_morph_key', 'model_test_id'); + $app['config']->set('permission.column_names.team_foreign_key', 'team_test_id'); $app['config']->set('database.default', 'sqlite'); $app['config']->set('database.connections.sqlite', [ 'driver' => 'sqlite', @@ -106,8 +116,6 @@ protected function getEnvironmentSetUp($app) */ protected function setUpDatabase($app) { - $app['config']->set('permission.column_names.model_morph_key', 'model_test_id'); - $app['db']->connection()->getSchemaBuilder()->create('users', function (Blueprint $table) { $table->increments('id'); $table->string('email'); @@ -148,6 +156,14 @@ protected function reloadPermissions() app(PermissionRegistrar::class)->forgetCachedPermissions(); } + /** + * Change the team_id + */ + protected function setPermissionsTeamId(int $id) + { + app(PermissionRegistrar::class)->setPermissionsTeamId($id); + } + public function createCacheTable() { Schema::create('cache', function ($table) {