Des champs de formulaire Symfony sécurisés par vos données avec Symfony

Dans le chapitre précédent, nous avons vu comment ajouter de la sécurité sur un champs de formulaire en fonction des rôles de l’utilisateur. Cependant la gestion de la sécurité peut être plus complexe. En effet, il est commun d’avoir besoin de l’objet (Article dans notre exemple) pour prendre une décision.

Dans ce chapitre, nous verrons comment autoriser l’édition du titre de l’article en fonction des rôles de l’utilisateur, mais aussi en fonction de la catégorie de l’article.

La règle de gestion est très simple : un utilisateur aura le droit d’éditer le titre d’un article de la catégorie PHP si il a le rôle ROLE_ARTICLE_CATEGORY_PHP. Nous pouvons formuler cette règle de manière plus générique :

Un utilisateur a le droit d’éditer le titre d’un article de la catégorie XXX si il a lui-même le rôle ROLE_ARTICLE_CATEGORY_XXX.

Mise à jour du modèle

On reprend les bases du chapitre un et on commence par ajouter la colonne category à l’entité Article. Dans un soucis de simplicité, nous allons hard-coder les différentes valeurs possible :

class Article
{
    const CATEGORIES = [
        'Default',
        'PHP',
        'Golang',
        'Ops',
    ];

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $category;

    // ...
}

L’entité Admin ayant déjà une propriété roles, nous avons juste à mettre à jour les fixtures:

class AppFixtures extends Fixture
 {
     public function load(ObjectManager $manager)
     {
         // ...

         $admin = new Admin();
         $admin->setName('alice');
-        $admin->setRoles(['ROLE_EDITOR']);
+        $admin->setRoles(['ROLE_EDITOR', 'ROLE_ARTICLE_CATEGORY_PHP']);
         $manager->persist($admin);

         // ...
     }
 }

Création d’un voter

Les voters sont très utiles pour vérifier les permissions d’un utilisateur.

Je ne sais pas pourquoi, mais j’ai pu remarquer au travers des formations ou des missions d’expertise chez des clients que les voters sont souvent méconnus des développeurs. Pourtant très simple à mettre en place et à tester, ils sont une alternative très puissante aux ACLs. Pour ma part, je n’ai jamais eu besoin d’avoir recours aux ACLs, et tant mieux car ce composant a été supprimé dans la version 4 de Symfony.

Nous allons implémenter un ArticleVoter qui aura la responsabilité de donner accès ou non à l’édition d’un article :

namespace App\Security\Voter;

use App\Entity\Article;
use App\Security\RoleMapping;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;

class ArticleVoter extends Voter
{
    protected function supports($attribute, $subject)
    {
        return \in_array($attribute, ['ARTICLE_EDIT'], true)
            && $subject instanceof Article;
    }

    protected function voteOnAttribute($attribute, $article, TokenInterface $token)
    {
        $user = $token->getUser();
        if (!$user instanceof UserInterface) {
            return false;
        }

        if ('ARTICLE_EDIT' === $attribute) {
            // ADMIN can do anything
            if (\in_array('ROLE_ADMIN', $user->getRoles(), true)) {
                return true;
            }

            $roleNeed = RoleMapping::ARTICLE[$article->getCategory()] ?? false;
            if (\in_array($roleNeed, $user->getRoles(), true)) {
                return true;
            }

            return false;
        }

        return false;
    }
}

Ici, nous utilisons la classe RoleMapping pour avoir une association entre la catégorie d’un article et le rôle nécessaire pour l’éditer :

namespace App\Security;

class RoleMapping
{
    const ARTICLE = [
        'Default' => 'ROLE_ARTICLE_CATEGORY_DEFAULT',
        'PHP' => 'ROLE_ARTICLE_CATEGORY_PHP',
        'Golang' => 'ROLE_ARTICLE_CATEGORY_GOLANG',
        'Ops' => 'ROLE_ARTICLE_CATEGORY_OPS',
    ];
}

Et voici le test unitaire de la classe Voter :

namespace App\Tests\Security\Voter;

use App\Entity\Admin;
use App\Entity\Article;
use App\Security\Voter\ArticleVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

class ArticleVoterTest extends TestCase
{
    private $voter;

    protected function setUp()
    {
        $this->voter = new ArticleVoter();
    }

    public function testVoteOnSometingElse()
    {
        $token = $this->prophesize(TokenInterface::class);

        $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $this->voter->vote($token->reveal(), null, ['FOOBAR']));
    }

    public function testVoteWhenNotConnected()
    {
        $article = new Article(new Admin());
        $token = $this->prophesize(TokenInterface::class);

        $this->assertSame(VoterInterface::ACCESS_DENIED, $this->voter->vote($token->reveal(), $article, ['ARTICLE_EDIT']));
    }

    public function provideVoteTests()
    {
        $admin = new Admin();
        $admin->setRoles([]);
        $article = new Article(new Admin());
        $article->setCategory('PHP');
        yield 'admin without role can not edit' => [VoterInterface::ACCESS_DENIED, $admin, $article];

        $admin = new Admin();
        $admin->setRoles(['ROLE_ADMIN']);
        $article = new Article(new Admin());
        $article->setCategory('PHP');
        yield 'ROLE_ADMIN can edit everything' => [VoterInterface::ACCESS_GRANTED, $admin, $article];

        $admin = new Admin();
        $admin->setRoles(['ROLE_ARTICLE_CATEGORY_PHP']);
        $article = new Article(new Admin());
        $article->setCategory('PHP');
        yield 'ROLE_ARTICLE_CATEGORY_PHP can edit PHP article' => [VoterInterface::ACCESS_GRANTED, $admin, $article];

        $admin = new Admin();
        $admin->setRoles(['ROLE_ARTICLE_CATEGORY_PHP']);
        $article = new Article(new Admin());
        $article->setCategory('Golang');
        yield 'ROLE_ARTICLE_CATEGORY_PHP can not edit Golang article' => [VoterInterface::ACCESS_DENIED, $admin, $article];
    }

    /** @dataProvider provideVoteTests */
    public function testVote(int $expected, Admin $admin, Article $article)
    {
        $token = new UsernamePasswordToken($admin, 'password', 'provider_key', $admin->getRoles());

        $this->assertSame($expected, $this->voter->vote($token, $article, ['ARTICLE_EDIT']));
    }
}

C’est un test unitaire assez classique, mais je pense qu’il est intéressant car il permet de mettre en avant différents points :

  • L’utilisation de la méthode setUp, qui permet de regrouper dans une méthode la création du SUT ;
  • L’annotation @dataProvider qui permet de fournir des jeux de tests ;
  • L’utilisation du mot clé yield, qui est plus lisible (et plus hype 😎) qu’un tableau ;
  • L’utilisation d’une clé (devant yield) qui permet de nommer les tests du dataprovider ;
  • L’utilisation de la librairie Prophecy pour la gestion des mocks, qui est plus lisible, simple (et plus hype) que celle de PHPUnit ;
  • La simplicité de mise en place d’un test sur les voters.

Mise à jour de l’extension de formulaire

L’AuthorizationChecker s’utilise de la façon suivante :

$authorizationChecker->isGranted($attribute, $subject);

$attribute est une string (ici ARTICLE_EDIT) et $subject peut être n’importe quoi (ici une instance de Article).

C’est la SecurityExtension qui va devoir récupérer l’article pour le passer à lAuthorizationChecker. Le composant Form utilise un composant nommé PropertyAccess pour accéder aux propriété de vos objets (entité, DTO, …). C’est naturellement ce composant que nous allons utiliser pour naviguer dans le graph d’objet pour récupérer l’article.

Le PropertyAccessor a besoin d’un propertyPath qui correspond au nom de la propriété (ou à une succession de propriétés : $foo->bar->baz). L’extension doit alors définir une nouvelle option is_granted_subject_path :

class SecurityExtension extends AbstractTypeExtension
{
    private $authorizationChecker;
    private $propertyAccessor;

    public function __construct(AuthorizationCheckerInterface $authorizationChecker, PropertyAccessorInterface $propertyAccessor)
    {
        $this->authorizationChecker = $authorizationChecker;
        $this->propertyAccessor = $propertyAccessor;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'is_granted_attribute' => null,
            'is_granted_subject_path' => null,
            'is_granted_hide' => false,
            'is_granted_disabled' => false,
        ]);
    }
}

Il suffit de mettre à jour la méthode isGranted de l’extension pour utiliser cette nouvelle option :

class SecurityExtension extends AbstractTypeExtension
{
    private function isGranted(array $options, FormInterface $form)
    {
        if (!$options['is_granted_attribute']) {
            return true;
        }

        $subject = null;

        if ($options['is_granted_subject_path']) {
            $subject = $this->propertyAccessor->getValue($form, $options['is_granted_subject_path']);
        }

        if ($this->authorizationChecker->isGranted($options['is_granted_attribute'], $subject)) {
            return true;
        }

        return false;
    }
}

Nous pouvons voir que la valeur de départ du PropertyAccessor est $form. C’est une instance de FormInterface qui correspond au type qui a l’option is_granted_attribute.

Cela implique qu’il faut connaître l’API public de FormInterface pour naviguer dedans et retrouver article. Cependant, dans la majorité des cas il suffira de « remonter » à la racine du form pour ensuite récupérer la donnée :

parent[.parent].data

dump($form);die; fera très bien l’affaire en dev pour retrouver ses petits.

Utilisation

Pour utiliser cette nouvelle option, il faut mettre à jour la classe ArticleType :

class ArticleType extends AbstractType
{
    private $tokenStorage;

    public function __construct(TokenStorageInterface $tokenStorage)
    {
        $this->tokenStorage = $tokenStorage;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', TextType::class, [
                'is_granted_disabled' => $options['is_granted_disabled'],
                'is_granted_attribute' => 'ARTICLE_EDIT',
                'is_granted_subject_path' => 'parent.data',
            ])
            // ..
        ;
    }
}

Et Voilà !

Bonus : ça marche même avec EasyAdmin :

easy_admin:
    entities:
        Article:
            form:
                fields:
                    -   property: title
                        type_options:
                            is_granted_attribute: ARTICLE_EDIT
                            is_granted_subject_path: parent.data

Conclusion

Encore une fois le composant de formulaire, grâce notamment aux FormExtensions, permet de changer facilement le comportement d’un formulaire. Il est primordial de bien connaître ce point d’extension de Symfony dès que l’on veut réaliser des formulaires avec des règles métiers complexes.

Vous pouvez aussi retrouver tout le code de ce deuxième chapitre sur Github.

Nos formations sur le sujet

  • Symfony avancée

    Décou­vrez les fonc­tion­na­li­tés et concepts avan­cés de Symfo­ny

blog comments powered by Disqus