[Перевод] Простой контроль доступа ACR в Laravel 10 (инструкция шаг за шагом)

В этой статье мы рассмотрим, как реализовать Role-Based Access Control (RBAC) в Laravel 10 для эффективного управления доступом пользователей.

RBAC — это модель безопасности, в которой пользователям назначаются роли на основе их должностных обязанностей, а доступ к ресурсам приложения предоставляется этим ролям.
Этот подход гарантирует, что только авторизованные пользователи имеют доступ к определенным функциям и данным в приложении.
Для реализации RBAC мы будем использовать пакет »wnikk/laravel-access-rules» с Github, который упрощает создание ролей и разрешений.

Мы рассмотрим шаги по созданию ролей и разрешений, назначению их пользователям и защите конфиденциальной информации от несанкционированного доступа.

Одним из основных преимуществ реализации RBAC в Laravel является возможность ограничения и контроля доступа.

С помощью RBAC вы можете определить роли для различных должностей, которые ограничивают доступ к функциям и данным на сайте.

Например, вы можете создать роль »администратор» с полным доступом к приложению, в то время как роль »гость» может иметь доступ только к определенным страницам.
Вы также можете создавать настраиваемые роли, которые имеют доступ к определенным функциям, таким как »менеджер» или »модератор».

Таким образом, пользователи имеют доступ только к функциям, которые необходимы для выполнения их целевых задач.

Для создания RBAC в Laravel мы будем использовать пакет »wnikk/laravel-access-rules» из composer, который предоставляет простой и гибкий способ создания ролей и разрешений.
Этот пакет позволяет нам назначать роли пользователям, назначать разрешения ролям и назначать разрешения напрямую пользователям.

Мы рассмотрим шаги, необходимые для настройки пакета, определения ролей и разрешений, а также назначения их пользователям.

Следуя этому пошаговому руководству, вы сможете легко реализовать RBAC в своем приложении Laravel и обеспечить безопасность учетных записей ваших пользователей.

С чего начнём?

Чтобы упростить процесс реализации прав доступа в Laravel, мы начнём с:

  1. User Management — Мы создадим управление пользователями с помощью Laravel 10. Это позволит легче применять права доступа в Laravel.

  2. Rules Management — Кроме того, мы реализуем управление правилами, чтобы ограничить доступ к контенту, определив список правил для проекта.

  3. Permits and inheritance Management — Управление разрешениями может использоваться для добавления ролей в учетные записи пользователей и назначения прав доступа в Laravel.

  4. News Management — Наконец, мы можем реализовать управление новостями и применять права доступа в Laravel для каждой роли, назначенной пользователю.

Мы также будем использовать функциональность CRUD, которая обеспечивает возможности: создание, чтение, обновление и удаление правил.

Если вы ищете примеры концепций, обсуждаемых в этой статье, вы можете найти их в соответствующем репозитории на GitHub. Просто перейдите по ссылке в репозиторий и просмотрите исходный код, чтобы увидеть реализацию примера. Это позволит вам лучше понять, как работают концепции на практике и как применить их в своих собственных проектах.

Шаг 1: Создание приложения на Laravel

Для начала реализации Laravel 10 первым шагом создадим новое веб-приложение. Для этого откройте терминал или командную строку и инициируйте создание нового приложения на Laravel:

composer create-project laravel/laravel rules-example

Шаг 2: Установка пакетов

Далее нам нужно установить необходимый пакет Wnikk для Access Control Rules (ACR) и пакет визуального контроля. Это можно легко сделать, открыв терминал и выполнить указанные ниже команды:

composer require wnikk/laravel-access-rules
composer require wnikk/laravel-access-ui

Чтобы внести изменения в пакет Wnikk, нам нужно выполнить команду, которая создаст конфигурационные файлы, файлы миграции и файлы представлений. Следуя этому шагу, вы сможете настроить пакет, чтобы он соответствовал конкретным требованиям вашего приложения:

php artisan vendor:publish --provider="Wnikk\\LaravelAccessRules\\AccessRulesServiceProvider"
php artisan vendor:publish --provider="Wnikk\\LaravelAccessUi\\AccessUiServiceProvider"

Шаг 3: Обновление модели User

Теперь мы будем интегрировать ACR с нашей существующей моделью пользователя. Этот шаг важен, чтобы гарантировать, что в нашем приложении правильно настроены механизмы контроля доступа. Нам нужно только добавить trait HasPermissions в модель:

use Wnikk\LaravelAccessRules\Traits\HasPermissions;

class User extends Model {
    // The User model requires this trait
    use HasPermissions;

Шаг 4: Настройка соединения с базой данных

Для целей этого примера мы будем использовать файловую базу данных SQLite. Чтобы начать, создайте пустой файл с именем »./database/database.sqlite» и настройте соединение с базой данных, как показано в приведенном примере.
Файл .env:

DB_CONNECTION=sqlite

На этом этапе мы готовы выполнить команду миграции. Выполнение этой команды позволит создать необходимые таблицы в нашей SQLite-базе данных.

php artisan migrate

Теперь, когда мы собрали работающую систему контроля доступа с помощью пакета ACR, следующим шагом будет добавление разрешений (permissions) для моделей в нашем приложении. Разрешения определяют, какие действия пользователь может выполнять для определенного ресурса.

Шаг 5: Создание миграции для новостей

Переходим к следующему шагу: созданию миграции для таблицы news. Чтобы выполнить это задание, выполните следующую команду, которая сгенерирует необходимый файл и позволит определить схему таблицы:

php artisan make:migration create_news_table

Это создаст новый файл миграции в директории database/migrations web-приложения. Ниже вы найдете полный код, необходимый для определения структуры таблицы, включая различные поля и их соответствующие типы:

id();
            $table->timestamps();
            $table->integer('user_id');
            $table->string('name', 70);
            $table->string('description', 320)->nullable();
            $table->text('body');
            $table->softDeletes();
        });
    }
    public function down(): void
    {
        Schema::dropIfExists('news');
    }
};

Теперь запустим миграцию повторно:

php artisan migrate

Шаг 6: Создание модели

Теперь создадим модель для новостей. Чтобы сгенерировать модель новостей, необходимо выполнить следующую команду Artisan. Это создаст модель новостей в каталоге app\Models.

php artisan make:model News

Пример кода для модели новостей:

Шаг 7: Создание Seeder

Теперь, когда у нас есть все необходимые таблицы в базе данных, настало время заполнить их тестовыми данными и настроить правила для них.

Для этого мы создадим Seeder — класс, которые позволят нам заполнить таблицы начальными данными.

1. Создадим несколько пользователей:

php artisan make:seeder CreateUserSeeder

Исходник файла database\seeders\CreateUserSeeder.php:

insert([
            'id' => 1,
            'name' => 'Test user 1',
            'email' => 'root@mail.com',
            'password' => Hash::make('12345'),
        ]);
        DB::table('users')->insert([
            'id' => 2,
            'name' => 'Test user 2',
            'email' => 'test@mail.com',
            'password' => Hash::make('password'),
        ]);
        DB::table('users')->insert([
            'name' => 'Test user 3',
            'email' => Str::random(10).'@mail.com',
            'password' => Hash::make(Str::random(10)),
        ]);
        DB::table('users')->insert([
            'name' => 'Test user 4',
            'email' => Str::random(10).'@mail.com',
            'password' => Hash::make(Str::random(10)),
        ]);
        DB::table('users')->insert([
            'name' => 'Test user 5',
            'email' => Str::random(10).'@mail.com',
            'password' => Hash::make(Str::random(10)),
        ]);
    }
}

2. Создаем несколько записей новостей.

php artisan make:seeder NewsTableSeeder

Исходник файла database\seeders\NewsTableSeeder.php:

 1,
            'name' => 'First news',
            'description' => 'Description of first news',
            'body' => 'Body content 1...',
        ]);
        News::create([
            'user_id' => 1,
            'name' => 'Second news',
            'description' => 'Description of second test news',
            'body' => 'Body content 2...',
        ]);
        News::create([
            'user_id' => 2,
            'name' => 'News of test user',
            'body' => 'Body content 3...',
        ]);
    }
}

3. Множество правил для тестирования.

php artisan make:seeder CreateRulesSeeder

Правила сами по себе:
Исходник файла database\seeders\CreateRulesSeeder.php:

 'example3.update',
            'title' => 'Changing different user data on example3',
            'options' => 'required|in:name,email,password'
        ]);

        // example #4 - global resource
        AccessRules::newRule('viewAny', 'Global rule "viewAny" for example4');
        AccessRules::newRule('view', 'Global rule "view" for example4');
        AccessRules::newRule('create', 'Global rule "create" for example4');
        AccessRules::newRule('update', 'Global rule "update" for example4');
        AccessRules::newRule('delete', 'Global rule "delete" for example4');

        // example #5 - resource for controller
        AccessRules::newRule('Examples.Example5.viewAny', 'Rule for one Controller his action "viewAny" example5');
        AccessRules::newRule('Examples.Example5.view', 'Rule for one Controller his action "view" example5');
        AccessRules::newRule('Examples.Example5.create', 'Rule for one Controller his action "create" example5');
        AccessRules::newRule('Examples.Example5.update', 'Rule for one Controller his action "update" example5');
        AccessRules::newRule('Examples.Example5.delete', 'Rule for one Controller his action "delete" example5');

        // example #6 - magic self
        AccessRules::newRule(
            'example6.update',
            'Rule that allows edit all news',
        'An example of how to use a magic suffix ".self" on example6'
        );
        AccessRules::newRule('example6.update.self', 'Rule that allows edit only where user is author');

        // example #7 - Policy
        AccessRules::newRule('Example7News.test', 'Rule event "test" example7');

        // Final example, add control to the Access user interface
        $id = AccessRules::newRule('Examples.UserRules.main', 'View all rules, permits and inheritance');
        AccessRules::newRule('Examples.UserRules.rules', 'Working with Rules', null, $id, 'nullable|in:index,store,update,destroy');
        AccessRules::newRule('Examples.UserRules.roles', 'Working with Roles', null, $id, 'nullable|in:index,store,update,destroy');
        AccessRules::newRule('Examples.UserRules.inherit', 'Working with Inherit', null, $id, 'nullable|in:index,store,destroy');
        AccessRules::newRule('Examples.UserRules.permission', 'Working with Permission', null, $id, 'nullable|in:index,update');
    }
}

4. Мы теперь создадим роль супер-администратора.

От которой будут наследоваться другие пользовательские роли. В этом шаге установлено три типа моделей, которые могут иметь разрешения в файле настроек по умолчанию (config/access.php): группы (groups), роли (roles) и пользователи (users). Для супер-администратора мы будем использовать роли.

php artisan make:seeder CreateRootAdminRoleSeeder

Вся логика заложена в том что у разрешений есть владелец, владелец это абстракция, которая закрепляется за любой моделью.

Исходник файла database\seeders\CreateRootAdminRoleSeeder.php:

newOwner('Role', 'root', 'RootAdmin role');

        // For example #1
        $acr->addPermission('example1.viewAny');

        // For example #2
        $acr->addPermission('example2.view');

        // For example #3
        $acr->addPermission('example3.update', 'name');
        $acr->addPermission('example3.update', 'email');
        $acr->addPermission('example3.update', 'password');

        // For example #4
        $acr->addPermission('viewAny');
        $acr->addPermission('view');
        $acr->addPermission('create');
        $acr->addPermission('update');
        $acr->addPermission('delete');

        // For example #5
        $acr->addPermission('Examples.Example5.viewAny');
        $acr->addPermission('Examples.Example5.view');
        $acr->addPermission('Examples.Example5.create');
        $acr->addPermission('Examples.Example5.update');
        $acr->addPermission('Examples.Example5.delete');

        // For example #6
        //For all - $acr->addPermission('example6.update');
        $acr->addPermission('example6.update.self');

        // For example #7
        $acr->addPermission('Example7News.test');

        // For final example
        $acr->addPermission('Examples.UserRules.index');
        $acr->addPermission('Examples.UserRules.rules');
        $acr->addPermission('Examples.UserRules.rules', 'index');
        $acr->addPermission('Examples.UserRules.rules', 'store');
        $acr->addPermission('Examples.UserRules.rules', 'update');
        $acr->addPermission('Examples.UserRules.rules', 'destroy');
        $acr->addPermission('Examples.UserRules.roles');
        $acr->addPermission('Examples.UserRules.roles', 'index');
        $acr->addPermission('Examples.UserRules.roles', 'store');
        $acr->addPermission('Examples.UserRules.roles', 'update');
        $acr->addPermission('Examples.UserRules.roles', 'destroy');
        $acr->addPermission('Examples.UserRules.inherit');
        $acr->addPermission('Examples.UserRules.inherit', 'index');
        $acr->addPermission('Examples.UserRules.inherit', 'store');
        $acr->addPermission('Examples.UserRules.inherit', 'destroy');
        $acr->addPermission('Examples.UserRules.permission');
        $acr->addPermission('Examples.UserRules.permission', 'index');
        $acr->addPermission('Examples.UserRules.permission', 'update');
    }
}

5. И, наконец-то, добавим наследование прав от супер-администратора ко всем пользователям.

php artisan make:seeder AddRoleToAllUserSeeder

Исходник файла database\seeders\AddRoleToAllUserSeeder.php:

inheritPermissionFrom('Role', 'root');

        // or
        // $acr = new AccessRules;
        // $acr->setOwner('Role', 'root');
        // foreach ($all as $one) $one->inheritPermissionFrom($acr);

        // or
        // $mainUser = User::find(1);
        // foreach ($all as $one) $one->inheritPermissionFrom($mainUser);
    }
}

Перейдем к импорту всех инструкций, созданных на этом шаге:

php artisan db:seed --class=CreateUserSeeder
php artisan db:seed --class=NewsTableSeeder
php artisan db:seed --class=CreateRulesSeeder
php artisan db:seed --class=CreateRootAdminRoleSeeder
php artisan db:seed --class=AddRoleToAllUserSeeder

Все те же манипуляции с контролем доступа и наследованием, можно выполнить используя интерфейс, который был добавлен в начале этой статьи.
Вы можете получить доступ к интерфейсу, открыв адрес »/accessui/» в вашем проекте, при базовых настройках когда он включен:

Список всех ролей, групп и пользователей:

Список всех правил:

Давайте перейдем к самой интересной части ☕

Различным методам проверки доступа и связанных с ними правил.
В дальнейшем контроллеры, использованные в примерах, будут возвращать JSON данные, как для SPA Frontend.
Таким образом, не требуется в этих примерах создавать еще шаблоны.

Пример 1

В этом примере мы используем middleware в маршрутизации, чтобы ограничить доступ к контроллеру.

Добавим в файл routes\web.php:

Route::get('/example1', [Example1Controller::class, 'index'])->middleware('can:example1.viewAny');

Контроллер стандартный и не используется для проверок, в данном примере.
Вариация файла …Example1Controller.php:

Что происходит в результате:

Пример 2

Давайте попробуем проверить разрешение в самом контроллере.

Добавим в файл routes\web.php:

Route::get('/example2', [Example2Controller::class, 'show']);

Исходник файла …Example2Controller.php:

toArray(), 200);
    }
}

Пример, как можно использовать фасад Laravel Gate.

Пример 3

Этот пример очень похож на предыдущий, но с использованием параметра опции.
Добавим в файл routes\web.php:

Route::any('/example3/{frm}', [Example3Controller::class, 'update']);

Исходник файла …Example3Controller.php:

value);

        $user = Auth::user();
        switch ($frm)
        {
            case(UserProfileFormEnum::Name):

                if($request->name) $user->fill( $request->only(['name']) );

            break;
            case(UserProfileFormEnum::Password):

                if($request->password) $user->password = Hash::make($request->password);

            break;
            case(UserProfileFormEnum::Email):

                $validator = Validator::make($request->all(), [
                    'email' => 'required|email',
                ]);
                if ($validator->fails()) abort('403', $validator->messages());

                $user->email = $request->email;

            break;
        }

        return Response::json($user->save(), 200);
    }
}

Почему возникает такое поведение и в чем отличие поля »Option» от стандартного определения правил?

Стоит отметить, что поле «Option» не предназначено не для правила, а к самим разрешением.
Это сделано для того, чтобы позволить создавать несколько разрешений в рамках одного правила.
Например, можно получить отдельный список записей по ID, к которым необходимо организовать доступ, не создавая отдельных таблиц или полей.

Пример 4

В этом примере мы воспользуем встроенную функцию $this→authorizeResource (), которая поставляется вместе с функцией ресурсов (resource). Эта функция очень удобна, так как автоматически создает проверки для следующих правил: «viewAny», «view», «create», «update» и «delete».
Добавим в файл routes\web.php:

Route::apiResource('example4', Example4Controller::class)->parameters([
    'example4' => 'news'
]);

Исходник файла …Example4Controller.php:

authorizeResource(News::class, 'News');
    }

    /**
     * Display a listing of the resource.
     */
    public function index()
    {
        return Response::json(News::all());
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        $news = News::create($request->toArray());

        return Response::json($news->id, 201);
    }

    /**
     * Display the specified resource.
     */
    public function show(News $news)
    {
        return Response::json($news->toArray());
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, News $news)
    {
        $news->fill($request->toArray());

        return Response::json($news->save);
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(News $news)
    {
        return Response::json($news->delete());
    }
}

Пример 5

В предыдущем примере мы использовали глобальные правила, что не очень удобно. Чтобы это исправить, мы можем создать trait который позволит создавать правила для каждого контроллера отдельно.

Исходник файла App\Http\Traits\GuardedController.php:

 'viewAny']
     *
     * @var string[]
     */
    //abstract protected $guardedMethods = [];

    /**
     * Do not automatically scan all available methods.
     *
     * @var bool
     */
    //abstract protected $disableAutoScanGuard = true;

    /**
     * List of resource methods which do not have model parameters.
     * @example ['index']
     *
     * @var string[]
     */
    //abstract protected $methodsWithoutModels = ['index'];

    /**
     * Get the map of resource methods to ability names.
     *
     * @return array
     */
    protected function resourceAbilityMap()
    {
        if (empty($this->disableAutoScanGuard)) {
            $methods = array_diff(
                get_class_methods($this),
                get_class_methods(Controller::class)
            );
            $map = array_combine($methods, $methods);
        } else {
            $map = [];
        }

        $map = array_merge($map, parent::resourceAbilityMap());
        $map = array_merge($map, $this->guardedMethods??[]);

        // Replace name for class App\Http\Controllers\Examples\Example1Controller
        // to guard prefix "Examples.Example1."
        $name = $this->getClassNameGate();

        // Replace standard rule "viewAny" to "Examples.Example1.viewAny"
        foreach ($map as &$item) {$item = $name.$item;}
        unset($item);

        return $map;
    }

    /**
     * Get the list of resource methods which do not have model parameters.
     *
     * @return array
     */
    protected function resourceMethodsWithoutModels()
    {
        $base = parent::resourceMethodsWithoutModels();

        return array_merge($base, $this->methodsWithoutModels??[]);
    }

    /**
     * Get name off class witch namespace for guard
     *
     * @param string|null $action
     * @return string
     */
    protected static function getClassNameGate(?string $action = null): string
    {
        // Replace name for class App\Http\Controllers\Examples\Example1Controller
        // to guard prefix "Examples.Example1."
        $name = str_replace([
            'App\\Http\\Controllers\\',
            '\\',
            'Controller'
        ], [
            '', '.', '.'
        ], static::class);

        return $name.$action;
    }
}

Контроллер и его пример остаются точно такими же, как в предыдущем примере.
Только добавляется трейт (файл …Example5Controller.php):

authorizeResource(News::class, 'News');
    }
...

Просмотр уже дает другую ошибку:

Таким образом, с минимальными изменениями в существующем коде
можно легко включить возможности динамического управления доступом.

Пример 6

Ниже представлен достаточно простой пример, похожий на второй.
Здесь присутствует использование »магиеской» проверки.
Добавим в файл routes\web.php:

Route::any('/example6/{news}', [Example6Controller::class, 'update']);

Исходник файла …Example6Controller.php:

fill($request->toArray());

        return Response::json($news->save?1:0);
    }
}

Давайте внимательнее рассмотрим это. Если у нас есть правило »example6.update.self»,
нам необходимо проверять правило »example6.update», система сама добавит ».self»,
если есть объект записи для проверки внутри ACR.

Другии словами работа ACR будет выглядеть так:

if (
    'example6.update' === $ability
    && Gate::allows('example6.update.self')
    && $user->id === $news->user_id
) {
    return true;
}

Кроме того, стоит отметить, что если мы не проверяем пользователя, а другую модель, например, модератор.
ACR отслеживает это, и проверка будет выглядеть, примерно так, внутри системы:

$moderator = App\Models\Moderator::find('...');
if (
    'example6.update' === $ability
    && Gate::forUser($moderator)->allows('example6.update.self')
    && $moderator->uuid === $news->moderator_uuid
) {
    return true;
}

За счет таких возможностей больше не нужно выполнять проверки на владельца, поскольку магический метод будет обрабатывать их автоматически.

Пример 7

Хотя это не ABAC, необходимая функциональность контроля доступа на основе атрибутов, может быть достигнута, путем механизма политик Laravel.

Все предыдущие примеры сосредоточены на проверке доступа к контроллеру, но мы можем использовать тот же подход в »Policy» для реализации контроля доступа через атрибуты со всеми их вариациями.
Для этого необходимо сгенерировать политику для нашей модели:

php artisan make:policy NewsPolicy –model=News

Исходник файла …NewsPolicy.php:

can('Example7News.allowedEditLast24Hours', $news)
            && stripos($user->name, 'author') !== false
            && ($news->created_at->isToday() || $news->created_at->isYesterday())
        ) {
            return true;
        }
        return null;
    }
}

После этого необходимо обновить значение $policies в классе AuthServiceProvider, как написано ниже:

protected $policies = [
    News::class => NewsPolicy::class,
];

Теперь проверим политику в контроллере:
Исходник файла …Example7Controller.php:

authorize('availableUpdateOnSomeTime', $news);

        return Response::json($news->toArray());
    }
}

Таким образом, теперь мы можем проверять не только правила индивидуально, но также проверять атрибуты модели или пользователя.

Важно отметить, что если вы добавите правило »availableUpdateOnSomeTime» и разрешение для пользователя, то политика не будет проверена.

Финальный пример

По умолчанию интерфейс AccessUi не включает проверку уровня предоставленного доступа.
Чтобы исправить это, мы можем создать прокси-контроллер, который будет проверять все разрешения перед любой манипуляцией с данными.

Сначала отключим стандартные маршруты AccessUi.
Для этого нужно отредактировать файл config/accessUi.php:

    /**
     * Panel Register
     *
     * This manages if routes used for the admin panel should be registered.
     * Turn this value to false if you don't want to use admin panel
     */
    'register' => false,

Затем мы создадим 2 контроллера:»UserRulesController» и »UserProfileController», которые используют трейт »RunsAnotherController» для запуска других контроллеров от AccessUi.
Также добавим представление в файлах »user-rules.blade.php» и »user-profile.blade.php».
Файлы немного длинные для статьи, но их можно просмотреть отдельно в репозитории.

Как результат, у нас будет отдельные страницы в нашем стиле с проверкой прав доступа

Страница профиля авторизованного пользователя:

Список правил и только ролей (скрыт список пользователей и групп):

Заключение, с помощью »wnikk/laravel-access-rules» (ACR, ACL, RBAC) в проекте на Laravel — можно создать, мощный способ обеспечения доступа, пользователей только к тем ресурсам, к которым они авторизованы.

С помощью встроенных в Laravel функций middleware и авторизации, можно легко создавать и управлять сложными политиками контроля доступа как на глобальном, так и на уровне конкретного контроллера или модели.

Используя Access-Control-Rules, разработчик может добавлять возможности динамического контроля доступа в свои приложения Laravel с минимальными изменениями кода, обеспечивая безопасность и легкость обслуживания приложения.

© Habrahabr.ru