Symfony2 Voters и Doctrine Filters на страже безопасности

    Все началось, когда я настраивал систему безопасности одной CRM. Как это часто бывает, в ней были пользователи с разными уровнями доступа к основным данным (назовем их entities). Вид основного грида у них был одинаковый, необходима была гибкость настроек доступа к entities. Сперва я подумал об ACL, но…

    … ничего такого не случилось.

    ACL (Access Control List) — это такие списки, которые хранят информацию о том, что может делать каждый пользователь (или группа пользователей) с каждым объектом защиты. У Symfony есть встроенный механизм ACL, и я полез его изучать. Для начала я просмотрел пример установки защиты на объект. При первом же прочтении мне не понравилось, что привилегии вешались на пользователя: таким образом мне пришлось бы городить огромные таблицы привилегий, перечисляя всех пользователей и их разрешенные действия. Впрочем, краткое гугление вывело рассказало мне, что кроме UserSecurityIdentity, который присутствовал в примере есть и RoleSecurityIdentity. Отлично! Кроме того, я приятно удивился, узнав, что вешать привилегии можно не только на объекты, но и на классы. Что же, отлично, но мне это все равно не подходит, т.к. привилегии различаются в зависимости от состояния Entity. Собрав все мысли в кучу, я представил как будет выглядеть все это в будущем: создаются лисэнеры, которые будут отлавливать создание Entity, изменение состояния Entity и писать, писать ACE в БД (для всех ролей, а их со старта было больше 20). А потом, когда пользователю понадобится что-то сделать, я буду искать одну (или несколько, если у пользователя несколько ролей) из многих миллионов ACE чтобы удостовериться, что действие разрешено. В общем система показалась мне довольно громоздкой и топорной, хотя, и этого у нее не отнять, функции она свои выполняет четко и дотошно.

    Некоторое время я даже рассматривал вариант написания своей системы ACL с преферансом и путанами, но отбросил как ненужное велостроение. Единственный плюс всей этой мишуры — я решил что буду использовать маски, так как ролей у каждого пользователя могло быть много, а мне понравились кумулятивные привилегии.

    Я решил разбить задачу, и сначала заняться ограничением доступа при получении Entities. Тут я подумал — а напишу-ка я кастомный репозиторий, который переопределил бы все основные операции доступа. Но это не спасло бы меня, если бы кто-то решил воспользоваться DQL, или даже создать запрос с join из другого репозитория. Тогда я вспомнил, про doctrine extensions, а конкретно — softdeletable. Самому мне он ни разу не пригодился, я просто знал что он есть.

    Это расширение может унять боль, когда надо удалять сущности с кучей связей (я всегда считал это симптоматическим лечением, и добросовестно настраивал каскады). Оно помечает неугодные entities как удаленные. И все. В БД они остаются, но doctrine старательно делает вид, что их не существует. Но все равно там была возможность “разудалить”, или хотя бы показать вообще все, вместе с “типа удаленными” записями.

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

    Через небольшой промежуток времени, я узнал как с этим жить, и написал свой первый фильтр. Тут ко мне подкрался вопрос включения этого самого фильтра. Меня не устраивало включать его каждый раз при запросе данных — все должно быть понятно и “просто работать” не только у меня, но и у остальных разработчиков, а также у тех бедолаг, которые когда-либо будут заниматься поддержкой сего. Сейчас уже не вспомню как я к этому пришел, но я написал Конфигуратор — сервис, который запускался бы при каждом запросе, и собственно включал бы фильтр, заодно подставляя нужные параметры. Со стороны бизнес логики ты строишь обычные запросы, а перед выполнением, фильтр дописывает туда код, который обрежет все то, что тебе видеть по статусу не положено.

    Сам фильтр:

    <?php
    namespace CRMBundle\Entity\Filter;
    
    use Doctrine\ORM\Mapping\ClassMetaData;
    use Doctrine\ORM\Query\Filter\SQLFilter;
    
    class EntityFilter extends SQLFilter
    {
    
        public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
        {
            if ($targetEntity->getName() != 'CRMBundle\Entity\Entity') {
                return '';
            }
    
            try {
                $statuses = $this->getParameter('statuses');
            } catch (\InvalidArgumentException $e) {
                return '';
            }
    
            if (empty($statuses)) {
                return '';
            }
    
            //эти две строки - костыль из-за проблем с кавычками, при передаче они просто "съедались"
            $allowedStates = substr($allowedStates, 1, -1);
            $allowedStates = str_replace('\\', '', $allowedStates);
    
            return $targetTableAlias.".status in (".$allowedStates.")";
        }
    }
    

    Конфигуратор:

    <?php
    namespace CRMBundle\Entity\Filter;
    
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
    use Doctrine\Common\Persistence\ObjectManager;
    
    class Configurator
    {
        protected $em;
        protected $tokenStorage;
    
        public function __construct(ObjectManager $em, TokenStorageInterface $tokenStorage)
        {
            $this->em              = $em;
            $this->tokenStorage    = $tokenStorage;
        }
    
        public function onKernelRequest()
        {
            if ($user = $this->getUser()) {
                $entity_filter = $this->em->getFilters()->enable('entity_filter');
                // жуткого вида implode - костыль со стороны передатчика
                $entity_filter->setParameter('allowedStates', "'".implode("', '", $this->getUser()->getAllowedStates('view'))."'");
            }
        }
    
        private function getUser()
        {
            $token = $this->tokenStorage->getToken();
    
            if (!$token) {
                return null;
            }
    
            $user = $token->getUser();
    
            if (!($user instanceof UserInterface)) {
                return null;
            }
    
            return $user;
        }
    }
    

    И минимально конфигурации:

    // config.yml
    services:
            doctrine.filter.configurator:
            class: CRMBundle\Entity\Filter\Configurator
            arguments:
                - "@doctrine.orm.entity_manager"
                - "@security.token_storage"
            tags:
                - { name: kernel.event_listener, event: kernel.request }
    
    doctrine:
        orm:
            filters:
                entity_filter:
                    class: CRMBundle\Entity\Filter\EntityFilter
                    enabled: false
    

    После этого, вызов, например $entity->getChildren(); превращается в doctrine.DEBUG: SELECT * FROM entity t0 WHERE t0.parent_id = ? AND ((t0.state in ('new', 'in_work'))) [3]

    Так я решил проблему доступа на чтение. А для всего остального есть mastercard voters. Я буду говорить про использование voters в Symfony 2.7, но имейте ввиду, что в версии 2.8 добавили класс Voter, который заменяет по своему функционалу AbstractVoter, который использовал я.

    Сам voter — простейший:

    <?php
    namespace CRMBundle\Security\Authorization\Voter;
    
    use Symfony\Component\Security\Core\Authorization\Voter\AbstractVoter;
    use CRMBundle\Entity\User;
    use Symfony\Component\Security\Core\User\UserInterface;
    
    class EntityVoter extends AbstractVoter
    {
        const VIEW = 'view';
        const EDIT = 'edit';
        const INFO_VIEW = 'info_view';
        const INFO_EDIT = 'info_edit';
        const ANS_VIEW = 'ans_view';
        const ANS_EDIT = 'ans_edit';
        const HISTORY = 'history';
    
        protected function getSupportedAttributes()
        {
            return array(self::VIEW, self::EDIT, self::INFO_VIEW, self::INFO_EDIT, self::ANS_VIEW, self::ANS_EDIT, self::HISTORY);
        }
    
        protected function getSupportedClasses()
        {
            return array('CRMBundle\Entity\Entity');
        }
    
        protected function isGranted($action, $entity = null, $user = null)
        {
            if (!$user instanceof UserInterface) {
                return false;
            }
    
            if (in_array($entity->getState(), $user->getAllowedStates($action))) {
                return true;
            }
    
            return false;
        }
    }
    

    И подключить его:

    // config.yml
    services:
        security.access.entity_voter:
            class:      CRMBundle\Security\Authorization\Voter\EntityVoter
            public:     false
            tags:
                - { name: security.voter }
    

    У Entity может быть одно из 13 состояний, и у меня было 7 действий, на которые нужно было дозволение. Это никак не укладывалось даже в 64-ех битовый инт, так что я сделал по маске на каждое действие и доверил их хранение ролям. Плюс у меня были еще и глобальные привилегии, не привязанные к Entity, так что в сумме у каждой роли было по 8 битовых масок. В методе getMask($action) у пользователя я делал побитовое “и” для нужной маски всех его ролей. Маски просты как окружность: 13 битов отражают разрешение или запрещение действия, за которое отвечает эта маска для каждого из 13 возможных состояний Entity. Так, я добавил пользователям метод getAllowedStates($action), который возвращает список состояний, в которых действие $action разрешено.

    // CRMBundle/Entity/User.php
    public function getMask($action)
    {
        $mask = 0;
        foreach ($this->userRoles as $role) {
            $mask = $mask | $role->getMask($action);
        }
        return $mask;
    }
    
    public function getAllowedStates($action = 'view')
    {
        $result = [];
        $mask = $this->getMask($action);
        foreach (['new', 'in_work', 'etc.'] as $key => $value) {
            if (((1 << $key) & $mask) != 0) {
                $result[] = $value;
            }
        }
    
        return $result;
    }
    
    // controller
    $this->denyAccessUnlessGranted('info_view', $entity, 'Недостаточно прав доступа!');
    

    Если по простому: в ключевых точках приложения, перед тем как совершить действие над Entity, вы вызываете denyAccessUnlessGranted($action, $entity, $declineMessage), передаете в него действие, которое хотите совершить, Entity, над которым будет совершено это действие и сообщение, с которым будет вызван Exception в случае отказа. Вот так просто. Когда будете писать кастомный voter, укажете какие действия и над сущностями какого типа он проверяет. Внутри voter есть доступ к пользователю, который хочет делать $action с $enitity. Таким образом, есть все, что нужно, и мне осталось только произвести проверку на наличие текущего состояния $entity в ответе getAllowedStates($action) у текущего пользователя и вернуть результат.

    Подытожу. Контроль доступа на получение данных я реализовал с помощью Doctrine Filter. Такой фильтр может добавлять условие ко всем запросам к выбранной сущности. Плюс решения в том, что со стороны бизнес-логики не требуется никаких исправлений, чтобы все заработало. Проверку разрешения других действий я реализовал с помощью Symfony Voter.
    Что из перечисленного вы использовали в «боевых» проектах

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

    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 9
    • 0
      Нечто подобное реализовывал + кеширование в Redis набора полномочий пользователя.

      Еще, вероятно, интересен был бы опыт использования ACL из Symfony security компонента. Ни разу пока не встречал в крупных проектах.
      • 0
        Ни разу пока не встречал в крупных проектах.


        Оно удобно только на маленьких проектах. На больших — только воутеры, оно хороши инкапсулирует бизнес ограничения и тд.
      • 0
        Спасибо за статью. Еще можно было бы использовать RBAC + бизнес правила. Мне кажется, хорошо подходит для вашей задачи. В yii2 работает из коробки. С symphony я не работал, к сожалению, но навеняка есть бандл для работы с rbac
        • 0
          Каюсь, не описал данный аспект в статье)

          В symfony RBAC тоже работает из коробки, и раньше он меня всегда устраивал. Но в этом проекте администратор должен иметь возможность отредактировать любые привилегии каждой роли, а это, на минуточку, 13*7 + 8 глобальных привилегий. Код уже начинает попахивать… Плюс то, что у пользователя может быть несколько из ~20 «настоящих» что-то значащих ролей, и у каждой из них есть ворох ролей, описывающих их привилегии. А если бы заказчик захотел бы добавить/удалить некоторые из них (пусть тот, у кого такого не было первый кинет в меня камень)? Я же застрелился бы!
          • 0
            Года 3 назад у меня был проектик со схожей ситуацией, писался на Yii с его RBAC. Там было где-то 7 ролей, но сложные правила вроде «пользователь может паблишить айтем только если его заапрувило минимум два чувака с такой-то ролью или один чувак с другой ролью». Ну и таких правил было не мало. На Yii вышло ужасно. А догадаться сделать цепочку обязанностей (что собственно воутеры из себя и представляют) на тот момент я не догадался.
            • 0
              Спасибо, буду иметь ввиду
              • 0
                Всё же следует различать, где безопасность (информационная), а где бизнес-логика. Правило, которое вы описали, выглядит как ограничение бизнес-процессов, а не как правило безопасности.
                • 0
                  Ну как бы да, с другой стороны это ограничение бизнес логики налагается на действия которое могут производить пользователи, и если RBAC дает весьма примитивные возможности то воутеры как раз таки хорошо подходят для таких случаев.
          • +1
            Спасибо за статью. Читал ее месяц назад, но ктож знал что что она пригодится так скоро.

            Voter может быть полезен при использования SonataAdminBundle.
            В конфигах включаешь проверку

            security:
                access_decision_manager:
                    strategy: unanimous
            

            и в Voter::getSupportedAttributes() можно возвращать стандартные роли типа:

            • ROLE_SONATA_USER_ADMIN_USER_VIEW
            • ROLE_SONATA_USER_ADMIN_USER_EDIT
            • ROLE_ACME_ADMIN_DEMO_VIEW
            • ROLE_ACME_ADMIN_DEMO_EDIT

            это позволит управлять доступом в админке без явного вызова функции denyAccessUnlessGranted и не меняя код админки вообще

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