Skip to content

Guards

TL;DR — Un Guard implémente CanActivate.canActivate(ctx): boolean | Promise<boolean> | Observable<boolean>. Il s'exécute après les middlewares, avant les interceptors et les pipes. Sa seule responsabilité : autoriser ou refuser l'exécution du handler. Il a accès au ExecutionContext — donc au handler, à la classe, aux métadonnées via Reflector. C'est le bon endroit pour auth, rôles, claims, tenant isolation, feature flags.

🧠 Mental model

Middleware ──► [ Guard.canActivate(ctx) ] ──► Interceptor (pre) ──► Pipe ──► Handler
                       │                                                       │
                       └─► false / throw ──► ForbiddenException / Unauthorized │

                                                          Interceptor (post) ◄ ┘

Analogie — Le Guard est le videur du club : il connaît la soirée du soir (le handler), regarde ta carte (token) et ta liste VIP (rôles attachés via metadata). Il dit oui ou non. Pas de transformation, pas de mutation du body — pour ça il y a les Interceptors et les Pipes.

ExecutionContext étend ArgumentsHost. Il offre :

  • getHandler() — la méthode du controller ciblée
  • getClass() — la classe du controller
  • switchToHttp() / switchToRpc() / switchToWs() — adapte le contexte par transport

Le Reflector lit les métadonnées posées par @SetMetadata() ou des décorateurs custom (@Roles(), @Public()).

🛠️ Code minimal

ts
// 1) Décorateur custom + clé de metadata
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
ts
// 2) JwtAuthGuard — pose req.user
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly jwt: JwtService, private readonly reflector: Reflector) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    if (isPublic) return true;

    const req = ctx.switchToHttp().getRequest();
    const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
    if (!token) throw new UnauthorizedException('missing_token');
    try {
      req.user = await this.jwt.verifyAsync(token);
      return true;
    } catch {
      throw new UnauthorizedException('invalid_token');
    }
  }
}
ts
// 3) RolesGuard — lit @Roles() via Reflector
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    if (!required?.length) return true; // route non protégée par rôle

    const { user } = ctx.switchToHttp().getRequest();
    if (!user) return false;
    return required.some((r) => user.roles?.includes(r));
  }
}
ts
// 4) Application globale + ordre
@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },   // 1er : exécuté en premier
    { provide: APP_GUARD, useClass: RolesGuard },     // 2e
  ],
})
export class AppModule {}

// Usage controller
@Controller('admin')
@Roles('admin')
export class AdminController {
  @Get('users') @Roles('admin', 'support') list() { /* ... */ }
  @Public() @Get('healthz') health() { return { ok: true }; }
}

🎯 Patterns courants

  1. @Public() opt-out — Guard JWT global + décorateur @Public() pour bypasser proprement les routes publiques. Évite d'attacher @UseGuards() partout. Inverse du pattern "whitelist" qui devient impraticable au-delà de 5 routes.
  2. Claim-based / Policy — au lieu de @Roles('admin'), utiliser @RequirePermissions('user:write') ou un PoliciesGuard qui exécute des fonctions (user, resource) => boolean (style CASL). Permet des règles fines : "user peut éditer un post s'il en est l'auteur OU s'il est admin du tenant".
  3. Tenant isolationTenantGuard qui vérifie que req.tenantId === resource.tenantId (combiné avec un loader d'entité dans un Interceptor ou param decorator). Pour multi-tenant strict, doublé par un filter ORM (Prisma extension, TypeORM subscriber) qui injecte WHERE tenantId = ? partout.
  4. Throttler Guard@nestjs/throttler v5+ : Guard global + @Throttle({ default: { limit: 10, ttl: 60_000 } }) par route. Supporte Redis store pour rate-limiting distribué cross-instances.
  5. Async guardscanActivate peut retourner une Promise (appel DB pour vérifier user actif, fetch policy depuis OPA/Casbin). Garder le coût bas, c'est sur le chemin chaud. Cacher en mémoire avec TTL 5-30s.
  6. Composition@UseGuards(JwtAuthGuard, RolesGuard, TenantGuard) s'exécute dans l'ordre déclaré, court-circuit dès qu'un retourne false/throw.
  7. Feature flag guard@FeatureFlag('beta-checkout') + FeatureFlagsGuard qui hit LaunchDarkly/Unleash. Permet de rouler des features par % d'utilisateurs sans déployer.
  8. Multi-strategy authJwtOrApiKeyGuard qui essaie d'abord JWT, sinon API key dans header x-api-key. Utile pour APIs publiques avec deux modes d'authentification.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

VersionNotes
Nest 7Reflector.get() standard ; pas de getAllAndOverride.
Nest 8Introduction de Reflector.getAllAndOverride() et getAllAndMerge() — préférer ces APIs pour combiner class + method metadata.
Nest 9RxJS 7. Guards qui retournent Observable<boolean> doivent typer Observable<boolean> (pas any).
Nest 10APP_GUARD peut être request-scoped (mais coût perf — instancié par requête).
Nest 11Support natif d'AsyncLocalStorage via @nestjs/core pour propager req.user hors du cycle de vie HTTP (jobs, events).

Reflector — API à connaître :

  • get(key, target) — single target.
  • getAllAndOverride(key, targets[])override : retourne le premier non-undefined (method bat class).
  • getAllAndMerge(key, targets[])merge : concat / union (ex. roles cumulés).

Nest 10.3+Reflector.createDecorator<T>() remplace SetMetadata + clé magique par un décorateur typé. Préfère-le sur les nouveaux projets : tu perds les clés string fragiles et tu gagnes l'inférence.

ts
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
// lecture typée — plus de string 'roles', plus de generic à la main
const required = this.reflector.getAllAndOverride(Roles, [ctx.getHandler(), ctx.getClass()]);
//    ^? string[] | undefined

🧭 Comment un·e staff raisonne sur les Guards

Trois questions cadrent toute décision d'autorisation. Les confondre est la source n°1 des bugs de sécurité.

QuestionMécanisme NestExempleErreur fréquente
Qui es-tu ? (authentification)JwtAuthGuard → pose req.user"ce token est valide, signé, non expiré"mélanger avec l'autorisation → 401/403 confondus
As-tu le droit ? (autorisation coarse)RolesGuard / scopes"tu es admin"hard-coder les rôles dans les handlers
Sur CETTE ressource ? (autorisation fine / ownership)PoliciesGuard + loader"tu es l'auteur de CE post"la faire dans le Guard sans charger la ressource → IDOR

Le piège architectural central : l'autorisation coarse (rôle) se décide sans toucher la DB ; l'autorisation fine (ownership, tenant) exige de charger la ressource, ce qui crée une tension. Deux écoles :

  • Guard charge la ressource (loader injecté, cf. exemple end-to-end). Avantage : décision centralisée, auditable. Coût : la ressource est chargée deux fois (Guard + service) sauf si tu la mets en cache request-scoped (req.loadedResource).
  • Service décide (le Guard ne fait que l'auth coarse, le service fait assertCanAccess(user, resource)). Avantage : pas de double-load, accès à tout le contexte métier. Coût : la décision est dispersée, plus dure à auditer.

Règle staff — RBAC/scopes (sans ressource) → Guard. Ownership/tenant/ABAC (avec ressource) → Guard avec loader + cache request ou policy service appelé en début de handler. Ne fais jamais un findById dans le Guard et un autre dans le service sans partager le résultat : c'est un N+1 silencieux sur le chemin chaud.

Defense-in-depth — un Guard n'est jamais ta seule ligne. Multi-tenant sérieux = Guard (rejet rapide) + filtre ORM systématique (WHERE tenantId = ? injecté via Prisma $extends / TypeORM subscriber). Si quelqu'un oublie le Guard sur une route, le filtre ORM rattrape. Si quelqu'un bypass l'ORM (raw SQL), le Guard rattrape. Les deux échouent ensemble = incident — mais il en faut deux.

Fail-closed, pas fail-open — la propriété de sécurité non négociable d'un Guard : tout chemin qui n'aboutit pas à un return true explicite doit refuser. Un try/catch qui avale une erreur DB et tombe en return true, un switch sans default, un await qui throw mais n'est pas attendu → autant de portes ouvertes. Écris tes Guards de sorte que l'absence de décision = deny. Corollaire : ne mets jamais le return true final hors du try. Si users.getRoles() throw (Redis down), tu veux un 403/503, pas un bypass. Teste explicitement le cas "dépendance en panne".

🔭 Observabilité & audit des décisions d'autorisation

Un refus d'accès est l'événement le plus intéressant de tout ton système pour la sécurité — et celui qu'on oublie le plus de tracer. Règle staff : chaque throw d'un Guard d'autorisation est un événement métier, pas un simple log d'erreur HTTP.

Trois signaux à émettre, dans l'ordre de valeur :

SignalQuoiPourquoi
Audit log (deny et allow sensible)who / action / resourceId / decision / reason / tenantId / requestIdpreuve de conformité (CNIL, SOC2, ACPR), forensics post-incident
Métriquecompteur authz_decision_total{guard, decision, reason}détecter un pic de 403 (attaque par énumération, bug de déploiement de rôles)
Trace (span)un span authz enfant de la requête, attribut decisionvoir le coût latence de l'autorisation sur le chemin chaud

Le piège : ne jamais logger le token, le mot de passe, ni le payload complet de la ressource. Logge des identifiants, pas des secrets ni des données personnelles brutes.

ts
// Un mixin de log à appeler dans le Guard avant de throw.
private deny(req: Request, reason: string, status: HttpStatus): never {
  this.audit.record({
    actor: req.user?.id ?? 'anonymous',
    tenantId: req.user?.tenantId,
    action: `${req.method} ${req.route?.path}`,
    decision: 'deny',
    reason,                       // code domaine stable, jamais un message libre
    requestId: req.headers['x-request-id'],
  });
  this.metrics.increment('authz_decision_total', { guard: 'PoliciesGuard', decision: 'deny', reason });
  throw new ForbiddenException({ code: reason });
}

Pourquoi un reason code stable'tenant_mismatch', 'budget_exhausted', 'no_voter_allowed' sont requêtables en SIEM et alertables. Un message libre ('vous n'avez pas accès') n'est ni l'un ni l'autre. Le reason est ton contrat avec l'équipe sécu.

Anti-pattern observabilité : logger le deny dans un ExceptionFilter global plutôt que dans le Guard. À ce stade tu as perdu le contexte (quel voter ? quelle ressource ?) — tu ne vois plus qu'un 403 anonyme. Logge à la source de la décision.

⚠️ Pitfalls

  1. Confondre 401 vs 403UnauthorizedException (token absent/invalide) ≠ ForbiddenException (token valide mais pas le droit). À gérer dans le Guard. Côté client, 401 ⇒ "refresh ton token", 403 ⇒ "demande à l'admin".
  2. Logique métier dans le Guard — un Guard décide oui/non. S'il fait du fetch d'entité pour le retourner au handler, c'est qu'il devrait être un Interceptor ou un param decorator avec service.
  3. Guard request-scoped sans nécessitéAPP_GUARD request-scoped coûte cher (toute la chaîne de DI ré-instanciée). N'utiliser que si vraie dépendance request-scoped (ex. REQUEST injecté).
  4. Lire les métadonnées du mauvais targetreflector.get(KEY, ctx.getHandler()) ignore les decorators posés sur la classe. Utiliser getAllAndOverride([handler, class]).
  5. Pas de propagation req.user en WS / RPCswitchToHttp() ne marche pas en WebSocket gateway. Utiliser switchToWs().getClient().user. Idem pour gRPC : switchToRpc().getContext().
  6. Ordre APP_GUARD non garanti entre modules — l'ordre dépend de l'ordre d'enregistrement. Pour un ordre déterministe, déclarer tous les APP_GUARD dans le AppModule racine.
  7. Throw vs return falsereturn false ⇒ Nest balance ForbiddenException générique. Préférer throw explicite avec message + code domain (ex. 'tenant_mismatch').
  8. Async pesant — appel DB synchrone dans chaque Guard ⇒ N+1 de latence. Cacher en mémoire / Redis avec TTL court (5–30s pour les rôles).
  9. Guard qui modifie l'entité retournée — sémantique pauvre. Si tu fetch l'utilisateur depuis la DB pour vérifier isActive, ne stocke pas l'entité complète dans req.user — c'est confus. Garder le user "JWT" intact, et faire le fetch en service si besoin.
  10. Token verification synchronejwt.verify() est sync mais lourd (vérif signature). Pour les rotations de clés (JWKS), passer en async avec cache des JWKs.

🧪 Testing

ts
// Unitaire — mock ExecutionContext et Reflector
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

const mkCtx = (user: any, handler = () => {}, klass = class {}): ExecutionContext => ({
  switchToHttp: () => ({ getRequest: () => ({ user }) }) as any,
  getHandler: () => handler,
  getClass: () => klass,
} as any);

it('RolesGuard accepte si user a un rôle requis', () => {
  const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['admin']) } as any;
  const guard = new RolesGuard(reflector);
  expect(guard.canActivate(mkCtx({ roles: ['admin', 'user'] }))).toBe(true);
});

it('RolesGuard refuse si pas le rôle', () => {
  const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['admin']) } as any;
  const guard = new RolesGuard(reflector);
  expect(guard.canActivate(mkCtx({ roles: ['user'] }))).toBe(false);
});
ts
// Intégration — TestingModule + overrideGuard
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideGuard(JwtAuthGuard).useValue({ canActivate: () => true })
  .compile();

const app = moduleRef.createNestApplication();
await app.init();
await request(app.getHttpServer()).get('/admin/users').expect(200);

Pour les Guards de policy complexes (CASL, OPA), tester les ability functions isolément, pas via Nest. Le Guard devient un wrapper trivial.

ts
// CASL ability test
it('admin can manage all posts', () => {
  const ability = abilityFactory.createFor({ id: 'u1', roles: ['admin'] });
  expect(ability.can('manage', 'Post')).toBe(true);
});

it('user can update own post only', () => {
  const ability = abilityFactory.createFor({ id: 'u1', roles: ['user'] });
  expect(ability.can('update', new Post({ authorId: 'u1' }))).toBe(true);
  expect(ability.can('update', new Post({ authorId: 'u2' }))).toBe(false);
});

🎬 Cas d'usage concrets

Scénario 1 — Cabinet d'avocats : RBAC fin

Qui — Un cabinet d'avocats parisien (75 avocats + 40 collaborateurs) qui a internalisé sa plateforme de gestion de dossiers. Rôles : associé, collaborateur, stagiaire, paralegal, assistante, admin-IT.

Problème métier — Chaque rôle a un sous-ensemble strict d'actions. Un stagiaire peut lire les dossiers où il est assigné, pas créer/modifier. Une assistante peut classer mais pas lire les correspondances confidentielles. Avant : if (user.role === ...) dispersé dans 30 services, oublis fréquents, audit interne en alerte.

Comment ce concept aide@Roles() decorator + RolesGuard global + Reflector.getAllAndOverride pour combiner controller et handler. Centralisation totale : un seul endroit pour comprendre qui peut quoi.

ts
@Controller('matters')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('associé', 'collaborateur')
export class MattersController {
  @Get() list(@CurrentUser() user) { return this.svc.listForUser(user); }

  @Get(':id/correspondences')
  @Roles('associé')
  correspondences(@Param('id') id: string) { return this.svc.getCorrespondences(id); }

  @Post(':id/notes')
  @Roles('associé', 'collaborateur', 'paralegal')
  addNote(@Param('id') id: string, @Body() dto: NoteDto) { return this.svc.addNote(id, dto); }
}

Gains chiffrés — Audit interne passé sans réserve, temps d'ajout d'un nouveau rôle (apparition du "responsable conformité") tombé de 2 jours à 2h, 0 incident d'accès non autorisé en 11 mois (vs ~3/an avant).

Scénario 2 — Banque privée : ABAC avec voters

Qui — Une banque privée FR (220 ETP) gérant des comptes à grands patrimoines. Les règles d'accès sont complexes : un banquier peut voir un client si (a) il en est le gestionnaire principal OU (b) il appartient à la même cellule de gestion OU (c) il a une délégation temporaire validée par le compliance officer.

Problème métier — Les règles RBAC simples ne suffisent pas. Il faut de l'ABAC (Attribute-Based Access Control) : décision basée sur user, ressource, contexte (heure, IP, niveau de confidentialité). CASL ou OPA seuls deviennent vite confus quand mélangés à des règles métier dynamiques.

Comment ce concept aide — Pattern "voters" inspiré de Symfony : plusieurs PolicyVoter chacun votent (ALLOW/DENY/ABSTAIN), un PoliciesGuard agrège. Chaque voter est testable isolément, l'ajout d'une nouvelle règle = nouveau voter, pas de modification du Guard.

ts
export interface PolicyVote { decision: 'allow' | 'deny' | 'abstain'; reason?: string; }
export interface PolicyVoter { vote(user: User, resource: any, action: string): Promise<PolicyVote>; }

@Injectable()
export class GestionnaireDirectVoter implements PolicyVoter {
  async vote(user: User, client: PrivateClient, action: string): Promise<PolicyVote> {
    if (action !== 'read') return { decision: 'abstain' };
    if (client.gestionnaireId === user.id) return { decision: 'allow', reason: 'direct_manager' };
    return { decision: 'abstain' };
  }
}

@Injectable()
export class CelluleVoter implements PolicyVoter {
  constructor(private readonly cellules: CellulesService) {}
  async vote(user: User, client: PrivateClient, action: string): Promise<PolicyVote> {
    if (action !== 'read') return { decision: 'abstain' };
    const sharedCellule = await this.cellules.findShared(user.id, client.gestionnaireId);
    return sharedCellule ? { decision: 'allow', reason: 'same_cellule' } : { decision: 'abstain' };
  }
}

@Injectable()
export class DelegationVoter implements PolicyVoter {
  constructor(private readonly delegations: DelegationsService) {}
  async vote(user: User, client: PrivateClient, action: string): Promise<PolicyVote> {
    const active = await this.delegations.findActive(user.id, client.id, new Date());
    return active ? { decision: 'allow', reason: 'delegation_active' } : { decision: 'abstain' };
  }
}

Gains chiffrés — Ajout de la règle "délégation temporaire" (demandée par compliance) en 1 jour, audit ACPR validé, 0 régression sur les règles existantes (chaque voter testé indépendamment, 100% couverture sur les voters).

Scénario 3 — SaaS immobilier : isolation multi-tenant

Qui — Une PropTech FR (45 ETP) éditant un SIRH-like pour gestionnaires de patrimoine immobilier. ≈ 8 200 cabinets clients. Chaque cabinet voit ses lots, ses mandats, ses propriétaires — et seulement les siens.

Problème métier — La fuite cross-tenant est inadmissible (CNIL + perte de confiance). Un TenantGuard qui vérifie req.tenant.id === resource.tenantId n'est pas suffisant : il faut aussi protéger contre IDOR (un user du cabinet A devine un id du cabinet B et y accède).

Comment ce concept aide — Un TenantIsolationGuard qui (a) vérifie l'auth, (b) lit le tenant courant via @CurrentUser, (c) charge la ressource demandée et vérifie tenantId, (d) pour les listes, force le WHERE tenantId = ? via un filter ORM Prisma. Combiné avec un middleware qui pose tenantId dans un ALS scope.

ts
@Injectable()
export class TenantIsolationGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    @Inject('TENANT_ALS') private readonly als: AsyncLocalStorage<{ tenantId: string }>,
  ) {}

  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();
    const userTenantId = req.user?.tenantId;
    if (!userTenantId) throw new ForbiddenException('no_tenant');

    // L'ALS sera lu par le Prisma extension pour filtrer toutes les queries
    const store = this.als.getStore();
    if (!store) throw new InternalServerErrorException('tenant_als_missing');
    if (store.tenantId !== userTenantId) throw new ForbiddenException('tenant_mismatch');
    return true;
  }
}

Gains chiffrés — 0 fuite cross-tenant en 18 mois post-déploiement, audit CNIL validé sans réserve, le test pentest annuel ne trouve plus de path IDOR (vs 3 trouvés en 2023).

🛠️ Exemple end-to-end

Use case — Banque privée. On expose GET /v1/private-clients/:id avec une politique d'accès complexe : associé peut tout, gestionnaire peut voir ses clients, banquier de la même cellule peut voir les clients de ses collègues, et un délégué temporaire validé compliance peut voir un client précis sur une fenêtre limitée.

ts
// src/auth/policies/policy-voter.ts
export interface PolicyVote {
  decision: 'allow' | 'deny' | 'abstain';
  reason?: string;
}

export interface PolicyVoter<TResource = any> {
  supports(action: string): boolean;
  vote(user: AuthUser, resource: TResource, action: string): Promise<PolicyVote>;
}

export const POLICY_VOTERS = Symbol('POLICY_VOTERS');
ts
// src/auth/policies/voters/role-voter.ts
import { Injectable } from '@nestjs/common';
import { PolicyVoter, PolicyVote } from '../policy-voter';

@Injectable()
export class RoleVoter implements PolicyVoter {
  supports(_action: string): boolean { return true; }

  async vote(user: AuthUser): Promise<PolicyVote> {
    if (user.roles.includes('associé')) return { decision: 'allow', reason: 'associé_role' };
    return { decision: 'abstain' };
  }
}
ts
// src/auth/policies/voters/manager-voter.ts
@Injectable()
export class ManagerVoter implements PolicyVoter<PrivateClient> {
  supports(action: string): boolean { return ['read', 'update'].includes(action); }

  async vote(user: AuthUser, client: PrivateClient): Promise<PolicyVote> {
    if (client.gestionnaireId === user.id) return { decision: 'allow', reason: 'direct_manager' };
    return { decision: 'abstain' };
  }
}
ts
// src/auth/policies/voters/cellule-voter.ts
@Injectable()
export class CelluleVoter implements PolicyVoter<PrivateClient> {
  constructor(private readonly cellules: CellulesService) {}

  supports(action: string): boolean { return action === 'read'; }

  async vote(user: AuthUser, client: PrivateClient): Promise<PolicyVote> {
    if (!client.gestionnaireId) return { decision: 'abstain' };
    const shared = await this.cellules.haveSharedCellule(user.id, client.gestionnaireId);
    if (shared) return { decision: 'allow', reason: 'same_cellule' };
    return { decision: 'abstain' };
  }
}
ts
// src/auth/policies/voters/delegation-voter.ts
@Injectable()
export class DelegationVoter implements PolicyVoter<PrivateClient> {
  constructor(private readonly delegations: DelegationsService) {}

  supports(action: string): boolean { return action === 'read'; }

  async vote(user: AuthUser, client: PrivateClient): Promise<PolicyVote> {
    const active = await this.delegations.findActiveDelegation({
      userId: user.id,
      clientId: client.id,
      at: new Date(),
    });
    if (active?.complianceValidated) return { decision: 'allow', reason: 'delegation_active' };
    return { decision: 'abstain' };
  }
}

Chaque voter est isolé, testable, et stateless. Le pattern supports() permet de court-circuiter le vote sur les actions non gérées.

ts
// src/auth/policies/policies.guard.ts
import { CanActivate, ExecutionContext, Inject, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector, ModuleRef } from '@nestjs/core';
import { PolicyVoter, POLICY_VOTERS } from './policy-voter';

export const POLICY_ACTION_KEY = 'policy_action';
export const POLICY_RESOURCE_LOADER_KEY = 'policy_resource_loader';

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    @Inject(POLICY_VOTERS) private readonly voters: PolicyVoter[],
    private readonly moduleRef: ModuleRef,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const action = this.reflector.getAllAndOverride<string>(POLICY_ACTION_KEY, [
      ctx.getHandler(), ctx.getClass(),
    ]);
    if (!action) return true;

    const req = ctx.switchToHttp().getRequest();
    const user = req.user;
    if (!user) throw new ForbiddenException('not_authenticated');

    const loaderToken = this.reflector.get<string>(POLICY_RESOURCE_LOADER_KEY, ctx.getHandler());
    const loader = loaderToken ? this.moduleRef.get(loaderToken, { strict: false }) : null;
    const resource = loader ? await loader.load(req) : null;

    let allowed = false;
    const reasons: string[] = [];
    for (const voter of this.voters) {
      if (!voter.supports(action)) continue;
      const vote = await voter.vote(user, resource, action);
      if (vote.decision === 'deny') {
        throw new ForbiddenException(`denied:${vote.reason}`);
      }
      if (vote.decision === 'allow') {
        allowed = true;
        reasons.push(vote.reason ?? 'allow');
        break; // first-allow wins
      }
    }
    if (!allowed) throw new ForbiddenException('no_voter_allowed');
    req.policyReasons = reasons;
    return true;
  }
}

Le Guard itère sur les voters et applique la sémantique first-allow-wins (commun en ABAC). deny court-circuite tout (un voter peut bloquer même si un autre allow ensuite). Le chargement de la ressource est délégué à un loader injecté — séparation propre entre policy et data access.

ts
// src/auth/policies/decorators.ts
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { POLICY_ACTION_KEY, POLICY_RESOURCE_LOADER_KEY } from './policies.guard';

export function RequirePolicy(action: string, loaderToken?: string) {
  return applyDecorators(
    SetMetadata(POLICY_ACTION_KEY, action),
    ...(loaderToken ? [SetMetadata(POLICY_RESOURCE_LOADER_KEY, loaderToken)] : []),
  );
}

Variante typée (Nest 10.3+, recommandée sur nouveau code) — supprime les clés string magiques au profit de Reflector.createDecorator. Tu gagnes l'inférence et tu rends impossible le mismatch de clé (la cause n°4 des Pitfalls) :

ts
// policy.decorators.ts
import { Reflector } from '@nestjs/core';
export const PolicyAction = Reflector.createDecorator<string>();
export const PolicyLoader = Reflector.createDecorator<string>();

// dans le Guard — plus de generic à la main, plus de string :
const action = this.reflector.getAllAndOverride(PolicyAction, [ctx.getHandler(), ctx.getClass()]);
//    ^? string | undefined

// au controller :
@Get(':id') @PolicyAction('read') @PolicyLoader('PrivateClientLoader')
byId(@Param('id') id: string) { /* ... */ }
ts
// src/private-clients/private-client.loader.ts
import { Injectable } from '@nestjs/common';
import { PrivateClientsService } from './private-clients.service';

@Injectable()
export class PrivateClientLoader {
  constructor(private readonly clients: PrivateClientsService) {}

  async load(req: any) {
    const id = req.params.id;
    return this.clients.findByIdRaw(id);
  }
}
ts
// src/private-clients/private-clients.controller.ts
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { PoliciesGuard } from '../auth/policies/policies.guard';
import { RequirePolicy } from '../auth/policies/decorators';
import { PrivateClientsService } from './private-clients.service';

@Controller({ path: 'private-clients', version: '1' })
@UseGuards(JwtAuthGuard, PoliciesGuard)
export class PrivateClientsController {
  constructor(private readonly clients: PrivateClientsService) {}

  @Get(':id')
  @RequirePolicy('read', 'PrivateClientLoader')
  byId(@Param('id') id: string) {
    return this.clients.findById(id);
  }
}
ts
// src/auth/policies/policies.module.ts
import { Module, Global } from '@nestjs/common';
import { Reflector, ModuleRef } from '@nestjs/core';
import { PoliciesGuard } from './policies.guard';
import { POLICY_VOTERS } from './policy-voter';
import { RoleVoter } from './voters/role-voter';
import { ManagerVoter } from './voters/manager-voter';
import { CelluleVoter } from './voters/cellule-voter';
import { DelegationVoter } from './voters/delegation-voter';
import { PrivateClientLoader } from '../../private-clients/private-client.loader';
import { CellulesModule } from '../../cellules/cellules.module';
import { DelegationsModule } from '../../delegations/delegations.module';

@Global()
@Module({
  imports: [CellulesModule, DelegationsModule],
  providers: [
    RoleVoter, ManagerVoter, CelluleVoter, DelegationVoter,
    {
      provide: POLICY_VOTERS,
      useFactory: (r, m, c, d) => [r, m, c, d],
      inject: [RoleVoter, ManagerVoter, CelluleVoter, DelegationVoter],
    },
    PrivateClientLoader,
    PoliciesGuard,
  ],
  exports: [PoliciesGuard, POLICY_VOTERS],
})
export class PoliciesModule {}

Cet exemple montre la puissance des Guards bien architecturés : (a) auth basique en JwtAuthGuard, (b) policy ABAC en PoliciesGuard avec voters injectables, (c) chargement de ressource délégué à un loader pour ne pas coupler le Guard au domaine, (d) métadonnées via decorator custom @RequirePolicy, (e) first-allow-wins avec deny qui court-circuite. Ajouter une 5e règle (par ex. "associé peut voir tous les clients VIP") = ajouter un voter + le déclarer dans POLICY_VOTERS, 0 modification du Guard.

🤖 Guards à la porte d'un endpoint IA

Quand tu exposes un agent LLM depuis NestJS (POST /v1/agents/chat en SSE, ou un endpoint MCP), le Guard est l'endroit où tu protèges le portefeuille, pas seulement l'identité. Un appel LLM coûte de l'argent réel et peut durer 30 s+ — c'est un profil de risque très différent d'un CRUD. Les trois préoccupations qui vivent naturellement dans la couche Guard :

  1. Cost-guard / quota par tenant — refuser avant de brûler des tokens si le tenant a dépassé son budget mensuel.
  2. Rate-limit spécialisé — les endpoints IA ont des limites bien plus basses (ex. 5 req/min) que le reste de l'API.
  3. Idempotency au bord — un client qui retry un POST de génération ne doit pas relancer (et re-payer) la génération.

Mental model — le Guard décide si la génération a le droit de commencer. Il ne stream pas (c'est le handler/interceptor). Il ne tronque pas la sortie (c'est le service). Il dit oui/non sur une base identité + budget + fraîcheur.

ts
// src/ai/guards/ai-cost.guard.ts
import { CanActivate, ExecutionContext, Injectable, HttpException, HttpStatus } from '@nestjs/common';

@Injectable()
export class AiCostGuard implements CanActivate {
  constructor(private readonly budgets: AiBudgetService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const tenantId = req.user?.tenantId;
    if (!tenantId) throw new HttpException('no_tenant', HttpStatus.FORBIDDEN);

    // Lecture O(1) depuis Redis — compteur de coût du mois courant.
    const { spentUsd, capUsd } = await this.budgets.snapshot(tenantId);
    if (spentUsd >= capUsd) {
      // 402 Payment Required : sémantique exacte pour "budget épuisé".
      throw new HttpException(
        { code: 'ai_budget_exhausted', spentUsd, capUsd },
        HttpStatus.PAYMENT_REQUIRED,
      );
    }
    return true;
  }
}

Le rate-limit IA n'est qu'un ThrottlerGuard avec une config dédiée — mais avec une clé par tenant, pas par IP (un tenant partage souvent une IP NAT) :

ts
// src/ai/guards/ai-throttler.guard.ts
import { ThrottlerGuard } from '@nestjs/throttler';
import { ExecutionContext, Injectable } from '@nestjs/common';

@Injectable()
export class AiThrottlerGuard extends ThrottlerGuard {
  // Clé = tenant, pas IP. Store Redis pour être correct cross-instances.
  protected async getTracker(req: Record<string, any>): Promise<string> {
    return req.user?.tenantId ?? req.ip;
  }
}
// route : @Throttle({ ai: { limit: 5, ttl: 60_000 } }) @UseGuards(AiThrottlerGuard)

L'idempotency au bord du Guard — un header Idempotency-Key (la norme Stripe). Le Guard ne fait que réserver la clé ; le replay du résultat est géré par un interceptor. Garder la sémantique propre :

ts
// src/ai/guards/idempotency.guard.ts
@Injectable()
export class IdempotencyGuard implements CanActivate {
  constructor(private readonly store: IdempotencyStore) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const key = req.headers['idempotency-key'];
    if (!key) return true; // header optionnel — pas notre rôle de l'imposer ici

    // SET NX EX : pose un verrou atomique. Si déjà pris ⇒ requête concurrente/replay.
    const reserved = await this.store.tryReserve(`idem:${req.user.tenantId}:${key}`, 24 * 3600);
    if (!reserved) {
      // 409 : une génération avec cette clé est en cours ou terminée.
      // L'interceptor servira le résultat mis en cache si dispo.
      req.idempotencyReplay = key;
    }
    return true; // on laisse passer ; l'interceptor décide replay vs nouvelle exécution
  }
}

Frontière critique : le Guard et l'AbortController. Le Guard s'exécute avant le stream — il ne voit donc pas le close du client. La déconnexion (onglet fermé, bouton Stop) doit être câblée dans le handler/service, pas dans le Guard :

ts
// Dans le service de streaming, PAS dans le Guard :
@Sse('chat')
chat(@Req() req: Request, @Body() dto: ChatDto): Observable<MessageEvent> {
  const ac = new AbortController();
  req.on('close', () => ac.abort()); // client parti ⇒ on annule l'appel LLM
  return this.agent.stream(dto, { signal: ac.signal });
}

Pourquoi le Guard ne gère PAS l'annulation : canActivate retourne une décision booléenne ponctuelle. L'annulation est un événement continu sur la durée du stream. Confondre les deux = un Guard qui ne se termine jamais. Le Guard dit "vas-y" ; le signal dit "arrête-toi". Côté serveur, ce signal est propagé au SDK Anthropic (client.messages.stream({ ... }, { signal })), qui coupe la connexion HTTP sortante — tu arrêtes de payer dès la déconnexion.

Le client LLM doit être injecté, jamais new Anthropic() dans un champ. Un Guard de cost-guard a besoin du pricing du modèle pour estimer le coût ; ce pricing vient du même module DI que le client. Pattern forRootAsync :

ts
// src/ai/anthropic.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';

export const ANTHROPIC = Symbol('ANTHROPIC');

@Global()
@Module({
  providers: [{
    provide: ANTHROPIC,
    inject: [ConfigService],
    useFactory: (cfg: ConfigService) =>
      new Anthropic({
        apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
        maxRetries: 3, // retries SDK natifs (429 / 5xx) avec backoff
      }),
  }],
  exports: [ANTHROPIC],
})
export class AnthropicModule {}

Modèles 2026 à connaître pour le routing/cost-guard : claude-opus-4-8 (flagship, raisonnement profond — cher), claude-sonnet-4-6 (équilibre prod par défaut), claude-haiku-4-5 (rapide/cheap, classification & guards LLM). Un cost-guard mature route le modèle selon le budget restant : Opus tant que budget OK, dégradation vers Haiku au-dessus de 80 % du cap plutôt qu'un 402 brutal.

🔁 Quand utiliser / éviter

Utiliser un Guard :

  • Authentification (présence/validité du token).
  • Autorisation (rôles, claims, scopes, ownership, tenant).
  • Feature flags par route (@FeatureFlag('beta')).
  • Throttling / quota.

Éviter un Guard, préférer Middleware :

  • Logique purement HTTP (CORS, body parsing, request-id).

Éviter un Guard, préférer Interceptor :

  • Charger une entité et l'attacher à la requête (un Guard peut le faire mais sémantique pauvre).
  • Audit / log d'accès post-handler.

Éviter un Guard, préférer Pipe :

  • Valider le payload — un Pipe le fait avec un typage propre via DTO + class-validator.

Règle de pouce — un Guard prend une décision booléenne basée sur qui appelle et quel handler est ciblé. Dès que tu te retrouves à faire du fetch d'entité ou de la transformation, sors du Guard.

🧰 Exemples avancés

Guard policy avec CASL

⚠️ CASL v6 — utilise createMongoAbility / MongoAbility, pas PureAbility nu. Les conditions objet ({ authorId: user.id }) ne sont matchées que par le conditionsMatcher Mongo intégré ; un PureAbility brut les ignore silencieusement → tout le monde passe. Piège classique de migration v5→v6.

ts
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';

type Actions = 'manage' | 'create' | 'read' | 'update' | 'delete';
type Subjects = 'all' | 'Post' | 'User' | Post | User;
export type AppAbility = MongoAbility<[Actions, Subjects]>;

@Injectable()
export class AbilityFactory {
  createFor(user: AuthUser): AppAbility {
    const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
    if (user.roles.includes('admin')) {
      can('manage', 'all');
    } else {
      can('read', 'Post');
      can('update', 'Post', { authorId: user.id });
      can('delete', 'Post', { authorId: user.id });
    }
    // detectSubjectType requis si tes sujets sont des classes plain
    return build();
  }
}

// Decorator @CheckPolicies
export const CHECK_POLICIES_KEY = 'check_policies';
export type PolicyHandler = (ability: AppAbility) => boolean;
export const CheckPolicies = (...handlers: PolicyHandler[]) => SetMetadata(CHECK_POLICIES_KEY, handlers);

// Guard
@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector, private readonly abilityFactory: AbilityFactory) {}
  canActivate(ctx: ExecutionContext): boolean {
    const handlers = this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, ctx.getHandler()) ?? [];
    const { user } = ctx.switchToHttp().getRequest();
    const ability = this.abilityFactory.createFor(user);
    return handlers.every((h) => h(ability));
  }
}

// Usage
@Get(':id') @CheckPolicies((a) => a.can('read', 'Post'))
get(@Param('id') id: string) { /* ... */ }

Guard async avec cache des rôles

ts
@Injectable()
export class CachedRolesGuard implements CanActivate {
  private readonly cache = new Map<string, { roles: string[]; exp: number }>();
  private readonly TTL = 30_000;

  constructor(private readonly users: UsersService, private readonly reflector: Reflector) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [ctx.getHandler(), ctx.getClass()]);
    if (!required?.length) return true;

    const req = ctx.switchToHttp().getRequest();
    const userId = req.user?.id;
    if (!userId) return false;

    const now = Date.now();
    let entry = this.cache.get(userId);
    if (!entry || entry.exp < now) {
      const roles = await this.users.getRoles(userId);
      entry = { roles, exp: now + this.TTL };
      this.cache.set(userId, entry);
    }
    return required.some((r) => entry!.roles.includes(r));
  }
}

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Chaque exercice suppose le précédent terminé.

1. @Public() + JWT global — implémenter

Objectif — Un JwtAuthGuard global qui protège tout par défaut, avec un opt-out @Public() propre sur healthz et login.

Indice/SolutionAPP_GUARD + reflector.getAllAndOverride(IS_PUBLIC_KEY, [getHandler(), getClass()]) au tout début de canActivate, return true si public. Vérifie que @Public() sur la classe rend bien toutes ses routes publiques (d'où getClass() dans le tableau). Test : GET /healthz sans token ⇒ 200 ; GET /me sans token ⇒ 401.

2. Ownership sans IDOR — durcir

ObjectifPATCH /posts/:id n'est autorisé qu'à l'auteur ou à un admin. Un user qui devine l'id d'un post d'autrui doit recevoir 403, pas 404 (ne pas fuiter l'existence... ou si — décide et justifie).

Indice/Solution — RBAC pur ne suffit pas : il faut charger le post. Implémente un loader injecté (cf. exemple end-to-end) ou un PoliciesGuard CASL avec condition { authorId: user.id }. Subtilité staff : 404 vs 403 est un choix de threat model — 404 cache l'existence (mieux contre l'énumération), 403 est plus honnête pour un back-office interne. Documente le choix. Mets le résultat du loader dans req.loadedPost pour que le handler ne re-findById pas.

3. Guard de rôles avec cache — durcir

Objectif — Les rôles vivent en DB et changent rarement. Évite un SELECT par requête sans servir des rôles périmés plus de 30 s.

Indice/Solution — Pars du CachedRolesGuard du fichier. Problèmes à traiter : (a) le Map en mémoire ne fonctionne pas multi-instance → passe à Redis avec EX 30 ; (b) la révocation immédiate (user banni) doit court-circuiter le cache → publie un event role.revoked qui invalide la clé ; (c) le cache stampede (1000 req simultanées, clé expirée) → un seul getRoles doit partir, les autres attendent (lock ou p-memoize).

4. Cost-guard pour endpoint IA — production-grade

ObjectifPOST /v1/agents/chat (SSE) refuse avec 402 si le tenant a dépassé son budget mensuel, et route vers claude-haiku-4-5 au lieu de refuser quand le budget restant < 20 %.

Indice/SolutionAiCostGuard lit budgets.snapshot(tenantId) depuis Redis (compteur INCRBY à la fin de chaque génération, basé sur les tokens réels renvoyés par l'API). Pour le routing : le Guard ne change pas le modèle (un Guard ne mute pas) — il pose req.aiTier = 'degraded' et le service lit ce hint pour choisir le modèle. Piège : le coût n'est connu qu'après la génération (tokens out) — le Guard travaille sur une estimation (max_tokens × prix) puis tu réconcilies post-stream. Garde une marge.

5. Casse-le : la course Guard / stream — break then fix

Objectif — Reproduis puis corrige un bug réel : un client lance une génération, le Guard passe (budget OK), le client coupe la connexion à la 2ᵉ seconde. Sans correction, tu continues à payer l'API Anthropic 28 s de plus.

Indice/Solution — Le bug : canActivate est ponctuel, il ne voit pas le close. Reproduis avec curl --max-time 2 -N. Le fix n'est pas dans le Guard : câble req.on('close', () => ac.abort()) dans le handler SSE et propage ac.signal jusqu'à client.messages.stream(params, { signal }). Vérifie avec un log côté serveur que l'appel sortant est bien interrompu (AbortError). Bonus : décrémente le budget réservé puisque la génération n'a pas abouti complètement.

6. Casse-le : l'ordre des APP_GUARDbreak then fix

ObjectifRolesGuard lit req.user mais reçoit undefined de façon intermittente. Trouve pourquoi et garantis l'ordre.

Indice/Solution — Cause : deux APP_GUARD enregistrés dans des modules différents → ordre d'exécution non déterministe ; parfois RolesGuard tourne avant JwtAuthGuard qui pose req.user. Reproduis en déplaçant l'enregistrement dans un module importé après. Fix : déclare tous les APP_GUARD dans le AppModule racine, dans l'ordre voulu (auth d'abord). Vérifie aussi que RolesGuard ne crashe pas si req.user est absent (if (!user) throw new UnauthorizedException() plutôt qu'un Cannot read 'roles' of undefined).

🎤 En entretien

Q : Différence Guard / Middleware / Interceptor pour faire de l'auth — pourquoi le Guard ? Le middleware s'exécute avant que Nest ne sache quel handler est ciblé : pas d'accès aux métadonnées de route (@Roles()), donc pas d'autorisation contextuelle. Le Guard reçoit l'ExecutionContext (handler + classe + Reflector) : c'est le premier point du cycle où "qui appelle quoi" est connu. L'interceptor est trop tard (il enveloppe l'exécution) et a une sémantique de transformation, pas de décision. Donc : auth = Guard.

Q : return false vs throw dans un Guard ?return false ⇒ Nest lève une ForbiddenException générique (403, message vague). throw te laisse choisir le statut (401 token absent vs 403 droit manquant) et un code domaine (tenant_mismatch, ai_budget_exhausted) exploitable côté client. En prod sérieux : toujours throw explicite ; return false est acceptable seulement dans un Guard trivial sans besoin de message.

Q : Comment empêcher un IDOR avec un Guard, et où est le piège de perf ? Le RBAC ne suffit pas : il faut charger la ressource pour comparer l'ownership/tenant. Le piège : si le Guard fait findById et le service refait findById, c'est un double-load sur le chemin chaud. Solution : loader injecté qui pose le résultat dans req (cache request-scoped), ou décider dans le service. Et defense-in-depth : un filtre ORM (WHERE tenantId=?) doublé du Guard — jamais une seule barrière.

Q : Un endpoint IA streamé ; où mets-tu l'auth, le quota, et l'annulation ? Auth + quota + rate-limit = couche Guard (canActivate, décision ponctuelle avant la génération — 402 si budget épuisé, clé de throttle par tenant pas par IP). L'annulation n'est pas dans le Guard : c'est un événement continu sur la durée du stream → req.on('close') dans le handler qui abort() un AbortController propagé jusqu'au SDK (signal), pour cesser de payer dès la déconnexion. Confondre les deux donne un Guard qui ne se termine jamais.

Q : Un APP_GUARD global instancié en singleton — comment injecter une dépendance request-scoped (ex. REQUEST) sans tuer les perfs ? Tu ne le fais pas frontalement : un Guard request-scoped force toute sa sous-chaîne de DI à être ré-instanciée par requête (coûteux, et il « contamine » les providers qu'il consomme). La donnée request vit déjà dans l'ExecutionContext (ctx.switchToHttp().getRequest()) — lis-la là plutôt que de l'injecter. Si tu as vraiment besoin d'un service request-scoped, garde le Guard en singleton et résous-le paresseusement via ModuleRef.resolve(Service, contextId) avec le ContextIdFactory.getByRequest(req). Règle : un Guard global doit rester un singleton stateless.

Q : Ton ThrottlerGuard protège 4 instances derrière un load-balancer — pourquoi la limite « 5 req/min » laisse passer 20 req, et comment corriger ? Le store par défaut de @nestjs/throttler est en mémoire, par process : chaque instance compte indépendamment → la limite est multipliée par le nombre d'instances. Fix : un store partagé (@nest-lab/throttler-storage-redis ou équivalent) pour un compteur global atomique (INCR/EXPIRE). Subtilité staff : la fenêtre glissante doit être atomique côté Redis (script Lua ou INCR+EXPIRE sur la même clé), sinon tu as une race au reset de fenêtre. Et choisis la bonne clé (getTracker) — par tenant, pas par IP, derrière un NAT.

🔗 Liens

Bibliothèque tech perso — Achref