How to Mix Security and Form with Symfony

In some applications, it could be required to disable some form fields depending on user’s roles.

In this article, we will see how to implement this feature thanks to a simple example: a blog engine.

Introduction & application’s bootstrap

To save time, we will use Symfony 4.1, Symfony Flex and the MakerBundle. All the code is published on Github. You can clone the repository to follow each steps.

Thanks to a few commands and a few files edit, we build a working blog in few minutes:

composer create-project symfony/website-skeleton form-and-security
cd form-and-security
composer req maker
bin/console make:entity Article
# We add `title`, and `content`
bin/console make:entity Admin
# We add `name`
bin/console make:entity Article
# We add the relationship `author` between Article and Admin
bin/console doctrine:database:create
bin/console make:migration
bin/console doc:migration:migrate
bin/console make:crud Admin
bin/console make:crud Article

Now we can setup the Security Component. To be simple, all users will have the same password: password. They will login with a form.

To keep this article light, we will not detail this feature here. But you can browse the diff on Github

Implementation

Only admins with role ROLE_ADMIN are allowed to update the title of the article.

Admins with role ROLE_EDITOR are allowed to update the content of the article.

In order to get a nice UX, we choose to keep the field in the form, but to disable it. Visually, this one will be grayed but visible.

For the DX, we want to configure the visibility with an option on the type:

class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', TextType::class, [
                'is_granted_attribute' => 'ROLE_ADMIN',
            ])
            // ...
        ;
    }
}

To do that, we need to create a form extension.

As the option is_granted_attribute will be available on all Types, the extension should extend FormType. Indeed, each Types extends (at some point) FormType.

namespace App\Form\Extension;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;

class SecurityExtension extends AbstractTypeExtension
{
    private $authorizationChecker;

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

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

    public function getExtendedType()
    {
        return FormType::class;
    }
}

We can now develop the most interesting part: disabling a widget according to user’s roles.

class SecurityExtension extends AbstractTypeExtension
{
    public function finishView(FormView $view, FormInterface $form, array $options)
    {
        if ($this->isGranted($options)) {
            return;
        }

        $this->disableView($view);
    }

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

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

        return false;
    }

    private function disableView(FormView $view)
    {
        $view->vars['attr']['disabled'] = true;

        foreach ($view as $child) {
            $this->disableView($child);
        }
    }
}

If the logged user doesn’t have the role contained in the is_granted_attribute option, then the widget will be disabled. This means that the widget will get the disable="disabled" HTML attribute, and in a recursive way.

However, we just open a security breach. IF a malicious user delete the HTML attribute, they will be able to update the value. To mitigate this breach, we should not use the data provided by the user. A Listener will do the job:

class SecurityExtension extends AbstractTypeExtension
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if (!$options['is_granted_attribute']) {
            return;
        }

        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
            if ($this->isGranted($options)) {
                return;
            }

            $event->setData($event->getForm()->getViewData());
        });
    }
}

Et Voilà! Quite easy to do, isn’t it?

Alternative way to do

There is an alternative way to do that. It relies on an not-so-known option of the OptionResolver.

class SecurityExtension extends AbstractTypeExtension
{
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'is_granted_attribute' => null,
            'disabled' => function (Options $options) {
                    return $this->isGranted($options);
            },
        ]);
    }

Here, disabled option is lazily computed and can depends on others options. So there are no need for finishView, disableView, and buildForm methods.

The code is really simpler, smaller! You may wonder: « But Greg, why did you choose complexity? ». Actually, this code is less flexible and will forbid us to implement the next chapter. 😜

Remove the widget, do not disable it

In some cases, like sensitive data, it can be better to remove the widget instead of disabling it. We will add a new option: is_granted_hide to do that. Thus, we will use it in the buildForm method.

class SecurityExtension extends AbstractTypeExtension
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        if (!$options['is_granted_attribute']) {
            return;
        }

        if ($options['is_granted_hide']) {
            $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
                if ($this->isGranted($options)) {
                    return;
                }

                $form = $event->getForm();

                $form->getParent()->remove($form->getName());
            });
        } else {
            $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) {
                if ($this->isGranted($options)) {
                    return;
                }

                $event->setData($event->getForm()->getViewData());
            });
        }
    }

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

What about new articles?

The title is mandatory. As Alice is not allowed to update the title of an article, she can not create new articles. Damn It! In some situations, we want to disable theses protections.

To fix this issue, we will add a new option: is_granted_disabled. If its value is true, there is nothing to do.

You can find the diff of this feature on Github.

Conclusion

Thanks to FormExtensions (among other things), The Form Component is really powerful and extensible. It is very important to know this extension point as soon as you want to achieve forms with complex business rules.

You can browse the code of this chapter on Github.

In the next episode, we will see how to use the current article to grant access or not.

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