June 02, 2017 Michaël Mollard 4 min read

You think that your entities need some finer access controls?

Changing the url in your admin panel gives access to hidden forms?

You've heard of ACL (Access Control List) but can't really see it as a feasible solution?

If so then you're just like me.

I've started working on a decently sized project with a backend powered by Sonata for the last few weeeks when I was tasked with granting edit access for certain admins to edit an entity they own and nothing else.

Problem: My problem was that my security configuration was set to use Roles and the application itself was too big to switch to an ACLs approach.

If this is also your case then let me take you through my solutions.

Some context for easier understanding

Let's imagine a really simple application.

You are the president of a group managing hundreds of hotels all over the world each supervised by a different general manager.

Your application is to be used both by you and each manager to store information about each of to store and manage information on each of those hotels.

Now in this simplest form, the application need to have two entities. A User and a Hotel entity.

Based on those requirement, you have two roles emerging:

President : He should be able to list, show, edit, create, delete all Hotel object. He is basically the super admin.

: He should be able to list, show, edit, create, delete all Hotel object. He is basically the super admin. General Manager: He should only be able to show and edit a single object. Only one Hotel. One of those role will be given to a User object at creation.

This means that your security.yml has to look something like that:

#app/config/security.yml ROLE_GENERAL_MANAGER: - ROLE_APP_ADMIN_HOTEL_SHOW - ROLE_APP_ADMIN_HOTEL_EDIT ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

This gives too much right to the general manager.

He can easily acces any hotel information simply by changing the id that will appear in the url when he is accessing his own.

What can we do to fix that?

The quick and dirty way

The part where the request is handled is the controller. So the first thing that comes to mind is to put the security logic there.

For that we need to understand that sonata uses a default CRUD controller for all of its admin classes. To implement our custom logic, we need to override this behaviour.

We start by extending the current controller in our bundle and implement our little security logic.

class CRUDController extends Controller { function editAction ( $id = null ) { $user = $this - > getUser ( ) ; $hotel = $user - > getHotel ( ) ; if ( $user - > hasRole ( 'ROLE_GENERAL_MANAGER' ) and $id != $hotel - > getId ( ) ) { throw new AccessDeniedException ( ) ; } return parent : : editAction ( $id ) ; } }

And then we add the controller as the one to be used by the Hotel admin.

app.admin.hotel : class : AppBundle\Admin\Hotel tags : - { name : sonata.admin , manager_type : orm , group : app } arguments : [ null , AppBundle\Entity\Hotel , AppBundle : CRUD ]

Now each time we try to edit an hotel we are not managing we will get the desired 403 error.

This way of doing things have two main disadvantages.

We don't have access to the object itself which could be useful to implement the ownership logic.

Our security logic is present in the controller and not isolated.

Security voters

If we look at the code in the sonata default CRUD controller we can notice those 3 lines of code checking for access on an instance of an entity.

if (false === $this->admin->isGranted('EDIT', $object)) { throw new AccessDeniedException(); }

Behind the scene, the isGranted function will start the voter security process of Symfony.

It will ask voters to decide if the current user can perform an action (here "EDIT") on a certain object.

The voters will then judge and give out an answer.

To handle the case of multiple voters, it is useful to change the voting strategy to unanimous in the security.yml of the application.

This mode means that if any voter were to block access to an object then the access would be blocked even if another one were to allow access.

This allows for a finer security configuration by stacking voters on the same class of object based on different conditions.

This can be done by adding the following:

security : access_decision_manager : strategy : unanimous

To get back to our hotel and it's security, to ban General Manager from modifying the hotel that do not belong to them, we need to define a security voter that supports "EDIT" and the Hotel class.

To do that, we need to extend the base Voter class and override two of its functions:

class HotelVoter extends Voter { private $decisionManager ; public function __construct ( AccessDecisionManagerInterface $decisionManager ) { $this - > decisionManager = $decisionManager ; } protected function supports ( $attribute , $object ) { if ( ! in_array ( $attribute , array ( "ROLE_APP_HOTEL_EDIT" ) ) ) { return false ; } if ( ! $object instanceof Hotel ) { return false ; } return true ; } protected function voteOnAttribute ( $attribute , $object , TokenInterface $token ) { $user = $token - > getUser ( ) ; if ( ! $user instanceof User ) { return false ; } if ( $this - > decisionManager - > decide ( $token , array ( 'ROLE_SUPER_ADMIN' ) ) ) { return true ; } return $user === $object - > getManager ( ) ; } }

All that's left is to register the security voter as a service with the right tags:

app.hotel_voter : class : AppBundle\Security\HotelVoter tags : - name : security.voter

Now when our crafty admin try to access any hotel he is not managing, he will be faced with desired 403 error ;)