La doc d'EasyAdmin documente setPermission() comme la voie standard pour restreindre l'accès aux actions, aux champs et aux entités. Sur ce blog, trois couches Symfony natives couvrent le même besoin : access_control par host pour fermer la porte d'entrée, #[IsGranted] au niveau class sur chaque controller, et une garde dans le métier pour les actions sensibles. À la fin du billet, on saura comment ces trois couches s'agencent, pourquoi ce choix, et où il s'arrête.
Les trois couches du contrôle d'accès
Sur ce projet, sécuriser une action admin se fait par superposition. Pas une seule garde quelque part : trois, indépendantes, à des niveaux différents de la stack.
| Couche | Où | Quoi | Quand elle intervient |
|---|---|---|---|
| 1 | config/packages/security.php |
access_control sur le host ^admin\. exige ROLE_ADMIN |
à la résolution de la requête, avant tout controller |
| 2 | #[IsGranted('ROLE_ADMIN')] au niveau class |
doublon de garde côté Symfony | au resolver du controller, avant __invoke() |
| 3 | garde dans le corps de l'action | CSRF, whitelist de champs, état de l'entité | juste avant l'effet de bord |
Aucune de ces couches n'est une feature EasyAdmin. Toutes sont du Symfony pur. Si demain on remplace EasyAdmin par autre chose, la sécurité ne déménage pas avec.
La couche racine : access_control par host
C'est le premier filet. Dans config/packages/security.php :
'role_hierarchy' => [
'ROLE_ADMIN' => 'ROLE_USER',
],
'access_control' => [
// ... exceptions PUBLIC_ACCESS : login, /connect/google,
// token endpoint et métadonnées de découverte OAuth2 ...
['host' => '^admin\.', 'roles' => 'ROLE_ADMIN'],
],Le regex ^admin\. matche admin.lecodeestdanslepre.fr : l'admin vit sur un sous-domaine dédié, pas sur un préfixe /admin de l'apex. Toute requête sur ce host doit présenter ROLE_ADMIN. Les seules exceptions, déclarées avant la règle host, sont les routes qui doivent rester accessibles pré-authentification : le retour OAuth de « Se connecter avec Google » et les endpoints de découverte OAuth2 du MCP.
Pourquoi un host plutôt qu'un préfixe ? Parce que le sous-domaine est déjà une frontière côté serveur : Caddy route admin.* vers son propre bloc de config, CrowdSec surveille le tout, et les cookies de session restent scopés. Aligner la garde Symfony sur la frontière réseau donne deux vues du même périmètre : ce qui entre par le host admin est admin, point.
La hiérarchie de rôles est minimale : ROLE_ADMIN inclut ROLE_USER, et c'est tout. Aucun ROLE_EDITOR, aucun ROLE_AUTHOR, pas de super-admin. Un seul rôle effectif côté UI admin. C'est ce choix qui rend la suite simple.
La couche class : #[IsGranted('ROLE_ADMIN')]
Deuxième filet. Chaque action custom de l'admin porte un #[IsGranted] au niveau class, pas method. Exemple sur InlineEditAction, l'action AJAX qui édite les cellules de l'index à la volée :
namespace App\Controller\Admin\Action\Field;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/field/{entityType}/{id}/inline-edit/{field}', name: self::class, methods: ['GET', 'POST'])]
#[IsGranted('ROLE_ADMIN')]
final class InlineEditAction extends AbstractController
{
public function __invoke(string $entityType, string $id, string $field, Request $request): array|JsonResponse
{
// ...
}
}Le pattern revient sur toutes les actions du dossier src/Controller/Admin/Action/ (Ai/, Content/, Field/, Security/). Le DashboardController porte la même annotation. Class-level, jamais method-level.
Pourquoi class plutôt que method ? Un seul invariant à tenir : toutes les méthodes du controller exigent le même rôle. Le déclarer une fois en haut évite les oublis sur une future méthode. Un junior qui ajoute getStats() n'aura pas à se souvenir d'y mettre IsGranted : avec le class-level, il n'a rien à faire.
C'est redondant avec la couche 1 ? Oui, et c'est l'idée. Si l'access_control est mal configuré pendant un refactor, l'annotation reste. Un test fonctionnel qui appelle un endpoint admin sans rôle obtient un 403 du resolver Symfony, pas une fuite jusqu'au code de l'action.
La couche métier : la garde dans l'action
Troisième filet. Pour les actions à effet de bord, la garde dépend du payload : un rôle ne suffit pas à dire si cette requête-là est légitime. La suite d'InlineEditAction enchaîne trois refus avant de toucher à la base :
if (!$this->inlineEditService->isFieldEditable($entityType, $field)) {
throw new BadRequestHttpException(\sprintf('Field "%s" is not editable inline for entity type "%s".', $field, $entityType));
}
$entity = $this->inlineEditService->findEntity($entityType, $id);
if ($entity === null) {
throw new NotFoundHttpException(\sprintf('Entity "%s" with id "%s" not found.', $entityType, $id));
}
// ... sur le POST uniquement :
if (!$this->isCsrfTokenValid('admin_ajax', $request->headers->get('X-CSRF-Token'))) {
return new JsonResponse(['error' => 'Token CSRF invalide.'], Response::HTTP_FORBIDDEN);
}Un champ hors whitelist part en 400 avant tout accès base. Une entité inconnue part en 404. Un POST sans token CSRF valide part en 403, même émis par un admin authentifié : le token voyage dans un header X-CSRF-Token posé par le JS de l'admin. #[IsGranted] ne sait rien de tout ça. La décision se prend dans le métier, au plus près de l'effet de bord.
Sur ce projet, la garde métier prend trois formes : check CSRF, whitelist de valeurs (un champ non éditable inline est rejeté avant d'exister en base), et validation d'état via le workflow. On revient sur cette dernière avec les batch actions.
configureActions() : déclarer sans sécuriser
Côté CrudController, configureActions() est la méthode où on déclare les actions qui apparaissent dans EasyAdmin (boutons de l'index, formulaire, détail). Important : la sécurité ne vit pas ici. Code observé dans AbstractContentCrudController, le parent des trois CRUDs content (Post, Page, Category) :
$duplicate = Action::new('duplicate', 'Dupliquer', 'fa fa-copy')
->linkToCrudAction('duplicateEntity')
->displayIf(static fn(AbstractContent $content): bool => $content->id instanceof Uuid)
;
$batchPublish = Action::new('batchPublish', 'Publier', 'fa fa-check-circle')
->linkToCrudAction('batchPublish')
->addCssClass('btn btn-success')
;Aucun setPermission(). Aucun displayIf() qui regarde un rôle. La déclaration EasyAdmin est purement cosmétique : libellé, icône, page d'apparition, route ciblée. La garde est ailleurs (les trois couches ci-dessus).
C'est délibéré. Mettre la garde dans configureActions() aurait deux défauts. D'abord, ça ne sécurise rien : un user peut toujours forger une requête vers l'URL EasyAdmin de l'action s'il connaît le pattern. Ensuite, ça brouille le contrat : un développeur qui lit setPermission(['ROLE_EDITOR']) croit que la garde est effective, alors qu'EasyAdmin ne fait que masquer le bouton.
Le seul displayIf() du projet est celui du code ci-dessus, et c'est un check d'UI, pas de permission : on n'affiche pas « Dupliquer » sur une entité qui n'a pas encore d'ID (formulaire de création), parce que dupliquer un brouillon non sauvé n'a pas de sens.
L'extraction qui n'a pas survécu
Un aveu au passage. La première version de ce billet documentait fièrement un UserActionConfigurationService : un service dédié qui portait la reconfiguration des actions du CRUD User, icônes et libellés, avec ses méthodes privées bien nommées et sa testabilité en isolation. Ce service n'existe plus. Il a été supprimé dans une passe de nettoyage dont le message de commit assume le diagnostic : « cleanups over-engineering » (PR #1629). Voici ce qui le remplace dans UserCrudController :
return $actions
->update(Crud::PAGE_INDEX, Action::NEW, static fn(Action $action): Action => $action->setIcon('fa fa-plus')->setLabel('Ajouter un utilisateur'))
->update(Crud::PAGE_INDEX, Action::EDIT, static fn(Action $action): Action => $action->setIcon('fa fa-edit')->setLabel('Modifier'))
->update(Crud::PAGE_INDEX, Action::DELETE, static fn(Action $action): Action => $action->setIcon('fa fa-trash')->setLabel('Supprimer'))
;Trois closures inline, zéro service, zéro test dédié. La règle de bascule reste valable (extraire quand la méthode dépasse vingt lignes ou injecte une dépendance), c'est son application qui avait dérapé : trois setIcon() et trois setLabel() ne justifient pas une classe. L'extraction est un outil, pas un réflexe.
Les batch actions : #[AdminRoute] et un trait
Les batch actions (publier en lot, dépublier en lot) sont un cas particulier : EasyAdmin les route via son propre mécanisme, et la méthode du controller se décore avec #[AdminRoute]. Code observé dans ContentBatchActionsTrait :
trait ContentBatchActionsTrait
{
#[AdminRoute]
public function batchPublish(AdminContext $context, BatchActionDto $batchActionDto): Response
{
return $this->applyBatchTransition($context, $batchActionDto, 'publish', 'publié');
}
#[AdminRoute]
public function batchUnpublish(AdminContext $context, BatchActionDto $batchActionDto): Response
{
return $this->applyBatchTransition($context, $batchActionDto, 'unpublish', 'dépublié');
}
// ...
}Trois entités content partagent les transitions de workflow publish / unpublish. Mutualiser dans un trait évite trois copier-coller. Le helper privé applyBatchTransition() factorise la boucle :
foreach ($batchActionDto->getEntityIds() as $id) {
$content = $entityManager->find($batchActionDto->getEntityFqcn(), $id);
if (!$content instanceof AbstractContent) {
continue;
}
if ($this->workflow->can($content, $transition)) {
$this->workflow->apply($content, $transition);
++$count;
}
}Le $this->workflow->can() est la vraie garde fonctionnelle : un brouillon ne peut pas être dépublié, le workflow refuse la transition. Pas de garde de rôle ici, on a déjà passé la couche 1 (host admin) et la couche 2 (IsGranted sur le controller). Tout user qui arrive là a le droit de tenter. C'est le workflow qui décide si l'opération a un sens.
Le trait porte d'ailleurs une cicatrice, documentée dans son docblock : le service AdminUrlGenerator dont il a besoin est injecté par setter avec l'attribut #[Required]. La première version utilisait l'annotation docblock @required, dont Symfony 7 a retiré le support : propriété jamais initialisée, et un 500 sur chaque batch action juste après le flush (#1378). Le genre de bug qui passe les tests unitaires et attend tranquillement la prod.
Le mot de la fin
Ce pattern à trois couches fonctionne tant qu'un seul rôle effectif suffit. Sur ce blog, c'est le cas : un seul auteur, un seul admin, pas de distinction EDITOR / REVIEWER / PUBLISHER. Le coût d'un Voter custom n'a jamais été payé.
Le jour où il faudrait introduire un ROLE_EDITOR qui voit le tab Métadonnées d'un Post mais pas le tab Schema, le pattern actuel se craquelle. La couche 1 (host) ne sait pas filtrer par tab. La couche 2 (IsGranted au niveau class) garde l'accès au controller, pas à un champ individuel. La couche 3 coûterait cher à dupliquer par tab. À ce moment-là, setPermission() reprend son intérêt : chaque action, chaque champ, chaque tab peut porter sa propre permission, et EasyAdmin fait le tri.
C'est le compromis qui pose la limite du choix actuel : la simplicité d'un seul rôle, ou la flexibilité d'un système de permissions, mais pas les deux gratuitement. Tant que la première suffit, on ne paie pas la seconde.
Si la doc d'EasyAdmin vous pousse vers setPermission() alors que vous n'avez qu'un rôle admin et une douzaine d'actions : un #[IsGranted] au niveau class et un access_control par host font le travail, sans la couche EasyAdmin.
Le QueryBuilder qu'on récupère dans apply() d'un filtre custom est le même que celui de Doctrine. C'est le sujet du prochain billet : écrire un filtre custom, du FilterInterface au FilterType.
Une coquille, une erreur dans ce billet ? Signale-la-moi.