Notez l'article 1 Star2 Stars3 Stars4 Stars5 Stars (1 votes, Moyenne: 4,00/5)
Loading...

Système de permissions personnalisé avec spring-boot (ACL)

Je vous invite, après lecture, à me laisser un commentaire ou à noter cet article afin de m'aider à m'améliorer (ou me corriger si besoin). Je vous en serais très reconnaissant. De même n'hésitez pas à intervenir si vous avez des questions. Bonne Lecture !

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)

package com.app.authorization.model;

import javax.persistence.*;
import java.util.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "role")
@Getter @Setter @NoArgsConstructor
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 20) 
    private String name;

    @Column(length = 6)
    private String longName;

    @OneToMany(mappedBy="role")
    private Set<Privilege> privileges = new HashSet<Privilege>();

}
Role

 

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.


package com.app.authorization.model;

import javax.persistence.*;
import java.util.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import com.fasterxml.jackson.annotation.JsonIgnore;

@Entity
@Table(name = "privilege")
@Getter @Setter @NoArgsConstructor
public class Privilege {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 20) 
    private String action;

    @Column(length = 40)
    private String entity;

    @Column
    private Boolean constrained;

    @ManyToOne
    @JoinColumn(name="role_id")
    @JsonIgnore
    private Role role;

    public Boolean isConstrained() {
        return constrained;
    }

    public void setIsConstrained(Boolean isConstrained){
        constrained = isConstrained;
    }

}
Privilege

 

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.

package com.app.authorization.repository;

import com.app.authorization.model.Privilege;
import com.app.authorization.model.Role;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PrivilegeRepository extends JpaRepository<Privilege, Long> {

    Privilege findPrivilegeByActionAndEntityAndRole(String action, String entity, Role role);
}
PrivilegeRepository

 

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.

package com.app.authorization.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.security.core.context.SecurityContextHolder;

import com.app.authorization.model.*;
import com.app.authorization.repository.*;
import com.app.security.UserPrincipal;


import java.util.*;
import java.lang.*;

/**
 * Service de vérification des privileges utilisateurs.
 */
@Service
public class AuthorizationSE {

    @Autowired
    private UserRepository userRepo;

    @Autowired
    private PrivilegeRepository privilegeRepo;

    /**
     * Vérifie l'autorisation pour les privileges qui ne sont pas sensé contenir de contraintes sur l'objet visé
     * @param action le type d'action demandée
     * @param entity l'objet visé
     * @return
     */
    public boolean can(String action, String entity) {
        UserPrincipal currentUser = (UserPrincipal)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        //recuperation du privilege par action et par entité : logiquement il n'en existe qu'un par role avec cette action et cet objet
        Privilege privilege = privilegeRepo.findByActionAndEntityAndRole(action, entity, currentUser.getRole());
        //si privileges existe et qu'il n'attend pas de vérification de contrainte
        return (null != privilege && !privilege.isConstrained());

    }

    /**
     * Vérifie l'autorisations pour les privileges qui comportent des contraintes sur l'objet visé
     * @param action le type d'action demandée
     * @param entity l'objet visé
     * @param entityId l'id de l'objet visé
     * @return Vrai ou Faux
     */
    public boolean can(String action, String entity, Long entityId) {

        UserPrincipal currentUser = (UserPrincipal)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        boolean authorized = false;
        //recuperation du privilege par action et par entité : Il ne peut en exister qu'un par role
        Privilege privilege = privilegeRepo.findByActionAndEntityAndRole(action, entity, currentUser.getRole());

        if (null == privilege) {
            return authorized;
        }

        //implémentation des logiques métier de vérification des contraintes
        switch (entity){
            //Vérification si l'utilisateur est affecté au contrat via la table d'affectation UserContract
            case "Contract":
                Optional<User> user = userRepo.findById(currentUser.getId());
                //Récuperation des contrats sur lesquel est affecté l'utilisateur
                List<UserContracts> userContracts = user.get().getUserContracts();
                for (UserContract userContract : userContracts) {
                    if (userContract.getContract().getId().equals(entityId)) {
                        authorized = true;
                        break;
                    }
                }
                break;
            //Vérification si l'utilisateur visé par la requete est le même que l'utilisateur actuellement authentifié
            case "User":
                if (currentUser.getId().equals(entityId)) {
                    authorized = true;
                }
                break;
            default :
                authorized = false;
                break;
        }

        return authorized;

    }

}
AuthorizationSE

 

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 :

package com.app.controller;

import org.springframework.web.bind.annotation.*;
import com.app.authorization.service.AuthorizationSE;

@RestController
public class UserController {

    /**
    * Met à jour un utilisateur en fonction des permissions
    */
    @preAuthorize("@authorizationSE.can('update', 'User') or @authorizationSE.can('update', 'User', #userId)")
    @PutMapping("/user/update/{userId}")
    public User updateUser(@PathVariable(value = "userId") Long userId){
        //code here...
    }
}
UserController

 

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.

J’espère que cet article à été clair et utile pour vous. Merci de noter cet article, et le must serait de me laisser un petit commentaire ! C’est toujours sympa de savoir ce qu’il y a de bien ou moins bien, et s’il y a une question ou une remarque discutons-en 😉

Notez l'article 1 Star2 Stars3 Stars4 Stars5 Stars (1 votes, Moyenne: 4,00/5)
Loading...

9 pensées sur “Système de permissions personnalisé avec spring-boot (ACL)

  • mai 2, 2019 à 8:43
    Permalink

    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

    Répondre
    • mai 6, 2019 à 6:52
      Permalink

      Merci beaucoup pour ton commentaire !

      Répondre
  • novembre 4, 2019 à 10:49
    Permalink

    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.

    Répondre
  • janvier 7, 2020 à 9:50
    Permalink

    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,

    Répondre
    • janvier 8, 2020 à 6:02
      Permalink

      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

      Répondre
  • février 10, 2020 à 2:00
    Permalink

    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 ?

    Répondre
    • février 10, 2020 à 7:19
      Permalink

      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

      public class Privilege implements GrantedAuthority {
      
          private static final long serialVersionUID = 500L;
      
          @Override
          public String getAuthority() {
              return this.action + ":" + this.entity + ":" + Boolean.toString(this.isConstrained);
          }
      
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Long id;
      
          @Column(name = "action")
          private String action;
      
          @Column(name = "entite")
          private String entity;
      
          @Column(name = "comporte_contrainte")
          private Boolean isConstrained;
      
          @ManyToOne
          @JoinColumn(name = "role_id", foreignKey = @ForeignKey(name = "fk_privileges_roles_id"))
          @JsonIgnore
          private Roles role;
      
          public Boolean isConstrained() {
              return isConstrained;
          }
      }

      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)

      List<Privilege> allPrivileges = privilegeRepo.findAll();
      
      Set<Privilege> authorities = allPrivileges.stream().collect(Collectors.toSet());

      Au moment de discriminer la règle dont on a besoin on filtre :

      List<Privilege> findByActionAndEntityAndRole = authorities.stream()
                                                                .filter(auth -> "myAction".equals(auth.getAction()) && "myEntity".equals(auth.getEntity()) && auth.getRole().getId() == 1)
                                                                .collect(Collectors.toList());

      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 ?

      Répondre
  • février 11, 2020 à 3:11
    Permalink

    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.

    Répondre

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

I accept that my given data and my IP address is sent to a server in the USA only for the purpose of spam prevention through the Akismet program.More information on Akismet and GDPR.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

Show Buttons
Hide Buttons