Mutualiser les autorisations Symfony avec un voter générique
Publié le
Arrête de copier-coller tes règles de sécurité ! Découvre comment bâtir un voter réutilisable pour gérer astucieusement les droits d'accès de tes entités
Aujourd'hui, on s'attaque à une problématique récurrente sur nos projets Symfony : la gestion des permissions d'accès à nos entités. Il est courant de répéter des logiques similaires dans plusieurs voters pour différents objets, particulièrement lorsqu'on développe un SaaS. Pas très DRY tout ça !
L'objectif est de bâtir un voter générique pour mutualiser la gestion des autorisations proprement. Attention, l'idée n'est pas de créer un voter spaghetti qui tenterait de tout gérer, mais bien de standardiser un cas d'usage précis. On a très souvent besoin de vérifier si un utilisateur ou une organisation a le droit d'accéder à une ressource spécifique : c'est exactement ce scénario que nous allons automatiser.
Standardiser la déclaration des droits avec une interface
Dans une premier temps, nous devons donner à nos entités un moyen clair d'identifier qui peut y accéder. La meilleure façon de standardiser ce comportement est de définir une interface.
interface UserRestrictedResourceInterface
{
public function allowedUsers(): array;
}
Il suffit ensuite d'implémenter cette interface sur les entités concernées. La méthode allowedUsers() devra alors retourner la liste des utilisateurs disposant des droits d'accès pour la ressource en question.
#[ORM\Entity]
class Invoice implements UserRestrictedResourceInterface
{
// ...
public function allowedUsers(): array
{
return [$this->getCustomer()];
}
}
On peut même aller plus loin en combinant plusieurs sources de droits, comme ici avec le propriétaire et ses collaborateurs.
#[ORM\Entity]
class Project implements UserRestrictedResourceInterface
{
// ...
public function allowedUsers(): array
{
return [
$this->getOwner(),
...$this->getCollaborators()->toArray(),
];
}
}
Un voter unique pour piloter les autorisations
Il est maintenant temps de mettre en place notre voter :
final class UserRestrictedResourceVoter extends Voter
{
public const string ATTRIBUTE = 'USER_RESTRICTED_RESOURCE';
protected function supports(string $attribute, mixed $subject): bool
{
return self::ATTRIBUTE === $attribute && $subject instanceof UserRestrictedResourceInterface;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if ($token instanceof NullToken) {
return false;
}
$user = $token->getUser();
return in_array($user, $subject->allowedUsers());
}
}
- La méthode
supports()vérifie que l'attribut de la demande d'autorisation est correct et que le sujet implémente bien notre interface. - La méthode
voteOnAttribute()s'assure d'abord qu'un utilisateur est bien connecté. Elle vérifie ensuite si cet utilisateur figure dans le tableau des personnes autorisées renvoyé par l'entité via la méthodeallowedUsers().
Il ne reste plus qu'à mettre en place cette demande d'autorisation sur nos contrôleurs grâce à l'attribut #[IsGranted].
final class ProjectController extends AbstractController
{
#[Route('/project/{id}', name: 'project_view')]
#[IsGranted(UserRestrictedResourceVoter::ATTRIBUTE, subject: 'project')]
public function view(Project $project): Response
{
return $this->render('project/view.html.twig', [
'project' => $project,
]);
}
}
Enfin, cette logique est également accessible directement dans nos templates Twig grâce à la fonction is_granted().
{% if is_granted('USER_RESTRICTED_RESOURCE', project) %}
<a href="{{ path('project_view', {id: project.id}) }}">Voir le projet</a>
{% endif %}
Améliorations et limitations
Dans cet article, je propose une implémentation centrée sur l'utilisateur. Cependant, dans le cas d'une plateforme multi-organisations comme un SaaS, il peut être particulièrement intéressant de décliner cette logique. On pourrait ainsi imaginer un OrganizationRestrictedResourceVoter.
Pour l'instant, notre interface est binaire : soit l'utilisateur a l'accès, soit il ne l'a pas. Cependant, dans la réalité, les accès diffèrent souvent selon le type d'action (voir, éditer, supprimer). L'usage d'un flag par type d'action peut alors s'avérer utile. En passant l'attribut demandé directement à la méthode de notre interface, l'entité pourrait retourner une liste d'utilisateurs spécifique à chaque opération. Cela permettrait de conserver un voter générique tout en offrant une granularité fine sur les droits métier.
Pour plus de flexibilité, on pourrait implémenter un système de privilèges permettant à certains rôles de passer outre ces restrictions, comme pour permettre aux administrateurs d'accéder à l'ensemble des ressources. Cette mécanique peut s'envisager de deux manières : soit directement dans le voter pour des logiques globales, soit au travers d'une interface complémentaire à implémenter sur nos entités. Cette seconde approche permettrait à chaque ressource de définir dynamiquement le rôle minimal requis pour consulter l'ensemble de ses données.
Il faut toutefois garder quelques éléments en tête :
- Si le volume d'utilisateurs par ressource devient trop important, une approche plus optimisée sera nécessaire, notamment au niveau du chargement des collections.
- Le contrôle d'accès étant une partie extrêmement sensible, l'implémentation de tests automatisés est ici fortement recommandée.
- Ce système doit rester simple et générique : pour des cas spécifiques ou des règles métier complexes, l'implémentation de voters dédiés utilisés en complément de ce système restera nécessaire.
Conclusion
Cette approche, bien qu’elle ne résolve pas tous les problèmes de l’univers, a le mérite de simplifier et d’industrialiser des cas récurrents. Les quelques pistes d’amélioration permettent d’ailleurs de couvrir une grande majorité des situations. Depuis que j’utilise des variantes de ce système, j’ai drastiquement réduit le nombre de voters nécessaires sur mes projets.
Je suis curieux d’avoir tes retours d’expérience et tes méthodes sur la gestion des autorisations. N’hésite pas à nous rejoindre sur Discord pour échanger sur le sujet.
À bientôt sur ce blog ou en live sur Twitch 👋.