Using Symfony Security voters to check user permissions with ease

Checking users permissions is a crucial part of many web projects. A single mistake can be devastating, leading to major data leaks or harmful consequences.

This is why being able to easily create, maintain and test the permissions of your web application is extremely important: it should be as simple as possible to do it, because of how important it is. Moreover, the tests covering the permissions and the security of your application should cover 100% of the possible cases: a single missing case can lead to serious issues.

That’s where a framework such as Symfony can help. In Symfony, the user system and the associated permissions are handled by the Security component, which creates a extensible context for your checks.

The Security component

The Symfony Security component contains a lot of features and I won’t go in details here. However, it’s important to understand the global architecture of this component:

If you want to learn more about the component, have a look at the official documentation.

When the component processes a request, there are two main steps:

  • the authentication tries to find the current user given the request (using a JWT token, a PHP session cookie, a username/password combination, etc.).
  • the authorization checks whether the current user is allowed to access the requested resource (and returns an 403 if it’s not).

I would like to introduce to you how to completely customize the authorization step by using voters.

Symfony Security voters

Security voters are a way for the Security component to delegate the check of permissions to your application. Using voters, your application will be able to handle custom actions, on custom entities, with custom logic.

Basically, the idea behind a voter is to allow the following:

namespace App\Controller;

class ProjectController
{
public function edit(Project $project)
{
$this->denyAccessUnlessGranted('edit', $project);
}
}

In these few lines of code, the Security component will call all the declared voters, checking whether or not they support the given object ($project) with for this specific attribute (edit). If they do support it, the component will ask them whether or not the user is allowed to edit the project.

You can declare voters by creating a simple class implementing the Security component VoterInterface. However, it’s much easier to extend the Voter class directly:

namespace App\Security;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProjectVoter extends Voter
{
public const EDIT = 'edit';

private const ATTRIBUTES = [
self::EDIT,
];

protected function supports($attribute, $subject)
{
return $subject instanceof Project
&& in_array($attribute, self::ATTRIBUTES);
}

/**
*
@param string $a
*
@param Project $p
*
@param TokenInterface $t
*
*
@return bool
*/
protected function voteOnAttribute(
$attribute,
$project,
TokenInterface $token
) {
switch ($attribute) {
case self::EDIT:
return $this->isOwner($token->getUser(), $project);
}

throw new \LogicException('Invalid attribute: '.$attribute);
}

private function isOwner(?User $user, Project $project)
{
if (!$user) {
return false;
}

return $user->getId() === $project->getOwner()->getId();
}
}

This voter declares how to handle the attribute “edit” for the Project entity. More precisely, it votes on an attribute: it gives the Security component information about whether or not this entity should be accessible for the given user, with the given action. If the voteOnAttribute method returns false, the entity is not accessible by the current user. Otherwise, it is.

In my opinion, voters are one of the most well designed part of Symfony: they are a powerful extension point of the framework, they are simple to understand and they can easily be unit tested.

As I explained in my previous article about Tips for a reliable and fast test suite, using unit tests where possible is always a good idea. In this specific case, voters are really easy to unit test because of how simple they are:

namespace App\Tests\Security;

class ProjectVoterTest extends TestCase
{
private function createUser(int $id): User
{
$user = $this->createMock(User::class);
$user->method('getId')->willReturn($id);

return $user;
}

public function provideCases()
{
yield 'anonymous cannot edit' => [
'edit',
new Project($this->createUser(1)),
null,
Voter::ACCESS_DENIED
];

yield 'non-owner cannot edit' => [
'edit',
new Project($this->createUser(1)),
$this->createUser(2),
Voter::ACCESS_DENIED
];

yield 'owner can edit' => [
'edit',
new Project($this->createUser(1)),
$this->createUser(1),
Voter::ACCESS_GRANTED
];
}

/**
*
@dataProvider provideCases
*/
public function testVote(
string $attribute,
Project $project,
?User $user,
$expectedVote
) {
$voter = new ProjectVoter();

$token = new AnonymousToken('secret', 'anonymous');
if ($user) {
$token = new UsernamePasswordToken(
$user, 'credentials', 'memory'
);
}

$this->assertSame(
$expectedVote,
$voter->vote($token, $project, [$attribute])
);
}
}

This unit test can be easily extended: by adding more cases, you can cover all the possible scenarios for each role, action and entity of your application. This allows you to ensure your voter handle all the cases properly, while still being extremely fast as it’s a unit test!