📜 ⬆️ ⬇️

Symfony2 Voters and Doctrine Filters on guard for security

It all started when I set up a single CRM security system. As it often happens, there were users with different levels of access to the main data (let's call them entities). The view of the main grid was the same, the flexibility of access settings to entities was necessary. At first I thought about the ACL, but ...

... nothing happened.

ACL (Access Control List) - these are lists that store information about what each user (or group of users) can do with each protection object. Symfony has a built-in ACL mechanism, and I got to study it. To begin, I looked at an example of installing protection on an object. At the first reading, I did not like that the privileges were hung on the user: thus I would have to fence huge tables of privileges, listing all users and their permitted actions. However, a short googling led told me that in addition to UserSecurityIdentity, which was present in the example, there is RoleSecurityIdentity . Fine! In addition, I was pleasantly surprised to learn that it is possible to hang privileges not only on objects, but also on classes. Well, fine, but it still does not suit me, since privileges vary depending on the state of the entity. Having collected all my thoughts in a heap, I presented how all this will look like in the future: create liseners that will catch the creation of Entity, change the state of Entity and write, write ACE in the database (for all roles, and there were more than 20 from the start). And then, when the user needs to do something, I will look for one (or several, if the user has several roles) from many millions of ACEs to make sure that the action is allowed. In general, the system seemed rather cumbersome and clumsy, although, and this can not be taken away from it, it performs its functions clearly and meticulously.

For a while, I even considered writing my ACL system with preference and confused , but I rejected it as an unnecessary construction. The only plus of all this tinsel - I decided that I would use masks, since each user could have many roles, and I liked cumulative privileges.
')
I decided to split the task, and first deal with restricting access when I receive Entities. Then I thought - and I’ll write a custom repository that would override all basic access operations. But this would not have saved me if someone decided to use DQL, or even create a query with join from another repository. Then I remembered about doctrine extensions, and specifically - softdeletable . He himself has never been useful to me, I just knew that he is.

This expansion can relieve pain when it is necessary to remove entities with a bunch of connections (I have always considered this a symptomatic treatment, and conscientiously set up the cascades). It marks unwanted entities as deleted. And that's all. They remain in the database, but doctrine diligently pretends that they do not exist. But still, there was an opportunity to “remove”, or at least show everything at all, along with “type of deleted” records.

It was exactly this kind of behavior that I needed, so I thanked the gods for the open source and it’s useful to see how they did it. So I found out about the filters .

After a short time, I learned how to live with it, and wrote my first filter. Then the question of including this filter came to me. It didn’t suit me to include it every time I request data - everything should be clear and “just work” not only with me, but with other developers, as well as with those poor fellows who will ever be engaged in supporting this. Now I don’t remember how I came to this, but I wrote the Configurator - a service that would run at every request, and actually turn on the filter, at the same time substituting the necessary parameters. From the business logic side, you build the usual queries, and before it is executed, the filter appends a code there that will cut everything that you cannot see by status.

Filter itself:

<?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.")"; } } 

Configurator:

 <?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; } } 

And minimal configuration:

 // 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 

After that, call for example $entity->getChildren(); turns into doctrine.DEBUG: SELECT * FROM entity t0 WHERE t0.parent_id = ? AND ((t0.state in ('new', 'in_work'))) [3] doctrine.DEBUG: SELECT * FROM entity t0 WHERE t0.parent_id = ? AND ((t0.state in ('new', 'in_work'))) [3]

So I solved the problem of access to reading. And for everything else there is a mastercard voters . I’ll talk about using voters in Symfony 2.7, but keep in mind that in version 2.8, Voter was added to the class, which replaces the AbstractVoter that I used in my functionality.

The voter itself is the simplest:

 <?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; } } 

And connect it:

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

Entity can have one of 13 states, and I had 7 actions for which permission was needed. It didn’t fit even into a 64-bit int, so I did the mask for each action and entrusted their storage to roles. Plus, I also had global privileges that were not tied to Entity, so in total, each role had 8 bit masks. In the getMask ($ action) method, I did a bitwise “and” for the user for the necessary mask of all its roles. The masks are simple as a circle: 13 bits reflect the permission or prohibition of the action for which this mask is responsible for each of the 13 possible states of the Entity. So, I added the getAllowedStates ($ action) method to users, which returns a list of states in which the $ action is allowed.

 // 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, '  !'); 

If it's simple: at key points of the application, before you perform an action on an Entity, you call denyAccessUnlessGranted ($ action, $ entity, $ declineMessage), transfer to it the action you want to perform, the Entity on which this action and message will be performed which will cause an Exception in case of a failure. Just like that. When you write custom voter, specify which actions and on what type of entities it checks. Inside voter there is access to a user who wants to do $ action with $ enitity. Thus, there is everything that is needed, and it remains for me to only check for the presence of the current state of $ entity in the getAllowedStates ($ action) response of the current user and return the result.

Summarize. I implemented data access control with the help of Doctrine Filter . Such a filter can add a condition to all requests to the selected entity. Plus, the solution is that business logic does not require any corrections to make it work. I implemented permission checking for other actions using Symfony Voter .

Source: https://habr.com/ru/post/273477/


All Articles