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 leUser. 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
| Besoin | Outil |
|---|---|
| « 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 section | rô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 appelezisGranted('CANCEL', $booking)partout (contrôleur, API, Twig) et la logique vit à un endroit — bien mieux que desif (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_ADMIN ⊃ ROLE_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 ?
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.