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 roleROLE_ARTICLE_CATEGORY_XXX
Section intitulée update-the-modelUpdate 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);
// ...
}
}
Section intitulée voterVoter
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.
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 namedataprovider
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.
Section intitulée form-extension-updateForm 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
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.
Section intitulée usageUsage
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
Section intitulée conclusionConclusion
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.
Commentaires et discussions
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Après avoir monté une nouvelle équipe de développement, nous avons procédé à la migration de toute l’infrastructure technique sur une nouvelle architecture fortement dynamique à base de Symfony2, RabbitMQ, Elasticsearch et Chef. Les gains en performance, en stabilité et en capacité de développement permettent à l’entreprise d’engager de nouveaux marchés…
La nouvelle version du site naissance.fr développée s’appuie sur Symfony 2 et Elasticsearch. Cette refonte propose un tunnel d’achat spécialement développé pour l’application. Aujourd’hui, le site est équipé d’une gestion d’un mode d’envoi des faire-parts différé, de modification des compositions après paiement et de prise en charge de codes promotionnels…
JoliCode continue d’accompagner les équipes web d’Afflelou en assurant la maintenance des différentes applications constituant la plateforme Web B2C. Nous mettons en place des bonnes pratiques avec PHPStan et Rector, procédons à la montée de version de PHP et Symfony, optimisons le code grâce au profiling, et collaborons avec l’infogéreur pour les…