Chapitre 9.4 — Voters & contrôle d’accès
⏱️ TL;DR — Les voters sont la façon idiomatique de faire du contrôle d’accès par objet en Symfony — exactement la parade à l’IDOR vue en Partie 5. Un voter est une classe qui répond à une question du type « cet utilisateur peut-il faire
EDITsur cet objet ? » en examinant l’objet (appartenance, tenant) et l’utilisateur. On l’appelle via#[IsGranted('EDIT', subject: 'cours')]sur un contrôleur, ou$this->denyAccessUnlessGranted('EDIT', $cours). Avantage : la logique d’autorisation est centralisée, testable, et appelée à chaque accès — on ne l’oublie plus dans unif. C’est le bon endroit où mettre « le formateur ne peut modifier que ses cours ».
🎯 Objectifs
- Comprendre pourquoi les voters sont le bon outil pour l’autorisation par objet.
- Écrire un voter (
supports,voteOnAttribute) qui vérifie l’appartenance. - L’appeler via
#[IsGranted]etdenyAccessUnlessGranted. - Centraliser et tester la logique d’accès (fermer les IDOR proprement).
Pourquoi un voter
Rappel de la Partie 5 : access_control (chapitre 9.3) protège des zones d’URL par rôle, mais pas l’accès à un objet précis. Le contrôle « ce formateur peut-il modifier ce cours ? » dépend de l’objet (à qui appartient-il ?) — c’est de l’ABAC. Éparpiller ce contrôle en if ($cours->getFormateur() === $this->getUser()) dans chaque contrôleur garantit l’oubli (Partie 5) : tôt ou tard, un contrôleur est écrit sans le if, et c’est l’IDOR.
Les voters résolvent ça : ils centralisent la décision d’autorisation dans une classe dédiée, appelée de façon uniforme partout. On écrit la règle une fois, on la teste, et chaque accès passe par elle.
Écrire un voter
Un voter étend Voter et implémente deux méthodes : supports (ce voter sait-il répondre à cette question ?) et voteOnAttribute (la décision).
// src/Security/Voter/CoursVoter.php
namespace App\Security\Voter;
use App\Entity\Cours;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class CoursVoter extends Voter
{
public const EDIT = 'EDIT';
public const VIEW = 'VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW], true)
&& $subject instanceof Cours;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false; // pas connecte -> refuse (fail secure)
}
/** @var Cours $cours */
$cours = $subject;
return match ($attribute) {
// le formateur ne peut EDIT que SES cours (ferme l'IDOR)
self::EDIT => $cours->getFormateur() === $user,
// VIEW : proprietaire, ou apprenant inscrit, ou admin (exemple)
self::VIEW => $cours->getFormateur() === $user
|| $cours->getApprenants()->contains($user),
default => false,
};
}
}Points de sécurité :
- Fail secure : si l’utilisateur n’est pas du bon type / non connecté →
return false(refus). Toute branche non prévue refuse. - La décision porte sur l’objet (
$cours->getFormateur() === $user) : c’est exactement le contrôle d’appartenance qui ferme l’IDOR. - On peut ajouter le tenant (multi-tenant, Partie 5) : vérifier que l’objet appartient à l’organisation de l’utilisateur.
Appeler le voter
Deux façons, selon le contexte :
Via l’attribut #[IsGranted] sur le contrôleur (déclaratif, concis) :
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/cours/{id}/edit')]
#[IsGranted('EDIT', subject: 'cours')] // appelle CoursVoter avec l'objet Cours resolu
public function edit(Cours $cours): Response
{
// si on arrive ici, l'acces EDIT sur CE cours est autorise
}Via denyAccessUnlessGranted dans le code (impératif, pour une logique conditionnelle) :
public function edit(Cours $cours): Response
{
$this->denyAccessUnlessGranted('EDIT', $cours); // leve une AccessDeniedException (403) sinon
// ...
}Dans les templates Twig, on utilise is_granted('EDIT', cours) pour afficher/masquer un bouton — mais attention : ça ne fait que gérer l’affichage ; la vraie protection reste côté contrôleur/voter (masquer un bouton n’est pas un contrôle d’accès, Partie 5).
⚠️ Piège — Masquer un bouton avec
is_granteddans Twig n’est pas une sécurité : c’est du confort d’UI. Si le contrôleur cible n’appelle pas#[IsGranted]/denyAccessUnlessGranted, un utilisateur peut atteindre l’action directement (URL,curl) → IDOR. La règle : chaque action sensible doit passer par le voter côté serveur, indépendamment de ce que l’UI affiche. Le contrôle d’affichage et le contrôle d’accès sont deux choses distinctes.
Centraliser et tester
Le grand bénéfice des voters : la logique d’accès vit dans un endroit identifié (les voters), pas dispersée. Cela permet de :
- Tester exhaustivement l’autorisation (tests unitaires du voter : « formateur A ne peut pas EDIT le cours de B »).
- Auditer en revue : on peut chercher les contrôleurs qui n’appellent pas de voter sur une action sensible (un endpoint qui manipule un objet sans
#[IsGranted]est suspect). - Corriger partout d’un coup : un changement de règle se fait dans le voter.
// Test de non-regression : un formateur ne peut pas editer le cours d'un autre
public function testFormateurNePeutPasEditerCoursAutrui(): void
{
$this->client->loginUser($formateurA);
$this->client->request('GET', '/cours/'.$coursDeB->getId().'/edit');
$this->assertResponseStatusCodeSame(403); // le voter refuse
}💡 Réflexe — Pour toute entité manipulée via une entrée (id dans l’URL), demande-toi : « ai-je un voter qui vérifie l’appartenance, et est-il appelé sur cette action ? ». Si tu écris
#[IsGranted('EDIT', subject: 'cours')], l’autorisation par objet est faite au bon endroit. Si tu te retrouves à écrire unif ($obj->getOwner() === $user)dans un contrôleur, c’est le signal d’extraire cette logique dans un voter réutilisable et testé.
🧭 Sur FormaCampus — L’autorisation par objet de FormaCampus repose entièrement sur des voters (
CoursVoter,NoteVoter,ClasseVoter…), chacun testé (un formateur A reçoit403sur les objets de B ; un apprenant ne voit que ses notes ; tout est cloisonné par tenant). Les contrôleurs utilisent#[IsGranted]; Twig utiliseis_grantedseulement pour l’affichage, jamais comme sécurité. C’est cette centralisation qui a permis de corriger d’un coup l’IDOR d’export découvert en Partie 5, et d’ajouter un test de non-régression. Aucune décision d’accès par objet n’est laissée à unifisolé dans un contrôleur.
✏️ Exercices
Exercice 1 — Écris la règle du voter. Un NoteVoter doit autoriser VIEW sur une Note si : l’utilisateur est l’apprenant concerné, ou le formateur de la classe de la note, ou un admin. Esquisse voteOnAttribute (pseudo-code accepté), en respectant le fail secure.
✅ Solution
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) return false; // fail secure
/** @var Note $note */
$note = $subject;
if ($attribute !== 'VIEW') return false; // ne gere que VIEW ici
// admin : acces (selon politique) — via roles
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) return true;
// l'apprenant concerne
if ($note->getApprenant() === $user) return true;
// le formateur de la classe de la note
if ($note->getClasse()->getFormateur() === $user) return true;
return false; // tout le reste : refuse
}La clé : on refuse par défaut (dernier return false), on n’autorise que sur des chemins explicites liés à l’objet (getApprenant(), getClasse()->getFormateur()), et on gère le cas non connecté / mauvais type en premier. On ajouterait le contrôle de tenant si l’appli est multi-organisation.
Exercice 2 — Le bouton masqué. Un dev protège l’édition d’un cours uniquement en masquant le bouton « Éditer » dans Twig avec {% if is_granted('EDIT', cours) %}. Le contrôleur edit() n’a pas de contrôle. Est-ce sûr ?
✅ Solution
Non, ce n’est pas sûr. is_granted dans Twig ne fait que masquer l’affichage du bouton : c’est du confort d’UI, pas un contrôle d’accès. Un utilisateur peut atteindre l’action directement (deviner/copier l’URL /cours/42/edit, curl) — le contrôleur edit() n’ayant aucun contrôle, il exécute l’édition → IDOR (un formateur édite le cours d’un autre). Correctif : ajouter le contrôle côté serveur dans le contrôleur, via #[IsGranted('EDIT', subject: 'cours')] ou $this->denyAccessUnlessGranted('EDIT', $cours). Le masquage Twig peut rester (bonne UX), mais la sécurité vit dans le voter appelé côté serveur, pas dans l’affichage.
🧠 Quiz de révision
1. À quel problème les voters répondent-ils ?
À l’autorisation par objet (ABAC) : « cet utilisateur peut-il faire telle action sur cet objet précis ? » — la parade à l’IDOR (Partie 5). Ils centralisent cette décision, au lieu de l’éparpiller en if dans les contrôleurs (où l’oubli guette).
2. Quelles sont les deux méthodes d’un voter et leur rôle ?
supports($attribute, $subject) : ce voter sait-il répondre à cette question (bon attribut, bon type d’objet) ? voteOnAttribute($attribute, $subject, $token) : la décision (true/false) selon l’utilisateur et l’objet (appartenance, tenant). On refuse par défaut (fail secure).
3. Comment appelle-t-on un voter depuis un contrôleur ?
Via l’attribut #[IsGranted('EDIT', subject: 'cours')] (déclaratif) ou $this->denyAccessUnlessGranted('EDIT', $cours) (impératif, lève une AccessDeniedException → 403). Les deux déclenchent le voter approprié avec l’objet.
4. is_granted dans Twig protège-t-il l’accès ?
is_granted dans Twig protège-t-il l’accès ?Non : il gère seulement l’affichage (masquer un bouton). La vraie protection est côté serveur (voter appelé dans le contrôleur). Masquer un bouton sans contrôle serveur laisse l’action atteignable directement (URL, curl) → IDOR.
5. Quel avantage de sécurité apporte la centralisation dans des voters ?
La logique d’accès est testable (tests de non-régression : A ne peut pas accéder à l’objet de B), auditable (repérer les contrôleurs sans voter), et corrigeable d’un coup (un changement de règle au même endroit). On ne dépend plus d’un if isolé, source d’oublis et d’IDOR.
Chapitre suivant : Doctrine, secrets & audit — fermer les injections avec Doctrine, protéger les formulaires (CSRF), poser les en-têtes, gérer les secrets (vault) et auditer les dépendances.