Développement web

CASL : gérer vos droits utilisateurs de manière isomorphique

CASL : gérer vos droits utilisateurs de manière isomorphique

Lorsque l’on créé une application et dans mon cas ce sont des applications web principalement en TypeScript, il y aura tôt ou tard nécessairement la question de la gestion des droits/permissions et des utilisateurs.

Alors vous êtes un peu en mode prototypage ou que vous n’avez aucune restriction ou contrainte et que vous souhaitez déléguer cette partie, je vous conseille d’utiliser Auth0 que j’ai déjà décrit dans l’un de mes articles.

C’était cas pour un des projets que j’ai réalisés il y a un an, mais pas aujourd’hui. À l’heure où j’écris ses lignes, je travaille sur un projet interne pour l’un de mes clients dans le secteur bancaire qui s’appelle Oney.

commitstrip raisonnable 2030

Source : https://www.commitstrip.com/fr/2020/12/01/the-best-bet-for-2030

Explication du contexte

Oney est très axé sur la sécurité et impose certaines recommandations et contraintes pour l’application que l’on doit développer. Nous sommes libres de gérer un système de droits/permissions de notre côté, mais nous devons passer par leur système d’authentification à savoir dans ce cas présent du Azure AD.

On aurait pu passer par Auth0 pour intégrer la connexion Azure AD et la gestion des droites permissions, mais on trouvait cela trop contraignant, car on allait utiliser qu’un faible pourcentage de ce que propose Auth0 en plus d’augmenter le nombre de services et de dépendances pour de l’authentification.

L’autre point essentiel est que notre client Oney voulait que l’on ait une totale maîtrise de leur authentification donc passer par un service externe comme Auth0 n’aurait pas été une bonne idée.

Je ne sais pas exactement où vous en êtes en programmation, au niveau de la réalisation d’un système de droits/permissions et authentification, mais cela peut devenir très compliqué à gérer surtout si on s’y prend mal dès le début et que l’on a bien réfléchi à ce qu’on voulait faire en amont.

Une question à vous poser quand vous gérer des droits/permissions

La question que je me pose désormais et que vous devriez suivre à l’avenir est la suivante : « Est-ce que mes conditions pour gérer ma permission ou mon droit commencent à devenir complexes ou farfelues ? ».

Si jamais vous répondez oui, alors cela veut dire que vous embarquez dans quelque chose qui n’est pas nécessaire ou qui n’est pas assez réfléchi. Et donc par conséquent, vous aurez tôt ou tard des problèmes.

N’oubliez jamais que ce que vous codez doit être simple à lire et à exécuter. Et la gestion des droits/permissions ne fait pas exception.

CASL, la librairie JavaScript de gestion d’autorisations isomorphique

J’ai développé un système d’authentification en intégrant Azure AD par la voie SAML2. J’ai utilisé des librairies JavaScript classiques comme passport et passport-saml.

Il me restait la gestion des droits/permissions et sur recommandations (et en explorer un peu le terrain) je me suis tourné vers CASL, une librairie de gestion d’autorisation isomorphique.

Mais c’est quoi ce nom barbare « isomorphique » ?

Pour CASL, cela veut dire qu’elle peut être utilisée à plusieurs échelles dans votre programmation.

Entendez par là que cette même librairie peut être utilisée côté BackEnd mais aussi côté FrontEnd.

De plus, le créateur ainsi que sa communauté ont fait en sorte de la rendre agréable et utilisable sur les plus gros framework FrontEnd JavaScript comme Angular, React, Vue ou Aurelia, mais également intégrable dans système ORM côté BackEnd, comme mongoose par exemple.

Tout cela pour vous dire qu’avec une seule librairie, vous pouvez gérer vos droits/permissions sur toute votre architecture applicative. Et ça c’est un excellent point fort et argument pour l’utiliser.

Les Abilities : 4 paramètres pour une gestion de droits/permissions simples et efficaces

Ce qui fait aussi la notoriété de CASL dans le monde du JavaScript/TypeScript c’est sa simplicité d’utilisation.

CASL part du principe suivant : vous devez définir les différents Abilities de vos utilisateurs. En d’autres termes, vous devez définir ce qu’un utilisateur peut faire et/ou ne pas faire.

Comment se construit une Ability ?

CASL possède une classe qui permet de construire ce dont vous avez besoin pour votre utilisateur.

Ils ont une classe qui se nomme AbilityBuilder qui vous donne droit alors à 3 constantes : « can », « cannot » et « rules ».

const { can, cannot, rules } = new AbilityBuilder();

rules contiendra toutes vos règles concernant votre « Ability » définies par les constantes « can » et « cannot ».

D’ailleurs pour rendre actif votre « Ability » vous devez retourner votre rules sous forme d’« Ability » avec ce code :

return new Ability(rules);

Les 4 paramètres de « can » et « cannot »

C’est avec les constantes « can » et « cannot » que vous allez définir vos permissions, ce que votre utilisateur a le droit de faire.

Ils peuvent prendre chacun 4 paramètres (le premier étant obligatoire) :

  • Action de l’utilisateur : c’est un mot-clé pour désigner ce que peut faire l’utilisateur. Par exemple pour du CRUD, ce sera Create, Read, Update et Delete
  • Sujet : c’est le sujet vis-à-vis de votre action. Si votre application gère des utilisateurs, des clients ou encore des devis, vous pourriez avoir Users, Customers ou Quote
  • Conditions : ce sont des conditions additionnelles de restriction et d’accès à votre action basé sur vos données. Par exemple, vous pourriez la condition suivante : mon utilisateur peut supprimer un devis à condition que ce soit lui qui l’a créé. En code cela donnerait par exemple : createdBy : user.id
  • Champs : ce sont la liste des champs que vous souhaitez restreindre par rapport à votre sujet. S’il y a certaines données que votre utilisateur peut voir, c’est ici qu’il faut les lister.

Chose à savoir : ne pas définir de « can » pour des permissions spécifiques revient à faire un « cannot ». Alors a quoi peut vraiment servir le « cannot », car si on ne mentionne rien c’est considéré comme un « cannot » ?

Tout simplement pour la compréhension de votre système de droit et la lecture de code qui le rend plus fluide et plus simple.

Utiliser un « cannot » vous permet décider textuellement qu’on a pas le droit de faire telle chose dans votre application.

Un exemple de code pour définir une Ability

Supposons que j’ai une application qui gère des utilisateurs et qui me permet de gérer des projets.

J’aurais 3 types d’utilisateurs :

  • Admin : l’admin a le droit de tout faire.
  • Project manager : il a le droit de lire les utilisateurs, de créer et lire des projets, mais uniquement modifier et supprimer les siens. Il n’a pas le droit de choisir les pays associés au projet
  • Contributor : il a le droit de lire les utilisateurs et de lire/mettre à jour des projets auquel il est mentionné contributor
export function defineAbilitiesFor(user: IUser): RawRuleOf<AppAbility>[] {
  const { can, cannot, rules } = new AbilityBuilder<AppAbility>();
  if (user && user.role) {

    switch (user.role) {
      case RoleEnum.admin:
        defineAdminRules(can, cannot);
        break;
      case RoleEnum.contributors:
        defineContributorRules(user, can, cannot);
        break;
      case RoleEnum.project_manager:
        defineProjectManagerRules(user, can, cannot);
        break;
      default:
        defineContributorRules(user, can, cannot);
        break;
    }
  }
  return rules;
}

function defineAdminRules(can, cannot): void {
  can('manage', 'all');
}

function defineContributorRules(user, can, cannot): void {
  can(['read', 'update'], 'Project', {
    'contributors.username': user.username,
  });

  can('read', 'Users');
}

function defineProjectManagerRules(user, can, cannot): void {
  can(['read', 'create'], 'Project');

  can(['delete', 'update'], 'Project', {
    'createdBy.username': user.username,
  });

  can('read', 'Users');
}

Pour ce faire, on crée une fonction qui va définir l’ability de votre utilisateur en fonction de ses informations et plus précisément son rôle.

Pour la lisibilité du code, j’ai créé des fonctions par type de rôle géré (ici admin, contributor et project manager) appelé suivant le rôle de l’utilisateur via un switch.

Et pour chacun des rôles, j’ai utilisé les « can » et « cannot » approprié par rapport à ce que mes utilisateurs peuvent faire.

C’est assez facile et lisible à lire, vous ne trouvez pas ?

Notez cette ligne :

can('manage', 'all')

Cette ligne permet de dire à CASL que votre utilisateur pourra tout faire.

Vérifier vos Abilities dans votre application

La définition des Abilities est plutôt simple et surtout très lisible. Mais après avoir défini vos cas de figure, il faut pouvoir les exploiter. Et il existe plusieurs solutions à cela suivant votre cas de figure.

Valider vos Abilities CASL côté BackEnd

Utiliser CASL côté API est vraiment intéressant. En général, vous allez protéger les routes de votre API vis-à-vis de l’Ability de votre utilisateur.

Comment cela se présente ?

En fait, cela va surtout dépendre de comment vous avez constitué votre API, mais la finalité sera la même avec CASL

Dans le code plus haut, j’ai défini une fonction defineAbilitiesFor qui retourne un objet de type Abilities. Et il est alors possible de tester les conditions que vous souhaitez dans votre code :

const ability = defineAbilitiesFor(user); // role project manager

ability.can('read', 'Project'); // true

ability.can('create', 'Users'); //false

Il suffit ensuite d’appeler la méthode « can » dans notre objet « ability » et de tester ce que l’on veut valider.

Il suffit ensuite de se faire un système pour valider ses routes d’API de manière dynamique par le biais des « can ».

Ce que j’ai mis en place pour mon API

Mon API étant en NestJS j’ai combiné 2 mécanismes contenus pour automatiser et protéger mes routes par ces abilities.

J’ai tout d’abord créé un decorator custom que j’ai nommé « Abilities » :

import { SetMetadata } from "@nestjs/common";

export const Abilities = (...abilities: string[]) => SetMetadata("abilities", abilities);

Ainsi sur chacune de mes routes d’API, j’ai utilisé cette syntaxe :

@Abilities("Sujet:action")

Par exemple pour ma route d’API qui permet de lire un projet, j’aurais l’« Abilities » suivant :

@Abilities("Projects:read")

J’ai couplé cela avec un AuthGuard, une fonctionnalité provenant de passport. Le AuthGuard permet de réaliser un traitement et des conditions que vous décidez.

Si votre traitement renvoi faux, votre API renverra une erreur disant que vous n’êtes pas autorisé à utiliser cette route et il n’entrera même pas sur votre route. Sinon il laissera le traitement de la route s’exécuter.

Et justement dans mon AuthGuard, j’effectue des vérifications par rapport à ce que j’ai renseigné dans mon décorator Abilities :

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Reflector } from "@nestjs/core";

@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
  private scopesRouting = [];
  private abilitiesRouting = [];
  private user;

  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    this.abilitiesRouting = [];
    this.scopesRouting = this.reflector.get<string[]>(
      "abilities",
      context.getHandler()
    );
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    this.user = user;
    if (err || !this.user.data) {
      throw err || new UnauthorizedException("Unauthorized");
    }

    if (!this.scopesRouting) {
      return this.user;
    }
    if (this.user.data.role !== "admin") {
      this.formatAbilitiesRouting();
      this.matchAbilities(this.user.abilities);
    }

    return this.user;
  }

  private formatAbilitiesRouting() {
    this.scopesRouting.forEach(scope => {
      const subject = scope.split(":")[0];
      const userAction = scope.split(":")[1];
      this.abilitiesRouting.push({ userAction, subject });
    });
  }

  private matchAbilities(abilitiesUser) {
    this.abilitiesRouting.forEach(ability => {
      if (!abilitiesUser.can(ability.userAction, ability.subject)) {
        throw new UnauthorizedException(`Unauthorized : abilities {${ability.userAction}, ${ability.subject}} invalid`);
      }
    });
  }
}

Ce code récupère le contenu de mon decorator « Abilities » que je découpe en 2 variables : le sujet et l’action. Puis je le teste via le « can » mon « Ability » pour vérifier si mon utilisateur a le droit d’accéder à la route.

On ne le voit pas dans ce code, mais en amont l’« Ability » de mon utilisateur a été créé lorsque j’ai décodé son jeton JWT, qui est issu de mon mécanisme d’authentification.

Ainsi, juste avec un décorator où je passe le sujet et l’action, je protège simplement, mais efficacement les routes de mon API.

Valider vos Abilities CASL côté FrontEnd

Pour le FrontEnd c’est un peu le même principe, mais il existe des mécanismes plus simple et beaucoup plus efficace suivant le framework que vous pouvez utiliser.

Le front de notre projet est réalisé en Angular et nous avons donc utilisé le package @casl/angular et on s’est basé sur cet exemple que vous pouvez retrouver le code ici.

Globalement le principe sera le même qu’au niveau Backend. Vous placez où vous voulez sécuriser l’information du sujet et de l’action pour que ce soit pris en compte par CASL.

Chez Angular, CASL a créé un pipe « able » que vous pouvez utiliser dans un ngIf par exemple :

<project-list 
  (listProjects)="loadProject($event)" 
  *ngIf="'Project' | able: 'read'"
></project-list>

Et comme vous voyez pour l’utilisation, c’est aussi simple que ça.

Vous devrez bien entendu faire comme j’ai fait côté API c’est-à-dire définir vos possibilités d’Abilities pour vos utilisateurs.

Epilogue

Au vu de ce que j’ai pu découvrir et tester dans mes précédents projets, CASL est pour moi l’une des meilleures librairies pour gérer vos permissions/droits en JavaScript.

Le fait qu’elle soit très polyvalente lui donne un atout indéniable : vous avez la même librairie côté front et côté back.

D’ailleurs, nous pensons mutualiser notre matrice de rôles/permissions sous forme de package commun au front et au back de l’application de notre client Oney.

Je n’ai pas montré d’exemple sur le filtrage par rapport aux données. En effet, j’exploite la partie condition que vous pouvez mettre dans les « can » et les « cannot » niveau Backend et FrontEnd.

Pour le Backend, je l’utilise pour ajouter des conditions dans mes requêtes MongoDb pour filtrer mes données.

Pour le FrontEnd, je l’utilise pour afficher/masquer des actions en me basant sur les données de mes objets dans Angular.

Vous l’aurez compris CASL est complet et efficace sans pour autant être une usine à gaz rendant votre code hyper complexe.

D’ailleurs, retenez bien ceux-ci : « Si vos conditions pour gérer vos permissions/droits vous semblent compliquées c’est que vous allez dans la mauvaise direction ».

Partager ce contenu
  • 1
    Partage

Leave a Comment

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