Введение
Здравствуйте дорогие Хабровчане.
Я продолжаю свой цикл статей о продвинутой авторизации действий с ресурсами в Laravel. Чтобы лучше понимать о чем пойдет речь этой статье — необходимо прочесть Первую часть.
Для начала вкратце повторю постановку задачи: Имеется большое количество моделей. Необходимо спроектировать гибкую и легко расширяемую систему авторизации действий пользователя в зависимости от его роли.
В данной части пойдет речь о настройке связки Политика(Policy)<=>Шлюз(Gate). А так же предложен один из вариантов записи прав пользователя в базу данных.
Ну и конечно же сразу уточню, что материал рассчитан на практикующих программистов, и будет сложен для понимания начинающему разработчику.
Часть 3. Политики(Policy), Шлюзы(Gate)
Теоретическая часть
Достаточно много материала в интернете можно найти на тему — «Что же выбрать Policy или Gate?». Определенное время я так же прибывал в подобном заблуждении, что нужно что-то выбирать. Но в конечном итоге пришел к заключению — это два звена одной цепи, которые наиболее эффективно использовать в связке.
В Laravel существуют два основных направления определения этой связки — ручная и автоматическая. Ручную я рассматривать не стану, так как поддержка такого типа связки потребует несоизмеримо больше усилий программиста, нежели настройка автоматической.
Политики — это специальные классы, которые предоставляют методы авторизации(разрешения) действия пользователя. Тогда как Шлюзы(Gate) — противоположный по назначению механизм. Его задача — запрещать все действия, которые не разрешены политикой. Таким образом шлюзу необходимо указать, какого типа ресурс руководствуется какой политикой. Стандартный механизм автоматического определения предполагает, что классы моделей располагаются в корне директории app. Тогда как в более или менее сложном проекте такое хранение моделей вносит неприятную неразбериху в файловом дереве. Именно с этой целью мы и условились перенести модели в папку app/Models.
Для определения расположения класса политики используется стандартный статический метод Gate::guessPolicyNamesUsing('callback'). Чтобы переопределить этот метод, необходимо его вызвать в теле метода boot() класса App\Providers\AuthServiceProvider(или любого другого подключенного сервис провайдера).
Приступим к практике
Gate
Функция обратного вызова (callback), передаваемая в метод guessPolicyNamesUsing() должна возвращать наименование, или массив возможных наименований политики в зависимости от модели. В моем случае достаточно было определить расположение одного класса политики. Имя класса модели отличается от соответствующего класса политики двумя вещами: Директорией и наличием приставки Policy в конце. Стоит обострить внимание на том, что в данном случае не только желательно, но и необходимо следовать соглашению об именовании сообщества Laravel (к примеру можно его найти в этом репозитории). Также для удобства пропишем роль SuperAdmin именно здесь, чтобы не заморачиваться с тем, что и так понятно — super-admin может все. Конечно же если бизнес логика приложения такого не предусматривает — то это ни к чему.
На синтаксисе версии PHP ниже 7.4 получилось достаточно компактно. На 7.4 и выше можно и того компактней.
<?php
namespace App\Providers;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
public function boot()
{
$this->defineSuperAdmin();
$this->defineAutoDiscover();
}
private function defineAutoDiscover()
{
Gate::guessPolicyNamesUsing(function ($class) {
return str_replace("\\Models\\", "\\Policies\\" , $class) . 'Policy';
});
}
private function defineSuperAdmin()
{
Gate::before(function($user) {
return $user->hasRole('super-admin') ? true : null;
});
}
}
Policy
Итак, мы указали системе, где искать политики. Самое время их создать. Для этого запустим команду в консоли:
php artisan make:policy PostPolicy --model=Models/Post
Фреймворк любезно сгенерировал нам стандартный класс политики. Но мы займемся оптимизацией системы. Так как я придерживаюсь сам, и советую другим придерживаться принципа DRY,- предлагаю создать абстрактный класс политики, который будет нести в себе основную логику работы с моделями. А дочерним классам отдать либо дополнительные методы, либо только объявление имени соответствующей модели. По аналогии с первой частью статьи, мы объявим абстрактный метод getModelClass() который должен возвращать строковое имя класса модели. Методы ж валидации действий по умолчанию соответствуют тем, которые прописаны в контроллерах модели (это поведение также можно изменить). Для удобства я продублирую их ниже. (import и export — созданные нами дополнительные методы)
<?php
protected function resourceAbilityMap()
{
return [
'index' => 'viewAny',
'show' => 'view',
'create' => 'create',
'store' => 'create',
'edit' => 'update',
'update' => 'update',
'destroy' => 'delete',
'import' => 'import',
'export' => 'export',
];
}
В приведенном выше примере — ключ это имя метода контроллера, а значение будет соответствовать имени метода политики. Сейчас осталось лишь, разобраться каким образом мы будем определять доступность того или иного действия для конкретного пользователя или группы пользователей. И здесь уж как кому подходит по ТЗ. Мой выбор пал на библиотеку от группы разработчиков Spatie. А именно на пакет laravel-permission. Я думаю, если вас заинтересовала тематика авторизации — об этом пакете вы, скорее всего, уже слышали. О методах начального посева данных, а в частности разрешений, немного ниже. Что нам необходимо сейчас знать — это то, что данная библиотека предоставляет методы $user->can() и $user->hasRole(), путем применения к модели пользователя трэйта Spatie\Permission\Traits\HasRoles
Модель пользователя (Models\User)
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use Notifiable;
use HasRoles;
/** в остальном все стандартно */
}
Стоит также отметить, что если на сайте используется две или более модели для авторизации (Admin и User, к примеру) — необходимо ко всем применить трейт HasRoles, даже если роли не будут к ним применяться.
Вооружившись этими знаниями мы можем уже создать абстрактный метод политики.
Главная политика (ModelPolicy)
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Post;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Database\Eloquent\Model;
abstract class ModelPolicy
{
use HandlesAuthorization;
abstract protected function getModelClass(): string;
public function viewAny(User $user)
{
return $user->can('view-any-' . $this->getModelClass());
}
public function view(User $user, Post $model)
{
return $user->can('view-' . $this->getModelClass());
}
public function create(User $user)
{
return $user->can('create-' . $this->getModelClass());
}
public function update(User $user, Post $model)
{
return $user->can('update-' . $this->getModelClass());
}
public function delete(User $user, Post $model)
{
return $user->can('delete-' . $this->getModelClass());
}
}
Аргумент внутри метода $user->can('ability') определяет доступность конкретному пользователю определенного действия. Формируете вы его по своему усмотрению. Для удобства я формировал по принципу 'действие'-'имя класса', где имя класса — имя модели (это видно в части по посеву данных). Здесь не использована функциональность отложенного уделения(SoftDelete). Но она без проблем добавляется по тому же принципу, что и дополнительные методы.
Далее создадим политику модели. Она будет содержать в себе методы по работе с конкретной моделью Post и метод getResourceName() который будет возвращать имя класса соответствующей модели.
Политика модели (PostPolicy)
<?php
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class PostPolicy extends ModelPolicy
{
protected function getModelClass(): string
{
return Post::class;
}
public function import(User $user)
{
return $user->can('import-' . $this->getModelClass());
}
public function export(User $user, Post $model)
{
return $user->can('export-' . $this->getModelClass());
}
}
В данном классе стоит обратить внимание на то, что метод import() не принимает в аргумент экземпляр модели, так как в контролере определено что он должен быть без модели. Если это не учесть — авторизация будет падать по якобы непонятным причинам. И можно провести долгое время за отладкой, и поиском виновника. Потому, внимательно отнеситесь к этому моменту.
Также отмечу, что в этой части статьи я не использовал никаких свойств конкретной модели, так как это уже более специфическая задача. Но в третьей части статьи я наглядно продемонстрирую как это сделать.
Часть 4. Начальный посев прав (Seeding)
Данная часть материала скорее бонусная, так как нет принципиальной разницы как именно вы наполняете свою базу данных правами пользователя. Но все же материал был бы незакончен без этой части.
Я буду использовать spatie/laravel-permission, потому и пример будет с учетом этого факта. Вы же можете выбрать любой другой принцип записи и запроса прав в БД.
С помощью команд консоли устанавливаем библиотеку и опубликуем миграции:
composer require spatie/laravel-permission php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
Далее генерируем класс посева (seeder)
php artisan make:seeder PermissionsSeeder
Полученный в результате класс необходимо записать в метод run() главного файла database/seeds/DatabaseSeeder.php. Это проинформирует фреймворк о необходимости запуска класса PermissionsSeeder во время общего посева базы.
Главный класс посева (DatabaseSeeder)
<?php
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run()
{
$this->call(PermissionsSeeder::class);
}
}
Так как мы предполагаем, что моделей должно быть достаточно много — не лучшим вариантом будет писать перечень разрешений в класс посева PermissionsSeeder. Я предлагаю создать папку database/data и создать в ней php файл аналогичный файлам конфигурации. Файл будет возвращать массив такой структуры:
role => model => action
Файл соответствия роль/модель/действие (permissions_roles.php)
<?php
return [
'admin' => [
'App\\Models\\Post' => [
'view',
'view-any',
'create',
'update',
'delete',
'import',
'export',
],
],
'App\\Models\\Post' => [
'post' => [
'view',
'view-any',
],
],
];
Пришло время применить наши настройки разрешений. И, пожалуй в этот раз я сначала продемонстрирую готовый класс, а после — разъясню назначение его методов.
Класс посева данных модели (PermissionsSeeder)
<?php
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class PermissionsSeeder extends Seeder
{
private $data = [];
public function run()
{
$this->loadData();
$this->seedRoles();
}
public function loadData(): void
{
$this->data = require_once database_path("data/permissions_roles.php");
}
private function seedRoles(): void
{
Role::create(['name' => 'super-admin', 'guard_name' => 'api']);
foreach ($this->data as $roleName => $perms) {
$role = Role::create(['name' => $roleName, 'guard_name' => 'api']);
$this->seedRolePermissions($role, $perms);
}
}
private function seedRolePermissions(Role $role, array $modelPermissions): void
{
foreach ($modelPermissions as $model => $perms) {
$buildedPerms = collect($perms)
->crossJoin($model)
->map(function ($item) {
$perm = implode('-', $item); //view-post
Permission::findOrCreate($perm, 'api');
return $perm;
})->toArray();
$role->givePermissionTo($buildedPerms);
}
}
}
Класс может показаться немного перегруженным, из-за большой вложенности. Но в даном случае избавиться от нее поможет выделение дополнительных вспомогательных, более специфических методов, что повлечет за собой разрастание размера класса. А это, как мне кажется, нецелесообразно.
Итак, идем по порядку:
- Метод run() точка входа в класс.
- Метод loadData() загружает наши настройки с папки database/data и помещает возвращаемое значение в прописанное нами свойство класса $data
- Метод seedRoles() является точкой входа в процесс посева данных. Он производит создание ролей, прописанных в свойстве $data, и запускает посев прав доступа для каждой из них. Роль 'super-admin' создается отдельно, и не участвует в назначение прав, т.к. эта роль не будет проходить проверку политик (так мы прописали в AuthServiceProvider).
- Метод seedRolePermissions() проходит по массиву разрешений, переданных в аргумент предыдущим методом, и по очереди «склеивает» имя модели и имя действия, формируя имена прав(permission). Права также необходимо создать в БД перед присвоением их ролям. А так как они(права) могут повторяться от роли к роли — создаем их методом findOrCreate(), предоставленным библиотекой laravel-permissions. Это немного избыточно по обращениям к БД, но в данном случае не критично, так как посев будет запущен лишь однажды.
Теперь можно запустить миграции и посев данных, и начинать тестировать приложение. Для этого в консоли выполняем команду:
php artisan migrate --seed
Теперь мы имеем очень гибкую и легко расширяемую систему. В то же время нам не нужно писать тонны повторяющегося кода! Все что нам необходимо сделать, чтобы создать защищенный ресурс это:
- Создать саму модель(Model).
- Создать контроллер(Controller) и унаследовать его от ModelController.
- Создать политику(Policy) и унаследовать ее от ModelPolicy.
- Определить права доступа.
При необходимости добавить защищенный метод, прописываем его в свойстве класса (см. часть 1) и создаем соответствующий метод политики (см. выше).
На этом пока что все. Надеюсь, что эта статья была полезна. А если кому-то интересна еще более тонкая настройка прав и ограничений пользователей, а именно — право пользователя смотреть/изменять конкретный атрибут модели — об этом в третьей части.
Mingun
Очень интересна, как раз возникла задача по тематике третьей части — право пользователя смотреть/изменять конкретный атрибут модели — и думаю, как её будет лучше и элегантней решить.
Ant-kul Автор
Это очено хорошо!)
Думаю, к следующему воскресенью будет готова.