Better authorization for Symfony 4
During my career as a web developer I have consistently noticed one weak area in practically all frameworks and CMS that I have been in contact with, and that is how permissions and authorization work. In this article I will concentrate on Symfony specifically and lay out the problems that I have identified in the authorization options that we currently have to choose from. I will also present a solution to these problems in the form of my Logical Authorization Bundle.
General issues
Here are some issues that are present in all of the options as far as I’m aware:
- Lack of overview. It is currently not possible to get some sort of list of all the permissions that are used on the site. The various route and entity permissions are very much scattered which makes them more difficult to review.
- No way to export permissions for interoperability. Let’s say you use Symfony as a headless API server. Currently if you want to check permissions on the client side in order to avoid unnecessary server calls, you have to implement the logic again on the client side. This makes your application less DRY and thus more prone to bugs.
Checking conditions manually
The most basic way to check permissions is to check the actual conditions manually in code wherever required, which looks a little something like this:
public function editPostAction(Post $post)
{
// ...
/** @var $token \Symfony\Component\Security\Core\Authentication\Token\TokenInterface */
/** @var $AuthorizationChecker \Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface */
if (($post->getOwner()->getId() !== $token->getUsername() || $post->isLocked())
&& !$AuthorizationChecker->isGranted('ROLE_MODERATOR')
&& !$AuthorizationChecker->isGranted('ROLE_ADMIN')
) {
throw new AccessDeniedHttpException();
}
// ...
}
(Example taken from https://stovepipe.systems/post/symfony-security-roles-vs-voters)
In this example the conditions required for letting the user edit a post are manually checked in a controller. The main problem with this approach is that it is prone to bugs due to low DRY. You have to duplicate the same code everywhere you want to check if a user should be able to edit a post, like for example in a Twig function to decide whether an edit button should be displayed for the post. This means that if you want to change the permission logic for editing a post, you have to find all the places where these permissions are checked and change them. In order to make our lives as developers a bit easier, we have been given several other options.
Voters
Voters are pretty good, which is the reason that they are currently the go-to solution for checking complex permissions. You only have to write the permissions logic once and then you can write a one-line access check everywhere you need to. If you want to change the permissions, you only need to do it in one place so there’s more DRY which reduces the risk for bugs. Having said that, I did find some inconveniences with this approach:
- If the permissions are complex they might be difficult to debug, because you don’t get any feedback regarding why they didn’t function as expected. Granted, this is no worse than any other code debugging, but wouldn’t it be nice to get information about each part of the logic when it comes to something as sensitive as permissions? That would make it easier to fix any problems with the logic.
- The permissions are written in a separate voter class. This means that if you want to review the permissions for an entity, you have to find the right voter class and look there. Wouldn’t it be nicer if we could declare permissions together with the mapping information?
access_control in security.yaml
For route permissions adding rows to access_control in security.yaml works well up to a point where the permissions get too complex and then the official recommendation is to use voters. Unfortunately, that causes the permission declarations to become more fragmented, so that for some routes the logic is visible in security.yaml but for the rest you have to look inside the voter classes to see exactly what permissions are actually used for a certain route.
The bundle jms/security-extra-bundle attempts to push the boundaries for what is possible to express in configuration without having to use voters. It accomplishes this by building on the Twig-related expression language. Technically, the expression language is powerful enough to express any degree of permission complexity, so that bundle is the closest to what I was looking for. However, this language is specifically meant to be compiled to PHP so I didn’t deem it suitable as an exportable configuration format.
ACL
ACL was pulled out of Symfony 4 because of lack of usage, apparently due to it being cumbersome and difficult to work with even though it had certain strengths. The lesson here is that a good authorization system needs to be easy to use or no one will use it.
My solution
Learning from these observations and from observations from other frameworks and CMS, I started working on an idea for an authorization bundle that could solve all of the problems above. These were my criteria:
- The permissions for each action should only need to be declared one single time
- I want to be able to review all of the permissions for a site on a single page
- I want to be able to export permissions for interoperability with client-side applications
- I want to be able to declare permissions together with the mapping for routes and entities so that there’s never any question of where I need to go in order to change permissions for a certain route of entity
- I want support for both entity-level and field-level permissions
- I want to easily be able to debug access checks in order to quickly find errors in my permission logic
These criteria led me to the conclusion that in my system, permissions must always be expressed in configuration rather than in code. Otherwise it would be impossible to fulfill at least (2) and (3). This meant that it was up to me to create a configuration format for permissions that was expressive enough to handle any degree of complexity, while at the same time being easily exportable to standardized data formats such as JSON for interoperability. I succeeded in this endeavor and wrote a parser for this format in several different languages. Notable among them is a parser in javascript which you can use on the client side to help fulfill criterium (3).
Now, regarding criterium (4) that was a little bit tricky but I ended up with a solution that I’m quite happy with. Using my bundle, you can simply declare permissions for a route like this:
class DefaultController extends Controller {
/**
* @Route("/route-role", name="route_role")
* @Method({"GET"})
* @Permissions({
* "role": "ROLE_ADMIN"
* })
*/
public function routeRoleAction(Request $request) {
return new Response('');
}
}
Needless to say, there is corresponding support for Doctrine ORM and even Doctrine MongoDB so that criterium (5) is fulfilled. Apart from annotations I have also added support for YAML and XML mapping. The syntax is completely consistent no matter if the permissions are for routes or entities, apart from unavoidable language differences between JSON, YAML and XML.
In order to fulfill criterium (6) I put a lot of effort into creating a helpful debug panel and ended up with this:
As you can see, it is possible to use a filter to make it easier to find the particular access check you’re interested in debugging.
In the top item no permissions were defined for the homepage route, so access was automatically granted.
In the bottom item an access check was made for the logout page, which requires that a user has an account. Because the current user was not logged in, access to the logout page was denied.
In the middle item an access check was made for the login page, which on the contrary requires that a user does not have an account. Because these permissions are complex, you can see them broken down into two rows under the heading “Permissions debug”. These rows are sorted by reverse order of complexity, with higher complexity at the top and lower complexity at the bottom. In the bottom row you can see that the user was not found to have an account (because it was an anonymous user), so the return value for that row is false, signified by the red dot. However, in the top row a NOT gate negates that part and changes the return value to true, so in the end access was granted to the login page.
Another thing that you can see on this debug panel is that there is a tab called “Permission Tree” at the top. If you click on that you will get a nice overview tree containing all the permissions that you have defined — both for routes and for entities, thus fulfilling criterium (2).
Aside from these features there are more to discover, like the possibility for certain users to bypass access checks completely and repository decorators for automatically filtering results according to permissions.
If you found this article interesting then please feel free to check out my bundle at https://github.com/ordermind/symfony-logical-authorization-bundle. I believe that this bundle makes Symfony best in class among web frameworks and CMS with regard to authorization, and I welcome applications for co-maintainers to ensure the longevity of this project. Enjoy!