Doctrine filters and annotations to improve security and ease development in a Symfony app
A user should only see his orders, his messages and so on, but should never see others’ data. But it probably happened that you forgot at some place to add this little WHERE condition that restricts the user to what he should see, in a Symfony param converter for instance.
I’m going to introduce an elegant and automatic solution to never forget these conditions in all of your queries, whatever the table (i.e. the Doctrine entity) and whatever the page in your Symfony application.
To do so, we are going to use Doctrine filters and annotations.
This way, you will enhance security, make your code easier to read (no need to create specific queries) and implementing new features is faster.
2015/04/23 note: this article has been updated for Symfony 2.6+
Suppose we have a User
entity and an Order
entity related to the User
one. A user should only see his orders and no others’ ones.
<?php
/** @Entity **/
class User
{
// ...
}
/** @Entity **/
class Order
{
// ...
/**
* @ManyToOne(targetEntity="User")
* @JoinColumn(name="user_id", referencedColumnName="id")
**/
private $user;
}
The whole idea is that any query on the order
table should add a WHERE user_id = :user_id
condition.
Step 1. Creation of a custom annotation for our restricted entities
We are going to create a new annotation for Doctrine entities.
The purpose of this annotation is twofold:
- Serve as a marker on the entity to tell Doctrine to automatically add an extra condition on the user
- Provide the Doctrine filter the name of the relation (the user field) to use.
Create an annotation class that will allow to tell the name of the “user” field:
<?php
namespace Acme\DemoBundle\Annotation;
use Doctrine\Common\Annotations\Annotation;
/**
* @Annotation
* @Target("CLASS")
*/
final class UserAware
{
public $userFieldName;
}
Step 2. Use of the annotation on the Order entity
Let’s mark the Order
entity as a “user aware” one.
<?php
namespace Acme\DemoBundle\Entity;
use Acme\DemoBundle\Annotation\UserAware;
/**
* Order entity
*
* @UserAware(userFieldName="user_id")
*/
class Order { ... }
This is where all the magic goes: by only marking the entity this way, all queries on it (and even when the entity is used in joined queries) will add the WHERE condition.
Step 3. Creation of a Doctrine filter class
We are going to create and configure a Doctrine filter. More information can be found on the Doctrine ORM documentation.
<?php
namespace Acme\DemoBundle\Filter;
use Doctrine\ORM\Mapping\ClassMetaData;
use Doctrine\ORM\Query\Filter\SQLFilter;
use Doctrine\Common\Annotations\Reader;
class UserFilter extends SQLFilter
{
protected $reader;
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
{
if (empty($this->reader)) {
return '';
}
// The Doctrine filter is called for any query on any entity
// Check if the current entity is "user aware" (marked with an annotation)
$userAware = $this->reader->getClassAnnotation(
$targetEntity->getReflectionClass(),
'Acme\\DemoBundle\\Annotation\\UserAware'
);
if (!$userAware) {
return '';
}
$fieldName = $userAware->userFieldName;
try {
// Don't worry, getParameter automatically quotes parameters
$userId = $this->getParameter('id');
} catch (\InvalidArgumentException $e) {
// No user id has been defined
return '';
}
if (empty($fieldName) || empty($userId)) {
return '';
}
$query = sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId);
return $query;
}
public function setAnnotationReader(Reader $reader)
{
$this->reader = $reader;
}
}
Step 4. Configure the Doctrine filter
Enable the filter in app/config/config.yml
:
doctrine:
orm:
filters:
user_filter:
class: Acme\DemoBundle\Filter\UserFilter
enabled: true
Add a listener for every request that initializes the Doctrine filter with the current user in your bundle services declaration file (AcmeDemoBundle/Resources/config/services.yml
):
services:
acme_demo.doctrine.filter.configurator:
class: Acme\DemoBundle\Filter\Configurator
arguments:
- "@doctrine.orm.entity_manager"
- "@security.token_storage"
- "@annotation_reader"
tags:
- { name: kernel.event_listener, event: kernel.request }
Or if you use XML format (services.xml
):
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="acme_demo.doctrine.filter.configurator" class="Acme\DemoBundle\Filter\Configurator">
<argument type="service" id="doctrine.orm.entity_manager"/>
<argument type="service" id="security.token_storage"/>
<argument type="service" id="annotation_reader"/>
<tag name="kernel.event_listener" event="kernel.request" />
</service>
</services>
</container>
Implement the configurator class:
<?php
namespace Acme\TestBundle\Filter;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Common\Annotations\Reader;
class Configurator
{
protected $em;
protected $tokenStorage;
protected $reader;
public function __construct(ObjectManager $em, TokenStorageInterface $tokenStorage, Reader $reader)
{
$this->em = $em;
$this->tokenStorage = $tokenStorage;
$this->reader = $reader;
}
public function onKernelRequest()
{
if ($user = $this->getUser()) {
$filter = $this->em->getFilters()->enable('user_filter');
$filter->setParameter('id', $user->getId());
$filter->setAnnotationReader($this->reader);
}
}
private function getUser()
{
$token = $this->tokenStorage->getToken();
if (!$token) {
return null;
}
$user = $token->getUser();
if (!($user instanceof UserInterface)) {
return null;
}
return $user;
}
}
Step 5. We’re done!
From now on, any query involving the Order entity will have a user_id = :user_id
condition, be it a select, an inner join and so on.
For any other entity that should be restricted to the connected user, just add the @UserAware
annotation on it and all queries will automatically appended with the a WHERE condition.
Your controllers can now look like this:
<?php
namespace Acme\DemoBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
class OrderController
{
/**
* Show order action
*
* @param Order $order
* @Template
*/
public function showAction(Order $order)
{
return ['order' => $order];
}
}
Any of the following queries will automatically add the WHERE condition (let’s suppose that the connected user had the 12345
id):
// SELECT * FROM order WHERE id = 1 AND user_id = 12345
$this->em->getRepository('DemoAcmeBundle:Order')->find(1);
// SELECT *
// FROM order_product op
// INNER JOIN order ON (op.order_id = o.id AND o.user_id = 12345)
// WHERE o.id = 987
$this->em->getRepository('DemoAcmeBundle:OrderProduct')
->createQueryBuilder('op')
->innerJoin('op.order', 'o')
->where('o.id = :order_id')
->setParameter('order_id', 987)
;