Каждому SaaS нужны роли. По учебнику это выглядит так: таблица ролей, таблица прав, две промежуточные таблицы, сидер для каждой и класс политик — и всё это до написания хоть одной фичи. Для инди-приложения с двумя ролями (админ и пользователь) я пошел другим путем: одна строковая колонка и минорная мидлваря на 15 строк. Вот как это выглядит целиком и почему этого достаточно.

Колонка 

 Schema::table('users', function (Blueprint $table) {
    $table->string('role')->default('user')->after('email');
});
 

Одна строка со значением по умолчанию  'user' .  role  находится в массиве модели  $fillable , поэтому передается через  User::create()  и  ->update() , как и любой другой атрибут. Здесь нет таблицы ролей, потому что при таком масштабе она ничего не дает: роли здесь — это фиксированный закрытый набор, определяемый разработчиком, а не данные, которыми управляет клиент.

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

Мидлварь 

 class EnsureUserHasRole
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        $user = $request->user();

        if (! $user || ! in_array($user->role, $roles, true)) {
            return response()->json(['message' => 'Forbidden'], 403);
        }

        return $next($request);
    }
}
 

Стоит обратить внимание на три вещи:

  1.   Вариативный  $roles . Зарегистрированный как алиас  role  в  bootstrap/app.php , сейчас он используется как  role:admin  — но завтра  role:admin,editor  будет работать без изменения кода. Мидлварь не знает названий ваших ролей; она просто проверяет вхождение.
  2.   Строгое  in_array . Этот третий аргумент,  true , отключает приведение типов в PHP. На границе авторизации строгость — это не педантичность: нестрогое сравнение — это как раз то, из-за чего проскакивают неожиданные совпадения.
  3.   403, а не 404. Валидный токен с неправильной ролью получает  {"message": "Forbidden"}  и ошибку 403. Я не скрываю существование эндпоинта — безопасность через неясность в вашем собственном админском API ничего не дает, но превращает отладку в кошмар.

В определении роутов важен порядок:

 Route::middleware(['auth:sanctum', 'role:admin'])->group(function () {
    Route::apiResource('users', UserController::class);
});
 

 auth:sanctum  выполняется первым, поэтому к моменту запуска проверки роли  $request->user()  уже заполнена. Поменяйте порядок местами, и мидлварь будет возвращать 403 для каждого запроса, включая запросы валидных админов, — отличный баг для полуночной отладки.

В чем все ошибаются: кто назначает роли 

Колонка ролей безопасна только тогда, когда безопасен путь назначения. У меня есть три правила:

  •   Регистрация жестко задает роль. Обработчик регистрации записывает  'role' => 'user'  напрямую; запрос никогда не содержит поле роли. Поле  role  в запросе на регистрацию было бы формой повышения привилегий с дополнительными шагами.
  •   Сидер отвечает за админа.  User::firstOrCreate(['email' => 'admin@demo.io'], [..., 'role' => 'admin'])  — идиопатический (идемпотентный), поэтому его можно запускать при каждом деплое без дублирования данных. Первый админ существует потому, что его создал деплой, а не потому, что это позволила какая-то форма.
  •   Изменение ролей проходит через эндпоинт только для админов.  StoreUserRequest  разрешает  role: in:admin,user , и это нормально — весь ресурс  users  находится за  role:admin . Когда админ повышает пользователя — это фича; когда незнакомец повышает сам себя — это уязвимость. Поле то же самое, граница доверия другая.

Когда от этого нужно отказаться 

Я добавлю настоящую систему ролей и прав в тот день, когда клиенту понадобится определять собственные роли: роли для каждого тенанта, гранулярные права вроде  users.delete . До тех пор колонка представляет собой одну строку, мидлварь состоит из 15 строк, и есть ровно одно место, где дается ответ на вопрос «разрешено ли этому пользователю?».

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