Nodefony
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Autorisation — le jury de voters (rôles, scopes, ownership)

stable@nodefony/securitymis à jour 2026-07-19

L’authentification établit qui tu es ; l’autorisation établit ce que tu as le droit de faire. Nodefony décide de chaque accès via un jury de voters : stratégie affirmative + veto DENY, et défaut DENY (Zero Trust — le silence ferme la porte). Deux voters intégrés (role, scope), un contrat ouvert pour la logique métier (ownership, multi-tenant). Ancré sur src/packages/@nodefony/security/nodefony/service/authorization.ts et nodefony/src/voter/.

📍 DocumentationSécuritéAutorisation

🧠 Le modèle mental — un jury qui vote#

Trois règles, et une seule ferme la porte par défaut :

  1. Un DENY suffit à bloquer (veto), et court-circuite le reste du jury.
  2. Sinon un GRANT suffit à accorder.
  3. Silence total (tous ABSTAIN, ou aucun voter compétent) → DENY. C’est le Zero Trust : on n’accorde jamais « par absence d’objection ».

📖 Lexique#

Terme Sens
Autorisation Décider des droits (≠ authentification, qui décide de l’identité).
Voter Un juré : sait décider de certains attributs (supports) et vote GRANT/DENY/ABSTAIN.
Attribut Le droit demandé : un rôle (ROLE_ADMIN), un scope (api:action), ou un verbe métier (doc.edit).
Clause Un groupe d’attributs déclaré par @IsGrantedOR interne, clauses empilées en AND.
Subject (sujet) La donnée sur laquelle porte la décision (un id de document, un tenant) — passée au voter.
RBAC Role-Based Access Control : droits selon le rôle.
Scope Permission fine d’une clé déléguée (clé API, JWT d’agent) — « ce que la clé peut faire ».
Hiérarchie de rôles ROLE_ADMIN hérite ROLE_USER — résolue et aplatie au boot.
IDOR Insecure Direct Object Reference : atteindre la ressource d’un autre en devinant son id.
OWASP A01 Broken Access Control — la faille n°1 du top 10 OWASP.
ALS AsyncLocalStorage : la « bulle » par requête qui porte identité et token.
Zero Trust Fermé par défaut : sans GRANT explicite, c’est DENY.

Qu’est-ce que l’autorisation — et quelle faille elle ferme#

Le contrôle d’accès défaillant est la faille n°1 du top OWASP (A01) : un utilisateur atteint une ressource qui n’est pas la sienne (IDOR), ou une action au-dessus de son niveau (élévation de privilège). La cause récurrente est un contrôle dispersé et optionnel — un endpoint oublie de vérifier.

Nodefony centralise la décision dans un service unique, appelé par les décorateurs (@IsGranted, @RequireScope) sur tous les transports, avec une posture fail-closed : au moindre doute (voter qui plante, silence du jury, moteur absent), c’est refusé — jamais accordé.

La vision Nodefony — un jury découplé et fail-closed#

Authorization.decide(token, attribute, subject?) (authorization.ts:70) itère les voters, teste supports() en place — zéro allocation par appel (authorization.ts:78-80) — et applique la stratégie ci-dessus. Points structurants :

🚀 Démarrage rapide#

Déclarer les droits sur tes actions — les trois axes#

Dans une app nodefony create app, la zone secure du scaffold (^/api/secure, voir firewall) authentifie déjà ; ici on décide des droits :

// nodefony/controllers/DocumentController.ts — complet, compile tel quel
import {
  controller,
  Controller,
  Get,
  Post,
  Param,
  IsGranted,
  RequireScope,
  CurrentUser,
} from "@nodefony/framework";
import type { ContextType } from "@nodefony/http";
import type { IUser } from "@nodefony/user";

@controller("/api/secure/documents")
class DocumentController extends Controller {
  constructor(context: ContextType) {
    super("DocumentController", context);
  }

  // Axe RÔLE (« qui tu es ») : réservé aux admins — hiérarchie résolue
  // (ROLE_NODEFONY_ADMIN hérite ROLE_ADMIN → passe aussi).
  @IsGranted("ROLE_ADMIN")
  @Post("/purge")
  purge(@CurrentUser() user: IUser) {
    return this.renderJson({ purgedBy: user.identifier });
  }

  // Axe SCOPE (« ce qu'une CLÉ peut faire ») : bride une clé API / un JWT ;
  // no-op pour une session humaine (ses droits passent par ses rôles).
  @RequireScope("documents:read")
  @Get("/")
  list(@CurrentUser() user: IUser) {
    return this.renderJson({ reader: user.identifier, roles: user.roles });
  }

  // Axe MÉTIER : le param de route `id` part au voter comme `subject`.
  @IsGranted("doc.edit", { subject: "id" })
  @Post("/{id}")
  edit(@Param("id") id: string) {
    return this.renderJson({ edited: id });
  }
}

export default DocumentController;

(Wiring : @controllers([DocumentController]) dans le module de l’app — nodefony create controller le fait pour toi.)

Ce qu’on observe#

# 1) Sans session : le FIREWALL répond 401 — l'autorisation n'a même pas été consultée
curl -si http://localhost:5151/api/secure/documents/ | head -1
# HTTP/1.1 401 Unauthorized

# 2) Session d'un utilisateur ROLE_USER (cookie posé par le login BFF, cf. firewall) :
#    le scope est un no-op pour un humain → 200
curl -s -b /tmp/jar http://localhost:5151/api/secure/documents/
# {"reader":"alice","roles":["ROLE_USER"]}

# 3) Même session sur l'action admin → 403 : authentifié MAIS pas autorisé
curl -si -b /tmp/jar -X POST http://localhost:5151/api/secure/documents/purge | head -1
# HTTP/1.1 403 Forbidden

Le refus laisse une trace côté serveur (jamais côté client) :

WARNING AUTHORIZATION access denied: "alice" → "ROLE_ADMIN" (abstain)

401 vs 403 : 401 = « prouve qui tu es » (authentification, firewall) ; 403 = « je sais qui tu es, tu n’as pas le droit » (autorisation, jury).

Le voter métier — ta règle d’ownership branchée au jury#

Pour l’attribut doc.edit déclaré ci-dessus, on enregistre un voter — découvert automatiquement au boot, aucun changement dans le cœur :

// nodefony/security/DocumentVoter.ts — chargé par le module de l'app (avant le boot)
import { registerVoterFactory, VoterVote } from "@nodefony/security";
import type { IAccessVoter, IToken } from "@nodefony/security";

/** Le repository de TES documents (posé au container par ton module). */
interface IDocumentRepository {
  find(id: string): Promise<{ ownerId: string; archived: boolean } | null>;
}

class DocumentVoter implements IAccessVoter {
  constructor(private readonly repository: () => IDocumentRepository | null) {}

  /** Ne capte QUE `doc.edit` — rôles et scopes restent aux voters intégrés. */
  supports(attribute: string): boolean {
    return attribute === "doc.edit";
  }

  async vote(
    token: IToken,
    _attribute: string,
    subject?: unknown,
  ): Promise<VoterVote> {
    const repo = this.repository();
    const doc =
      repo && typeof subject === "string" ? await repo.find(subject) : null;
    if (!doc) return VoterVote.ABSTAIN; // hors de mon domaine → les autres axes décident
    if (doc.archived) return VoterVote.DENY; // veto EXPLICITE : personne n'édite un archivé
    return doc.ownerId === token.getUserIdentifier()
      ? VoterVote.GRANT
      : VoterVote.ABSTAIN; // pas le sien → le default-DENY du jury ferme
  }
}

// Découvert automatiquement par le service `authorization` au boot.
registerVoterFactory("documentVoter", ({ container }) => {
  // Construction seule ici — la résolution du repository reste lazy.
  return new DocumentVoter(() =>
    container.get<IDocumentRepository>("documentRepository"),
  );
});

Observable : le propriétaire obtient 200 sur POST /api/secure/documents/42 ; un autre utilisateur connecté obtient 403 et le log dit access denied: "bob" → "doc.edit" on 42 (abstain).

⚠️ Attention

Dans un voter métier, renvoie ABSTAIN quand tu ne sais pas te prononcer (document introuvable, attribut hors domaine) — pour laisser les autres axes décider. Réserve DENY au veto explicite (ressource gelée/bannie) : un DENY bat tous les GRANT du même attribut.

🧑‍⚖️ La stratégie du jury en situation#

Situation 1 — rôle OU voter métier ? (l’IDOR ne se ferme pas par un rôle)#

Ton app édite des documents : POST /api/secure/documents/{id} doit être réservé au propriétaire. Or tous tes utilisateurs connectés portent ROLE_USER :

@IsGranted("ROLE_USER")                    // ❌ ferme la porte aux anonymes… mais PAS l'IDOR :
@Post("/{id}") edit() {}                   //    alice peut éditer le document de bob

@IsGranted("doc.edit", { subject: "id" })  // ✅ le jury reçoit l'id → le voter tranche sur la DONNÉE
@Post("/{id}") edit() {}
La requête ❌ avec ROLE_USER ✅ avec doc.edit
alice édite son document 200 200 (GRANT du propriétaire)
alice édite le document de bob 200 — IDOR ! 403 (abstain → défaut DENY)
anonyme 401 (firewall) 401 (firewall)

Règle de choix : un rôle décide d’une catégorie d’action (« qui peut purger ? ») ; un voter métier décide sur la donnée (« CE document est-il le sien ? »). Si la réponse exige un lookup (ownership, tenant, état), c’est un voter.

Situation 2 — le veto DENY (gel légal : personne, même pas le propriétaire)#

Conformité : un document sous gel légal (litige en cours) ne doit être édité par personne — pas même son propriétaire. On ajoute un second voter qui capte le même attribut doc.edit :

class LegalHoldVoter implements IAccessVoter {
  supports(attribute: string): boolean {
    return attribute === "doc.edit";
  }
  async vote(
    _token: IToken,
    _attribute: string,
    subject?: unknown,
  ): Promise<VoterVote> {
    return (await isUnderLegalHold(subject))
      ? VoterVote.DENY
      : VoterVote.ABSTAIN;
  }
}
Le jury sur doc.edit Verdict
DocumentVoter GRANT (propriétaire) + LegalHoldVoter ABSTAIN ✅ 200
DocumentVoter GRANT + LegalHoldVoter DENY ❌ 403 (veto) — le DENY bat le GRANT

Dès le DENY, le jury s’arrête — court-circuit, inutile de finir (authorization.ts:94-97).

Contre-exemple piégeux : le veto ne traverse pas une clause OR. Dans @IsGranted(["ROLE_ADMIN", "doc.edit"]), chaque attribut est un jury séparé (Resolver.ts:592-600) : si ROLE_ADMIN est accordé, doc.edit — et son veto — n’est même pas consulté. Un interdit absolu se porte en clause AND : empiler @IsGranted("ROLE_ADMIN") puis @IsGranted("doc.edit", { subject: "id" }).

Situation 3 — le silence ferme la porte (la typo devient un 403, pas une faille)#

Tu déploies @IsGranted("doc.edti") (faute de frappe), ou tu as oublié d’enregistrer ton voter. Aucun voter compétent → refus par défaut (!granted, authorization.ts:100-108) : la route répond 403 systématiquement, et le log nomme la cause :

WARNING AUTHORIZATION access denied: "alice" → "doc.edti" (no-voter)

Un framework fail-open aurait laissé passer — la typo serait une faille silencieuse. Ici elle se voit au premier test.

💡 Astuce

La raison entre parenthèses dit quoi corriger : no-voter = aucun voter ne capte l’attribut (typo, voter non enregistré) · abstain = des voters ont regardé, aucun n’a accordé (droit manquant) · veto = un DENY explicite · error = un voter a planté (voir le log ERROR).

🧰 Déclarer l’exigence — @IsGranted, @RequireScope, @Anonymous#

Les décorateurs n’écrivent que des métadonnées (0 import @nodefony/security, 0 cycle) ; le moteur authorization est résolu par nom au runtime (Resolver.ts:577-578) :

Déclaration Sémantique
@IsGranted("ROLE_ADMIN") un attribut — rôle, scope ou verbe métier (IsGranted(), routerDecorators.ts:839)
@IsGranted(["A", "B"]) OR interne — un attribut accordé suffit (SecurityClause.anyOf, routerDecorators.ts:407-412)
empiler @IsGranted / @RequireScope AND — toutes les clauses doivent passer (SecurityRequirement.clauses, routerDecorators.ts:426)
décorateur de classe + de méthode fusion en AND, figée UNE fois par route (computeSecurityRequirement(), routerDecorators.ts:1500)
@IsGranted("doc.edit", { subject: "id" }) le param de route id est passé au voter (Resolver._resolveSubject(), Resolver.ts:613-617)
@RequireScope("orders:read") axe scope — metadata dédiée, fusionnée dans le même SecurityRequirement (RequireScope(), routerDecorators.ts:760)
@Anonymous() action publique — override les gardes de classe (security: null) + skip l’authn (Anonymous(), routerDecorators.ts:887)
@CurrentUser() injecte l’utilisateur de l’ALS — jamais le credential (CurrentUser, routerDecorators.ts:1236)

La garde s’évalue dans Resolver.executeAction() AVANT l’instanciation DI du controller — un 403 court-circuite tout, y compris initialize() (_enforceSecurity, Resolver.ts:331-336). Le même executeAction sert le pipeline HTTP et l’invoke WS-RPC : une garde, tous les transports. L’enforcement déroule chaque clause : OR interne via un decide() par attribut, AND entre clauses (Resolver._enforceSecurity(), Resolver.ts:576-606).

❗ Important

Fail-closed intégral : route gardée mais moteur authorization absent (module security non chargé) OU aucune identité résolue (route hors zone firewall) → 403 direct (Resolver.ts:582-584). Une route gardée doit être couverte par une zone — voir firewall.

🧑‍⚖️ Les voters intégrés — deux axes, un même jury#

Voter (registre) Axe Capte Non-satisfait →
role qui es-tu ? ROLE_* ABSTAIN
scope que peut faire cette clé ? api:action ABSTAIN (machine) / GRANT (humain)
le tien est-ce ta ressource ? doc.edit, … ABSTAIN conseillé (veto = DENY)
rolel’axe « qui tu es »#

Capte les attributs ROLE_* (RoleVoter.supports(), RoleVoter.ts:25-27) et vote :

  • GRANT si l’utilisateur possède le rôle, hiérarchie résolue ; ABSTAIN sinon — jamais DENY (RoleVoter.vote(), RoleVoter.ts:33-35). L’absence d’un rôle ne doit pas opposer un veto aux autres axes (un accès peut être légitime via un scope ou l’ownership) : c’est le default-DENY du jury qui ferme, pas ce voter. C’est aussi ce qui rend l’OR (@IsGranted(["A","B"])) possible.
  • La hiérarchie est lue en lazy depuis le container — clé roleHierarchy (RoleVoter.ts:30-32), posée par le firewall au boot (firewall.ts:206).
  • Sync par nature → Promise.resolve, pas de wrapper async inutile (RoleVoter.ts:36-38).
scopel’axe « ce qu’une clé déléguée peut faire »#

Frère du role sur l’autre axe. Capte la forme conventionnée api:action — un :, jamais ROLE_* (ScopeVoter.supports(), ScopeVoter.ts:46-48) → aucune collision avec les rôles ni un verbe métier. Le cœur est le modèle de confiance :

  • Jeton humain (session, userpassword, anonymous) → GRANT no-op : un scope ne bride jamais un humain, son autorisation passe par ses rôles (ScopeVoter.ts:52-54).
  • Jeton machine (apikey, jwt, oauth2, ou tout type futur) → GRANT si le scope exact est présent, sinon ABSTAIN (ScopeVoter.ts:57-61).
  • Fail-closed côté machine : NON_SCOPABLE_TOKEN_TYPES est une allowlist d’humains (ScopeVoter.ts:17-21) — tout type absent (mtls, agent…) est considéré scopable, donc bridé par défaut. Un nouveau type de jeton délégué est fermé par oubli, jamais ouvert.
  • Pur : aucune dépendance, aucune I/O — instancié une fois au boot.

La hiérarchie de rôles — aplatie et vérifiée au boot#

RoleHierarchyWalker se déclare dans la config (use("@nodefony/security", { roleHierarchy }), voir firewall) et fait deux choses au boot :

🧩 Étendre le jury — le contrat et le registre#

Le contrat IAccessVoter (IAccessVoter.ts:20-26) tient en deux méthodes :

L’enregistrement passe par le registre — registerVoterFactory(name, factory) (voterRegistry.ts:39-44), consommé une fois au boot (listVoterFactories(), voterRegistry.ts:47-49). La fabrique reçoit { container } et ne fait que construire : les résolutions coûteuses restent lazy dans l’instance (cf. le DocumentVoter du Démarrage rapide).

Pourquoi un registre et pas un scan DI des @injectable : les interfaces TS sont effacées à la compilation — rien à scanner au runtime ; le registre est le marqueur explicite (voterRegistry.ts:10-16). Convention-frère : authenticatorRegistry, tokenStoreRegistry.

🔌 HTTP et WebSocket — une garde, N transports#

📜 Normes appliquées#

Domaine Norme / posture Ancrage
Contrôle d’accès OWASP Top 10 A01 (IDOR, élévation) défaut DENY du jury (authorization.ts:100-108)
Modèle Zero Trust (fermé par défaut) 403 fail-closed du Resolver (Resolver.ts:582-584)
Journalisation de sécurité audit des refus, jamais des octrois #auditDenyrecordAudit (authorization.ts:113-142)

⚡ Performance & mémoire#

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause (dans le code) Correction
Accès refusé alors que le rôle existe Attribut mal formé (pas ROLE_…) → le RoleVoter n’entre pas Respecter le préfixe ROLE_
403 systématique sur une route gardée Moteur absent OU identité non résolue — route hors zone (Resolver.ts:582-584) Couvrir la route par une zone firewall
Un voter métier bloque tout Il renvoie DENY au lieu d’ABSTAIN quand il ne s’applique pas Renvoyer ABSTAIN hors de son domaine
Un DENY n’a pas bloqué Attributs d’une clause = jurys séparés (OR) — un autre attribut a accordé Porter l’interdit en clause AND (empiler les @IsGranted)
Clé API accède à une action non prévue Type de jeton traité comme humain (allowlist) Vérifier que le type n’est pas dans NON_SCOPABLE_TOKEN_TYPES
ROLE_ADMIN n’hérite pas ROLE_USER Hiérarchie non déclarée / non posée au container Déclarer roleHierarchy (config security) au boot
Boot qui plante « cycle détecté » Hiérarchie de rôles cyclique Casser le cycle (le message nomme le chemin)
Accès accordé à un voter qui a planté (n’arrive pas) fail-closed : une erreur de voter = refus Corriger le voter ; l’erreur est loggée ERROR

📡 Observabilité — Studio#

Écran Roles (studio/frontend/src/routes/Roles.tsx) : la hiérarchie de rôles consommée par les voters. Écran Audit : les refus du jury (catégorie authz, action access.denied, avec la raison). Écran Firewall : zones et trace de décision — l’amont du jury.

🧪 Tests & couverture#

Quatre familles couvrent la brique — les chiffres exacts vivent dans la carte de l’aperçu (régénérée depuis vitest, jamais figée ici) :

Couverture : npm run coverage dans @nodefony/security.

🔗 Pour aller plus loin#