Michaël Perrin

Sustainable web development.

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)
;

Michaël Perrin

Read more posts by this author.

comments powered by Disqus