Skip to Content

Chapitre 11.5 — Sécurité de l’API

Partie 11 : API Platform — Chapitre 5/8 Version de référence : API Platform 4 / Symfony 7.4.

⏱️ TL;DR

  • L’API est protégée par le firewall JWT stateless (Partie 10.4). Au-delà, on sécurise chaque opération avec l’option security (une expression) ou securityPostDenormalize.
  • Ces expressions utilisent vos voters (Partie 10.2) : security: "is_granted('CANCEL', object)".
  • Pour qu’un utilisateur ne voie que ses ressources dans une collection, on ajoute une extension Doctrine qui filtre par l’utilisateur courant (pas juste un voter par item).
  • On restreint les opérations par rôle (is_granted('ROLE_ADMIN')) et on peut masquer des champs selon le rôle (groupes dynamiques).
  • Pour un dev Next.js : la sécurité est déclarée sur l’API (le front ne peut pas la contourner) ; le front se contente de gérer 401/403.

🎯 Objectifs

  • Sécuriser chaque opération avec security/securityPostDenormalize.
  • Réutiliser les voters dans les expressions.
  • Filtrer une collection par l’utilisateur courant.
  • Restreindre opérations et champs par rôle.

1. Deux niveaux de protection

  1. Le firewall JWT (^/api, stateless) garantit que l’appelant est authentifié (sinon 401).
  2. La sécurité par opération décide s’il a le droit de faire cette action sur cette ressource (sinon 403).

2. L’option security par opération

Chaque opération accepte une expression de sécurité (langage ExpressionLanguage), évaluée avant exécution :

use ApiPlatform\Metadata\{ApiResource, Get, GetCollection, Post, Patch, Delete}; #[ApiResource(operations: [ new GetCollection(), // liste : accessible à tout ROLE_USER (via access_control) new Get(security: "is_granted('VIEW', object)"), // voir : le voter décide new Post(security: "is_granted('ROLE_USER')"), // créer : connecté new Patch(security: "is_granted('EDIT', object)"), // modifier : voter new Delete(security: "is_granted('ROLE_ADMIN')"), // supprimer : admin only ])] class Booking { /* ... */ }

Variables disponibles dans l’expression : object (la ressource concernée), user (l’utilisateur courant), et les fonctions is_granted(...). On réutilise ainsi les voters écrits en Partie 10.2 : la logique « le propriétaire ou un admin » vit une fois et sert le back-office et l’API.

3. securityPostDenormalize : après construction de l’objet

Pour un POST/PATCH, l’objet n’existe pas encore (ou est modifié) au moment du security initial. securityPostDenormalize est évalué après que l’objet a été construit/hydraté à partir de l’entrée — utile pour valider selon les nouvelles valeurs :

new Patch( security: "is_granted('EDIT', object)", securityPostDenormalize: "is_granted('EDIT', object)", // revérifie après modif )

4. Filtrer une collection par l’utilisateur

security: "is_granted('VIEW', object)" protège un item, mais pas une collection : GET /api/bookings renverrait toutes les réservations, puis exclurait celles interdites — au mieux inefficace, au pire fuite de données (le total, la pagination trahissent l’existence d’autres). La bonne solution : filtrer la requête en amont via une extension de collection Doctrine.

// src/Doctrine/CurrentUserBookingExtension.php use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; final class CurrentUserBookingExtension implements QueryCollectionExtensionInterface { public function __construct(private readonly Security $security) {} public function applyToCollection(QueryBuilder $qb, /* ... */, string $resourceClass, ?Operation $operation = null, array $context = []): void { if ($resourceClass !== Booking::class) { return; } $user = $this->security->getUser(); if ($this->security->isGranted('ROLE_ADMIN') || !$user instanceof User) { return; // un admin voit tout } $alias = $qb->getRootAliases()[0]; $qb->andWhere("$alias.user = :current")->setParameter('current', $user); // ne voir QUE les siennes } }

Désormais, GET /api/bookings ne renvoie que les réservations de l’utilisateur courant (sauf admin), au niveau SQL — filtrage sûr et performant (la pagination et le total sont corrects).

⚠️ Piège de fuite en collection : ne comptez jamais sur un voter par item pour sécuriser une collection. Un voter qui exclut des items après chargement ne protège pas des métadonnées (total, pagination) et charge des données inutiles. Filtrez la requête (extension Doctrine) pour que les données interdites n’entrent jamais dans le jeu de résultats.

5. Masquer des champs selon le rôle

Certains champs ne doivent apparaître que pour certains rôles (ex. les notes internes d’une réservation, visibles des admins). On ajoute un groupe dynamique selon l’utilisateur, via un context builder :

// squelette : ajoute le groupe 'admin:read' si l'utilisateur est admin final class AdminContextBuilder implements SerializerContextBuilderInterface { public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array { $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); if ($normalization && $this->security->isGranted('ROLE_ADMIN')) { $context['groups'][] = 'admin:read'; } return $context; } }

Les champs annotés #[Groups(['admin:read'])] ne sortent alors que pour les admins.

6. Récapitulatif de la sécurité API de BookLab

AspectMécanisme
Authentificationfirewall JWT stateless (Partie 10.4)
Autorisation par itemsecurity: "is_granted('X', object)" + voters
Autorisation créationsecurity: "is_granted('ROLE_USER')"
Collections filtrées par userextension Doctrine (WHERE user = current)
Champs par rôlegroupes dynamiques (context builder)
Rôles d’accès grossiersaccess_control (^/api)

💡 Pour un dev Next.js : toute cette sécurité est côté serveur (l’API), impossible à contourner depuis le front. Le front Next.js ne fait que réagir : cacher un bouton (confort), gérer 401 (refresh/login) et 403 (refus). Ne dupliquez pas les règles d’autorisation côté front comme s’il s’agissait de sécurité — c’est de l’UX. La vérité est dans l’API.

✏️ Exercices

Exercice 1. Configurez les opérations de Booking : liste et détail pour tout utilisateur connecté (avec voter VIEW sur le détail), création pour ROLE_USER, annulation via voter CANCEL, suppression admin only.

✅ Solution

#[ApiResource(operations: [ new GetCollection(), // + extension Doctrine qui filtre par user new Get(security: "is_granted('VIEW', object)"), new Post(security: "is_granted('ROLE_USER')"), new Patch(security: "is_granted('CANCEL', object)"), // ou une opération custom "cancel" new Delete(security: "is_granted('ROLE_ADMIN')"), ])] class Booking { /* ... */ }

Les voters VIEW/CANCEL (Partie 10.2) sont réutilisés dans les expressions.

Exercice 2. Pourquoi un voter VIEW par item ne suffit-il pas à empêcher un utilisateur de « voir » l’existence des réservations des autres via GET /api/bookings ?

✅ Solution

Parce qu’un voter par item s’applique après que la collection a été chargée : le total (hydra:totalItems), la pagination et le temps de réponse trahissent l’existence d’autres réservations, et on charge des données inutiles. La protection correcte est une extension de collection Doctrine qui ajoute WHERE user = current au niveau SQL : les réservations des autres n’entrent jamais dans le résultat.

Exercice 3. Le front cache un bouton « Supprimer » si l’utilisateur n’est pas admin. Est-ce suffisant pour sécuriser la suppression via l’API ?

✅ Solution

Non : cacher le bouton est du confort d’UI. Un utilisateur peut appeler DELETE /api/bookings/{id} directement (curl, outils). La sécurité réelle est l’expression security: "is_granted('ROLE_ADMIN')" sur l’opération API (côté serveur), qui renvoie 403 aux non-admins. Le front ne fait que réagir ; l’API décide.

🧠 Quiz de révision

1. Quels sont les deux niveaux de sécurité de l’API ?

(1) Le firewall JWT (authentification, 401 sinon) ; (2) la sécurité par opération (security/voters, 403 sinon).

2. Comment sécurise-t-on une opération avec un voter ?

Via l’option security: "is_granted('X', object)" sur l’opération, qui réutilise le voter (Partie 10.2).

3. Comment un utilisateur ne voit-il que ses propres ressources en collection ?

Par une extension de collection Doctrine qui ajoute WHERE user = current au QueryBuilder (filtrage SQL), pas par un voter par item.

4. À quoi sert securityPostDenormalize ?

À évaluer la sécurité après construction/hydratation de l’objet (POST/PATCH), pour décider selon les nouvelles valeurs.

5. La sécurité côté front remplace-t-elle la sécurité de l’API ?

Non : le front ne fait que réagir (cacher, gérer 401/403). La sécurité réelle est déclarée sur l’API, côté serveur, impossible à contourner.


Prochain chapitre : 11.6 — OpenAPI, versioning & CORS — documenter, versionner et ouvrir l’API au front.