How to Use Your Data in a Form Security Extension

Previously on we saw how to add security on a form widget. However the process to grant or deny access could only rely on the user’s roles. But in real life project, the access decision may depend on more than just the user’s roles. For example, it can depend on the current Article.

In this chapter, we will see how to grant editing access to a user according to its own roles but also with the article category.

The business rule is simple: a user can edit the title of an article about PHP if he has the role ROLE_ARTICLE_CATEGORY_PHP. In a more generic way:

a user can edit the title of an article in the category XXX if he has the role ROLE_ARTICLE_CATEGORY_XXX

Update the model

We start by adding the category column in the entity Article. For the sake of simplicity, we will hard-code all categories:


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

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

    // ...
}

As the Admin entity has a roles property/column, we can only edit our 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);

         // ...
     }
 }

Voter

Voters are very useful to control user permissions.

I don’t really know why, but I have noticed (during trainings, coaching, conferences) that many dev don’t know about voters. Yet really easy to setup, implement, and they are very powerful alternatives to ALCs. I never needed to use ACLs, and so much the better because this component [was removed in the version 4 of Symfony](https://symfony.com/doc/current/security/acl.html).

So we are going to implement an ArticleVoter who will be responsible to edit the article:


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

For the sake of simplicity (yes again, I do love simplicity), we are using a RoleMapping class to get a mapping between article categories and needed role to edit them.


 'ROLE_ARTICLE_CATEGORY_DEFAULT',
        'PHP' => 'ROLE_ARTICLE_CATEGORY_PHP',
        'Golang' => 'ROLE_ARTICLE_CATEGORY_GOLANG',
        'Ops' => 'ROLE_ARTICLE_CATEGORY_OPS',
    ];
}

An here is the unit test:


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']));
    }
}

Such test is quite classic. But I think it could be interesting to describe some features that could be unknown:

  • The use of method setUp, which allow to gather the SUT setup;
  • The annotation @dataProvider which allow to run the same test with different data;
  • The use of the yield keyword, which is more readable (and more hype) than an array;
  • The use of the key (before yield) which allow to name dataprovider tests;
  • The use of Prophecy library to build mock. It’s easier, readable than the original one in PHPUnit;
  • The simplicity of a Voter test.

Form Extension Update

The AuthorizationChecker is used like following:

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

Where $attribute is a string (here ARTICLE_EDIT) and $subject could be anything (here and Article instance).

The SecurityExtension will have to grab the article for giving it to the AuthorizationChecker. The Form component is using another component called [Property Access](https://symfony.com/doc/current/components/property_access.html) to grab properties from your objects (entity, DTO, …). That’s why we’ll use it to navigate through the object graph and grab the article.

The PropertyAccessor needs a propertyPath which corresponds to the property name (or a chain of properties : $foo->bar->baz). So the extension need a new 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,
        ]);
    }
}

And now we can simply update our isGranted method to use this new 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;
    }
}

The PropertyAccessor starts with $form. This is an instance of FormInterface who corresponds to the type who have the is_granted_attribute option.

This means you should know the FormInterface public API to navigate into and find the article. However, in most of cases, you will need to go upper into the form and then to get the data:

parent[.parent][.parent][.parent].data

dump($form);die; is really useful in such situation.

Usage

To use this new shinny option, we need to update our ArticleType class.


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: it works really well with EasyAdmin too:

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

Conclusion

Again, FormExtension is really powerful, and must be known in order to reduce code duplication.

You can grab and play with the code hosted on 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