Skip to content

Authorization avec CASL (ABAC) dans NestJS

TL;DR — CASL est une bibliothèque isomorphique d'autorisation qui implémente l'ABAC (Attribute-Based Access Control). On définit des abilities (can/cannot) sur des subjects (entités) avec des conditions (filtres sur attributs). Côté Nest, on l'intègre via un PoliciesGuard et un décorateur @CheckPolicies. CASL brille pour le multi-tenant, l'évaluation côté serveur ET client (même règles partagées), et peut générer des conditions MongoDB/SQL pour filtrer en base. À comparer avec RBAC (rôles plats) et ReBAC (relations type Google Zanzibar / OpenFGA).

🧠 Mental model — diagramme ASCII + analogie

CASL modélise l'autorisation comme un ensemble de règles évaluées dans l'ordre. Chaque règle dit « cet utilisateur peut/ne peut pas effectuer cette action sur ce sujet, si ces conditions sont vraies sur ses attributs ».

                  ┌────────────────────────────────────────────────────┐
                  │              AbilityBuilder pour user X             │
                  ├────────────────────────────────────────────────────┤
                  │  can('read',   'Article')                          │
                  │  can('update', 'Article', { authorId: X.id })      │
                  │  cannot('delete','Article',{ published: true })    │
                  │  can('manage', 'Article', { tenantId: X.tenantId })│
                  └────────────────────────────────────────────────────┘

                                       ▼  build()
                  ┌────────────────────────────────────────────────────┐
                  │                   Ability (immutable)               │
                  │   can('update', article) ─► true/false              │
                  │   accessibleBy(ability) ─► MongoQuery / SQL where   │
                  └────────────────────────────────────────────────────┘

  Comparaison :

      RBAC : user → role → permissions (plate, simple, peu expressive)
      ABAC : decision(user, action, resource, env) → bool (très expressive)
      ReBAC: relations(user, resource) → bool (Google Zanzibar, OpenFGA)

Analogie : un trousseau de clés avec étiquettes conditionnelles. La clé « ouvrir » fonctionne sur la porte « Article » seulement si l'étiquette dit authorId === moi. Le concierge (le Guard) regarde le trousseau, lit les étiquettes, et autorise ou refuse.

🛠️ Code minimal (ts)

ts
// src/casl/casl-ability.factory.ts
import { Injectable } from '@nestjs/common';
import {
  AbilityBuilder,
  AbilityClass,
  ExtractSubjectType,
  InferSubjects,
  PureAbility,
  createMongoAbility,
  MongoAbility,
} from '@casl/ability';
import { Article } from '../article/article.entity';
import { User } from '../user/user.entity';

export enum Action {
  Manage = 'manage', // wildcard
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

export type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
export type AppAbility = MongoAbility<[Action, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: { id: string; role: 'admin' | 'editor' | 'reader'; tenantId: string }) {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(
      createMongoAbility as AbilityClass<AppAbility>,
    );

    if (user.role === 'admin') {
      can(Action.Manage, 'all', { tenantId: user.tenantId });
    } else if (user.role === 'editor') {
      can(Action.Read, Article, { tenantId: user.tenantId });
      can(Action.Create, Article);
      can(Action.Update, Article, { authorId: user.id });
      cannot(Action.Update, Article, { locked: true }).because('Article locked');
      can(Action.Delete, Article, { authorId: user.id, published: false });
    } else {
      can(Action.Read, Article, { published: true, tenantId: user.tenantId });
    }

    return build({
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}
ts
// src/casl/policies.guard.ts
import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory, AppAbility } from './casl-ability.factory';

export type PolicyHandler =
  | ((ability: AppAbility, ctx: ExecutionContext) => boolean | Promise<boolean>)
  | { handle(ability: AppAbility, ctx: ExecutionContext): boolean | Promise<boolean> };

export const CHECK_POLICIES_KEY = 'check_policies';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
  Reflect.metadata(CHECK_POLICIES_KEY, handlers);

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private factory: CaslAbilityFactory,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const handlers =
      this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, ctx.getHandler()) ?? [];
    const req = ctx.switchToHttp().getRequest();
    const ability = this.factory.createForUser(req.user);
    req.ability = ability;
    for (const h of handlers) {
      const ok = typeof h === 'function' ? await h(ability, ctx) : await h.handle(ability, ctx);
      if (!ok) throw new ForbiddenException();
    }
    return true;
  }
}
ts
// src/article/article.controller.ts
import { Controller, Get, Param, Patch, Body, UseGuards, Req } from '@nestjs/common';
import { Action } from '../casl/casl-ability.factory';
import { CheckPolicies, PoliciesGuard } from '../casl/policies.guard';
import { ArticleService } from './article.service';
import { ForbiddenError } from '@casl/ability';
import { accessibleBy } from '@casl/mongoose'; // ou '@casl/prisma' selon l'ORM
import { Article } from './article.entity';

@Controller('articles')
@UseGuards(PoliciesGuard)
export class ArticleController {
  constructor(private readonly svc: ArticleService) {}

  @Get()
  @CheckPolicies((ab) => ab.can(Action.Read, Article))
  async list(@Req() req) {
    // accessibleBy compile les règles `read Article` en MongoQuery / WHERE SQL
    // filtrant la collection au plus près du modèle de permission.
    const where = accessibleBy(req.ability).Article;
    return this.svc.findAll(where);
  }

  @Patch(':id')
  async update(@Param('id') id: string, @Body() dto: any, @Req() req) {
    const article = await this.svc.findOneOrThrow(id);
    ForbiddenError.from(req.ability).throwUnlessCan(Action.Update, article);
    return this.svc.update(article, dto);
  }
}

🎯 Patterns courants — 3-6 patterns

1. Policy handlers réutilisables (classes)

Plutôt que de répéter des arrow functions, on peut créer des classes PolicyHandler injectables et composables.

ts
@Injectable()
export class ReadArticlePolicyHandler implements PolicyHandler {
  handle(ability: AppAbility) {
    return ability.can(Action.Read, Article);
  }
}

@Get()
@CheckPolicies(new ReadArticlePolicyHandler())
list() { /* ... */ }

Pour un policy avec paramètre dépendant de la requête (:id), on garde l'arrow function ou on utilise un interceptor qui charge la ressource avant le guard.

2. Conditions sur attributs et accessibleBy (filtrage en base)

Le killer feature de CASL : transformer les conditions en MongoQuery ou en where SQL pour ne récupérer en base que les enregistrements autorisés.

ts
import { accessibleBy } from '@casl/mongoose';

@Get()
async list(@Req() req) {
  const query = accessibleBy(req.ability).Article;
  return this.articleModel.find({ $and: [{ published: true }, query] });
}

Pour TypeORM/Prisma, utiliser @casl/prisma qui transforme en where Prisma natif :

ts
import { accessibleBy } from '@casl/prisma';

const articles = await this.prisma.article.findMany({
  where: { AND: [accessibleBy(req.ability).Article, { published: true }] },
});

C'est un gain énorme : on n'a plus à filtrer manuellement après lecture, et le SQL est généré au plus près du modèle de permission.

3. Multi-tenant rules

L'isolation tenant est un cas typique d'ABAC. Au lieu de répéter WHERE tenantId = ? partout, on l'inscrit dans les abilities.

ts
if (user.role === 'admin') {
  can(Action.Manage, 'all', { tenantId: user.tenantId });
}

Toutes les règles héritent du filtre tenant. accessibleBy génère automatiquement le WHERE tenantId = ?. Si un développeur oublie le filtre dans un query, la requête retourne 0 lignes (au lieu de fuiter cross-tenant). C'est un cran de sécurité crucial.

4. ABAC dynamique chargé depuis la DB

Les règles peuvent venir d'une table policies modifiable par les admins.

ts
type StoredRule = { action: string; subject: string; conditions: Record<string, unknown>; inverted: boolean };

async createForUser(user: User) {
  const stored: StoredRule[] = await this.policyRepo.findForUser(user.id);
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
  for (const r of stored) {
    const fn = r.inverted ? cannot : can;
    fn(r.action as Action, r.subject as Subjects, interpolate(r.conditions, user));
  }
  return build();
}

interpolate remplace les placeholders (${user.id}, ${user.tenantId}) par les valeurs réelles. Mettre un cache LRU + invalidation pour éviter de rejouer les requêtes à chaque appel.

5. Comparaison RBAC vs ABAC vs ReBAC

  • RBAC : « les admins peuvent tout ». Simple, mais combinatoire explosive dès qu'on a besoin de conditions (admin du tenant X uniquement). Solution Nest : RolesGuard + @Roles('admin').
  • ABAC : « les éditeurs peuvent modifier les articles dont ils sont l'auteur dans leur tenant ». Très expressif. CASL excelle ici.
  • ReBAC : « Alice peut lire ce document parce qu'elle est dans le groupe partagé par Bob qui l'a partagé en lecture seule ». Modèle Google Zanzibar, OpenFGA, SpiceDB. Idéal pour les graphes de relations complexes (Google Docs, GitHub).

CASL peut imiter du ReBAC simple via des conditions qui interrogent une table de relations, mais au-delà de 2-3 hops, préférer un vrai moteur ReBAC.

6. Performance : compiler une seule fois par requête

createMongoAbility compile les règles en JIT (sift.js pour les conditions). Pour des règles complexes, recréer l'ability à chaque requête coûte. Mémoiser par userId + rulesVersion :

ts
@Injectable()
export class CachedAbilityFactory {
  private cache = new LRUCache<string, AppAbility>({ max: 1000, ttl: 60_000 });

  createForUser(user: User) {
    const key = `${user.id}:${user.rulesVersion}`;
    const hit = this.cache.get(key);
    if (hit) return hit;
    const built = this.build(user);
    this.cache.set(key, built);
    return built;
  }
}

Invalider le cache quand on change les règles (event policy.updated).

🔄 Versions — Nest 7 → 11 + libs tierces

  • Nest 7-8 : CASL v4 utilisait Ability et defineAbility ; type-safety limitée, pas de typage strict sur subjects.
  • Nest 9 : CASL v5 introduit defineAbility typé et subject(type, obj) helper. Recommandé pour TypeScript strict.
  • Nest 10 : CASL v6 sépare @casl/ability (core) de @casl/mongoose, @casl/prisma, @casl/angular, @casl/react. API plus modulaire. createMongoAbility remplace Ability direct.
  • Nest 11 : CASL v6.x stable. Compatible Express 5. @casl/prisma v1.5 ajoute le support des relations imbriquées dans accessibleBy.
  • @casl/mongoose v8 : breaking sur accessibleBy (retourne un MongoQuery au lieu d'un plugin sur le schema).
  • @casl/prisma : actif, supporte Prisma 5 et 6. Watch out pour les changements de signature accessibleBy entre versions mineures.
  • Alternatives à considérer en 2025-2026 : Cerbos (PDP externe, policies en YAML), OpenFGA (ReBAC managed), Oso (ABAC + ReBAC en Polar DSL). Pour des règles très dynamiques en multi-tenant SaaS, Cerbos ou OpenFGA peuvent surpasser CASL côté ops.

⚠️ Pitfalls — 6-10 pièges

  1. Oublier tenantId dans les conditions globales. Sans cette précaution, un admin du tenant A peut voir les données du tenant B. Toujours ajouter { tenantId: user.tenantId } même pour manage all.
  2. detectSubjectType mal configuré. Si ability.can('read', article) retourne toujours false, c'est souvent que CASL n'arrive pas à identifier le type de article. Configurer detectSubjectType ou utiliser subject('Article', article).
  3. Conditions sur des propriétés non chargées. Si l'entité Article n'a pas chargé son authorId (lazy relation), la condition { authorId: user.id } échoue. Toujours s'assurer que les attributs requis sont chargés avant l'évaluation.
  4. Mélanger can('manage', 'all') et règles spécifiques. manage est un wildcard. Si on ajoute cannot('delete', 'Article') après can('manage', 'all'), ça fonctionne (les cannot annulent). Mais l'ordre compte : cannot doit venir après le can global qu'il restreint.
  5. Évaluer les permissions sans charger la ressource. ability.can('update', Article) (type-level) est différent de ability.can('update', article) (instance). La première vérifie qu'il existe au moins une règle, la seconde évalue les conditions. Toujours évaluer sur l'instance pour les routes PATCH /:id.
  6. accessibleBy qui retourne {}. Si l'utilisateur a une règle can('read', 'all') sans conditions, accessibleBy retourne {} (tout autorisé). En multi-tenant, c'est un bug catastrophique. Toujours forcer une condition tenant.
  7. Pas de tests d'autorisation négative. On teste souvent « l'admin peut faire X », rarement « le reader ne peut PAS faire X ». Les régressions arrivent sur les chemins négatifs.
  8. Couplage CASL au domain model. Importer CASL dans les entités du domaine pollue la couche métier. Garder CASL dans une couche authorization/ séparée.
  9. Re-create ability à chaque appel imbriqué. Dans une méthode qui fait 50 vérifs, recréer l'ability à chaque fois est gaspilleur. Construire une fois par requête (le PoliciesGuard l'attache à req.ability).
  10. Audit log absent. Toute décision d'autorisation devrait être loggable. ForbiddenError.setDefaultMessage permet de logger la règle bloquante (error.message, error.subject, error.action).

🧪 Testing — exemples concrets

Test unitaire de l'AbilityFactory

ts
// src/casl/casl-ability.factory.spec.ts
import { CaslAbilityFactory, Action } from './casl-ability.factory';
import { Article } from '../article/article.entity';

describe('CaslAbilityFactory', () => {
  const factory = new CaslAbilityFactory();

  it('editor can update own article in same tenant', () => {
    const ability = factory.createForUser({ id: 'u1', role: 'editor', tenantId: 't1' });
    const own = Object.assign(new Article(), { authorId: 'u1', tenantId: 't1', locked: false });
    expect(ability.can(Action.Update, own)).toBe(true);
  });

  it('editor cannot update locked article', () => {
    const ability = factory.createForUser({ id: 'u1', role: 'editor', tenantId: 't1' });
    const locked = Object.assign(new Article(), { authorId: 'u1', tenantId: 't1', locked: true });
    expect(ability.can(Action.Update, locked)).toBe(false);
  });

  it('editor cannot update article in other tenant', () => {
    const ability = factory.createForUser({ id: 'u1', role: 'editor', tenantId: 't1' });
    const foreign = Object.assign(new Article(), { authorId: 'u1', tenantId: 't2', locked: false });
    expect(ability.can(Action.Update, foreign)).toBe(false);
  });

  it('admin can delete any article in own tenant', () => {
    const ability = factory.createForUser({ id: 'a1', role: 'admin', tenantId: 't1' });
    const target = Object.assign(new Article(), { authorId: 'other', tenantId: 't1' });
    expect(ability.can(Action.Delete, target)).toBe(true);
  });

  it('reader sees only published articles', () => {
    const ability = factory.createForUser({ id: 'r1', role: 'reader', tenantId: 't1' });
    const draft = Object.assign(new Article(), { tenantId: 't1', published: false });
    const live = Object.assign(new Article(), { tenantId: 't1', published: true });
    expect(ability.can(Action.Read, draft)).toBe(false);
    expect(ability.can(Action.Read, live)).toBe(true);
  });
});

Test du PoliciesGuard (mocking ExecutionContext)

ts
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PoliciesGuard, CHECK_POLICIES_KEY } from './policies.guard';

const ctxFactory = (user: any): ExecutionContext =>
  ({
    switchToHttp: () => ({ getRequest: () => ({ user }) }),
    getHandler: () => ({}),
  } as any);

it('blocks user without matching policy', async () => {
  const reflector = { get: () => [() => false] } as unknown as Reflector;
  const factory = new CaslAbilityFactory();
  const guard = new PoliciesGuard(reflector, factory);
  await expect(
    guard.canActivate(ctxFactory({ id: 'u1', role: 'reader', tenantId: 't1' })),
  ).rejects.toThrow('Forbidden');
});

Test e2e du filtrage accessibleBy

ts
// test/articles.e2e-spec.ts
it('list endpoint only returns accessible articles', async () => {
  await seedArticle({ tenantId: 't1', published: true });
  await seedArticle({ tenantId: 't1', published: false });
  await seedArticle({ tenantId: 't2', published: true });

  const readerToken = signFor({ id: 'r1', role: 'reader', tenantId: 't1' });
  const res = await request(app.getHttpServer())
    .get('/articles')
    .set('Authorization', `Bearer ${readerToken}`)
    .expect(200);

  expect(res.body).toHaveLength(1); // seulement t1 + published
  expect(res.body[0].tenantId).toBe('t1');
});

Test du message d'erreur (because)

ts
import { ForbiddenError } from '@casl/ability';

it('throws with reason when locked', () => {
  const ability = factory.createForUser({ id: 'u1', role: 'editor', tenantId: 't1' });
  const locked = Object.assign(new Article(), { authorId: 'u1', tenantId: 't1', locked: true });
  try {
    ForbiddenError.from(ability).throwUnlessCan(Action.Update, locked);
    fail('should throw');
  } catch (err) {
    expect(err.message).toContain('Article locked');
  }
});

Test de performance / cache LRU

ts
it('caches abilities per user version', () => {
  const spy = jest.spyOn(CachedAbilityFactory.prototype as any, 'build');
  const user = { id: 'u1', role: 'editor', tenantId: 't1', rulesVersion: 1 };
  factory.createForUser(user);
  factory.createForUser(user);
  expect(spy).toHaveBeenCalledTimes(1);
  factory.createForUser({ ...user, rulesVersion: 2 });
  expect(spy).toHaveBeenCalledTimes(2);
});

🎬 Cas d'usage concrets

Cabinet juridique — ABAC par dossier et client

Qui : SaaS de gestion de cabinets, 500 cabinets clients. Chaque dossier juridique a un avocat responsable, des collaborateurs assignés, et un client. Confidentialité absolue inter-dossiers : un avocat ne voit que ses dossiers + ceux où il est explicitement collaborateur.

Problème : RBAC ne suffit pas. Le rôle "avocat" ne dit rien sur QUELS dossiers il peut consulter. Il faut une règle ABAC qui croise l'identité utilisateur, son rôle, et l'attribut assigneeIds de la ressource.

ts
@Injectable()
export class CaseAbilityFactory {
  createFor(user: User) {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
    if (user.role === 'PARTNER') {
      can('manage', 'Case', { firmId: user.firmId });
    } else if (user.role === 'LAWYER') {
      can('read', 'Case', { firmId: user.firmId, assigneeIds: { $in: [user.id] } });
      can('update', 'Case', { firmId: user.firmId, leadLawyerId: user.id });
    } else if (user.role === 'PARALEGAL') {
      can('read', 'Case', { firmId: user.firmId, assigneeIds: { $in: [user.id] }, confidentialityLevel: { $lt: 3 } });
    }
    cannot('read', 'Case', { sealed: true }).because('Case is sealed by court order');
    return build({ detectSubjectType: (o) => o.constructor as any });
  }
}

@Controller('cases')
export class CaseController {
  @Get(':id')
  async show(@Param('id') id: string, @CurrentUser() user: User) {
    const c = await this.repo.findOneOrFail(id);
    const ability = this.factory.createFor(user);
    ForbiddenError.from(ability).throwUnlessCan('read', c);
    return c;
  }

  @Get()
  async list(@CurrentUser() user: User) {
    const ability = this.factory.createFor(user);
    return this.repo.findAccessible(accessibleBy(ability).Case);
  }
}

Gains : zéro fuite inter-dossiers depuis 18 mois en production. Le filtrage accessibleBy génère un WHERE SQL qui restreint les listes en base directement, évitant le pattern dangereux "tout charger puis filtrer côté app". Audit Ordre des avocats validé.

Banque retail — délégation temporaire de pouvoir

Qui : banque mutualiste 6 millions de clients. Un conseiller peut déléguer ses pouvoirs à un remplaçant pendant ses congés. Le remplaçant hérite des accès clients du conseiller, mais uniquement sur la période déclarée et avec un audit log spécifique.

Problème : ABAC pur sur le rôle ne suffit pas. Il faut intégrer une notion temporelle (now() between delegation.startsAt and endsAt) et tracer chaque action effectuée sous délégation.

ts
@Injectable()
export class BankingAbilityFactory {
  constructor(private readonly delegations: DelegationsService) {}

  async createFor(user: User) {
    const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
    const portfolioIds = [user.id];
    const activeDelegations = await this.delegations.findActiveFor(user.id, new Date());
    for (const d of activeDelegations) {
      portfolioIds.push(d.delegatorId);
    }
    can('read', 'CustomerFile', { advisorId: { $in: portfolioIds } });
    can('update', 'CustomerFile', { advisorId: { $in: portfolioIds }, frozen: false });
    return build();
  }
}

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest();
    return next.handle().pipe(tap(async () => {
      const onBehalfOf = req.user.delegations?.find((d: any) => d.coversTarget(req.params?.id));
      if (onBehalfOf) {
        await this.audit.log({
          actorId: req.user.id,
          onBehalfOfId: onBehalfOf.delegatorId,
          action: `${req.method} ${req.route.path}`,
          resourceId: req.params.id,
        });
      }
    }));
  }
}

Gains : délégations gérées en self-service par les conseillers, audit ACPR conforme sur le "qui agissait pour qui". Le pattern factory async permet de précharger les délégations actives en un seul query au lieu de vérifier à chaque check.

Plateforme immobilière — agence / agent / mandant

Qui : portail B2B pour agences immobilières, 1 200 agences clientes, 8 000 agents. Trois niveaux d'accès : directeur d'agence (tout), agent (ses mandats), mandant propriétaire (son bien seulement, lecture).

Problème : un agent peut être inscrit dans plusieurs agences (multi-employeur). Les permissions varient selon l'agence courante et le mandat. Un propriétaire connecté ne doit voir que ses biens, jamais les commissions ni les autres mandats de l'agence.

ts
@Injectable()
export class RealEstateAbilityFactory {
  createFor(user: User, context: { currentAgencyId?: string }) {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
    if (user.role === 'DIRECTOR' && user.directedAgencyIds.includes(context.currentAgencyId!)) {
      can('manage', 'Mandate', { agencyId: context.currentAgencyId });
      can('read', 'Commission', { agencyId: context.currentAgencyId });
    } else if (user.role === 'AGENT') {
      can('read', 'Mandate', { agencyId: { $in: user.agencyIds }, assignedAgentId: user.id });
      can('update', 'Mandate', { agencyId: { $in: user.agencyIds }, assignedAgentId: user.id, status: { $ne: 'CLOSED' } });
    } else if (user.role === 'OWNER') {
      can('read', 'Mandate', { ownerId: user.id });
      cannot(['read'], 'Commission');
      cannot('read', 'Mandate', ['internalNotes', 'commissionAmount']);
    }
    return build();
  }
}

Gains : modèle multi-agence sans dupliquer la logique. La protection par champs (cannot('read', 'Mandate', ['internalNotes'])) garantit qu'aucun propriétaire ne verra jamais les notes internes ni les montants commission, même via un autre endpoint. Audit RGPD facilité par la centralisation des règles.

🛠️ Exemple end-to-end

Contexte : SaaS de gestion de patrimoine pour conseillers en gestion de patrimoine (CGP). Chaque CGP gère des clients personnes physiques, certaines fiches sont co-gérées par plusieurs CGP du cabinet, et les clients premium ont un accès lecture à leur propre dossier via un portail. Règles ABAC complètes avec filtrage liste, vérification d'instance, champs masqués, et audit log.

ts
// src/abac/ability.factory.ts
import { AbilityBuilder, createMongoAbility, MongoAbility, ExtractSubjectType, InferSubjects } from '@casl/ability';
import { Injectable } from '@nestjs/common';

type Action = 'manage' | 'create' | 'read' | 'update' | 'delete';
type Subject = InferSubjects<typeof ClientFile | typeof Portfolio | typeof Transaction | 'all'>;
export type AppAbility = MongoAbility<[Action, Subject]>;

@Injectable()
export class WealthAbilityFactory {
  createFor(user: AuthenticatedUser): AppAbility {
    const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);

    if (user.role === 'ADMIN') {
      can('manage', 'all');
    } else if (user.role === 'CGP') {
      can('read', ClientFile, {
        firmId: user.firmId,
        $or: [
          { leadAdvisorId: user.id },
          { coAdvisorIds: { $in: [user.id] } },
        ],
      });
      can('update', ClientFile, { firmId: user.firmId, leadAdvisorId: user.id, status: { $ne: 'ARCHIVED' } });
      can('create', ClientFile);
      can('read', Portfolio, { 'clientFile.firmId': user.firmId, 'clientFile.leadAdvisorId': user.id });
      can('manage', Transaction, { 'portfolio.clientFile.leadAdvisorId': user.id });
      cannot('read', ClientFile, ['internalCompliance']).because('Compliance only');
    } else if (user.role === 'COMPLIANCE') {
      can('read', 'all', { firmId: user.firmId });
      cannot(['create', 'update', 'delete'], 'all');
    } else if (user.role === 'CLIENT_PORTAL') {
      can('read', ClientFile, { id: user.clientFileId });
      can('read', Portfolio, { clientFileId: user.clientFileId });
      can('read', Transaction, { 'portfolio.clientFileId': user.clientFileId });
      cannot('read', ClientFile, ['internalNotes', 'commissionRate', 'internalCompliance']);
      cannot('read', Transaction, ['internalFee', 'rebate']);
    }

    return build({ detectSubjectType: (o) => o.constructor as ExtractSubjectType<Subject> });
  }
}

// src/abac/abilities.guard.ts
@Injectable()
export class AbilitiesGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly factory: WealthAbilityFactory,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const rules = this.reflector.get<RequiredRule[]>('check_ability', context.getHandler()) ?? [];
    if (rules.length === 0) return true;
    const req = context.switchToHttp().getRequest();
    const ability = this.factory.createFor(req.user);
    for (const rule of rules) {
      ForbiddenError.from(ability)
        .setMessage(`Forbidden: ${rule.action} ${rule.subject}`)
        .throwUnlessCan(rule.action, rule.subject);
    }
    req.ability = ability;
    return true;
  }
}

// src/clients/client-file.controller.ts
@Controller('client-files')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
export class ClientFileController {
  constructor(
    private readonly repo: ClientFileRepository,
    private readonly transactions: TransactionService,
    private readonly factory: WealthAbilityFactory,
    private readonly audit: AuditLogService,
  ) {}

  @Get()
  @CheckAbilities({ action: 'read', subject: ClientFile })
  async list(@CurrentUser() user: AuthenticatedUser, @Query() q: ListQueryDto) {
    const ability = this.factory.createFor(user);
    const where = accessibleBy(ability).ClientFile;
    const files = await this.repo.find({ where, take: q.limit ?? 20, skip: q.offset ?? 0 });
    return files.map((f) => this.applyFieldRestrictions(f, ability));
  }

  @Get(':id')
  async show(@Param('id') id: string, @CurrentUser() user: AuthenticatedUser) {
    const ability = this.factory.createFor(user);
    const file = await this.repo.findOneOrFail({ where: { id } });
    ForbiddenError.from(ability).throwUnlessCan('read', file);
    await this.audit.log('client_file.read', { userId: user.id, clientFileId: id, role: user.role });
    return this.applyFieldRestrictions(file, ability);
  }

  @Patch(':id')
  async update(@Param('id') id: string, @Body() dto: UpdateClientFileDto, @CurrentUser() user: AuthenticatedUser) {
    const ability = this.factory.createFor(user);
    const file = await this.repo.findOneOrFail({ where: { id } });
    ForbiddenError.from(ability).throwUnlessCan('update', file);
    for (const field of Object.keys(dto)) {
      if (!ability.can('update', file, field)) {
        throw new ForbiddenException(`Cannot update field ${field}`);
      }
    }
    Object.assign(file, dto);
    await this.repo.save(file);
    await this.audit.log('client_file.updated', { userId: user.id, clientFileId: id, fields: Object.keys(dto) });
    return this.applyFieldRestrictions(file, ability);
  }

  private applyFieldRestrictions(file: ClientFile, ability: AppAbility): Partial<ClientFile> {
    const out: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(file)) {
      if (ability.can('read', file, key)) out[key] = value;
    }
    return out as Partial<ClientFile>;
  }
}

Factory typée, guard générique qui lit les règles via decorator, filtrage liste via accessibleBy, vérification instance + champ pour les mises à jour, masquage de champs en sortie selon les permissions, audit log systématique. Le SaaS protège 50 000 dossiers clients avec une matrice 4 rôles × 8 ressources × 6 actions et zéro fuite de données depuis 2 ans en production.


🔁 Quand utiliser / éviter

Utiliser quand : SaaS multi-tenant avec règles complexes ; besoin de partager les règles entre frontend (React/Angular) et backend (UI conditionnelle + API consistent) ; règles dépendant d'attributs de l'utilisateur ET de la ressource (ownership, statut, tenant) ; besoin de filtrer les listes en base avec accessibleBy ; règles éditables par les admins en runtime.

Éviter quand : RBAC plat suffit (3 rôles, peu de conditions) — un RolesGuard simple est plus lisible ; relations complexes type graphe (préférer OpenFGA, SpiceDB) ; politique d'entreprise centralisée multi-services (préférer un PDP externe type Cerbos ou OPA pour avoir une source de vérité unique) ; besoin de policy-as-code auditable (préférer Rego/OPA qui a un langage formel et des outils de testing matures).

Tableau de décision :

CasRecommandation
2-3 rôles, pas de conditionsRolesGuard simple
Conditions sur attributs, multi-tenantCASL
Règles partagées front/backCASL
Graphes de relations (sharing, hiérarchies)OpenFGA / SpiceDB
Multi-services, policy as codeCerbos / OPA
Très haute performance, règles statiquesCode en dur + guards

🧰 Modélisation des permissions — patrons d'écriture

Choisir les actions

Une erreur fréquente est de copier-coller le CRUD HTTP comme actions (create, read, update, delete). En pratique, le métier a des actions plus précises : publish, archive, assign, approve. Si l'action métier est différente du verbe HTTP, exposer un POST /articles/:id/publish avec @CheckPolicies((ab) => ab.can('publish', Article)) plutôt qu'un PATCH avec un if (dto.published) en interne.

Choisir les subjects

Les subjects peuvent être :

  • des classes (Article, User) : type-checking, idéal pour les vérifications en amont.
  • des strings ('Article', 'all') : pratique pour les règles stockées en DB.
  • des instances : pour évaluer des conditions précises sur les attributs.

Convention recommandée : utiliser les classes en code, les strings en règles persistées, et tagger les instances via subject('Article', plainObject) quand l'objet vient d'un ORM qui ne préserve pas la classe.

ts
import { subject } from '@casl/ability';

const plain = await prisma.article.findUnique({ where: { id } });
const tagged = subject('Article', plain!);
ability.can('update', tagged); // évalue les conditions sur les attributs

Modéliser un workflow d'approval

Pour un workflow type draft → review → published, on définit les actions transitionnelles plutôt que des update génériques :

ts
// Les conditions CASL sont des MongoQuery (objets), PAS des closures :
// la branche par rôle se fait en JS autour des `can`, pas via un `.when()`.
can('submit-for-review', Article, { status: 'draft', authorId: user.id });
if (user.role === 'reviewer') {
  can('approve', Article, { status: 'review' }).because('Only reviewers can approve');
  can('reject',  Article, { status: 'review' });
}
can('publish', Article, { status: 'approved' });

Piège fréquent

CASL n'a pas de .when(closure). Les conditions sont des objets MongoQuery évalués par sift.js ({ status: 'review' }, { authorId: { $in: [...] } }). Toute logique « dépend d'une variable runtime » doit s'exprimer soit en condition objet, soit en branchant le can() en JS au moment du build de l'ability. C'est précisément ce qui rend l'ability sérialisable et partageable front/back.

Cette approche déclarative se rapproche d'une machine à états et facilite l'audit (who can submit-for-review on this article ?). Bonus : un endpoint POST /articles/:id/transitions qui mappe l'action métier (approve, reject) vers la transition d'état rend la matrice de permissions auto-documentée — chaque transition est une action CASL distincte.

Délégation et impersonation

Pour permettre à un support agent d'agir au nom d'un utilisateur, on crée une ability combinée : permissions du support intersectées avec celles de l'utilisateur cible. CASL ne le fait pas nativement, mais on peut composer manuellement :

ts
function intersect(a: AppAbility, b: AppAbility): AppAbility {
  const rules = a.rules.filter((r) => b.can(r.action as Action, r.subject as any));
  return createMongoAbility(rules);
}

Toujours logguer l'impersonation (actor: supportId, on_behalf_of: userId) pour l'audit.

Audit log structuré des décisions

Chaque décision d'autorisation devrait laisser une trace exploitable. Pour cela, on encapsule les vérifications dans un service qui logge systématiquement.

ts
@Injectable()
export class AuthorizationService {
  constructor(private readonly logger: Logger, private readonly factory: CaslAbilityFactory) {}

  authorize(user: User, action: Action, subject: any): void {
    const ability = this.factory.createForUser(user);
    const allowed = ability.can(action, subject);
    this.logger.log({
      msg: 'authz.decision',
      userId: user.id,
      tenantId: user.tenantId,
      action,
      subject: typeof subject === 'string' ? subject : subject.constructor.name,
      subjectId: subject?.id,
      allowed,
    });
    if (!allowed) {
      const rule = ability.relevantRuleFor(action, subject);
      throw new ForbiddenException(rule?.reason ?? 'Forbidden');
    }
  }
}

Avec ce pattern, n'importe quel SOC peut filtrer msg=authz.decision allowed=false pour détecter les tentatives suspectes (e.g. cross-tenant). Les relevantRuleFor aide aussi le debug : on sait exactement quelle règle a bloqué l'accès.

Documentation des règles pour l'équipe

Une matrice de permissions générée automatiquement aide l'équipe produit à comprendre qui peut faire quoi. On parcourt les ability.rules pour chaque rôle de test et on génère un Markdown ou un JSON consommable.

ts
const roles = ['admin', 'editor', 'reader'];
const actions = [Action.Create, Action.Read, Action.Update, Action.Delete];
const subjects = ['Article', 'User'];
const matrix: any[] = [];
for (const role of roles) {
  for (const subject of subjects) {
    for (const action of actions) {
      const ab = factory.createForUser({ id: 'x', role: role as any, tenantId: 't' });
      matrix.push({ role, subject, action, allowed: ab.can(action, subject as any) });
    }
  }
}
fs.writeFileSync('docs/permissions.json', JSON.stringify(matrix, null, 2));

Pousser ce fichier en CI et rendre obligatoire la mise à jour si les règles changent (sinon le diff PR signale la régression).

🤖 CASL pour autoriser des agents IA (NestJS + Angular)

CASL étant isomorphique, il est l'outil idéal pour le cas « un agent IA agit au nom d'un utilisateur ». Le piège de sécurité numéro un des features agentiques : un outil (tool) exposé au LLM exécute une action sans repasser par la couche d'autorisation de l'utilisateur. Le modèle hallucine un deleteAllInvoices, votre tool l'exécute en service-account, et vous avez un incident. La discipline staff : l'ability de l'utilisateur est le périmètre dur de tout ce que l'agent peut faire en son nom.

Principe : l'agent hérite (au plus) de l'ability de l'utilisateur

ts
// src/agent/agent-tools.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { ForbiddenError, subject } from '@casl/ability';
import { accessibleBy } from '@casl/prisma';
import { CaslAbilityFactory, Action, AppAbility } from '../casl/casl-ability.factory';

@Injectable()
export class AgentToolsService {
  constructor(
    private readonly factory: CaslAbilityFactory,
    private readonly prisma: PrismaService,
  ) {}

  /**
   * Chaque tool exposé au LLM reçoit l'ability de l'utilisateur courant.
   * Le LLM ne reçoit JAMAIS un client privilégié — il propose une action,
   * le serveur la valide contre l'ability avant exécution.
   */
  async runTool(name: string, args: unknown, ability: AppAbility) {
    switch (name) {
      case 'list_articles': {
        // Le LLM ne peut lister QUE ce que l'utilisateur peut lire.
        // accessibleBy applique le filtre tenant + ownership en base.
        return this.prisma.article.findMany({
          where: accessibleBy(ability).Article,
          take: 50,
        });
      }
      case 'archive_article': {
        const { id } = args as { id: string };
        const article = await this.prisma.article.findUniqueOrThrow({ where: { id } });
        // throwUnlessCan => si l'utilisateur ne peut pas, l'agent ne peut pas.
        ForbiddenError.from(ability).throwUnlessCan(
          Action.Update,
          subject('Article', article),
        );
        return this.prisma.article.update({ where: { id }, data: { archived: true } });
      }
      default:
        throw new ForbiddenException(`Unknown tool ${name}`);
    }
  }
}

Trois invariants à tenir, sinon vous fuitez :

InvariantPourquoiComment
L'ability est construite à partir du JWT de l'utilisateur final, pas d'un service-accountSinon l'agent a les droits du serveur (god mode)factory.createForUser(req.user) dans le guard, attaché à req.ability
Chaque tool re-valide avec throwUnlessCan sur l'instance chargéeLe LLM peut passer un id arbitraire (cross-tenant)charger la ressource puis subject('X', row)
Les listes passent par accessibleBy, jamais « tout charger puis filtrer »Le filtrage applicatif fuit en pagination/aggregationfiltre poussé en base

Streaming agentique avec annulation et garde d'autorisation

Quand l'agent streame des tokens en SSE et appelle des tools dans la boucle, l'autorisation doit être ré-évaluée à chaque tool-call, pas une seule fois à l'entrée. On câble aussi un AbortController sur la déconnexion client pour ne pas continuer (et payer) un run abandonné.

ts
// src/agent/agent.controller.ts
import { Controller, Sse, Req, MessageEvent } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { Observable } from 'rxjs';

@Controller('agent')
@UseGuards(JwtAuthGuard, PoliciesGuard)
export class AgentController {
  constructor(
    @Inject(ANTHROPIC) private readonly anthropic: Anthropic, // DI, voir forRootAsync ci-dessous
    private readonly tools: AgentToolsService,
    private readonly factory: CaslAbilityFactory,
  ) {}

  @Sse('chat')
  chat(@Req() req): Observable<MessageEvent> {
    const ability = this.factory.createForUser(req.user); // périmètre dur
    const ac = new AbortController();
    req.on('close', () => ac.abort()); // client parti => on coupe le run

    return new Observable((subscriber) => {
      (async () => {
        try {
          let messages = [{ role: 'user' as const, content: req.query.q as string }];
          // Boucle agentique : tant que le modèle demande des tools, on exécute
          // chacun EN RE-VALIDANT l'autorisation, puis on renvoie le résultat.
          for (let hop = 0; hop < 8 && !ac.signal.aborted; hop++) {
            const stream = this.anthropic.messages.stream(
              { model: 'claude-sonnet-4-6', max_tokens: 1024, messages, tools: TOOL_SCHEMAS },
              { signal: ac.signal },
            );
            stream.on('text', (delta) => subscriber.next({ data: { type: 'token', delta } }));
            const final = await stream.finalMessage();

            const toolUses = final.content.filter((b) => b.type === 'tool_use');
            if (toolUses.length === 0) { subscriber.complete(); return; }

            const results = [];
            for (const tu of toolUses) {
              try {
                const out = await this.tools.runTool(tu.name, tu.input, ability); // <-- garde CASL
                results.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(out) });
              } catch (e) {
                // Refus d'autorisation renvoyé AU MODÈLE comme résultat d'outil :
                // il s'adapte au lieu de boucler. On ne crash pas la requête.
                results.push({ type: 'tool_result', tool_use_id: tu.id, is_error: true, content: 'forbidden' });
                subscriber.next({ data: { type: 'tool_denied', tool: tu.name } });
              }
            }
            messages = [...messages, { role: 'assistant', content: final.content }, { role: 'user', content: results }];
          }
          subscriber.complete();
        } catch (e) {
          if (!ac.signal.aborted) subscriber.error(e);
        }
      })();
      return () => ac.abort();
    });
  }
}

Points staff sur ce code :

  • Client Anthropic injecté via forRootAsync (jamais new Anthropic() en champ — non testable, clé en dur, pas de retries configurables) :
ts
// src/llm/llm.module.ts
import { Module } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');

@Module({
  providers: [{
    provide: ANTHROPIC,
    inject: [ConfigService],
    useFactory: (cfg: ConfigService) =>
      new Anthropic({ apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'), maxRetries: 3 }),
  }],
  exports: [ANTHROPIC],
})
export class LlmModule {}
  • Modèles : claude-opus-4-8 (flagship, raisonnement lourd / arbitrage de policies), claude-sonnet-4-6 (boucle agentique par défaut, bon rapport latence/coût), claude-haiku-4-5 (classification/routing d'intent rapide). Le SDK gère retries + backoff ; ne pas re-streamer un run abouti après une déconnexion.
  • Idempotence en BullMQ : si la boucle agentique tourne en job de fond (long run, batch), keyer le job sur un generationId stable. Au retry, ne pas re-exécuter les tools à effet de bord déjà appliqués — persister un log {generationId, toolUseId, status} et court-circuiter les tool_use_id déjà done. Retry cost-aware : ne ré-appeler le LLM que sur les tools restants, pas tout le run.
  • Cost-guard / rate-limit au bord : un guard (avant PoliciesGuard) qui compte les tokens/coût par tenantId et 429 au-delà du quota. CASL répond « as-tu le droit », le cost-guard répond « peux-tu te le permefre » — deux dimensions distinctes.

Côté Angular : masquer l'UI selon la MÊME ability

Le gain isomorphique : les mêmes règles JSON sérialisées du backend pilotent le rendu. Un bouton « Archiver » ne s'affiche que si l'ability le permet — et le backend re-valide (le front est un confort UX, jamais la frontière de sécurité).

ts
// ability.service.ts (Angular 18, standalone, signals)
import { Injectable, signal, computed } from '@angular/core';
import { createMongoAbility, RawRuleOf, MongoAbility } from '@casl/ability';
import { AppAbility, Action } from './app-ability';

@Injectable({ providedIn: 'root' })
export class AbilityService {
  private readonly ability = signal<AppAbility>(createMongoAbility());

  // Le backend expose GET /me/abilities renvoyant `ability.rules` sérialisées.
  setRules(rules: RawRuleOf<AppAbility>[]) {
    this.ability.set(createMongoAbility(rules));
  }

  can(action: Action, subject: unknown) {
    return computed(() => this.ability().can(action, subject as never));
  }
}
html
<!-- composant : signal réactif, recalcule si l'ability change -->
@if (canArchive()) {
  <button (click)="archive()">Archiver</button>
}

Pour une UI d'agent qui streame, on combine ça avec une timeline de tool-calls en union discriminée (pending | running | streaming | done | error) : un tool_denied reçu en SSE rend l'étape error avec un libellé « action non autorisée », pendant que le bouton Stop câble un AbortController qui annule client ET serveur (le req.on('close') plus haut). Le détail de cette UI vit dans le chapitre Angular streaming — ici l'essentiel est que l'ability est le contrat partagé entre les deux côtés.

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Stack supposée NestJS 11 + @casl/ability v6 + Prisma/Mongoose.

1. Le PoliciesGuard typé de base — implémenter

Objectif : recâbler @CheckPolicies + PoliciesGuard from scratch pour une ressource Comment avec règles editor peut update son propre commentaire non verrouillé.

Indice/Solution : Reflect.metadata(CHECK_POLICIES_KEY, handlers) côté décorateur ; dans le guard, lire via reflector.get, construire l'ability, attacher à req.ability, throw ForbiddenException au premier handler false. Tester les 3 cas : own/unlocked → ok, own/locked → ko, foreign → ko. Penser detectSubjectType.

2. Filtrage liste sans fuite — production-grade

Objectif : un endpoint GET /comments qui ne retourne via accessibleBy que les commentaires lisibles, avec pagination, et qui renvoie 0 ligne plutôt que 500 si l'utilisateur n'a aucune règle de lecture.

Indice/Solution : where: accessibleBy(ability).Comment. Cas piège : si la règle est can('read','all') sans condition, accessibleBy retourne {} → fuite multi-tenant. Ajouter un test e2e qui seede 3 tenants et asserte qu'un reader de t1 ne voit jamais t2. Forcer une condition tenantId même sur les règles all.

3. ABAC dynamique depuis la DB + cache invalidé — production-grade

Objectif : charger les règles d'une table policies éditable, interpoler ${user.id}/${user.tenantId}, et mémoiser l'ability par userId:rulesVersion avec invalidation sur event policy.updated.

Indice/Solution : LRUCache keyé ${user.id}:${user.rulesVersion} ; bump rulesVersion (ou cache.delete) sur l'event. Piège : l'interpolation naïve par string ouvre une injection de condition — valider que les placeholders sont dans une allowlist (user.id, user.tenantId, user.firmId) et jamais eval. Tester qu'un changement de policy invalide bien le cache au check suivant.

4. Field-level + masquage en sortie — production-grade

Objectif : un CLIENT_PORTAL lit son ClientFile mais jamais internalNotes/commissionRate ; l'API doit masquer ces champs en sortie et rejeter toute tentative de les modifier.

Indice/Solution : cannot('read', ClientFile, ['internalNotes', 'commissionRate']) ; en sortie, filtrer via ability.can('read', file, key) champ par champ ; en update, boucler sur Object.keys(dto) et throwUnlessCan('update', file, field). Test : un PATCH avec commissionRate doit faire 403, même si l'utilisateur peut update le reste du dossier.

5. Casser le multi-tenant, puis réparer — break-then-fix

Objectif : reproduire une fuite cross-tenant, l'observer, la corriger, et écrouer la régression par un test.

Indice/Solution : retirer { tenantId: user.tenantId } du can('manage', 'all') d'un admin → un admin de t1 lit t2. Le démontrer par un test e2e rouge. Réparer en ré-ajoutant la condition tenant et en ajoutant un test « négatif » systématique. Bonus staff : écrire un test générique qui, pour chaque rôle, asserte qu'accessibleBy(ability).X n'est jamais {} (garde anti-fuite globale).

6. Autoriser un agent IA sans god mode — break-then-fix, niveau staff

Objectif : un tool archive_article(id) exposé à un LLM ; démontrer qu'avec un client privilégié il archive un article cross-tenant, puis le verrouiller derrière l'ability de l'utilisateur.

Indice/Solution : version cassée → le tool utilise un repo service-account, le LLM passe un id de t2, ça passe. Version réparée → le tool reçoit req.ability, charge la ligne, ForbiddenError.from(ability).throwUnlessCan(Action.Update, subject('Article', row)), et le refus est renvoyé au modèle comme tool_result is_error (il s'adapte) plutôt que de crasher la requête. Vérifier qu'un AbortController câblé sur req.on('close') coupe bien le run à la déconnexion.

🎤 En entretien

Q : Différence entre RBAC, ABAC et ReBAC, et quand basculer de l'un à l'autre ? RBAC = user → role → permissions (plat, explose en combinatoire dès qu'il y a des conditions) ; ABAC = decision(user, action, resource, env) → bool (expressif, CASL excelle, idéal multi-tenant + ownership) ; ReBAC = autorisation par relations dans un graphe (Zanzibar/OpenFGA, idéal sharing/hiérarchies à N hops). On bascule RBAC→ABAC quand les permissions dépendent d'attributs de la ressource, et ABAC→ReBAC quand on dépasse 2-3 hops de relations.

Q : Pourquoi accessibleBy est-il un argument de sécurité et pas seulement de perf ? Parce qu'il pousse le filtre d'autorisation en base (WHERE SQL / MongoQuery) au lieu du pattern « tout charger puis filtrer en mémoire », qui fuit en pagination, agrégation et count. Bonus : en multi-tenant, si une condition tenant manque, accessibleBy renvoie {} (tout) — c'est le bug catastrophique à tester ; bien fait, un oubli de filtre renvoie 0 ligne plutôt que de la donnée cross-tenant.

Q : Le ability.can('read', Article) au niveau type renvoie true mais l'utilisateur ne devrait pas voir CET article. Bug ? Non, c'est attendu : la vérification type-level (Article la classe) répond « existe-t-il au moins une règle read sur Article ? », alors que la vérification instance-level (article l'objet) évalue les conditions. Sur une route GET /:id, il faut toujours charger la ressource et évaluer sur l'instance (subject('Article', row) si l'ORM ne préserve pas la classe), sinon on autorise au mauvais grain.

Q : Comment exposez-vous des outils à un agent LLM sans qu'il devienne un vecteur d'escalade de privilèges ? L'ability de l'utilisateur final (construite depuis son JWT, pas un service-account) est le périmètre dur : chaque tool re-valide avec throwUnlessCan sur l'instance chargée, les listes passent par accessibleBy, et un refus est renvoyé au modèle comme tool_result is_error pour qu'il s'adapte. On ajoute au bord un cost-guard/rate-limit (dimension orthogonale à l'autorisation) et un AbortController pour couper le run à la déconnexion client. Le LLM propose, le serveur dispose.

🔗 Liens

Bibliothèque tech perso — Achref