Все началось, когда я настраивал систему безопасности одной 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.")"; } }
И минимально конфигурации:
// 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) у текущего пользователя и вернуть результат.
ссылка на оригинал статьи http://habrahabr.ru/post/273477/
Добавить комментарий