Système de permissions personnalisé avec spring-boot (ACL)
Une chose assez récurrente dans une application est d’avoir besoin de définir ce qu’un utilisateur peut faire ou ne peut pas faire. Nativement Spring-security permet de filtrer les autorisations avec les fonctions « hasRole() ». Cependant ne filtrer que sur les rôles est assez limitant car certaines application peuvent nécessiter une gestion de droit plus complexe et plus granuleuse.
Il existe pour cela avec Spring le module Spring-ACL (Access Control List) qui fonctionne avec Spring-security.
Pour ma part je trouve que Spring-ACL est très complexe à mettre en place, difficilement lisible et offre une complexité qui n’est pas forcément nécessaire pour une petite ou moyenne application. En outre il manque de souplesse pour sa mise en place sur un modèle de données spécifique ou déjà existant.
Je propose donc ici une implémentation de filtres pour @preAuthorize et @postAuthorize de Srpring-security qui permettrons de faire du filtrage façon ACL.
Si vous avez déjà implémenté spring-security il est fort probable que vous ayez une table Role (ou qui s’y apparente) dans votre modèle de données.
L’implémentation ici proposée nécessitera d’implémenter une nouvelle table (et une seule) dans votre modèle de données. La table des privilèges qui référencera les différents droits par type d’action, type d’objet visé et par rôle.
Cette solution n’est sans doute pas la meilleure mais fonctionne très bien et comporte l’avantage d’être relativement simple à mettre en place puisqu’elle ne nécessite que très peu de modifications du modèle de données.
Prérequis :
- Avoir implémenté Spring-security pour disposer du UserPrincipal et des filtres @preAuthorize ou @postAuthorize
- S’assurer que le UserPrincipal contiens le rôle de l’utilisateur
Mise en situation
Admettons une application de gestion de contrats qui dispose de deux rôles.
- MANAGER : le directeur ou chef d’equipe qui peut saisir des contrats, des collaborateur et les modifier sans contrainte
- COLLABORATEUR : qui ne peut voir et modifier que lui même ou eventuellement certains autres utilisateurs sous certaines conditions. Il ne peut également agir que sur les contrat sur lesquels il a été affecté (ce qui présuppose qu’il existe une table de mapping des User par Contract)
Arrêtons nous sur la capacité d’agir sur les utilisateurs :
On voit ici que la gestion des permissions peut s’avérer complexe car aussi bien les managers que les collaborateurs ont des droits sur les utilisateurs mais, pour les uns, sur tous les utilisateurs, et pour les autres, sur eux-même ou, en tout cas, sous certaines conditions que vous définirez.
Un simple hasRole que permet Spring-security n’est donc pas envisageable car :
- si l’on met un @preAuthorize(« hasRole(ROLE_MANAGER) ») sur un update du User alors le rôle COLLABORATEUR ne pourras pas le modifier.
- si on met un @preAuthorize(« hasRole(ROLE_MANAGER) or hasRole(ROLE_COLLABORATEUR) »), les deux rôles pourront modifier tout les utilisateurs. Ce n’est pas ce que nous voulons ici. Nous préférerions qu’effectivement le MANAGER puisse modifier tout les collaborateurs et que le COLLABORATEUR ne puisse modifier que lui-même ou peut-être d’autres mais sous certaines conditions.
Création des rôles
Tout d’abord définissons les rôles : (Votre modèle de Role peut être complètement différent, nous aurons juste besoin de son ID et de référencer les privilèges)
[pastacode lang= »java » manual= »package%20com.app.authorization.model%3B%0A%0Aimport%20javax.persistence.*%3B%0Aimport%20java.util.*%3B%0Aimport%20lombok.Getter%3B%0Aimport%20lombok.NoArgsConstructor%3B%0Aimport%20lombok.Setter%3B%0A%0A%40Entity%0A%40Table(name%20%3D%20%22role%22)%0A%40Getter%20%40Setter%20%40NoArgsConstructor%0Apublic%20class%20Role%20%7B%0A%0A%20%20%20%20%40Id%0A%20%20%20%20%40GeneratedValue(strategy%20%3D%20GenerationType.IDENTITY)%0A%20%20%20%20private%20Long%20id%3B%0A%0A%20%20%20%20%40Column(length%20%3D%2020)%20%0A%20%20%20%20private%20String%20name%3B%0A%0A%20%20%20%20%40Column(length%20%3D%206)%0A%20%20%20%20private%20String%20longName%3B%0A%0A%20%20%20%20%40OneToMany(mappedBy%3D%22role%22)%0A%20%20%20%20private%20Set%3CPrivilege%3E%20privileges%20%3D%20new%20HashSet%3CPrivilege%3E()%3B%0A%0A%7D » message= »Role » highlight= » » provider= »manual »/]
Création des privilèges
Pour les privilèges représentons nous tout d’abord ce qu’ils sont.
Un privilege est en definitive une action autorisée sur une entité exemple : lire un contrat.
Néanmoins certains couple action/entité comporterons des règles métier afin de déterminer si pour un rôle précis, « lire contrat » est autorisé pour tout les contrats ou seulement un type de contrat ou les contrats affectés etc…(le nombre de règles n’ont de limites que le périmètre sur lequel vous travaillez…)
il faut donc un indicateur pour savoir si ce couple action/entité (ce privilège) comportera des règles (sera contraint ou non).
On ajoutera donc a notre modèle un indicateur boolean et l’on rattachera ce privilège à un rôle.
[pastacode lang= »java » manual= »%0Apackage%20com.app.authorization.model%3B%0A%0Aimport%20javax.persistence.*%3B%0Aimport%20java.util.*%3B%0Aimport%20lombok.Getter%3B%0Aimport%20lombok.NoArgsConstructor%3B%0Aimport%20lombok.Setter%3B%0Aimport%20com.fasterxml.jackson.annotation.JsonIgnore%3B%0A%0A%40Entity%0A%40Table(name%20%3D%20%22privilege%22)%0A%40Getter%20%40Setter%20%40NoArgsConstructor%0Apublic%20class%20Privilege%20%7B%0A%0A%20%20%20%20%40Id%0A%20%20%20%20%40GeneratedValue(strategy%20%3D%20GenerationType.IDENTITY)%0A%20%20%20%20private%20Long%20id%3B%0A%0A%20%20%20%20%40Column(length%20%3D%2020)%20%0A%20%20%20%20private%20String%20action%3B%0A%0A%20%20%20%20%40Column(length%20%3D%2040)%0A%20%20%20%20private%20String%20entity%3B%0A%0A%20%20%20%20%40Column%0A%20%20%20%20private%20Boolean%20constrained%3B%0A%0A%20%20%20%20%40ManyToOne%0A%20%20%20%20%40JoinColumn(name%3D%22role_id%22)%0A%20%20%20%20%40JsonIgnore%0A%20%20%20%20private%20Role%20role%3B%0A%0A%20%20%20%20public%20Boolean%20isConstrained()%20%7B%0A%20%20%20%20%20%20%20%20return%20constrained%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20public%20void%20setIsConstrained(Boolean%20isConstrained)%7B%0A%20%20%20%20%20%20%20%20constrained%20%3D%20isConstrained%3B%0A%20%20%20%20%7D%0A%0A%7D » message= »Privilege » highlight= » » provider= »manual »/]
On pourra finalement référencer dans la table privilège une série de capacités pour chaque rôle qui ressemblera à ceci.
id | action | entity | constrained | role_id |
1 | list | User | 1 | 2 |
2 | update | User | 1 | 2 |
3 | update | User | 0 | 1 |
4 | affect | Contract | 0 | 1 |
5 | … | … | … | … |
Ici on voit par exemple que :
- le role_id 2 (COLLABORATEUR) peut modifier les utilisateurs mais avec une ou des conditions (constrained = 1) (conditions qui seront définies dans les règles métier un peu plus tard..)
- le role_id 1 (MANAGER) peut modifier les utilisateurs sans condition (constrained = 0)
Il va ensuite falloir permettre à nos filtre d’aller chercher en base un privilège, en fonction de l’action demandée, de l’objet visé et du rôle de l’utilisateur connecté via le Repository de JPA.
[pastacode lang= »java » manual= »package%20com.app.authorization.repository%3B%0A%0Aimport%20com.app.authorization.model.Privilege%3B%0Aimport%20com.app.authorization.model.Role%3B%0Aimport%20org.springframework.data.jpa.repository.JpaRepository%3B%0A%0Apublic%20interface%20PrivilegeRepository%20extends%20JpaRepository%3CPrivilege%2C%20Long%3E%20%7B%0A%0A%20%20%20%20Privilege%20findPrivilegeByActionAndEntityAndRole(String%20action%2C%20String%20entity%2C%20Role%20role)%3B%0A%7D » message= »PrivilegeRepository » highlight= » » provider= »manual »/]
Service de vérification et règles métier
II ne nous reste plus qu’a implémenter le service de vérification des privilèges qui contiendra entre-autre les règles métier pour les privilèges conditionnés.
[pastacode lang= »java » manual= »package%20com.app.authorization.service%3B%0A%0Aimport%20org.springframework.beans.factory.annotation.Autowired%3B%0Aimport%20org.springframework.web.bind.annotation.*%3B%0Aimport%20org.springframework.stereotype.Service%3B%0Aimport%20org.springframework.security.core.context.SecurityContextHolder%3B%0A%0Aimport%20com.app.authorization.model.*%3B%0Aimport%20com.app.authorization.repository.*%3B%0Aimport%20com.app.security.UserPrincipal%3B%0A%0A%0Aimport%20java.util.*%3B%0Aimport%20java.lang.*%3B%0A%0A%2F**%0A%20*%20Service%20de%20v%C3%A9rification%20des%20privileges%20utilisateurs.%0A%20*%2F%0A%40Service%0Apublic%20class%20AuthorizationSE%20%7B%0A%0A%20%20%20%20%40Autowired%0A%20%20%20%20private%20UserRepository%20userRepo%3B%0A%0A%20%20%20%20%40Autowired%0A%20%20%20%20private%20PrivilegeRepository%20privilegeRepo%3B%0A%0A%20%20%20%20%2F**%0A%20%20%20%20%20*%20V%C3%A9rifie%20l’autorisation%20pour%20les%20privileges%20qui%20ne%20sont%20pas%20sens%C3%A9%20contenir%20de%20contraintes%20sur%20l’objet%20vis%C3%A9%0A%20%20%20%20%20*%20%40param%20action%20le%20type%20d’action%20demand%C3%A9e%0A%20%20%20%20%20*%20%40param%20entity%20l’objet%20vis%C3%A9%0A%20%20%20%20%20*%20%40return%0A%20%20%20%20%20*%2F%0A%20%20%20%20public%20boolean%20can(String%20action%2C%20String%20entity)%20%7B%0A%20%20%20%20%20%20%20%20UserPrincipal%20currentUser%20%3D%20(UserPrincipal)SecurityContextHolder.getContext().getAuthentication().getPrincipal()%3B%0A%20%20%20%20%20%20%20%20%2F%2Frecuperation%20du%20privilege%20par%20action%20et%20par%20entit%C3%A9%20%3A%20logiquement%20il%20n’en%20existe%20qu’un%20par%20role%20avec%20cette%20action%20et%20cet%20objet%0A%20%20%20%20%20%20%20%20Privilege%20privilege%20%3D%20privilegeRepo.findByActionAndEntityAndRole(action%2C%20entity%2C%20currentUser.getRole())%3B%0A%20%20%20%20%20%20%20%20%2F%2Fsi%20privileges%20existe%20et%20qu’il%20n’attend%20pas%20de%20v%C3%A9rification%20de%20contrainte%0A%20%20%20%20%20%20%20%20return%20(null%20!%3D%20privilege%20%26%26%20!privilege.isConstrained())%3B%0A%0A%20%20%20%20%7D%0A%0A%20%20%20%20%2F**%0A%20%20%20%20%20*%20V%C3%A9rifie%20l’autorisations%20pour%20les%20privileges%20qui%20comportent%20des%20contraintes%20sur%20l’objet%20vis%C3%A9%0A%20%20%20%20%20*%20%40param%20action%20le%20type%20d’action%20demand%C3%A9e%0A%20%20%20%20%20*%20%40param%20entity%20l’objet%20vis%C3%A9%0A%20%20%20%20%20*%20%40param%20entityId%20l’id%20de%20l’objet%20vis%C3%A9%0A%20%20%20%20%20*%20%40return%20Vrai%20ou%20Faux%0A%20%20%20%20%20*%2F%0A%20%20%20%20public%20boolean%20can(String%20action%2C%20String%20entity%2C%20Long%20entityId)%20%7B%0A%0A%20%20%20%20%20%20%20%20UserPrincipal%20currentUser%20%3D%20(UserPrincipal)SecurityContextHolder.getContext().getAuthentication().getPrincipal()%3B%0A%20%20%20%20%20%20%20%20boolean%20authorized%20%3D%20false%3B%0A%20%20%20%20%20%20%20%20%2F%2Frecuperation%20du%20privilege%20par%20action%20et%20par%20entit%C3%A9%20%3A%20Il%20ne%20peut%20en%20exister%20qu’un%20par%20role%0A%20%20%20%20%20%20%20%20Privilege%20privilege%20%3D%20privilegeRepo.findByActionAndEntityAndRole(action%2C%20entity%2C%20currentUser.getRole())%3B%0A%0A%20%20%20%20%20%20%20%20if%20(null%20%3D%3D%20privilege)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20authorized%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20%2F%2Fimpl%C3%A9mentation%20des%20logiques%20m%C3%A9tier%20de%20v%C3%A9rification%20des%20contraintes%0A%20%20%20%20%20%20%20%20switch%20(entity)%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%2F%2FV%C3%A9rification%20si%20l’utilisateur%20est%20affect%C3%A9%20au%20contrat%20via%20la%20table%20d’affectation%20UserContract%0A%20%20%20%20%20%20%20%20%20%20%20%20case%20%22Contract%22%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Optional%3CUser%3E%20user%20%3D%20userRepo.findById(currentUser.getId())%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%2F%2FR%C3%A9cuperation%20des%20contrats%20sur%20lesquel%20est%20affect%C3%A9%20l’utilisateur%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20List%3CUserContracts%3E%20userContracts%20%3D%20user.get().getUserContracts()%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20for%20(UserContract%20userContract%20%3A%20userContracts)%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20(userContract.getContract().getId().equals(entityId))%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20authorized%20%3D%20true%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20break%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20break%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%2F%2FV%C3%A9rification%20si%20l’utilisateur%20vis%C3%A9%20par%20la%20requete%20est%20le%20m%C3%AAme%20que%20l’utilisateur%20actuellement%20authentifi%C3%A9%0A%20%20%20%20%20%20%20%20%20%20%20%20case%20%22User%22%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20(currentUser.getId().equals(entityId))%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20authorized%20%3D%20true%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20break%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20default%20%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20authorized%20%3D%20false%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20break%3B%0A%20%20%20%20%20%20%20%20%7D%0A%0A%20%20%20%20%20%20%20%20return%20authorized%3B%0A%0A%20%20%20%20%7D%0A%0A%7D » message= »AuthorizationSE » highlight= » » provider= »manual »/]
Appel du service
On peut désormais appeler ce service dans les filtre @preAuthorize et @postAuthorize par exemple :
- vérifier si l’utilisateur courant PEU METTRE A JOUR TOUT LES UTILISATEURS
@preAuthorize(« @abilityService.can(‘update’, ‘User’) »)
ou
- vérifier si l’utilisateur courant PEU METTRE A JOUR CET UTILISATEUR EN PARTICULIER
@preAuthorize(« @abilityService.can(‘update’, ‘User’, #UserId) »)
(où le #UserId est l’id du User envoyé dans la requête. la fonction can() vérifiera si l’id demandé correspond à celui de l’utilisateur courant)
On pourra tout à fait combiner les deux à savoir :
@preAuthorize(« @abilityService.can(‘update’, ‘User’) or @abilityService.can(‘update’, ‘User’, #UserId) »)
ce qui reviens à dire : « vérifier si l’utilisateur courant dispose des droits de modification sur tout les utilisateurs OU sur celui-ci en particulier »
les règles de gestion à ce niveau varieront donc d’un projet à l’autre. ici nous avons pris la comparaison très simple entre l’id de l’utilisateur courant et celui de l’objet visé, ce qui empêche l’utilisateur de modifier un autre utilisateur que lui même.
Ci-dessous : mieux que des mots :
[pastacode lang= »java » manual= »package%20com.app.controller%3B%0A%0Aimport%20org.springframework.web.bind.annotation.*%3B%0Aimport%20com.app.authorization.service.AuthorizationSE%3B%0A%0A%40RestController%0Apublic%20class%20UserController%20%7B%0A%0A%20%20%20%20%2F**%0A%20%20%20%20*%20Met%20%C3%A0%20jour%20un%20utilisateur%20en%20fonction%20des%20permissions%0A%20%20%20%20*%2F%0A%20%20%20%20%40preAuthorize(%22%40authorizationSE.can(‘update’%2C%20’User’)%20or%20%40authorizationSE.can(‘update’%2C%20’User’%2C%20%23userId)%22)%0A%20%20%20%20%40PutMapping(%22%2Fuser%2Fupdate%2F%7BuserId%7D%22)%0A%20%20%20%20public%20User%20updateUser(%40PathVariable(value%20%3D%20%22userId%22)%20Long%20userId)%7B%0A%20%20%20%20%20%20%20%20%2F%2Fcode%20here…%0A%20%20%20%20%7D%0A%7D » message= »UserController » highlight= » » provider= »manual »/]
Conclusion
L’exemple pris ici pour illustrer mes propos est très simpliste mais ce système peut permettre de créer une gestion d’habilitation pour une application plusieurs niveaux de droits avec des permissions très entremêlées, et ce, de manière relativement simple et lisible.
Ta solution m’a l’air pas mal du tout, et très simple à appréhender!
Je pense que Spring ACL n’est pas extrêmement compliqué *une fois que l’on a un peu de pratique avec*. C’est vrai qu’au démarrage, c’est assez complexe à appréhender, notamment parce qu’un des besoins importants qu’ils ont essayé de respecter est lié aux performances.
Si le critère des performances est pas fondamental, ce que tu proposes me semble plus rapide à mettre en place, bravo!
Cyril
Merci beaucoup pour ton commentaire !
Bonjour,
Je te remercie pour ton article, je trouve que c’est un système complet pour gérer les permissions.
Je voudrais savoir dans le cas où le UserPrincipal ne contient pas le rôle de l’utilisateur comment faire ? est ce qu’il s’agit de la version de java ?
Merci par avance.
Bonjour,
Pour ajouter les rôles à ton utilisateur courant, il te faut ta propre implémentation de UserDetails. Un exemple ici parmis les solutions possible ==> https://stackoverflow.com/questions/20349594/adding-additional-details-to-principal-object-stored-in-spring-security-context
Amuses-toi bien 😉
Bonjour,
J’ai lu votre tutoriel et je vous remercie pour cet article très intéressant.
Cependant après l’implémentation de la solution, j’ai remarqué que mon utilisateur n’arrive pas a accéder aux contenus dont il n’est pas l’auteur avant que je crée une ressource et une fois une ressource créer avec cet utilisateur, il peut accéder à tous les autres ressources même ce qui ne l’appartienne pas.
Aussi j’ai pas très bien compris l’idée des deux méthodes can et de leurs utilisations en même temps, y’a t’il une possibilité de ne faire qu’une seule méthode tout en répondant aux deux problématiques évoqués dans le début de l’article.
Avez vous une idée de la personnalisation de la solution avec l’implémentation de PermissionEvaluator de spring boot ?
Je vous remercie en avance et vous souhaite une bonne soirée.
Cdt,
Bonjour,
Si vous en avez le temps et la possibilité, pouvez vous poster un exemple de votre implémentation sur stackoverflow (avec la référence de l’article), puis m’envoyer le lien. Cela nous permettra de voir ce qui dans votre cas ne fonctionne pas avec mon approche et peut-être creuser une nouvelle approche.
Merci
Bonjour, et merci pour cet article intéressant.
Je compte l’implémenté avec une petite modification.
Au niveau des perfs, pour éviter l’appel systématique du privilegeRepository dans les méthodes « can » ont peut « reconstruire » les privilèges grâce au authorities de l’objet authentication du contexte spring security (eux même issus d’un JWT par exemple).
On pourrais ainsi avoir une string « update:user:contrained » (format à définir) qui permettrais de reconstruire le privilège sans passé par la BDD.
A la connexion la méthode getAuthorities de UserDetails s’occuperais de transformer les privilèges en string (SimpleGrantedAuthority) dans ce même format.
Qu’en pensez vous ?
Bonjour et merci !
L’idée n’est pas mauvaise. Après plutôt que de partir sur une transformation de String puis ensuite remapper la chaine en la splitant je trouverais plus élégant de faire en sorte que Privilege devienne une implémentation de GrantedAuthority (tout comme SimpleGrantedAuthority). du même coup le getAuthority() deviens un bête toString de Privilege et il ne servira à priori pas.
Ainsi à la connexion on alimente effectivement le UserDetails avec une liste de privilege cette fois et ensuite on peut filtrer sur ce que l’on veut.
voici en gros ce que cela donnerais très rapidement :
On implémente GrantedAuthority sur la classe Privilege
A la connexion on récupère tout les privilège et on construit UserDetails avec cette liste :
(en partant du principe que tu as ta propre implémentation de UserDetails)
Au moment de discriminer la règle dont on a besoin on filtre :
Tout cela est intéressant mais je ne vois pas trop le gain de perf. sachant que la requête de base de donnée proposée dans l’article ici est une requête très simple alors qu’avec l’autre solution on va faire effectuer des transformations, des boucles, des filtres etc… tout ça par la JVM, alors que SQL ou autre sont des langages prévus pour ça et très performants au niveau discrimination de donnée.
Il faudrais vraiment pousser les tests pour vérifier le gain/perte de perf avec l’une ou l’autre des solutions.
Qu’en penses tu ?
Finalement je suis parti sur la première solution avec l’appel du repository.
La requête faite en base est effectivement plus simple et ciblé sur un privilège en particulier, donc rapide à exécuter.
Je me suis aussi rendu compte qu’il peut y avoir une désynchronisation entre les privilèges réel en base et ceux mis dans le JWT au moment du login.