1. Règles d'URL : un chemin sans signification
Si l’on simplifie avec une analogie domestique, la request-level security — c'est un gardien à l’entrée d’un immeuble qui regarde le laissez-passer et décide « laisser passer / ne pas laisser passer ». C’est utile, mais le gardien ne sait pas pourquoi vous entrez et vers quel coffre vous allez. Dans le monde du backend, le rôle de « coffre » est joué par la méthode de service : c’est là que se produit l’« action dangereuse » — publication de contenu, blocage d’un utilisateur, changement de rôles. Et si nous ne protégeons que l’entrée par URL, nous protégeons la route, mais pas le « coffre » lui-même.
En termes de Spring Security, l’autorisation au niveau des requêtes (request-level authorization) s’appuie sur HttpServletRequest : le chemin, la méthode HTTP, parfois les en-têtes, parfois l’origine et d’autres paramètres de la requête. Mais la règle au niveau des requêtes ne sait pas ce qu’il y a à l’intérieur du contrôleur, que vous appelez reviewService.publish(draftId), et encore moins que le même publish(...) peut être appelé depuis un autre contrôleur, un autre endpoint, ou même pas via HTTP (par exemple, depuis un autre service). Par conséquent, « URL fermé » ne signifie pas encore « opération métier fermée ».
Pour être le plus concret possible, prenons une configuration familière où la matrice d’accès n’est exprimée qu’à travers des zones d’URL :
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Endpoints GET publics : accessibles à tous, y compris les utilisateurs anonymes
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// Zone éditeur : nécessite une autorité spécifique (authority)
.requestMatchers("/api/editor/**").hasAuthority("draft:review")
// Zone admin : autorité distincte pour la gestion des utilisateurs
.requestMatchers("/api/admin/**").hasAuthority("user:manage")
// Tout le reste : nécessite simplement une authentification (n'importe qui, mais pas anonyme)
.anyRequest().authenticated()
);
// Construction du SecurityFilterChain qui sera appliqué à toutes les requêtes HTTP
return http.build();
}
}
Au niveau du « périmètre », tout semble propre : la lecture publique pour tous, la zone éditeur pour ceux qui savent review, la zone admin pour ceux qui savent gérer les utilisateurs. Mais il est important de voir la limite de cet extrait : c’est volontairement un snapshot encore purement request-level, où les droits ne vivent qu’au niveau des entrées. Il sépare bien les zones par URL, mais le publish(...) lui-même, lockUser(...) et les autres actions dangereuses n’ont pas encore reçu leur propre verrou.
C’est ici qu’apparaît rapidement la différence que la zone d’URL lisse : draft:review et draft:publish ne sont pas la même chose, tout comme user:read et user:manage. Au niveau de la zone, ils sont trop similaires, mais au niveau de l’opération métier, ils ne le sont plus.
2. Quand la protection par URL ne suffit pas
La première fois, on le remarque généralement non pas en cours, mais en production (ce qui, honnêtement, est un laboratoire trop coûteux). Vous ajoutez un nouveau endpoint, vous refactorez quelque chose, vous déplacez du code entre contrôleurs — et soudain il s’avère que l’interdiction de l’« opération dangereuse » était liée à une URL spécifique, et non à l’opération elle-même. Et même sans mauvaise intention, un « trou » apparaît : non pas parce que vous êtes une mauvaise personne, mais parce que le cerveau n’arrive pas à garder en mémoire toutes les entrées possibles du système. Examinons trois scénarios très concrets.
Imaginons notre service qui effectue réellement une action dangereuse :
import org.springframework.stereotype.Service;
@Service
class ReviewService {
public void publish(Long draftId) {
// IMPORTANT : c'est une opération métier (pas un "morceau de contrôleur"), elle peut être appelée depuis différents endroits
// TODO : changer le statut DRAFT/SUBMITTED -> PUBLISHED
// TODO : définir publishedAt
// TODO : journaliser l'événement de publication (pour l'audit), si requis par le domaine
}
}
Scénario 1 : une méthode de service appelée par deux contrôleurs différents
Au début, nous avons un endpoint « correct », par exemple de la zone editor :
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class EditorController {
private final ReviewService reviewService;
EditorController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@PostMapping("/api/editor/drafts/{id}/publish")
void publish(@PathVariable Long id) {
// Le contrôleur est fin : il reçoit la requête HTTP et délègue l'opération métier au service
reviewService.publish(id);
}
}
Et tout semble bien : /api/editor/** est protégé, donc publish est protégé. Mais ensuite (dans le futur, ou simplement « un vendredi soir » quand on veut rentrer chez soi), quelqu’un ajoute un endpoint pour la zone admin, parce que « l’admin peut tout faire » :
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class AdminDraftController {
private final ReviewService reviewService;
AdminDraftController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@PostMapping("/api/admin/drafts/{id}/publish")
void publishAsAdmin(@PathVariable Long id) {
// ATTENTION : on appelle toujours la même opération métier publish(...)
// mais la règle URL sur /api/admin/** peut être complètement "autre chose" (par exemple, seulement user:manage)
reviewService.publish(id);
}
}
Si la règle request-level sur /api/admin/** concerne la gestion des utilisateurs (user:manage), alors du point de vue du « périmètre » vous n’êtes pas du tout obligé de penser à la publication des brouillons. Mais l’opération métier s’est déjà ouverte via une nouvelle entrée. Et vous devez soit élargir les règles d’URL (et les maintenir mentalement), soit espérer que personne n’ajoutera une troisième porte vers cette même méthode.
Scénario 2 : un seul motif d’URL décrit une zone trop large
Une histoire très fréquente — « on fait tout d’un coup » :
.requestMatchers("/api/editor/**").authenticated()
C’est pratique, jusqu’à ce qu’apparaisse dans la zone editor quelque chose qui ne doit être accessible qu’à une partie des utilisateurs (par exemple, la file de révision visible par tous les éditeurs, mais publish/reject seulement par certains). Au niveau des URL, vous commencez soit à fragmenter les zones et à multiplier les matchers, soit à accepter que « puisque tu es entré, tu peux tout faire ».
Au début, c’est normal — on ne construit pas un ACL d’entreprise. Mais il est important de comprendre que plus le matcher est large, moins il ressemble à une vraie règle métier. Ce n’est pas « mauvais », c’est simplement une finalité différente : les règles request-level sont un grossier découpage de zones, pas un modèle précis de l’accès métier.
Scénario 3 : l’opération métier est appelée depuis autre chose qu’un contrôleur
C’est le moment où le développeur Junior dit généralement : « Attendez… on pouvait faire ça ? »
Oui, on peut. À l’intérieur de l’application, la méthode reviewService.publish(...) peut être appelée :
— depuis un autre service (par exemple, vous faites un orchestration flow) ;
— depuis un gestionnaire d’événements ;
— depuis un composant utilitaire « temporaire » ;
— dans les tests (ce qui est particulièrement amusant : le test passe, mais la production échoue sur les accès, parce que la sécurité ne résidait que dans la couche HTTP).
La request-level security ne protège que le chemin qui commence par une requête HTTP. Tout ce qui se passe « à l’intérieur » — ce ne sont plus que des appels Java.
On peut le représenter sous forme d’un schéma simple. Il est volontairement sans détails sur les entrailles de Spring, pour mettre en évidence l’essentiel :
flowchart TD
A["Requête HTTP"] --> B["Règles request-level du SecurityFilterChain"]
B --> C["Contrôleur"]
C --> D["Méthode de service : publish/lockUser/changeRoles"]
E["Autre contrôleur"] --> D
F["Autre service"] --> D
G["N'importe quel composant Spring"] --> D
Si la protection ne vit qu’au point B, alors les branches E/F/G (toute entrée non‑HTTP ou « autre entrée HTTP ») restent sans lien direct avec la règle que vous associez « mentalement » à publish. Et c’est exactement le moment où naît dans le projet un sentiment de fragilité : tout semble « dans les règles », mais on a peur de refactorer.
3. Duplication des règles et copier-coller
L’une des erreurs les plus coûteuses dans le développement backend n’est pas d’« écrire mal », mais d’« écrire la même chose à deux endroits, puis avec le temps quelque chose de différent ». La sécurité s’y prête parfaitement : vous pouvez honnêtement protéger un endpoint dans un contrôleur, puis ajouter un deuxième endpoint, puis un troisième — et à un moment donné les règles commencent à diverger. Non pas parce que vous êtes paresseux, mais parce que le cerveau n’est pas un système de contrôle de version.
Presque toujours, l’histoire commence innocemment : « extrayons la même opération dans un service pour ne pas dupliquer le code métier ». Et c’est correct. Mais si la sécurité ne reste que dans les règles d’URL, on obtient une symétrie étrange :
— le code métier, nous l’avons centralisé dans le service ;
— le code de sécurité, nous ne l’avons pas centralisé et l’avons dispersé sur les entrées.
En pratique, vous rencontrez de tels effets : un endpoint renvoie 403, un autre renvoie 200, mais tous deux appellent la même méthode. Ou inversement : vous avez ajouté un « nouveau bon endpoint », mais vous avez oublié de fermer l’ancien — et il continue de fonctionner. C’est particulièrement « amusant » lorsque l’ancien endpoint n’est pas utilisé par le frontend et que personne ne le teste manuellement. Il existe simplement en silence et attend son heure de gloire.
Pour sentir la différence, il est utile de comparer deux « visions du monde » — request-level et service-level. Je vais le faire sous forme de tableau, parce que le cerveau humain a parfois besoin d’un rectangle plutôt que d’un flux de conscience.
| Ce que nous protégeons | Où la règle est écrite | Sur quoi la règle peut s’appuyer | Combien d'« entrées » la règle couvre |
|---|---|---|---|
| Entrée HTTP (URL, méthode) | SecurityFilterChain | path, HttpMethod, headers | Seulement les entrées qui matchent dans SecurityFilterChain |
| Opération métier (publish/lockUser) | à côté de la méthode de service | authentification et droits courants | Tout appel de méthode via l’infrastructure Spring (détails plus tard) |
La deuxième ligne — c’est justement le « changement de mentalité » pour lequel nous sommes venus à la method security. Non pas pour « remplacer les règles d’URL », mais pour ajouter un deuxième niveau qui protège non pas la route, mais l’action.
4. Le contrôleur n’est pas un endroit pour l’autorisation
À ce stade, une pensée naturelle surgit généralement : « OK, les règles d’URL ne sont pas parfaites. Alors je vais faire une vérification dans le contrôleur : if (user not editor) throw — et voilà. »
Cela ressemble à une solution, mais cela se transforme rapidement en problème. Le contrôleur est une couche dédiée à HTTP : paramètres de requête, codes de statut, DTO, sérialisation, un peu de validation. Dès que vous commencez à écrire une autorisation complète dans le contrôleur, il devient un « centre de décision », et le service devient un « exécuteur stupide ». Exactement l’inverse de ce que nous voulons.
Voici un exemple d’« autorisation dans le contrôleur » qui semble normale, mais qui en pratique se propage rapidement :
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class EditorController {
private final ReviewService reviewService;
EditorController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@PostMapping("/api/editor/drafts/{id}/publish")
void publish(@PathVariable Long id, Authentication auth) {
// Mauvaise idée : le contrôleur commence à "décider" qui a le droit de faire quoi,
// et cette logique se disperse sur d'autres entrées (autres contrôleurs/endpoints)
if (auth == null || auth.getAuthorities().isEmpty()) {
// Mauvaise idée pour une API : une exception sur "l'état de l'application" devient 500,
// alors que par nature il devrait y avoir 401/403 (selon la situation)
throw new IllegalStateException("No authorities");
}
// Même si la vérification est faite "joliment", vous dupliquez quand même les règles sur les entrées
reviewService.publish(id);
}
}
Le problème n’est pas que IllegalStateException n’est « pas jolie » (même si ça l’est aussi). Le problème est que vous venez de mélanger les responsabilités. Le contrôleur n’est plus seulement responsable de l’entrée du système, mais aussi du modèle d’accès. Et maintenant, si une deuxième entrée vers reviewService.publish(...) apparaît — vous devrez copier cette logique là aussi. Puis encore une fois. Et encore une fois. Et voilà que vous construisez votre mini‑Spring Security à la main, alors que nous avons déjà Spring Security dans le projet.
De plus, la logique dans le contrôleur conduit presque toujours à une mauvaise sémantique des erreurs. Tout le bloc précédent portait sur la distinction entre 401 et 403, et sur la nécessité d’avoir des erreurs uniformes. Les if manuels transforment très facilement un 403 en 500, ou l’inverse, et le client reçoit des surprises.
C’est pourquoi la pensée du jour est un peu ennuyeuse, mais elle sauve les nerfs : le contrôleur doit être fin, le service doit être intelligent, et les règles d’accès doivent vivre à côté de ce qu’elles protègent. Si nous protégeons « publish », alors la règle ne doit pas être « autour de l’URL », mais « autour de publish ».
5. Exemples de risques : publish et opérations admin
Il est important non seulement de philosopher, mais aussi de voir les endroits concrets du projet où la protection par URL commence presque inévitablement à « ne pas coïncider » avec les opérations métier. Dans notre Secure Content Platform API, il y a deux familles d’actions caractéristiques : la modération éditoriale du contenu et les opérations administratives sur les comptes. Elles sont intéressantes car le coût d’une erreur y est élevé, et il est donc plus facile de les percevoir comme des opérations « dangereuses » qui doivent être protégées de la manière la plus évidente possible.
Prenons deux services très proches par leur sens : l’un fait la modération de contenu, l’autre gère les utilisateurs.
import org.springframework.stereotype.Service;
@Service
class ReviewService {
public void publish(Long draftId) {
// Opération dangereuse : publication de contenu (cela change l'état du système)
// Ici, il est particulièrement important que la règle d'accès soit attachée à la méthode, et pas seulement à l'URL
}
}
import org.springframework.stereotype.Service;
@Service
class AdminUserService {
public void lockUser(Long userId) {
// Opération dangereuse : blocage du compte utilisateur
// C'est aussi une "capability" du système, et on souhaite une règle explicite à côté de la méthode
}
}
Au niveau des règles d’URL, ces opérations vivent généralement dans des zones différentes :
— publish/reject : /api/editor/**
— lock/enable/change roles : /api/admin/**
Et tout semble cohérent. Mais voici où apparaît la subtilité.
Subtilité n°1 : zone ≠ opération. /api/admin/** dans notre projet concerne les utilisateurs. Si vous ajoutez accidentellement une opération de contenu (comme dans l’exemple avec AdminDraftController), alors la règle sur /api/admin/** ne reflète pas du tout le sens de publish. Elle reflète le sens de « gestion des utilisateurs ». Ce sont des droits différents (user:manage vs draft:publish). Au final, soit vous ouvrez publish à des gens à qui il ne devrait pas être ouvert, soit à l’inverse vous « cassez » publish pour ceux qui en ont besoin.
Subtilité n°2 : différents endpoints peuvent mener à la même action. Vous pouvez avoir un endpoint qui publie un brouillon, un deuxième qui « publie et pingue immédiatement les abonnés » (conditionnellement), un troisième qui « publie depuis la file d’attente ». Tous peuvent appeler la même méthode de service. Et vous souhaitez qu’il y ait une seule règle : « peut publier celui qui a le droit publish ». Et plus tôt vous commencerez à penser ainsi, moins vous aurez peur d’ajouter de nouveaux endpoints.
Subtilité n°3 : les méthodes de service sont de véritables capabilities métier. Si dans le code il y a une méthode lockUser(...), c’est déjà une capability du système : « nous savons bloquer des comptes ». Et on souhaite fortement qu’à côté de cette méthode se trouve une règle la plus évidente possible : « qui a le droit de faire cela ». Pas dans le README, pas dans la tête, pas dans le contrôleur, pas dans SecurityConfig tout en bas — mais à côté de l’action elle-même.
On voit déjà quelle règle nous est nécessaire : l’accès ne doit pas pendre sur une nouvelle porte dans SecurityConfig, mais directement sur l’opération publish, lockUser ou changeRoles. C’est pour cela qu’apparaît une couche séparée — la method security, qui sait mettre un verrou sur la méthode de service, et pas seulement sur l’URL.
6. Deux niveaux de protection : URL et méthodes
Il est très important de ne pas tirer une mauvaise conclusion. Les règles d’URL ne deviennent pas « mauvaises » et ne doivent pas être supprimées simplement parce que nous avons un deuxième niveau de protection. Au contraire : elles restent le premier rempart de la défense, et ce sont elles qui coupent rapidement les requêtes anonymes et délimitent les zones de l’application. Si vous supprimez les règles request-level et ne conservez que la method security, vous obtiendrez une application étrange et moins prévisible : les contrôleurs seront appelés plus souvent, la gestion des erreurs peut devenir moins évidente, et la configuration perdra sa « carte des entrées ».
La bonne image est la suivante : les règles request-level sont responsables de faire en sorte que la requête arrive dans la bonne zone et ne passe pas « par la porte ouverte ». Les règles service-level sont responsables de ce que, même à l’intérieur de la zone, on ne puisse pas faire ce pour quoi on n’a pas le droit. C’est ce qu’on appelle la « defense in depth », mais sans emphase : simplement deux verrous au lieu d’un, parce que les gens oublient parfois d’en fermer un.
Si vous gardez cela en tête, vous acquérez une habitude de conception utile. Lorsque vous ajoutez un nouveau endpoint, vous vous posez deux questions. D’abord « dans quelle zone tombe-t-il, et que dira SecurityFilterChain ? », puis « quelle opération métier appelle-t-il, et cette opération a-t-elle une protection locale ? ». Et cette deuxième question — c’est ce qui nous manquait jusqu’à présent.
7. Erreurs typiques lors de la protection des opérations
Erreur n°1 : considérer que « fermer /api/editor/** signifie que publish est protégé pour toujours ».
Ce raisonnement ne fonctionne que tant que vous êtes sûr que publish est appelé depuis exactement un seul contrôleur et qu’il n’y aura jamais de deuxième entrée. Dans un projet réel, de nouvelles entrées apparaissent constamment : nouveaux endpoints, nouveaux scénarios, poignées administratives temporaires, refactoring. Si la protection ne vit que dans SecurityFilterChain, vous commencez à protéger des routes, et non des actions — et la sécurité devient fragile.
Erreur n°2 : déplacer l’autorisation dans le contrôleur via des if, parce que « c’est plus rapide ».
L’autorisation dans le contrôleur conduit presque toujours à une dispersion des règles dans le projet, à de la duplication et à une mauvaise sémantique des erreurs. De plus, cela complique la maintenance : vous changez une règle métier — et vous devez chercher dans tout le code dans quel contrôleur elle était « cousue ». Résultat : les contrôleurs grossissent, les services maigrissent, et le projet devient un labyrinthe où le droit d’accès dépend du chemin par lequel vous êtes entré.
Erreur n°3 : protéger les zones avec des matchers trop larges et essayer d’exprimer des règles métier précises uniquement via l’URL.
La tentation de « tout fermer en une ligne » est compréhensible, surtout quand on veut rapidement obtenir une base de sécurité fonctionnelle (working security baseline). Mais plus la règle d’URL est large, moins elle ressemble à un droit métier. À un moment donné, vous commencez soit à fragmenter les matchers à l’infini, soit à accepter que « dans la zone, on peut tout faire ». Les deux options passent mal à l’échelle à mesure que le projet grandit et que de nouvelles autorités (authorities) apparaissent.
Erreur n°4 : confondre « ce qui est pratique à configurer » avec « ce qui est correct de protéger ».
La configuration par URL est effectivement pratique : elle est visible au même endroit et se lit de haut en bas. Mais les opérations métier ne vivent pas dans la config, mais dans les services. Si une opération est dangereuse et importante (publish, lock user, change roles), alors le souhait de voir à côté d’elle une règle d’accès explicite n’est pas un caprice, mais une tentative de rendre le système compréhensible et résistant aux changements. Lorsque la protection est placée à côté de l’action, le projet ressemble moins à un château de cartes et davantage à un vrai backend que l’on peut faire évoluer sans crainte.
GO TO FULL VERSION