Skip to content

Authorization — roles, voters, AccessDecisionManager

TL;DR — L'autorisation Symfony repose sur des voters consultés par l'AccessDecisionManager. Chaque voter vote GRANTED, DENIED ou ABSTAIN sur un couple (attribute, subject). La strategy (affirmative / consensus / unanimous / priority) décide. Les rôles sont des voters par défaut ; les voters custom encodent la logique métier ("l'utilisateur peut-il éditer CE post ?").

Mental model — ASCII diagram + analogy

Analogie : Un voter = juré dans un tribunal. L'AccessDecisionManager est le président : il pose la question (isGranted('EDIT', $post)), récolte les votes, applique la règle (majorité, unanimité), prononce le verdict.

   isGranted('EDIT', $post)


   AccessDecisionManager
       strategy: affirmative

   ┌────────┼────────────────────────────────────┐
   ▼        ▼                                    ▼
RoleVoter  AuthenticatedVoter   PostVoter (custom)
  abstain   abstain              GRANTED

                            return TRUE (affirmative needs 1)

Strategies :

  • affirmative (défaut) : 1 GRANTED suffit (les DENIED sont ignorés tant qu'il y a un GRANTED).
  • consensus : strictement plus de GRANTED que de DENIED ; en cas d'égalité, allow_if_equal_granted_denied (défaut true) tranche.
  • unanimous : 0 DENIED requis ; il faut au moins un GRANTED (sinon on retombe sur allow_if_all_abstain).
  • priority (6.2+) : on consulte les voters dans l'ordre de leur priority (tag DI) ; le premier qui vote GRANTED ou DENIED tranche, les suivants ne sont pas consultés.

Mental model du staff — Une décision = (strategy) ∘ (Σ votes). Le voter ne décide pas, il vote. La strategy est globale à l'application (un seul access_decision_manager), donc on ne mixe pas "unanimous pour l'admin, affirmative ailleurs" via la config : on encode la sévérité dans le voter (renvoyer DENIED plutôt qu'ABSTAIN quand on veut bloquer fermement). C'est le point que 90 % des devs ratent : changer la strategy est un levier rare ; le vrai levier est ABSTAIN vs DENIED.

Pourquoi ABSTAINDENIED (le cœur du sujet)

strategy = affirmative

VoterA: ABSTAIN   VoterA: DENIED
VoterB: GRANTED   VoterB: GRANTED
─────────────     ─────────────
=> GRANTED        => GRANTED   (affirmative ignore le DENIED !)

strategy = unanimous

VoterA: ABSTAIN   VoterA: DENIED
VoterB: GRANTED   VoterB: GRANTED
─────────────     ─────────────
=> GRANTED        => DENIED    (1 DENIED bloque tout)

Règle de conception : un voter renvoie ABSTAIN (« je ne me prononce pas, ce sujet/attribut n'est pas mon affaire ») quand supports() ne couvre pas le cas, et DENIED quand il veut activement interdire. Sous affirmative, un DENIED n'a de poids que si aucun autre voter ne vote GRANTED — d'où le danger d'un voter trop laxiste ailleurs.

Code minimal — realistic snippet

Voter custom

php
<?php
namespace App\Security\Voter;

use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/** @extends Voter<string, Post> */
final class PostVoter extends Voter
{
    public const EDIT = 'POST_EDIT';
    public const VIEW = 'POST_VIEW';
    public const DELETE = 'POST_DELETE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::VIEW, self::DELETE], true)
            && $subject instanceof Post;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;
        }

        return match ($attribute) {
            self::VIEW   => $this->canView($subject, $user),
            self::EDIT   => $this->canEdit($subject, $user),
            self::DELETE => $this->canDelete($subject, $user),
        };
    }

    private function canView(Post $post, User $user): bool
    {
        return $post->isPublished() || $this->isOwner($post, $user);
    }

    private function canEdit(Post $post, User $user): bool
    {
        return $this->isOwner($post, $user);
    }

    private function canDelete(Post $post, User $user): bool
    {
        // ⚠️ Ne PAS faire confiance à $user->getRoles() pour ROLE_ADMIN si la
        // hiérarchie de rôles compte : getRoles() ne déplie pas role_hierarchy.
        // Pour un check de rôle hiérarchique, passer par isGranted('ROLE_ADMIN')
        // (cf. RoleHierarchyVoter) — ici on injecte donc Security au besoin.
        return $this->canEdit($post, $user) || in_array('ROLE_ADMIN', $user->getRoles(), true);
    }

    /**
     * Compare par identité métier (id), pas par référence : un Author chargé
     * en proxy Doctrine n'est pas forcément === au User du token.
     */
    private function isOwner(Post $post, User $user): bool
    {
        return $post->getAuthor()?->getId() !== null
            && $post->getAuthor()->getId() === $user->getId();
    }
}

Utilisation dans un controller (attribute)

php
<?php
namespace App\Controller;

use App\Entity\Post;
use App\Security\Voter\PostVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_USER')]
final class PostController extends AbstractController
{
    #[Route('/posts/{id}/edit', name: 'post_edit')]
    #[IsGranted(PostVoter::EDIT, subject: 'post', message: 'Not your post.')]
    public function edit(Post $post): Response
    {
        // si on arrive ici, l'utilisateur est ROLE_USER ET autorisé sur ce post
        return $this->render('post/edit.html.twig', ['post' => $post]);
    }
}

Dans un service / Twig

php
<?php
use Symfony\Bundle\SecurityBundle\Security;

public function __construct(private Security $security) {}

public function archive(Post $post): void
{
    if (!$this->security->isGranted(PostVoter::DELETE, $post)) {
        throw $this->createAccessDeniedException();
    }
    // ...
}
twig
{% if is_granted('POST_EDIT', post) %}
    <a href="{{ path('post_edit', { id: post.id }) }}">Edit</a>
{% endif %}

Expression Language dans security.yaml

yaml
access_control:
    - path: ^/admin
      allow_if: "is_granted('ROLE_ADMIN') and request.headers.get('X-Source') == 'office'"
    - path: ^/api/billing
      roles: 'ROLE_BILLING'
      ips: ['10.0.0.0/8', '192.168.1.0/24']

Role hierarchy

yaml
security:
    role_hierarchy:
        ROLE_ADMIN:       [ROLE_USER, ROLE_MODERATOR]
        ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

Strategy custom

yaml
security:
    access_decision_manager:
        strategy: unanimous       # affirmative (def) | consensus | unanimous | priority
        allow_if_all_abstain: false

Patterns courants — 3–6 patterns

  1. Voter par aggregate : un voter par entité avec attributes constants (POST_EDIT, POST_VIEW). Lisible et testable.
  2. #[IsGranted] sur paramètre : combiné à ParamConverter/MapEntity, sécurise la route sans if dans le controller.
  3. Decision strategy unanimous pour zones critiques : utile en admin où un seul DENIED doit bloquer.
  4. is_granted dans Twig : afficher/cacher boutons et menus.
  5. Expression language pour conditions sans voter (IP, header, heure). Au-delà → voter.
  6. Voter cacheable : pour calculs coûteux, mettre un cache en mémoire de la décision par (user, attribute, subject) dans un voter.

Versions — Symfony 5.4 / 6.4 / 7.x

Topic5.46.47.x
IsGrantedAnnotation via Sensio bundleAttribute natif Symfony\Component\Security\Http\Attribute\IsGrantedIdem, plus complet
Security serviceSymfony\Component\Security\Core\SecuritySymfony\Bundle\SecurityBundle\Security (déplacé)Idem
priority strategyAbsentPrésentPrésent
Voter::ACCESS_GRANTED/DENIED/ABSTAINintInchangéInchangé
Vote with explanationVoteEvent 6.2+Stable
Authorization checkerOKOKOK
  • Sensio\Bundle\FrameworkExtraBundle est déprécié → migrer vers les attributes natifs Symfony.
  • En 6.2+, VoteEvent permet d'observer les votes (debug, audit log).
  • En 7.3, voteOnAttribute() peut accepter un 4ᵉ paramètre Vote $vote (ou retourner un Vote) pour attacher un message explicatif au refus, exposé dans l'AccessDeniedException et le profiler — fini les 403 muets.
php
// Symfony 7.3+ : voter qui explique son refus
use Symfony\Component\Security\Core\Authorization\Voter\Vote;

protected function voteOnAttribute(
    string $attribute,
    mixed $subject,
    TokenInterface $token,
    ?Vote $vote = null,   // injecté par le framework en 7.3+
): bool {
    if (!$subject instanceof Post) {
        return false;
    }
    if ($subject->isLocked()) {
        $vote?->addReason('Post verrouillé par la modération.');
        return false;
    }
    // ...
    return true;
}

Comment un staff engineer raisonne là-dessus

Le flux réel à l'intérieur de l'AccessDecisionManager

isGranted($attr, $subject)AuthorizationChecker::isGranted() → récupère le Token courant → AccessDecisionManager::decide($token, [$attr], $subject). Le manager itère sur tous les voters taggés security.voter, appelle vote() sur chacun, agrège selon la strategy, renvoie un bool. Points non-évidents :

  • Tous les voters sont appelés à chaque check (sauf strategy priority qui court-circuite). Avec 30 voters et affirmative, chaque is_granted dans une boucle Twig = 30 appels supports(). D'où l'importance d'un supports() ultra-rapide (juste des instanceof / in_array, zéro I/O).
  • supports() doit être pur et sans effet de bord. Toute requête DB y est un anti-pattern : elle s'exécute même quand le voter va abstenir. Mettre les accès DB dans voteOnAttribute(), après le filtre supports().
  • Le token peut être anonyme/null. En 6.4+, plus de AnonymousToken ; $token->getUser() renvoie null pour un visiteur non authentifié. Toujours guarder if (!$user instanceof User) return false;.
  • L'ordre des voters n'a d'importance que pour la strategy priority (via le tag priority). Pour les autres, l'ordre est indifférent car on agrège tout.

Performance — le piège des N voters × N checks

Le coût d'autorisation explose silencieusement dans les listes. Afficher 50 posts avec un bouton « éditer » conditionné par is_granted('POST_EDIT', post) = 50 décisions × tous les voters. Si le PostVoter charge l'auteur en lazy-loading, c'est 50 requêtes N+1 cachées dans la couche sécurité. Mitigations, par ordre de préférence :

TechniqueQuandCoût/risque
supports() strict + pas d'I/Otoujoursgratuit, obligatoire
Pré-charger les sujets (fetch join l'auteur)listesrequête maîtrisée en amont
Cache mémoire par requête HTTP dans le voter (array<string,bool> clé userId.attr.subjectId)calculs coûteux répétésinvalidation = durée de vie du worker, OK car stateless par requête
CacheableVoterInterface (7.x)voter dont la décision ne dépend que de (attr, type de subject)mauvais si la décision dépend de l'instance du subject

Le CacheableVoterInterface ne cache pas la décision finale — il indique au framework si le voter supporte un (attribute, subjectType) afin de l'éliminer en amont sans même l'instancier dans certaines optims internes. Ne pas le confondre avec un cache de résultat métier.

Observabilité — auditer les décisions

Trois leviers, du moins au plus intrusif :

  1. Profiler / dump : en dev, le panneau Security du profiler liste chaque Voter et son vote pour la requête. Premier réflexe pour un 403 mystère.
  2. VoteEvent (6.2+) / Vote reasons (7.3+) : brancher un listener sur Symfony\Component\Security\Core\Event\VoteEvent pour logger (voter, attribute, vote, subject) → audit trail conforme RGPD/SOC2.
  3. Decorator du AccessDecisionManager : pour tracer la décision finale (et pas seulement les votes individuels) dans OpenTelemetry, décorer Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface et émettre un span authz.decide avec attribute, subject.type, granted.
php
#[AsDecorator('security.access.decision_manager')]
final readonly class TracingAccessDecisionManager implements AccessDecisionManagerInterface
{
    public function __construct(
        #[AutowireDecorated] private AccessDecisionManagerInterface $inner,
        private TracerInterface $tracer,
    ) {}

    public function decide(TokenInterface $token, array $attributes, mixed $object = null, bool $allowMultipleAttributes = false): bool
    {
        $span = $this->tracer->spanBuilder('authz.decide')->startSpan();
        try {
            $granted = $this->inner->decide($token, $attributes, $object, $allowMultipleAttributes);
            $span->setAttribute('authz.attributes', implode(',', $attributes));
            $span->setAttribute('authz.granted', $granted);
            return $granted;
        } finally {
            $span->end();
        }
    }
}

Sécurité — modèle de menace de la couche autorisation

  • Confused deputy / IDOR : c'est le rôle des voters. Sans voter sur le subject, #[IsGranted('ROLE_USER')] laisse un user éditer le post d'un autre via son id. Le voter avec subject: est la barrière anti-IDOR.
  • Fail-open par ABSTAIN : un voter qui abstient + allow_if_all_abstain: true (ou un autre voter laxiste sous affirmative) = porte ouverte. Défaut sain : allow_if_all_abstain: false.
  • Autorisation ≠ filtrage des données : un voter protège l'accès à un objet déjà chargé ; il ne filtre pas une liste. Pour ne pas charger les objets interdits (et éviter la fuite par timing/count), filtrer en SQL/QueryBuilder côté repository, le voter restant la défense en profondeur.
  • TOCTOU : la décision est prise à l'instant du check. Si l'état change entre is_granted (affichage) et l'action (POST), re-vérifier côté action — ne jamais se fier au seul masquage Twig.

Pitfalls — 5–8 concrete traps

  1. Voter qui retourne ABSTAIN partout : si tous abstiennent, allow_if_all_abstain décide. Par défaut DENIED → 403 mystère.
  2. supports() trop permissif : voter qui dit "yes" pour tout fait voter sur des subjects non concernés → faux negatives.
  3. Comparaison d'entités par référence : $post->getAuthor() === $user peut échouer si l'un est un proxy Doctrine. Comparer les id.
  4. Strategy affirmative + voter trop laxiste : un seul GRANTED ouvre la porte. Bien revoir tous les voters.
  5. isGranted dans un constructeur de service : token pas encore initialisé en CLI → exception. À déférer en runtime.
  6. IsGranted sur méthode privée : ignoré, ne fonctionne que sur action publique routée.
  7. Role en string libre : ROLE_admin vs ROLE_ADMIN : Symfony n'est pas case-insensitive sur les rôles. Convention MAJUSCULES.
  8. Expression language non escapé : injecter du request.attributes.get('id') dans une expression → potentiel ; toujours utiliser ce langage pour des conditions statiques, pas user-controlled.
  9. switch_user non protégé : impersonation puissante. Limiter à ROLE_SUPER_ADMIN ou condition allow_if.

Testing — phpunit / KernelTestCase

php
<?php
namespace App\Tests\Security;

use App\Entity\Post;
use App\Entity\User;
use App\Security\Voter\PostVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

final class PostVoterTest extends TestCase
{
    public function testAuthorCanEdit(): void
    {
        $user = new User('[email protected]');
        $post = new Post(author: $user, body: '...');
        $token = new UsernamePasswordToken($user, 'main', ['ROLE_USER']);

        $voter = new PostVoter();
        $result = $voter->vote($token, $post, [PostVoter::EDIT]);

        self::assertSame(PostVoter::ACCESS_GRANTED, $result);
    }

    public function testOtherUserCannotEdit(): void
    {
        $author = new User('[email protected]');
        $other  = new User('[email protected]');
        $post = new Post(author: $author, body: '...');

        $token = new UsernamePasswordToken($other, 'main', ['ROLE_USER']);

        $voter = new PostVoter();
        self::assertSame(PostVoter::ACCESS_DENIED, $voter->vote($token, $post, [PostVoter::EDIT]));
    }
}

Test fonctionnel : loginUser($user) puis assert 403 / 200 sur les routes protégées.

🎬 Cas d'usage concrets

Scénario 1 — Voter ABAC par dossier dans un cabinet juridique

Le DMS du cabinet impose une règle métier complexe : un collaborateur n'a accès qu'aux dossiers où il figure dans la collection Intervenant, sauf s'il appartient à l'équipe compliance (qui accède à tout pour audit). De plus, les dossiers tagués confidentiel sont restreints aux associés. Ces règles ne sont pas exprimables par ROLE_* seuls. Un voter DossierVoter reçoit chaque sujet Dossier et la demande d'action (view, edit, delete, archive), consulte la liste des intervenants, le tag confidentiel, et l'appartenance équipe via User::estDansEquipe('compliance'). La stratégie est affirmative mais le voter renvoie systématiquement ACCESS_DENIED plutôt qu'ACCESS_ABSTAIN quand l'action n'est pas autorisée, pour éviter toute fuite via un autre voter laxiste. L'équipe a documenté une matrice de droits (rôle × action × état dossier) générée automatiquement à partir des tests fonctionnels du voter, ce qui sert à la fois de specs et de support de revue annuelle de conformité.

Scénario 2 — Voter avec limite quotidienne pour virements bancaires

La néobanque applique un voter VirementVoter à toute opération de virement sortant. Au-delà des règles statiques (le compte source doit appartenir à l'utilisateur, le statut KYC doit être valide), le voter consulte un service LimiteQuotidienne qui calcule en temps réel le cumul des virements de la journée glissante. Pour un compte standard, la limite est de 1 500 €, portée à 10 000 € pour les comptes pro vérifiés. Le voter renvoie ACCESS_DENIED avec une explication exposée dans un événement AuthorizationDenied pour audit. Si la limite est dépassée, l'utilisateur voit un écran de challenge SCA renforcé (biométrie via WebAuthn) plutôt qu'un simple rejet. Cette logique est testée unitairement avec un repository in-memory simulant l'historique journalier, et un test d'intégration valide le parcours complet contre PostgreSQL.

Scénario 3 — Voter immobilier mandant/agence avec hiérarchie de réseau

Un SaaS immobilier français gère 2 200 agences réparties en 18 réseaux franchisés. Une fiche Bien appartient à une agence mandante. L'accès à la fiche est régi par un voter BienVoter qui implémente une hiérarchie : l'agence mandante a accès complet (édition, retrait, photos), les autres agences du même réseau ont un accès co-mandat (saisie de visites, propositions), les agences hors réseau n'ont qu'un accès lecture aux fiches partagées dans le portail inter-réseaux. Le voter dépend d'un service ReseauResolver qui charge la hiérarchie en cache Redis (TTL 5 minutes) pour éviter d'aller en base à chaque permission check. Les négociateurs indépendants rattachés à plusieurs agences (multi-cartes) déclenchent un voter spécifique qui résout l'agence active depuis la session. Le système est observable via OpenTelemetry : chaque décision du voter trace le sujet, l'attribut, et le motif du déni.

🛠️ Exemple end-to-end

Use case : voter DossierVoter couvrant view/edit/archive avec règles d'intervenants, équipe compliance et tag confidentiel.

php
<?php
// src/Security/Voter/DossierVoter.php
declare(strict_types=1);

namespace App\Security\Voter;

use App\Domain\Dossier\Entity\Dossier;
use App\Domain\User\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

/**
 * @extends Voter<string, Dossier>
 */
final class DossierVoter extends Voter
{
    public const VIEW = 'dossier.view';
    public const EDIT = 'dossier.edit';
    public const ARCHIVE = 'dossier.archive';

    public function __construct(private readonly LoggerInterface $logger) {}

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $subject instanceof Dossier
            && in_array($attribute, [self::VIEW, self::EDIT, self::ARCHIVE], true);
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false;
        }
        \assert($subject instanceof Dossier);

        if ($user->estDansEquipe('compliance')) {
            return true; // Audit ne peut pas être bloqué
        }

        if ($subject->estConfidentiel() && !$user->aRole('ROLE_ASSOCIE')) {
            $this->logger->info('Refus dossier confidentiel', [
                'user' => $user->getId(), 'dossier' => $subject->getId(),
            ]);
            return false;
        }

        if (!$subject->aIntervenant($user)) {
            return false;
        }

        return match ($attribute) {
            self::VIEW => true,
            self::EDIT => $subject->estOuvert() && $user->aRole('ROLE_COLLABORATEUR'),
            self::ARCHIVE => $subject->estCloture() && $user->aRole('ROLE_ASSOCIE'),
        };
    }
}

// src/UI/Http/Controller/DossierController.php
namespace App\UI\Http\Controller;

use App\Domain\Dossier\Entity\Dossier;
use App\Security\Voter\DossierVoter;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class DossierController extends AbstractController
{
    #[Route('/dossiers/{id}', methods: ['GET'])]
    #[IsGranted(DossierVoter::VIEW, subject: 'dossier')]
    public function show(#[MapEntity] Dossier $dossier): Response
    {
        return $this->render('dossier/show.html.twig', ['dossier' => $dossier]);
    }

    #[Route('/dossiers/{id}/archiver', methods: ['POST'])]
    #[IsGranted(DossierVoter::ARCHIVE, subject: 'dossier')]
    public function archive(#[MapEntity] Dossier $dossier): Response
    {
        $dossier->archiver();
        return $this->redirectToRoute('dossier_list');
    }
}

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser-puis-réparer. Fais-les dans l'ordre.

1. OrderVoter de base — implémenter

Objectif : écrire un voter qui autorise ORDER_VIEW/ORDER_CANCEL selon le propriétaire et l'état de la commande (annulable seulement si pending).

Indice/Solution : supports() = instanceof Order && in_array($attr, [...]). Dans voteOnAttribute, guarder $user instanceof User, comparer par id, puis match($attribute) avec ORDER_CANCEL => $owner && $order->getStatus() === OrderStatus::Pending. Test unitaire pur (sans kernel) en construisant un UsernamePasswordToken.

2. Anti-N+1 dans une liste — production-grade

Objectif : une page liste 200 commandes avec un bouton « annuler » conditionné par voter. Profiler montre 200+ requêtes. Ramener à O(1) requêtes côté voter.

Indice/Solution : (a) fetch join le customer dans la requête de liste pour supprimer le lazy-load ; (b) ajouter un cache mémoire par requête dans le voter : private array $cache = [] clé "{$user->getId()}:{$attr}:{$order->getId()}". Vérifier au profiler que le nombre de requêtes ne dépend plus du nombre de lignes.

3. Strategy & ABSTAIN — casser puis réparer

Objectif : reproduire un fail-open. Avec strategy affirmative, ajouter un LegacyVoter qui vote GRANTED dès que $subject est un Order (trop laxiste). Constater qu'un non-propriétaire peut annuler. Réparer sans changer la strategy globale.

Indice/Solution : la cause est affirmative + un GRANTED parasite. Réparations possibles : restreindre le supports() du LegacyVoter, ou — meilleure leçon — comprendre que DENIED ne suffit pas sous affirmative. Migrer la zone sensible vers une vérification où un seul GRANTED ne peut pas tout ouvrir : soit passer la stratégie à unanimous, soit supprimer le voter parasite. Documenter pourquoi DENIED était inopérant ici.

4. Voter avec dépendance temps réel + explication — production-grade

Objectif : WithdrawalVoter qui refuse un retrait au-delà d'une limite quotidienne calculée par un service, et explique le refus (Symfony 7.3 Vote::addReason) exposé dans la réponse d'erreur.

Indice/Solution : injecter DailyLimitService (interface, mockable). I/O uniquement dans voteOnAttribute, jamais dans supports(). Tester avec un repository in-memory simulant l'historique. Brancher un ExceptionListener qui sérialise les reasons du AccessDeniedException en JSON problem+json.

5. Décorateur d'audit OpenTelemetry — casser puis observer

Objectif : décorer AccessDecisionManagerInterface pour tracer chaque décision, puis introduire volontairement un voter qui jette une exception et observer l'impact (la sécurité ne doit pas fail-open en cas d'erreur voter).

Indice/Solution : reprendre TracingAccessDecisionManager ci-dessus. Vérifier qu'une exception dans un voter remonte (et donc refuse l'accès via 500, pas un 200 silencieux). Leçon : un voter qui plante = pas une autorisation accordée ; garder les voters total et déterministes.

6. Hiérarchie de rôles vs getRoles() — casser puis réparer

Objectif : un voter teste in_array('ROLE_ADMIN', $user->getRoles()). Un ROLE_SUPER_ADMIN (qui hérite de ROLE_ADMIN via role_hierarchy) est refusé à tort. Corriger.

Indice/Solution : getRoles() ne déplie pas role_hierarchy. Injecter Security et utiliser $this->security->isGranted('ROLE_ADMIN') (qui passe par le RoleHierarchyVoter), ou injecter RoleHierarchyInterface pour déplier explicitement. Attention à la récursion si on rappelle isGranted sur un attribut traité par le même voter.

🎤 En entretien

Q : Différence entre ACCESS_ABSTAIN et ACCESS_DENIED ? Pourquoi ça compte ? R : ABSTAIN = « pas mon affaire » (n'influence pas le vote) ; DENIED = « j'interdis activement ». Sous la strategy affirmative (défaut), un DENIED est ignoré dès qu'un autre voter vote GRANTED — donc pour bloquer fermement il faut soit passer en unanimous, soit s'assurer qu'aucun voter laxiste ne vote GRANTED. C'est le levier de conception principal, bien avant le choix de strategy.

Q : Tu as 200 lignes dans une liste avec un is_granted par ligne et un problème de perf. Que regardes-tu ? R : D'abord le profiler Security pour compter les votes et les requêtes. La cause typique est un N+1 : le voter lazy-load une relation. Je rends supports() sans I/O, je pré-charge les sujets (fetch join) et j'ajoute un cache mémoire par requête dans le voter. En dernier recours, CacheableVoterInterface si la décision ne dépend pas de l'instance.

Q : #[IsGranted('ROLE_USER')] suffit-il à empêcher un user d'éditer le post d'un autre ? R : Non — c'est une faille IDOR classique. ROLE_USER vérifie un rôle global, pas la propriété du sujet. Il faut un voter sur le subject : #[IsGranted('POST_EDIT', subject: 'post')], et comparer par id (pas par référence, à cause des proxies Doctrine).

Q : Où mettrais-tu une logique « limite de virement quotidienne » : voter, service ou Expression Language ? R : Dans un voter, car c'est une règle métier liée à un sujet, testable et réutilisable (controller, Twig, service). L'Expression Language convient aux conditions statiques sur token/request (IP, header). L'access_control YAML est à éviter pour du métier. Le calcul temps réel vit dans un service injecté, appelé depuis voteOnAttribute (jamais supports()).

Quand utiliser / éviter

  • Voters : règles métier liées à un sujet (post, order, document).
  • Rôles seuls : permissions globales sans sujet (admin / user).
  • Expression Language : conditions composites simples basées sur le token et la request.
  • À éviter : encoder du métier dans access_control YAML quand un voter sera plus lisible et testable.

Liens

Bibliothèque tech perso — Achref