Laravel 5. Иерархический RBAC для самых маленьких

    Как вам очевидно известно, RBAC — это управление доступом на основе ролей. Все, кто создавали системы чуть большие чем домашняя страничка и чуть меньшие чем Госуслуги, задумывались о том, как разграничить права пользователей.


    В этой статье я не буду рассказывать о том, что такое RBAC и почему это хорошо (хотя немного, конечно, расскажу), а познакомлю вас со своей скромной разработкой (h-rbac) и попытаюсь объяснить, почему она по некоторым аспектам лучше, чем известные "монстры".


    EDITED: Модуль прекрасно работает с Laravel 6.*
    Начиная с Laravel 5.5 нет необходимости регистрировать провайдер в app.php, т.к. используется auto-discovering пакетов.


    Вступление


    Два столпа на которых держится RBAC — это роли и операции (role and permission). Слово "операция" мне нравится больше чем "разрешение", да и смысл отражает более верно. Выглядит это так:


    Код → Операция → Роль → Пользователь

    Таким образом в коде мы проверяем возможность выполнения операции:


    if ($request->user()->can('add-to-favorites')) {
       // делаем что-то связанное с добавлением статьи в избранное
    }

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


    Ни в коем случае не стоит проверять в коде наличие у пользователя роли, т.к. сегодня "менеджер" может добавлять в избранное, а завтра уже не может. Искать по тексту и исключать из проверок отдельные роли — дело не благодарное, собственно для этого и существуют "операции".


    Операция всегда непосредственно связана с блоком программы (см. пример выше) и при изменении ролей здесь точно ничего менять не придется. Зато в состав роли операции включаются и исключаются легко, просто и централизованно.


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


    Мастодонты


    Все, кто создавали системы чуть большие... что-то я повторяюсь. Короче, все вы знаете основных игроков рынка ограничения доступа для Laravel. Это:



    Это действительно серьезные продукты имеющие много разных "плюшек" вроде переопределения операций для конкретного пользователя и т.п., но у меня к ним один простой вопрос (прощай карма, нам было хорошо вместе):


    Как разрешить пользователю редактировать только свои статьи?


    Хм… а никаких встроенных механизмов для этого нет! И это очень странно, т.к. в большинстве систем пользователи генерируют какой-то контент (например статьи) и должны иметь возможность его редактировать, при этом не имея доступа к редактированию чужого.


    И еще: что если мой проект не такой уж большой? Что если весь этот геморрой с хранением ролей и операций в БД, связующими таблицами для "многие ко многим" и созданием целого UI для управления всем этим хозяйством излишен?
    — Та-дам!


    Большой секрет для маленькой...


    Итак, пришло время поговорить о собственных проделках. Название статьи не случайно звучит "...RBAC для самых маленьких". Речь, конечно, не идет о ТТХ программистов, а скорее о размере проектов (и, конечно же, эта оценка весьма условная).


    Если в вашем проекте не очень много операций и не очень много ролей и вы не против мечтали хранить их в виде массива, то рад представить вам модуль h-rbac (hierarchical RBAC with callbacks).


    Нет никаких противоречий с тем, чтобы добавить к модулю разные провайдеры и хранить роли и операции хоть в БД, хоть в жп... любом другом интересующем вас месте.

    Сразу хочу признаться, что принцип, использованный в данном модуле, был подсмотрен мной в Yii. Он был избавлен от ненужной (на мой взгляд) сущности Task и реального извращения в виде bizRule, вычисляющейся с помощью eval() (естественно, я говорю о Yii 1.1, в 2.0 стало по-другому, но, на мой взгляд, тоже довольно запутанно).


    Требования


    Для работы с модулем требуется Laravel 5.1 или выше.


    Начать хочется с того, что стандартная система разрешений (операций) появилась в Laravel начиная с версии 5.1. Она имеет хорошую инфраструктуру, много полезных методов, поддерживается в blade и даже позволяет разрешить пользователю редактировать свои статьи (т.е. передавать в проверку аргументы), но(!) там напрочь отсутствуют роли...


    … все пользователи равны — прямо демократия какая-то. Но мы-то с вами знаем, что обязательно должен быть кто-то "равнее" остальных. Иначе никак!


    Так вот, модуль h-rbac, по сути, является надстройкой над стандартным механизмом авторизации (не путать с аутентификацией!), добавляющий туда роли, а также иерархию операций. Поэтому вы продолжите пользоваться абсолютно всеми стандартными "плюшками", но в контексте наличия ролей.


    Установка


    С помощью Composer


    $ composer require dlnsk/h-rbac

    Зарегистрируем провайдер в config/app.php


    Dlnsk\HierarchicalRBAC\HRBACServiceProvider::class,

    Опубликуем нужные нам элементы


    $ php artisan vendor:publish --provider="Dlnsk\HierarchicalRBAC\HRBACServiceProvider"

    а именно:


    • конфигурационный файл (config/h-rbac.php)
    • миграцию (добавляет текстовое поле role к таблице users)
    • класс конфигурации ролей, операций и колбэков (app/Classes/Authorization/AuthorizationClass.php)

    Да, да, дорогие друзья, как сказано в заголовке, это модуль "для самых маленьких", поэтому у пользователя может быть только одна роль! Но с другой стороны, ведь роли — это всего лишь массив, добавить еще одну проще простого.


    Блэкджек и девочки


    Возьмем следующие роли:


    • admin
    • manager
    • user

    и набор операций:


    • update-post
    • add-to-favorites

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


    Для этого мы на самом деле должны создать три операции и объединить их в цепь начиная с самой открытой и до самой ограниченной:


    update-post → update-post-in-category → update-own-post

    class AuthorizationClass extends Authorization
    {
        public function getPermissions() {
            return [
                'update-post' => [
                        // Необязательное свойство "описание"
                        'description' => 'Редактирование любых статей',
                        // Используется для создания цепи (иерархии) операций
                        'next' => 'update-post-in-category',
                    ],
                'update-post-in-category' => [
                        'description' => 'Редактирование статей в определенной категории',
                        'next' => 'update-own-post',
                    ],
                'update-own-post' => [
                        'description' => 'Редактирование собственных статей',
                        // Здесь цепь заканчивается
                    ],
                // Избранное
                'add-to-favorites' => [
                        'description' => 'Добавление статьи в список избранных',
                    ],
            ];
        }
    }

    Назначение операций ролям очень простое. Кто что может делать очевидно:


    class AuthorizationClass extends Authorization
    {
        public function getRoles() {
            return [
                'admin' => [
                        'update-post',
                    ],
                'manager' => [
                        'update-post-in-category',
                    ],
                'user' => [
                        'update-own-post',
                        'add-to-favorites',
                    ],
            ];
        }
    }

    Обратите внимание, что по существу у нас только две операции update-post и add-to-favorites именно возможность их выполнения мы и должны проверять. Вспомогательные операции update-post-in-category и update-own-post будут проверены автоматически, т.к. они являются содержимым одной с update-post цепи.


    // PostController.php
    
    class PostController extends Controller
    {
        public function update(Post $post)
        {
            $this->authorize('update-post', $post);
            // продолжаем, если операция разрешена
        }
    }

    <!-- post-view.php -->
    
    <h1>{{ $post->title }}</h1>
    <ul class="post-tools">
        <li class="post author">{{ $post->author->username }}</li>
        @can('update-post', $post)
             <li class="post update"><button></button></li>
        @endcan
        @can('add-to-favorites')
             <li class="post add-favorite"><button></button></li>
        @endcan
    </ul>

    Операция add-to-favorites подразумевает возможность добавить в избранное любую статью, т.е. никаких дополнительных проверок делать не нужно, достаточно, чтобы эта операция содержалась в роли пользователя. По этой причине можно не передавать в проверку объект $post (но я бы передавал его в любом случае, т.к. сегодня дополнительных условий нет, а завтра могут появиться).


    Осталось выяснить, как делать проверки тех самых дополнительных условий для update-post-in-category и update-own-post. Для этого достаточно добавить два метода (названия методов получаются путем камелкейсизации(о-па!) названия операции):


    class AuthorizationClass extends Authorization
    {
        public function updatePostInCategory($user, $post, $permission) {
            // Данный метод возвращает модель в случае, если $post содержит id модели
            $post = $this->getModel(\App\Post::class, $post);
    
            return $user->category_id === $post->category_id;
        }
    
        public function updateOwnPost($user, $post, $permission) {
            $post = $this->getModel(\App\Post::class, $post);
    
            return $user->id === $post->user_id;
        }
    }

    Параметр $permission в обоих этих методах будет содержать название изначально запрошенной операции update-post.


    Логика проверки


    Все операции находящиеся в цепи проверяются одна за другой в выбранном пользователем порядке и операция:


    • разрешается, если она содержится в роли и функция дополнительной проверки отсутствует;
    • разрешается, если она содержится в роли и функция дополнительной проверки возвращает true (проверка остальных операций цепи прекращается);
    • запрещается, если ни одна из операций цепи не содержится в роли;
    • запрещается, если для всех операций цепи, содержащихся в роли, функции дополнительной проверки вернули false.

    Фишка для ленивых


    Давайте добавим операцию delete-post. Логика подсказывает, что удалять пользователь может те статьи, которые он может редактировать. Подобная ситуация встречается достаточно часто и чтобы не создавать дополнительную цепь операций и абсолютно идентичные функции проверки аргумента воспользуемся одной хитростью — параметром equal:


    class AuthorizationClass extends Authorization
    {
        public function getPermissions() {
            return [
                'update-post' => [
                        // Необязательное свойство "описание"
                        'description' => 'Редактирование любых статей',
                        // Используется для создания цепи (иерархии) операций
                        'next' => 'update-post-in-category',
                    ],
                'update-post-in-category' => [
                        'description' => 'Редактирование статей в определенной категории',
                        'next' => 'update-own-post',
                    ],
                'update-own-post' => [
                        'description' => 'Редактирование собственных статей',
                        // Здесь цепь заканчивается
                    ],
                // Избранное
                'add-to-favorites' => [
                        'description' => 'Добавление статьи в список избранных',
                    ],
                // Удаление
                'delete-post' => [
                        'description' => 'Удаление статей',
                        'equal' => 'update-post',  // Применяем правила аналогичные редактированию
                    ],
            ];
        }
    
        public function getRoles() {
            return [
                'admin' => [
                        'update-post',
                        'delete-post',
                    ],
                'manager' => [
                        'update-post-in-category',
                    ],
                'user' => [
                        'update-own-post',
                        'add-to-favorites',
                        'delete-post',
                    ],
            ];
        }
    }

    Исходя из этого примера admin сможет удалять любые посты, user — только свои собственные, а вот manager не сможет удалить вообще ничего, т.к. в его роли нет операции delete-post, а значит и проверять ничего не требуется.


    Пишите, Шура, пишите...


    Как уже было сказано раньше, модуль является надстройкой над стандартной для Laravel 5.1 и выше системой авторизации, поэтому использовать его нужно так, как написано в документации, а если коротенько, то вот вам примерчики:


    if (\Gate::allows('update-post', $post)) {
        // делаем что-нибудь, если это разрешено текущему пользователю
    }
    ...
    if (\Gate::denies('update-post', $post)) {
        abort(403);
    }
    ...
    if (\Gate::forUser($user)->allows('update-post', $post)) {
        // делаем что-нибудь, если это разрешено другому пользователю
    }

    Из модели User:


    if ($request->user()->can('update-post', $post)) {
        // делаем что-нибудь
    }
    ...
    if ($request->user()->cannot('update-post', $post)) {
        abort(403);
    }

    В контроллере:


    $this->authorize('update-post', $post);

    С помощью Blade


    @can('update-post', $post)
        <!-- Текущий пользователь может обновить статью -->
    @else
        <!-- Текущий пользователь не может обновить статью -->
    @endcan
    
    @cannot('update-post', $post)
        <!-- Текущий пользователь не может обновить статью -->
    @endcannot

    Кроме того, специально для плохих мальчиков и девочек, добавлена дополнительная директива @role которую можно использовать вместе с @else


    @role('user|manager')
        <!-- Текущий пользователь имеет любую из ролей -->
    @endrole

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


    Вся конфигурация операций и ролей в одном месте
    // app/Classes/Authorization/AuthorizationClass.php
    <?php
    namespace App\Classes\Authorization;
    use Dlnsk\HierarchicalRBAC\Authorization;
    
    class AuthorizationClass extends Authorization
    {
        public function getPermissions() {
            return [
                'update-post' => [
                        // Необязательное свойство "описание"
                        'description' => 'Редактирование любых статей',
                        // Используется для создания цепи (иерархии) операций
                        'next' => 'update-post-in-category',
                    ],
                'update-post-in-category' => [
                        'description' => 'Редактирование статей в определенной категории',
                        'next' => 'update-own-post',
                    ],
                'update-own-post' => [
                        'description' => 'Редактирование собственных статей',
                        // Здесь цепь заканчивается
                    ],
                // Избранное
                'add-to-favorites' => [
                        'description' => 'Добавление статьи в список избранных',
                    ],
                // Удаление
                'delete-post' => [
                        'description' => 'Удаление статей',
                        'equal' => 'update-post',  // Применяем правила аналогичные редактированию
                    ],
            ];
        }
    
        public function getRoles() {
            return [
                'admin' => [
                        'update-post',
                        'delete-post',
                    ],
                'manager' => [
                        'update-post-in-category',
                    ],
                'user' => [
                        'update-own-post',
                        'add-to-favorites',
                        'delete-post',
                    ],
            ];
        }
    
        ////////////// Callbacks ///////////////
        public function updatePostInCategory($user, $post) {
            // Данный метод возвращает модель в случае, если $post содержит id модели
            $post = $this->getModel(\App\Post::class, $post);
    
            return $user->category_id === $post->category_id;
        }
    
        public function updateOwnPost($user, $post) {
            $post = $this->getModel(\App\Post::class, $post);
    
            return $user->id === $post->user_id;
        }
    }

    P.S.:


    Чуть не забыл… Названия ролей "admin", "manager" и "user" взяты в этой статье просто в качестве примера. На самом деле роль "admin" встроена в модуль и не требует определения. Она делает пользователя СУПЕРПОЛЬЗОВАТЕЛЕМ. К нему не применяются никакие проверки, ему разрешено абсолютно все. Ура, товарищи!


    Ссылки:


    1. RBAC Авторизация в YII и LDAP
    2. Модуль h-rbac на GitHub
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 26

      0

      Почему решили делать свой класс Authorization, а не встроиться в механизм политик?

        0
        Честно говоря, не помню. Модуль был написан довольно давно, но кажется что-то в этих политиках мне не понравилось…
        Я попробую вспомнить и напишу вам.
          0

          Я поразмыслил немного, да и некоторые комментарии навели меня на определенные мысли.
          Во-первых у меня было мало времени, т.к. нужно было срочно сделать вменяемую систему авторизации. \Gate:before() в этом контексте было приемлемым и очень элегантным решением — не нужно менять никаких предков, просто поставил и сразу все работает как нужно.
          В связи с Policies сразу встал вопрос: как организовать иерархию операций? Тогда мне некогда было об этом думать серьезно.
          Сегодня я поразмышлял на эту тему основательно и, в принципе, придумал способ встраивания. Но выглядит это как некоторое притягивание за уши… по крайней мере в нынешнем виденьи.
          Конечно использование Policies несколько структурирует пермишины, но с другой стороны и усложняет восприятие целостной картины, а роли еще сильней усложнят ее. Вот и возникает вопрос: а нужно ли это усложнение для не слишком больших проектов? На мой взгляд — нет.

          0
          В свое время, когда понадобилось сделать rbac с ограничениями просмотра по ролям, гоняясь за универсальностью, отказался от понятия permissions и оставил только roles. В результате в мидлвале роута пишу что-то подобное `rbac:admin|manager&own`, что означает, что админ может видеть (и редактировать в случае update) все, а менеджер только принадлежащие ему сущности. Завел небольшую конвенцию, что в урле id будет называться с префиксом сущности, например `users/user_id` и в мидлваре проверяю, если встречается `own`, то есть ли у пользователя этот id в связи ->users(), если не нашлось, то в ->user(), иначе доступ запрещен. Здесь, конечно, предполагается преобразование этого самого user_id в users() и/или user() (что не всегда выглядит красиво в случае составных названий) и регулярный поход в базу и не сказать, что способ удобный, но избавляет от тонны permissions. :)
            +1
            Как разрешить пользователю редактировать только свои статьи?

            Roles, Bouncer — могут такое
              0
              Ожидал этого комментария :)
              Да, свои могут. А, например, только статьи опубликованные за последний месяц?
              Ну то есть, вы же понимаете, что там просто задействованы отношения, а здесь можно воротить что угодно.
                0
                Bouncer — может такое :)
                  0

                  Я очень внимательно изучил документацию и даже исходники (но не очень внимательно). И единственное, что я заметил


                  // You can also grant an ability only to a specific model
                  Bouncer::allow($user)->to('edit', $post);

                  совершенно не то, о чем мы говорим. Bouncer позволяет разрешить/запретить операцию с какой-то конкретной моделью. Можно глянуть из любопытства сюда:



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

                    0
                    Извиняюсь, по первому взгляду на https://github.com/JosephSilber/bouncer/blob/master/src/Clipboard.php#L26 думал, что все позитивные проверки будут дальше проваливаться в polices или abilities.
                    Если бы автор там возвращал null, то всё так и работало бы.
                    Извиняюсь ещё раз за невнимательность)
              0
              Есть еще такая библиотека, которая легче и удобнее, чем эти монстры и умеет во вложенность /middleware с помощью встроенной механиики guard
                0
                То что в middleware можно перехватить request увидел. Но как в контроллере вызвать проверку permission и передать в проверку аргумент не понял.
                  0
                  Да, библиотеки, разработанные в компании Spatie, не один раз ускорили процесс разработки тех или иных модулей десятки тысяч разработчиков. И laravel-permission не исключение. Я лично применяю ее в своих проектах.

                  Но все же, у этой библиотеки не все так гладко получается, особенно когда в проекте больше одного guard-а.
                    0
                    Не всё гладко, но отдать дань spatie их решения очень сильно сопутствую вектору развития самого Laravel и садятся как влитые в проекты. Чего не скажешь о конкурентных решениях
                  0
                  Хм… а никаких встроенных механизмов для этого нет! И это очень странно, т.к. в большинстве систем пользователи генерируют какой-то контент (например статьи) и должны иметь возможность его редактировать, при этом не имея доступа к редактированию чужого.

                  Серьёзно? Прям таки нет
                    0

                    В том месте, откуда взята эта цитата, речь шла о больших, серьезных модулях и что в них нет такого простого и нужного механизма (передача параметра в функцию-проверку).
                    В Laravel такой механизм есть и именно благодаря его наличию существует опираясь на эту инфраструктуру данный модуль.
                    Но в Laravel нет ролей и это обесценивает Policies на 90%. Фактически они превращаются в фильтры — если я сделаю как написано в примере:


                    class PostPolicy {
                        public function update(User $user, Post $post) {
                            return $user->id === $post->user_id;
                        }
                    }

                    то начну фильтровать всех пользователей. Как же мне тогда "менеджеру" разрешить редактировать все? Писать if? Куда? В код? Спасибо, не надо!
                    Или в метод update что-то вроде if($user->role=='manager')-elseif-elseif-else? Тоже увольте.
                    Вот и получается, что Policies есть, а применить их в реальном проекте затруднительно.

                    0

                    Спасибо. Хотелось бы увидеть такую-же статью о ABAC.

                    0
                    zizaco/entrust?
                      0
                      А что с ним? Он есть в списке.
                      0
                      Метод интересный. Но я сделал еще проще, гораздо. В провайдере Auth подключил полисы, в которых проверяю принадлежит ли пользователю контент. А в методе регистрации создаю роли типа такого:

                      // All privileges without limitation (for admin and manager).
                              Gate::define('administer-users', function ($user) {
                                  return $user->role == 'manager' || $user->role == 'admin';
                              });
                      


                      Будет время, напишу об этом у себя в блоге: https://cleverman.org
                        0

                        Не вполне вас понял.


                        1. Как вы реализуете последовательность проверок в полисях? (update-post → update-post-in-category → update-own-post)


                        2. В примере вы определяете пермишин в котором на самом деле проверяете роли… это очень странно, т.е. фактически это эквивалент проверки ролей в коде. В любой статье по RBAC (и в этой тоже) написано, что проверять роли в коде это идеологически неверно. В коде нужно проверять разрешения (операции).
                          Если в вашем примере administer-users больше не должны видеть какое-то меню, то нужно лезть в код и убирать can('administer-users') бог знает в скольких местах и в каком-нибудь обязательно забыть… В случае вышеописанного модуля необходимо просто в настройках изъять операцию из роли администратора.
                          0
                          Вы знаете, я много лет проработал с Друпал 7. Писал свои модули, свой функционал и т.д. Так вот, если бы увидели, как система ролей и прав сделана там, то у вас был бы нервный срыв и не проходящий ужас, еще очень долго. Поэтому, для мелких и чуть больше проектов, нет смысла вообще городить огород с разветвленными правами.

                          В полисах я сделал так:
                           public function destroyPost(User $user, Post $post)
                              {
                                  return $user->id == $post->user_id || Gate::check('administer-users');
                              }
                          
                              public function editPost(User $user, Post $post)
                              {
                                  return $user->id == $post->user_id || Gate::check('administer-users');
                              }
                          


                          Опять же таки, я решил пойти путем «с хвоста». Право 'administer-users' всегда дает доступ ко всему, у кого есть роль админ или менеджер. Есть еще право 'user-content' с ролью user (или любой другой, какую вы назначите). Вот именно оно всегда и везде проверяется. Если мне нужно всем владельцам закрыть возможность в редактировании своих постов, то не трогая контроллеры, можно просто вернуть false в полисе.

                          Если мне нужно вообще все запретить для авторизованных пользователей, то я верну false в проверке для права 'user-content'.

                          Плюс не стоит забывать еще тот момент, что мы работаем с фреймворком, и все бремя ответственности по разработке архитектуры ложится на программиста. Поэтому, было бы очень здорово продумать еще на начальной стадии разработки, что это за проект, каковы его цели, кому нужен доступ, а кому нет, что вообще должны делать пользователи, а что им нельзя. И исходя из этого уже создавать систему прав. А то, очень часто, вначале пилится проект, а потом уже думается, а что делать с ролями и правами. И начинается забег с костылями. Это сугубо мое личное мнение. Никому его не навязываю.
                          У меня есть один проект на Drupal 7, Best-House.org, так там в настройках прав есть 300!!! полей с доступом к тому или другому функционалу. Это все можно нащелкать мышкой (просмотр, редактирование, удаление, доступ к настройкам и т.д.) и это к каждому полю. После этого трэша, я сказал себе хватит. И упростил все до состоянии неотесанной доски.
                        –2
                        Оставлю это здесь
                        Bouncer::allow($user)->to('edit', $post);
                        

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

                      Самое читаемое