dimanche 9 février 2014

Spring: utiliser un BeanFactoryPostProcessor et des annotations pour un développement plus sûr

Spring offre de nombreuses possibilités d'extension, notamment en permettant d'agir à différents moment du cycle de vie des beans. C'est le cas des "PostProcessor".

Dans cet article, je vais montrer comment, dans un cas bien spécifique, réduire les risques d'erreur de développement en utilisant un BeanFactoryPostProcessor, combiné à une annotation de stéréotype "custom".

Le contexte

Afin de restreindre les droits d'accès à certaines fonctionnalités, j'ai développé un module non intrusif. L'objectif est qu'il soit simple à utiliser. Le développeur ne doit, pour sécuriser l'accès à une méthode, qu'ajouter une annotation sur ladite méthode, en précisant la règle à appliquer et des paramètres spécifiques à cette règle. Le module fera le reste.

Par exemple, il suffira d'annoter une méthode avec 
@AccessRule(rule=RoleRule.class, params={"ADMIN"})
public void foo(){
     //..
}
pour que l'accès à la méthode "foo" soit limité aux utilisateurs ayant un rôle d'administrateur.

Pour cela, une classe RoleRule doit exister. Le module fournit plusieurs règles de base, dont la RoleRule, mais les développeurs sont encouragés à créer leurs propres règles.

L'implémentation de la RoleRule pourrait ressembler à ceci:
/**
* Exemple simplifié de la RoleRule, 
* la classe réelle est plus complexe... et mieux écrite
*/
public class RoleRule{
     private String role;

     public boolean check(SecurityContext context){
          User user = (User) context.find("USER"); //Par exemple
          return user.hasRole(role);
     }

     public void setParams(String[] params){
          role = params[0];
     }
}
Cette classe est réutilisable. Par exemple:
@AccessRule(rule=RoleRule.class, params={"SUPPORT"})
public void bar(){
     //..
}
limite l'exécution de la méthode "bar" aux seuls les utilisateurs ayant le rôle "support".

Le fonctionnement du module est en dehors du scope de cet article, mais il est intéressant de savoir qu'il cherche et trouve l'annotation @Rule sur la méthode invoquée, instancie la règle déclarée dans l'annotation, y injecte les paramètres (méthode setParams) et vérifie la méthode "check" en lui passant un contexte de sécurité. Si cette méthode renvoie true, l'accès est accordé et la méthode (bar) exécutée, sinon l'accès est refusé sans que la méthode (bar) soit exécutée.

Utilisation de Spring

Ce système de gestion des droits d'accès est utilisé depuis plusieurs années et il est très stable. Il y a cependant une fonctionnalité qui est très peu documentée et que les développeurs ignorent généralement.

J'ai écrit plus haut que le module de sécurité instanciait la règle, j'ai un peu menti...

En réalité, il commence par vérifier l'existence d'un contexte Spring et s'il existe, il essaye d'y récupérer une instance de la règle.

Cela signifie donc qu'une règle peut être un bean Spring. Elle peut donc être déclarée, soit dans le fichier XML, soit via une annotation de stéréotype (typiquement @Component). L'avantage d'utiliser Spring, c'est que ça permet d'injecter dans la règle d'autres beans Spring (des services par exemple) et de les y utiliser.

Si cette possibilité n'est pas documentée, c'est qu'elle présente un risque.

Les applications que nous écrivons sont généralement des applications Web, donc forcément multithread. En regardant le code de la RoleRule ci-dessus, il est évident qu'elle ne peut être gérée par Spring qu'à condition d'être un prototype. En effet, dans un contexte multithread, une règle singleton ne pourrait survivre aux différents paramétrages dont elle fera l'objet. Dans de rares cas (pas de paramétrage par exemple), une règle pourrait être un singleton, mais c'est exceptionnel.

Le problème, c'est que Spring crée par défaut des singletons et qu'il faut donc penser à les configurer correctement.

Dans nos projets, où les beans Spring sont configurés avec des annotations, cela signifie qu'une règle devrait ressembler à ceci:
@Component
@Scope("prototype")
public class RoleRule{
     // Implémentation
}

Hélas! La majorité des beans (pour ne pas dire la totalité) sont des singletons et le risque d'oublier cette annotation @Scope est grand. Le réflexe sera d'écrire:
@Component
public class RoleRule{
     // Implémentation
}

Soit un singleton, qui donnera des résultats aléatoires dans un contexte multithread.

Solution

Il faut donc trouver un moyen pour que, lorsqu'une règle est définie comme un bean Spring, elle soit par défaut un prototype et ce, même si la configuration standard dit le contraire. Par contre, il faut laisser la possibilité de la configurer comme un singleton dans les rares cas où ce serait correct (ce qui suppose que le développeur sait ce qu'il fait).

Un stéréotype custom

La première étape consiste à configurer un stéréotype spécifique pour dire qu'une classe, un futur bean, est une règle et qu'elle doit donc obéir à certains points de configuration.

Le code pour cette annotation peut s'écrire comme suit:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface Rule {
     boolean singleton() default false;
}

De cette manière, il suffit au développeur d'annoter la classe de règle pour en faire un bean (avec le scan des packages activé afin de découvrir les beans). 
@Rule
public class RoleRule{
     // Implémentation
}

Nous verrons dans un instant comment garantir que cette règle sera bien un prototype, mais notons dés à présent que le cas exceptionnel où une règle peut être un singleton doit être expressément écrit:
@Rule(singleton=true)

Automatiser la configuration

Voyons maintenant comme faire pour que, alors qu'il n'y a aucune annotation @Scope("prototype"), la configuration soit correcte.

L'idée est d'utiliser un BeanFactoryPostProcessor qui changera la configuration d'un bean règle au démarrage de Spring.

Le principe d'un BeanFactoryPostProcessor est simple: si lui-même est déclaré comme un bean Spring, il sera automatiquement appelé et il aura la possibilité de modifier les définitions contenues dans le contexte Spring, avant la création de beans. Dans notre cas, il suffira de trouver les beans annotés @Rule et modifier leur configuration en fonction des paramètres de l'annotation.

Voici son implémentation;
@Component
public class RuleBeanFactoryPostProcessor implements BeanFactoryPostProcessor {

     public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
          String[] beanNames = beanFactory.getBeanDefinitionNames();
          for(String name:beanNames){
               BeanDefinition beanDefinition = beanFactory.getBeanDefinition(name);
               try {
                    Class<?> clazz = Class.forName(beanDefinition.getBeanClassName());
                    Rule annotated = clazz.getAnnotation(Rule.class);
                    if(annotated!=null){
                         beanDefinition.setScope(annotated.singleton()?"singleton":"prototype");
                    }
               } catch (ClassNotFoundException e) {
                    throw new BeansException("Class for bean "+name+" not found (really?)",e){};
               }
          }
     }

}

A noter l'annotation @Component pour que le PostProcessor soit un bean Spring et soit donc actif. Alternativement, il peut aussi être configuré au niveau du XML.

Une fois appelée, la méthode "postProcessBeanFactory" vérifie pour chaque définition de bean, la présence de l'annotation @Rule. Si elle est trouvée, elle change le scope dans la définition en fonction de la propriété "singleton" de l'annotation, propriété qui est false par défaut.

Tests et démo

Comme expliqué dans Deux projets prototypes et didactiques, le projet TestSpring sert de laboratoire à ce genre de d'expérimentation.

Vous y trouverez:



Aucun commentaire:

Enregistrer un commentaire