---
title: "Autorisation — le jury de voters (rôles, scopes, ownership)"
source: src/packages/@nodefony/security/docs/authorization.md
url: https://nodefony.github.io/nodefony-core/docs/modules/security/authorization/
---


> 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/`.

📍 [Documentation](../../../) › [Sécurité](../) › **Autorisation**

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

```mermaid
flowchart TD
  Q["decide(token, attribute, subject)"] --> L{"pour chaque voter<br/>supports(attribute) ?"}
  L -->|non| L
  L -->|oui| V["vote() → GRANT / DENY / ABSTAIN"]
  V -->|DENY| DZ["❌ refus immédiat (veto)<br/>+ audit WARNING"]
  V -->|GRANT| G["granted = true<br/>(on continue le jury)"]
  V -->|ABSTAIN| L
  G --> E{"fin du jury"}
  L --> E
  E -->|"au moins un GRANT, aucun DENY"| OK["✅ accès accordé (muet)"]
  E -->|"aucun GRANT (tous ABSTAIN / 0 voter)"| DZ2["❌ refus par défaut<br/>(Zero Trust)"]
```

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 `@IsGranted` — **OR 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é.

## Où se situe Nodefony parmi les modèles de contrôle d'accès

Le contrôle d'accès a plusieurs façons de répondre à « as-tu le droit ? », selon **ce sur quoi**
la décision porte : un rôle porté par le compte (RBAC), des attributs du sujet, de la ressource et
du contexte (ABAC), une relation dans un graphe (ReBAC), ou une politique écrite hors du code
(XACML, et les moteurs qui s'en inspirent). Ce ne sont pas des concurrents : les systèmes réels en
empilent plusieurs.

Nodefony fait du **RBAC hiérarchique** — des rôles, et une hiérarchie qui dit lesquels en couvrent
d'autres, au sens des composantes _Core_ et _Hierarchical_ du modèle NIST (ANSI INCITS 359-2004,
révisée 2012). Le reste se **branche** au jury, décrit plus bas : un voter maison qui lit des
attributs fait de l'ABAC, un voter qui interroge un moteur de graphe (OpenFGA, SpiceDB, Ory Keto —
famille issue de l'article _Zanzibar_, Google, 2019) fait du ReBAC.

**Ce qui n'est PAS couvert**, et qu'il vaut mieux savoir avant de concevoir :

- **la séparation des devoirs** (deux rôles incompatibles sur un même compte) n'est pas outillée ;
  elle se joue à l'affectation des rôles, pas à la décision ;
- **le filtrage d'une liste** — « montre-moi seulement les lignes que j'ai le droit de voir » — ne
  passe pas par le jury : chaque décision porte sur **une** ressource. Ce filtrage appartient à ta
  requête de dépôt ;
- **le contexte de la requête** (heure, adresse, en-têtes, corps) n'est pas au contrat d'un voter ;
  ce qui lui parvient, c'est le jeton et le sujet ;
- **aucune décision « accordé, à condition que »** (les _obligations_ de XACML) : le verdict est
  binaire.

En pratique, et c'est le cas dans 95 % des applications : des rôles, plus **un** voter d'ownership
pour les cas où le rôle ne suffit pas.

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

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

- **Fail-closed sur erreur** : un voter qui `throw` (lookup DB down, bug) ne fait ni accorder
  l'accès ni planter la requête en 500 — on **refuse** cette décision + log `ERROR`
  (`authorization.ts#Authorization.decide`). Même posture que le firewall sur une erreur interne.
- **Découverte par registre** : les voters sont instanciés **une fois au boot** par
  `Authorization.#build()` (`authorization.ts#Authorization.#build`) depuis le registre — aucun nom en dur dans
  le service. Les builtins `role`/`scope` s'enregistrent à l'import (`voterRegistry.ts`).
- **Transport-agnostique** : l'audit lit `getUserIdentifier()` (et non `getUser()`), commun au
  token HTTP **et** au token WS `IRealtimeToken` (`authorization.ts#Authorization.#auditDeny`).
- **Audit asymétrique** : tout **refus** est audité par `Authorization.#auditDeny()` — WARNING +
  `recordAudit` (`authorization.ts#Authorization`) ; les octrois restent **muets** (volume, pas un
  signal). Le refus porte sa raison : `veto` / `abstain` / `no-voter` / `error`.

## 🚀 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](../firewall/)) authentifie déjà ; ici on décide des **droits** :

```typescript
// 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

```bash
# 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** :

```typescript
// 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)`.

> [!WARNING]
> 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` :

```typescript
@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` :

```typescript
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#Authorization.decide`).

**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#Resolver._enforceSecurity`) : 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#Authorization`) : 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.

> [!TIP]
> 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#Resolver._callWithIdempotency`) :

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

La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts#Resolver._enforceSecurity`). 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#Resolver._enforceSecurity`).

> [!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#Resolver._callWithIdempotency`). Une route gardée doit être couverte par une zone — voir
> [firewall](../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`)    |

### `role` — l'axe « qui tu es »

Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts#RoleVoter.supports`) et vote :

- **`GRANT`** si l'utilisateur possède le rôle, hiérarchie résolue ; **`ABSTAIN` sinon — jamais
  `DENY`** (`RoleVoter.vote()`, `RoleVoter.ts#RoleVoter.vote`). 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#RoleVoter.vote`), posée par le firewall au boot (`firewall.ts#Firewall.#build`).
- Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts#RoleVoter.vote`).

#### Deux échelles de rôles — plateforme et organisation

Une **convention de nom** sépare deux échelles, et c'est elle qui prépare le jour où plusieurs
organisations partageront une instance :

| Préfixe           | Échelle          | Qui le porte               | Scopable ? |
| ----------------- | ---------------- | -------------------------- | ---------- |
| `ROLE_NODEFONY_*` | **plateforme**   | l'exploitant de l'instance | **jamais** |
| `ROLE_*`          | **organisation** | un compte de l'application | oui        |

L'échelle plateforme est la seule qui traverse l'isolation entre organisations. Les ressources du
**processus** — journaux, base, supervision, ramasse-miettes — sont donc gardées par elle, jamais
par un rôle d'organisation : un administrateur applicatif n'a pas à lire l'état du serveur qui
héberge aussi les autres.

> ⚠️ **Ne donne jamais un `ROLE_NODEFONY_*` à un compte client.** Un « administrateur » chez le
> client, c'est `ROLE_ADMIN`. C'est une convention : **rien dans le code ne l'impose** pour tes
> propres rôles — seules les politiques de plateforme du framework sont vérifiées par un test.

### `scope` — l'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#ScopeVoter.supports`) → 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#ScopeVoter.vote`).
- **Jeton machine** (`apikey`, `jwt`, `oauth2`, ou tout type futur) → `GRANT` si le scope exact
  est présent, sinon `ABSTAIN` (`ScopeVoter.ts#ScopeVoter.vote`).
- **Fail-closed côté machine** : `NON_SCOPABLE_TOKEN_TYPES` est une **allowlist d'humains**
  (`ScopeVoter.ts#NON_SCOPABLE_TOKEN_TYPES`) — 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.

### Qui OBTIENT un scope — `grantableScopes`

Le voter `scope` contrôle un scope à l'**usage** : il regarde ce que le jeton porte, jamais comment
il l'a obtenu. `@RequireScope("vitrine:write")` et les `scopes` d'un outil MCP sont des contrôles
d'usage. Reste à décider qui a le droit de **recevoir** ce scope : sans réponse, l'émetteur de
l'application l'accorde à tout compte connecté qui le demande, et le contrôle d'usage ne filtre
plus rien.

```typescript
use("@nodefony/security", {
  grantableScopes: {
    "vitrine:write": ["ROLE_ADMIN"], // un scope exact
    "shop:*": ["ROLE_SHOP"], // une famille
    "shop:refund": ["ROLE_SHOP_MANAGER"], // l'exact l'emporte sur la famille
  },
});
```

- **Clé** : un scope exact, ou une famille `préfixe:*`. Une clé exacte l'emporte sur une famille,
  la famille la plus longue sur une plus courte ; l'ordre des clés ne décide de rien
  (`ScopeGrantPolicy.rolesFor()`, `scopeGrants.ts#ScopeGrantPolicy.rolesFor`).
- **Valeur** : les rôles dont **un** suffit, hiérarchie de rôles comprise. Une liste vide est
  refusée au démarrage : pour n'accorder un scope à personne, on ne l'expose pas.
- **Non déclaré** : le scope reste accordé à tout compte connecté. Rien ne change pour une
  application qui ne déclare rien.
- **Refusées au démarrage** : `admin:read`, `admin:write` et `admin:*`, réservés au plan
  d'administration du framework (`scopeGrantKeyProblem()`, `scopeGrants.ts#scopeGrantKeyProblem`). Le message nomme
  la clé et la raison.

Un seul juge pour les deux émetteurs de l'application, et pour les deux familles de scopes
(`refusedScopes()`, `scopeGrants.ts#refusedScopes`) :

| Émetteur                                       | Scope refusé                                                                                                              | Pourquoi cette forme                                                        |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Jeton d'accès (`/token`, `security:mcp:token`) | retiré ; le jeton est émis avec le reste, le refus est nommé au journal (`tokenService.ts#TokenService.#grantableScopes`) | RFC 6749 §3.3 : le serveur accorde moins que demandé et le dit dans `scope` |
| Renouvellement d'un jeton                      | rejugé sur les rôles du compte relu (`tokenService.ts#TokenService.refresh`)                                              | un rôle retiré retire son scope au prochain renouvellement                  |
| Clé API                                        | création refusée, **403** qui nomme le scope (`apiKeys.ts#ApiKeyService.createForSubject`)                                | une clé créée avec moins que demandé surprendrait sans rien dire            |

`grantableScopes` ne borne **pas** un jeton signé par un émetteur tiers (Keycloak…) : celui-ci
accorde selon sa propre politique. Et ce n'est pas une règle de zone : un jeton ne sait pas quelle
zone le lira, et `security:mcp:token` signe sans aucune requête.

**Depuis un module.** Un module complète la section par `module-security` dans sa configuration
(voir [configuration](../../../guides/configuration/)). Un préfixe = un propriétaire :
la fusion de configuration fusionne les listes **case par case**, si bien qu'une clé déclarée à deux
endroits mêle les deux listes de rôles.

### 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](../firewall/)) et fait deux choses au boot :

- **Aplatissement DFS précalculé** (`#detectCycles()` puis `#precompute()`,
  `RoleHierarchyWalker.ts#RoleHierarchyWalker.#precompute`) → `RoleHierarchyWalker.hasRole()` est **O(1)** sur le hot path
  (`RoleHierarchyWalker.ts#RoleHierarchyWalker.hasRole`).
- **Détection de cycles** par DFS coloré — un arc vers un nœud « en cours de visite » = cycle, et
  le boot **jette avec le chemin complet** `A → B → A` (`RoleHierarchyWalker.ts#RoleHierarchyWalker.#detectCycles`) : jamais
  de boucle infinie silencieuse en production.

## 🛡️ Le rôle par défaut d'une zone — et ce qu'il n'écrase pas

Une zone du pare-feu peut exiger un rôle **par défaut** chez elle (`roles`, voir
[firewall](../firewall/)). C'est le filet d'un espace d'administration : une route que personne
n'a pensé à garder hérite du rôle de sa zone, au lieu d'être ouverte à tout compte connecté.

La précédence est un **remplacement, jamais un cumul de rôles** : si l'action déclare sa propre
garde de rôle, c'est elle qui décide — y compris pour ouvrir plus largement que la zone. C'est
voulu : une page réservée à l'exploitant (`ROLE_SUPERVISOR`) doit rester atteignable dans une zone
fermée à l'administrateur de plateforme.

Quatre cas gardent la main, et chacun est une déclaration explicite :

| Ce qui décide à la place                  | Pourquoi                                                                                          |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
| une garde de **rôle** (`@IsGranted`)      | l'action a dit ce qu'elle exige                                                                   |
| le **pont du plan d'administration**      | il résout un rôle par point d'entrée, sous une route unique — d'où l'existence de `me`, `mine`    |
| une route qui **est** le mécanisme d'auth | lui imposer un rôle serait un verrou dont la clé est à l'intérieur                                |
| l'**ouverture** d'une connexion WebSocket | une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée séparément |

> 🔴 **`@RequireScope` seul n'est pas une décision d'identité.** Un scope dit ce qu'une clé
> déléguée peut faire en ton nom ; sur une session humaine, le voter de scope accorde sans
> condition. Une action gardée par un scope seul reste donc soumise au rôle de sa zone — les deux
> axes se **cumulent**. Les traiter comme interchangeables ouvrait la route à tout compte connecté.

Le dernier cas mérite d'être compris, parce qu'il ressemble à un trou sans en être un : une frame
`api.request {path}` repasse par la **même** décision que la requête HTTP équivalente. L'invariant
tenu est `api.request {path}` ≤ `GET {path}`.

## 🚪 Trois portes décident — et deux ne passent pas par le jury

C'est la chose la plus utile à savoir avant d'écrire un voter, et la plus facile à ignorer :

| Porte                                      | Qui tranche                         | Le jury est-il consulté ? |
| ------------------------------------------ | ----------------------------------- | ------------------------- |
| une action gardée (`@IsGranted`), la zone  | `Authorization.decide()`            | **oui**                   |
| un point d'entrée du plan d'administration | son rôle déclaré (`isAdminGranted`) | non                       |
| une frame WebSocket (canal, `api.request`) | la politique de canal               | non                       |

Les trois appliquent la **hiérarchie** de rôles, donc un même rôle y rend le même verdict. Mais un
voter maison — ABAC, ReBAC, ownership — n'est consulté que par la première. Une règle métier qui
doit valoir partout se place donc dans le code de l'action, pas seulement dans un voter.

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

Le contrat `IAccessVoter` (`IAccessVoter.ts#IAccessVoter`) tient en deux méthodes :

- `supports(attribute, subject?)` — test **bon marché** : ce voter sait-il décider de cet
  attribut ? Appelé sur chaque voter à chaque `decide()`.
- `vote(token, attribute, subject?)` — **async** (les voters métier font des lookups DB) ; renvoie
  un `VoterVote` : `GRANT` / `DENY` / `ABSTAIN` (`IAccessVoter.ts#VoterVote`).

L'enregistrement passe par le registre — `registerVoterFactory(name, factory)`
(`voterRegistry.ts#registerVoterFactory`), consommé une fois au boot (`listVoterFactories()`,
`voterRegistry.ts#listVoterFactories`). 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#IVoterFactoryContext`). Convention-frère : `authenticatorRegistry`, `tokenStoreRegistry`.

## 🔌 HTTP et WebSocket — une garde, N transports

- **La même garde** : `Resolver.executeAction()` (`Resolver.ts#Resolver.executeAction`) est le point unique
  d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
  `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
  `ws-isgranted-jwt`).
- **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
  et `IRealtimeToken` (WS) (`authorization.ts#Authorization.#auditDeny`).
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
  hiérarchie** : `satisfies()` (`frameAuthorizer.ts#satisfies`) délègue à `Firewall.hasRole()`
  (`firewall.ts#Firewall.hasRole`) — les rôles exigés par un canal héritent comme partout ailleurs.

## 📜 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#Authorization`)                  |
| Modèle de rôles            | RBAC **hiérarchique**, au sens des composantes _Core_ et _Hierarchical_ d'ANSI INCITS 359 — sans séparation des devoirs | `RoleHierarchyWalker.reachableRoles()`                                    |
| Combinaison des votes      | _deny-overrides_ : un refus l'emporte, le silence ferme                                                                 | `Authorization.decide()`                                                  |
| Scopes délégués            | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine                          | `ScopeVoter.vote()`                                                       |
| Modèle                     | **Zero Trust** (fermé par défaut)                                                                                       | 403 fail-closed du Resolver (`Resolver.ts#Resolver._callWithIdempotency`) |
| Journalisation de sécurité | audit des refus, jamais des octrois                                                                                     | `#auditDeny` → `recordAudit` (`authorization.ts#Authorization`)           |

> Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
> ANSI 359 » (ni sessions de rôles, ni séparation des devoirs), pas « conforme XACML » (un seul
> algorithme de combinaison, aucune obligation), et pas « compatible Zanzibar » (pas d'expansion,
> pas de liste, pas de jeton de cohérence). Ce qu'il offre, c'est de quoi **brancher** ces moteurs
> pour une décision ponctuelle.

## ⚡ Performance & mémoire

- **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
  alloc (`Resolver.ts#Resolver._enforceActionSecurity`) ; l'exigence est **figée une fois** par route et partagée entre
  requêtes (`SecurityRequirement`, `routerDecorators.ts#SecurityRequirement`).
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts#Authorization.decide`),
  instanciés **une seule fois** au boot (`authorization.ts#Authorization.decide`).
- **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
  (`RoleHierarchyWalker.ts#RoleHierarchyWalker.hasRole`).
- **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
  `JSON.stringify` aveugle (`describeSubject()`, `authorization.ts#describeSubject`).

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

| Symptôme                                                                | Cause                                                                                           | Correction                                                              |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Une action gardée par `@RequireScope` seul reste accessible à un humain | le voter de scope accorde sans condition à une session — il ne contraint que les clés déléguées | ajouter un `@IsGranted`, ou compter sur le rôle par défaut de la zone   |
| `@RequireScope("openid")` refuse tout, sans message                     | un scope sans `:` n'est capté par aucun voter → le jury ferme par défaut                        | employer la forme `api:action`, ou déclarer un voter pour cette famille |

| 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#Resolver._callWithIdempotency`) | 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) :

- **unit** : `authorization.test` (le jury + la stratégie + le RoleVoter/hiérarchie),
  `scopeVoter` (l'axe scope + fail-closed machine), `securityDecorators` et
  `securityEnforcement` (framework : métadonnées + garde du Resolver), `realtimeFrameLock` (le
  verrou de frame) ;
- **intégration** : `securityGuard.integration` (framework, la garde `@IsGranted` sur serveur
  réel), `ws-data-plane-auth` (http, le pont WS authentifié) ;
- **e2e transport** : `ws-isgranted-jwt` (http — `@IsGranted` bout en bout sur WebSocket + JWT) ;
- **attaque** : `authorization.attack` (escalade verticale, cycle DoS, confusion d'attribut,
  composition d'axes), `frameAuthorizer.attack` et `realtimeFramePollution.attack` (frames WS
  hostiles).

Couverture : `npm run coverage` dans `@nodefony/security`.

## 🔗 Pour aller plus loin

- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](../) · [Toute la documentation](../../../)
- 🧭 **Pages sœurs** : [Firewall](../firewall/) · [Jetons](../tokens/)

- L'authentification qui précède l'autorisation → [authenticators](../authenticators/)
- Le firewall qui pose l'identité et appelle le jury (zones, WS) → [firewall](../firewall/)
- Vue d'ensemble sécurité → [index](../)
