Skip to Content

Chapitre 10.2 — Rôles & voters

Partie 10 : Sécurité — Chapitre 2/6 Version de référence : Symfony 7.4.

⏱️ TL;DR

  • Un rôle est une chaîne (ROLE_ADMIN, ROLE_PROVIDER) portée par le User. La hiérarchie de rôles fait qu’un rôle en implique d’autres.
  • Autorisation grossière : access_control (par chemin) et #[IsGranted('ROLE_ADMIN')] (par contrôleur/action).
  • Autorisation fine : un voter répond à « cet utilisateur peut-il faire cette action sur cet objet ? » (ex. « annuler CETTE réservation »).
  • On vérifie avec isGranted('EDIT', $booking) / denyAccessUnlessGranted(...) / #[IsGranted('EDIT', 'booking')] / is_granted() en Twig.
  • Les voters sont autoconfigurés (Partie 5.3) : créer la classe suffit.
  • Pour un dev Next.js : les rôles ≈ un contrôle grossier (RBAC), les voters ≈ une policy par ressource (« l’owner ou un admin »), centralisée et testable.

🎯 Objectifs

  • Attribuer des rôles et configurer une hiérarchie.
  • Protéger des chemins et des actions par rôle.
  • Écrire un voter pour des permissions fines sur un objet.
  • Vérifier les droits en contrôleur et en Twig.

1. Les rôles

Un User porte une liste de rôles (des chaînes commençant par ROLE_). On les stocke en base (souvent une colonne JSON) :

#[ORM\Column] private array $roles = []; public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; // tout utilisateur connecté a ROLE_USER return array_unique($roles); }

Hiérarchie de rôles

Pour éviter de lister tous les rôles partout, on définit une hiérarchie : un rôle en implique d’autres.

# config/packages/security.yaml security: role_hierarchy: ROLE_PROVIDER: ROLE_USER ROLE_ADMIN: [ROLE_PROVIDER, ROLE_USER] ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

Ainsi, un ROLE_ADMIN est aussi ROLE_PROVIDER et ROLE_USER — on n’assigne que le rôle le plus haut.

💡 Pour un dev Next.js : c’est du RBAC (Role-Based Access Control) classique. La hiérarchie évite les listes de rôles à rallonge. Mais les rôles seuls sont grossiers (« est admin ») ; pour « peut modifier CET objet », il faut des voters (§3).

2. Autorisation grossière : chemins et actions

Par chemin (access_control, vu au chapitre 10.1) :

access_control: - { path: ^/admin, roles: ROLE_ADMIN }

Par action avec l’attribut #[IsGranted] (sur la classe ou la méthode) :

use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('ROLE_ADMIN')] #[Route('/admin/providers', name: 'admin_provider_index')] public function index(): Response { /* réservé aux admins */ }

Ou impérativement dans le code :

$this->denyAccessUnlessGranted('ROLE_ADMIN'); // lève une 403 si pas admin

⚠️ Piège « défense en profondeur » : ne vous reposez pas uniquement sur access_control (par URL). Une action sensible doit aussi être protégée au niveau du contrôleur (#[IsGranted]) ou du voter. Si une route change de chemin ou qu’une nouvelle route touche la même logique, la protection par URL peut être contournée. Empilez les couches : URL + action + voter.

3. Les voters : l’autorisation fine

Un voter répond à une question contextuelle : « cet utilisateur peut-il EDIT / CANCEL / VIEW cet objet ? ». C’est indispensable pour des règles comme « seul le propriétaire de la réservation (ou un admin) peut l’annuler ».

// src/Security/Voter/BookingVoter.php declare(strict_types=1); namespace App\Security\Voter; use App\Entity\{Booking, User}; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; final class BookingVoter extends Voter { public const VIEW = 'VIEW'; public const CANCEL = 'CANCEL'; protected function supports(string $attribute, mixed $subject): bool { return in_array($attribute, [self::VIEW, self::CANCEL], true) && $subject instanceof Booking; } protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); if (!$user instanceof User) { return false; // non authentifié } /** @var Booking $booking */ $booking = $subject; // un admin peut tout if (in_array('ROLE_ADMIN', $user->getRoles(), true)) { return true; } return match ($attribute) { self::VIEW => $booking->getUser() === $user, // voir : le propriétaire self::CANCEL => $booking->getUser() === $user && $booking->getStatus() !== BookingStatus::Cancelled, default => false, }; } }

Un voter implémente deux méthodes : supports() (ce voter est-il concerné par cet attribut + ce type d’objet ?) et voteOnAttribute() (la décision). Il est autoconfiguré (Partie 5.3) : créer la classe suffit, aucune config.

4. Utiliser le voter

En contrôleur :

#[Route('/api/bookings/{id}/cancel', methods: ['POST'])] public function cancel(Booking $booking, BookingCanceller $canceller): Response { $this->denyAccessUnlessGranted('CANCEL', $booking); // → le voter décide (403 sinon) $canceller->cancel($booking); return new Response(null, 204); }

Ou via l’attribut, avec le sujet nommé :

#[IsGranted('CANCEL', 'booking')] // 'booking' = le nom de l'argument public function cancel(Booking $booking): Response { /* ... */ }

En Twig (pour n’afficher un bouton que si autorisé) :

{% if is_granted('CANCEL', booking) %} <form method="post" action="{{ path('booking_cancel', { id: booking.id }) }}"> <button>Annuler</button> </form> {% endif %}

⚠️ Piège : cacher un bouton en Twig (is_granted) n’est pas une sécurité — c’est du confort d’UI. La vraie protection est côté serveur (denyAccessUnlessGranted/#[IsGranted] dans le contrôleur). Un utilisateur peut toujours forger la requête. Toujours vérifier dans le contrôleur ; l’affichage conditionnel n’est qu’un plus.

5. Rôles vs voters : quand quoi

BesoinOutil
« réservé aux admins » (zone entière)rôle (access_control, #[IsGranted('ROLE_ADMIN')])
« le propriétaire OU un admin » (sur un objet)voter (isGranted('EDIT', $obj))
Règle métier (« pas si déjà annulée »)voter
Contrôle grossier par sectionrôle

Règle simple : rôle pour du grossier (par zone/section), voter dès que la décision dépend de l’objet ou d’une règle métier.

💡 Pour un dev Next.js : le voter est l’équivalent d’une policy (façon Laravel Policies, ou une fonction can(user, action, resource)), mais centralisée, autoconfigurée et testable en isolation. Vous appelez isGranted('CANCEL', $booking) partout (contrôleur, API, Twig) et la logique vit à un endroit — bien mieux que des if (user.id === booking.userId || user.isAdmin) éparpillés.

✏️ Exercices

Exercice 1. Écrivez la partie voteOnAttribute d’un ServiceVoter pour l’attribut EDIT : un service n’est modifiable que par son prestataire propriétaire ou un admin.

✅ Solution

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); if (!$user instanceof User) { return false; } /** @var Service $service */ $service = $subject; if (in_array('ROLE_ADMIN', $user->getRoles(), true)) { return true; } return $attribute === 'EDIT' && $service->getProvider()->getOwner() === $user; // le propriétaire du prestataire }

(+ supports() renvoyant vrai pour EDIT sur un Service.)

Exercice 2. Pourquoi {% if is_granted('CANCEL', booking) %} autour d’un bouton ne suffit-il pas à sécuriser l’annulation ?

✅ Solution

Parce que c’est purement côté affichage : cacher le bouton empêche le clic normal, mais un utilisateur peut forger la requête POST directement (curl, outils dev). La sécurité réelle doit être côté serveur dans le contrôleur : $this->denyAccessUnlessGranted('CANCEL', $booking) (ou #[IsGranted]). Le is_granted en Twig n’est qu’un confort d’UI ; il ne remplace pas la vérification serveur.

Exercice 3. Quand utiliser un rôle plutôt qu’un voter, et inversement ?

✅ Solution

Rôle : contrôle grossier par zone/section (« /admin réservé à ROLE_ADMIN »), indépendant d’un objet précis. Voter : décision fine qui dépend de l’objet (« le propriétaire de CETTE réservation ») ou d’une règle métier (« pas si déjà annulée »). En résumé : rôle pour « qui a accès à quoi de général », voter pour « peut-il agir sur CET objet précis ».

🧠 Quiz de révision

1. Qu’est-ce qu’un rôle et à quoi sert la hiérarchie de rôles ?

Une chaîne ROLE_* portée par le User. La hiérarchie fait qu’un rôle en implique d’autres (ROLE_ADMINROLE_USER), évitant de lister tous les rôles.

2. Quelles deux méthodes implémente un voter ?

supports($attribute, $subject) (ce voter est-il concerné ?) et voteOnAttribute(...) (la décision d’autorisation).

3. Comment vérifie-t-on un droit fin dans un contrôleur ?

$this->denyAccessUnlessGranted('CANCEL', $booking) (ou l’attribut #[IsGranted('CANCEL', 'booking')]) — le voter décide, 403 sinon.

4. Cacher un bouton avec is_granted en Twig sécurise-t-il l’action ?

Non : c’est du confort d’UI. La sécurité réelle est côté serveur (contrôleur), car une requête peut être forgée.

5. Rôle ou voter pour « le propriétaire ou un admin peut modifier cet objet » ?

Un voter : la décision dépend de l’objet (propriété) — un rôle seul ne suffit pas.


Prochain chapitre : 10.3 — Login form & hachage — l’entité User, le hachage des mots de passe et le formulaire de connexion.