Еще раз о Security в Symfony2 подход user-resource-privilege

Не так давно взялся за Symfony2. Не смотря на то, что до этого имел достаточно богатый опыт общения с Zend1, барьер входа для меня оказался высоким. Вдоволь начитавшись у меня начало что-то получаться. Наибольшие затруднения вызвал вопрос разграничения прав доступа. Практически все мои поиски выводили меня на FOSUserBundle или обрывки информации о том, как можно расширить функционал модуля Security из стандартной поставки фреймворка. Каких-либо преимуществ для себя в громоздком FOSUserBundle я не обнаружил. Поэтому эта статья будет о том, как я допиливал Symfony2 Security под свои нужды. Цель была следующая: symfony2 + security + разграничение прав доступа на уровне объекта в зависимости от роли пользователя. В этой статье не будет ничего про наследование ролей и кумулятивные привилегии, информацию о которых вы, без труда, найдете сами. Схема прав в моем проекте: запрещено все, что не разрешено. Один пользователь имеет строго одну роль. Роль имеет доступ к различным ресурсам с различным набором привилегий. Разные роли могут иметь доступ к одним и тем же ресурсам с разными или равными наборами привилегий. Я не буду пытаться сделать код максимально абстрактным, а просто буду использовать фрагменты из своего проекта, связанные с функциональностью заказ-нарядов на обслуживание техники.

Итак, к делу. У нас есть правильно настроенный проект, в нем создан BackendWorkorderBundle, настроены все роутеры и фаерволы. Т.е. есть все, за исключением прав доступа. Включая аутификацию. Для проектирование БД использовался инструмент MySQL Workbench. Отличная штука. Есть версия под Linux. Структура таблиц выглядит так:

-- -----------------------------------------------------
-- Table `backend_role`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `backend_role` (
  `role_id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(45) NULL,
  `description` VARCHAR(45) NULL,
  PRIMARY KEY (`role_id`))
ENGINE = InnoDB;


-- -----------------------------------------------------
-- Table `backend_user`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `backend_user` (
  `user_id` INT NOT NULL AUTO_INCREMENT,
  `role_id` INT NOT NULL,
  `firstname` VARCHAR(45) NULL,
  `lastname` VARCHAR(45) NULL,
  `printname` VARCHAR(45) NULL,
  `username` VARCHAR(45) NULL,
  `salt` VARCHAR(255) NULL,
  `password` VARCHAR(255) NULL,
  `created` DATETIME NULL,
  `updated` DATETIME NULL,
  `last_login` DATETIME NULL,
  `is_active` TINYINT(1) NULL,
  PRIMARY KEY (`user_id`),
  INDEX `fk_backend_user_backend_role1_idx` (`role_id` ASC),
  CONSTRAINT `fk_backend_user_backend_role1`
    FOREIGN KEY (`role_id`)
    REFERENCES `parts`.`backend_role` (`role_id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;


-- -----------------------------------------------------
-- Table `backend_rule`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `backend_rule` (
  `rule_id` INT NOT NULL AUTO_INCREMENT,
  `role_id` INT NOT NULL,
  `resource_id` VARCHAR(255) NULL,
  `privileges` TEXT NULL,
  PRIMARY KEY (`rule_id`),
  INDEX `fk_backend_rule_backend_role1_idx` (`role_id` ASC),
  CONSTRAINT `fk_backend_rule_backend_role1`
    FOREIGN KEY (`role_id`)
    REFERENCES `parts`.`backend_role` (`role_id`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

Проверять наличие привилегий можно двумя способами:
1. Из twig is_granted('[наименование привилегии]', [объект])
2. Из контроллера $this->get('security.context')->isGranted('[наименование привилегии]', [объект])
Второй аргумент не обязателен, но необходим для целей моего проекта (станет понятно чуть ниже в коде voter'а). Напоминаю, что исключение объекта из html страницы не отменяет проверку данных в контроллере.

Код voter'a. Забыл упомянуть, что в проекте есть есть еще один бандл BackendCoreBundle, которые вбирает в себя наиболее общие функции для всего Backend'a. Подробнее о voter'ах можно почитать здесь.
<?php
// /src/Backend/CoreBundle/Security/Authorization/Voter/PrivilegeVoter.php

namespace Backend\CoreBundle\Security\Authorization\Voter;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class PrivilegeVoter implements VoterInterface 
{
    public function supportsAttribute($attribute) 
    {
        return true;
    }

    public function supportsClass($class)
    {
        return in_array($class, array(
          'Backend\WorkorderBundle\Entity\Workorder'
       ));
    }

    public function vote(TokenInterface $token, $object, array $attributes)
    {
	//применим ли voter к объекту определенного класса.
	//необходимо так как наш вотер будет опрашиваться во всех случаях контроля привилегий.
        if ( !($this->supportsClass(get_class($object))) ) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        foreach ($attributes as $attribute) { //необходимо адаптировать функцию под ваши нужды
            if ( !$this->supportsAttribute($attribute) ) {
                return VoterInterface::ACCESS_ABSTAIN;
            }
        }

	//магия творится здесь
        $user = $token->getUser();
        $privileges = $user->getPrivileges();
        $resourceId = $object->getResourceId();
        
        $acess_granted = false;
        foreach ($attributes as $attribute) {
            if (isset($privileges[$resourceId])) {
                $resource_privileges = $privileges[$resourceId];
                if (in_array($attribute, $resource_privileges)) {
                    $acess_granted = true;
                } else {
                    $acess_granted = false;
                    break;
                }
            }
        }
        
        if ($acess_granted)
            return VoterInterface::ACCESS_GRANTED;
       
       return VoterInterface::ACCESS_DENIED;
    }
}


Фунция getPrivileges для user объявлена в объекте doctrine, связанном с таблицей backend_user
<?php
///src/Backend/CoreBundle/Entity/BackendUser.php

namespace Backend\CoreBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\AdvancedUserInterface;

/**
 * BackendUser
 *
 * @ORM\Table(name="backend_user")
 * @ORM\Entity
 */
class BackendUser implements AdvancedUserInterface, \Serializable
{
..
    public function getPrivileges()
    {
	//цепочка выглядит так: backend_user->backend_role->backend_rule
	//функция $rule->getPrivileges() возвращает значение поля privileges таблицы backend_rule
	//то есть текущая функция возвращает массив ключами которого являеются resource_id,
	//а элементами массивы привилений для доступа к этому ресурсу (хранятся через запятую)

        $rules = $this->getRole()->getRules();
        $result = array();
        foreach ($rules as $rule){
            $result[$rule->getResourceId()] = explode(",", $rule->getPrivileges());
        }
        return $result;
    }
..
}


Регистрируем voter в /app/config/security.yml

services:
    security.access.privilege_voter:
        class:      Backend\CoreBundle\Security\Authorization\Voter\PrivilegeVoter
        public:     false
        tags:
           - { name: security.voter }

Вы, наверное, обратили внимание, что в функции vote вызывается $object->getResourceId(). Выглядит метод следующим образом
<?php
// /src/Backend/WorkorderBundle/Entity/Workorder.php
namespace Backend\WorkorderBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;

use Doctrine\ORM\Mapping as ORM;

/**
 * Workorder
 *
 * @ORM\Table(name="workorder")
 * @ORM\Entity
 */
class Workorder
{
..
    public function getResourceId()
    {
	//функция добавлена для гибкости и в текущий момент возвращает имя класса
	//В данном случае Backend\WorkorderBundle\Entity\Workorder
        return \Doctrine\Common\Util\ClassUtils::getClass($this);
    }
..
}


That's it! Критика, как обычно, привествуется, если кто-то может указать на недостатвки этого подхода и возможные проблемы при масштабировании — был бы очень рад.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 17

    0
    Не совсем понимаю почему бы не использовать встроенный в Symfony 2 ACL.
      0
      Если честно, я просто не понял сути этого механизма. Насколько я понял из описания, он используется для контроля доступа к конкретному экземпляру объекта (комментарию пользователя в примере), а не к классу однотипных объектов (ко всем заказ-нарядам, как моем случае). Да и не очень понятно как это увязывать с БД. Если вы можете дать ссылки на какие-то другие статьи про ACL, буду очень благодарен.
        0
        Вы все поняли правильно. Меня смущает вот эта проверка: is_granted('[наименование привилегии]', [объект]), потому и предложил посмотреть в сторону ACL.
        Если вам не нужна проверка на уровне объекта то почему бы не использовать просто массив ролей для пользователя, аля: [ROLE_CREATE_WORKORDER, ROLE_UPDATE_WORKORDER, ROLE_CREATE_TICKET, ROLE_DELETE_TICKET], или раз уж «Один пользователь имеет строго одну роль» то использовать группу к которой уже привязывать список ролей (у себя делаю именно так).
          0
          По сути тот метод который я предложил и есть ваш массив ролей, только со срезом по классу объекта с помощью voter и передачи экземпляра объекта в него. То есть пользователь имеет доступ CREATE_WORKORDER EDIT_WORKORDER VIEW_WORKORDER, но в базе и во всех вызовах эти права у меня именуются в виде create, view, edit для класса объекта workorder. Такие же права могут быть для другого объекта salesorder: create, view, edit. Тк мне показалось неудобным придумывать уникальный и к тому же длинный идентификатор для каждого нового разрешения, которое может потребовать бизнес-логика.
      +2
      Спасибо за статью. Хотелось бы заметить, что get_class($object) в случае с doctrine 2 не всегда вернет корректное имя класса, лучше использовать \Doctrine\Common\Util\ClassUtils::getClass
        +1
        Покопал немного поисковики, но ничего конкретного о причинах и условиях появления проблемы не нашел. В моем случае возвращаемые значения равны. Но думаю, разработчики doctrine не от скуки програмили этот класс. Спасибо за комментарий! PS: Какой любопытный здесь сайт. Рейтинги, кармы…
      0
      Недавно решал похожую задачу с Voter'ами для разграничения прав по тарифным планам. Отлично подходит этот механизм.
      Единственное, может кто-то сталкивался — как вы работаете с isGranted(...) в CLI скриптах (symfony commands/PHPUnit tests)? Там ведь $token не определен.

      Для PHPUnit тестов я инициализировал securityContext через new AnonymousToken(...) — но не знаю, павильно ли это.
        +1
        Не могу представить ситуацию когда в Symfony Command понадобится isGranted, но вообще никто не мешает добавить опции --user и --pass и авторизовать пользователя.
        Для модульных тестов просто делается мок, но если сервисы построены правильно обычно даже этого не требуется, т.к. вся аутентификация происходит уровнем выше: или в контроллере, или через Secure аннотацию сервиса.
          0
          Ну вот вам наш пример: имеются тарифные планы на сайте, проверка которых реализуется с помощью isGranted('%такой-то план%'). Внутри в Voter'е проверяется текущий юзер, его тарифный план. Мы вынесли всю сложную (времезатратную) логику в сервер очередей, при этом когда начинает работать Consumer, естественно уже никакаго юзера нет. Но при этом из множества действий необходимо для конкретного тарифного плана выполнять только определенные.

          или через Secure аннотацию сервиса.

          Не совсем здесь вас понял. Тоесть при наличии secure аннотации у сервиса (над классом?) он может вызваться или нет? Будет ли кидаться исключение, или код просто пропустит этот участок?
            +1
            Примера не понял. Можно чуть подробней?
            А про тесты, я обычно в config.yml для тестового окружения просто не помечаю сервисы тэгом security.secure_service. Подробнее можно почитать в документации. Хотя конечно все зависит от задач.
              0
              Видимо, плохо я пояснил. Постараюсь по-подробнее:

              В системе имеются три тарифных плана для пользователей, которые дают им разные фичи. Чем дороже план, тем больше фич имеет пользователь. Проверка на то, доступен ли тот или иной функционал пользователю производится через isGranted('') и Voter.

              Например, в контроллере:

              // ...
              if ($secureContext->isGranted('PRICE_PLAN_XXX')) {
                  // делаем что-то специфическое
              }
              // ...
              


              Внутри это работает через Voter, первый параметр которого содержит $token — через который мы получаем текущего сессионного пользователя и проверяем, действительно ли у него в БД содержится необходимый план и следует ли дать что-то (ну или запретить).

              Вот, как я уже сказал, много функционала у нас вынесено в сервер очередей, чтобы как можно больше ускорить загрузку страницы. Так, например, генерация PDF — ее обрабатывает отдельный Consumer в бэкграунде. Условимся, что если пользователь имеет тарифный план YYY, который дороже чем XXX, то ему дополнительно шлем еще 1 файл. Так вот чтобы это проверить, мы в Consumer (та же коснольная команда, почти), пишем опять проверку:

              // ...
              if ($secureContext->isGranted('PRICE_PLAN_YYY')) {
                  // шлем еще 1 файл
              }
              // ...
              


              и тут возникает ошибка, что security context не инициализирован — нет токена. Выше я уже писал, что проблему временно решил через инициализацию анонимным токеном (встроенный в Symfony), и передачей объекта $user вторым параметром:

              // ...
              if ($secureContext->isGranted('PRICE_PLAN_YYY', $user)) {
                  // шлем еще 1 файл
              }
              // ...
              


              В поисках более лучшего решения…
        0
        Насколько я понял в текущей реализации Вы используете модель в качестве ресурса для ACL, почему бы не использовать контроллер, как ресурс? Это как раз логика работы приложения, а не логика работы домена. Тем более, что ранее Вы работали с ZF1, где это прописано во всех туториалах.

        Типичный пример: 2 разных уровня доступа к модели(для одной роли на чтение, для другой — чтение + запись), в Вашем случае надо будет генерить новую модель со своим getResourceId().
          0
          Касаемо двух моделей в моей ситуации — мне такое сложно представить, так как контроль прав и все вызовы для БД проходят только через контроллер. Но вы правы. В качестве ресурса лучше использовать контроллер. Начал это понимать сегодня. Если кому-то будет интересно, то название контроллера и действия в twig можно получить так {{ app.request.attributes.get("_controller") }}
            0
            Почему бы тогда не использовать секцию access_control в security.yml? Что-то вроде:
            access_control: - { path: ^/club, role: [ROLE_CLIENT] }
              0
              Потому, что разделение прав доступа должно происходить в интерфейсе пользователя и контроллере. «Эта кнопка у него доступна, эта недоступна» и тд.
            0
            В итоге:

            // /src/Backend/CoreBundle/Security/Authorization/Voter/PrivilegeVoter.php
            // ..
                public function supportsClass($class)
                {
                    return in_array($class, array(
                      'Backend\CoreBundle\Entity\SecurityContextResource'
                   ));
                }
            // ..
            


            // /src/Backend/CoreBundle/Entity/SecurityContextResource.php
            
            namespace Backend\CoreBundle\Entity;
            
            class SecurityContextResource
            {
                private $resourceId;
                
                public function __construct($resourceId)
                {
                    $this->resourceId = $resourceId;
                }
                
                public function getResourceId()
                {
                    return $this->resourceId;
                }
            }
            


            // /src/Backend/WorkorderBundle/Controller/DefaultController.php
            
            //..
                private $resource;
                
                private function getResource()
                {
                    if (!is_object($this->resource))
                        $this->resource = new SecurityContextResource('/backend/workorder');
            
                    return $this->resource;
                }
            //..
            


            Вызывать так $this->get('security.context')->isGranted('view', $this->getResource())

          Only users with full accounts can post comments. Log in, please.