@nodefony/security 10.0.0-alpha.6 → 10.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.149.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
77
77
  import SecurityToken from "./nodefony/command/security-token.js";
78
78
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
79
79
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
80
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
81
- import __decorate from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
80
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
81
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
82
82
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
83
83
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
84
84
  import "./nodefony/errors/index.js";
@@ -44,6 +44,7 @@ const areaSchema = z.strictObject({
44
44
  stateless: z.boolean().default(false).describe("Stratégie d'identité AU-DESSUS du protocole (HTTP reste stateless par nature). false (défaut) : la zone PEUT tenir un registre serveur — la session n'est créée qu'AU LOGIN (jamais pour un anonyme, zéro alloc), cookie opaque révocable (BFF). true : aucun registre — chaque requête porte sa preuve complète (JWT/clé API), la session est ignorée même si un cookie est présent. ⚠️ Une zone destinée à un appelant NON-NAVIGATEUR (service partenaire, script, agent) doit être `true` : laissée à `false`, elle marche à l'essai — un navigateur ou un `curl -c` renvoie le cookie posé — puis échoue chez le vrai client, qui ne stocke rien et repart anonyme à chaque requête. Le symptôme est un 401 intermittent en production, que rien dans le code ne montre."),
45
45
  mode: z.enum(["first", "all"]).default("first").describe("Chaîne d'authenticators : 'first' = le premier qui reconnaît la requête authentifie (ex. cookie OU bearer) ; 'all' = tous doivent passer (ex. mtls+jwt zone admin)."),
46
46
  authenticators: z.array(z.string()).default([]).describe("Authenticators de la zone (sémantique selon `mode`). Validés au boot contre le registre."),
47
+ roles: z.array(z.string()).default([]).describe("Rôle(s) exigés PAR DÉFAUT dans la zone, en OU (l'un suffit ; hiérarchie comprise). Vide (défaut) = la zone n'exige qu'une identité, et toute route qu'elle couvre est ouverte à n'importe quel compte authentifié — c'est un défaut OUVERT, qu'une surface d'administration ne doit pas garder. Renseigné, le rôle s'applique à toute route de la zone qui ne décide PAS elle-même de son autorisation : une route gardée (`@IsGranted`, `@RequireScope`) et le pont du plan d'administration (qui résout un rôle par point d'entrée) gardent la main — sans quoi une zone fermée à l'administrateur écraserait un point d'entrée voulu accessible à son propriétaire (`me`, `sessions/mine`). Un refus ici est un 403 (authentifié, pas autorisé), jamais un 401."),
47
48
  entryPoint: z.string().optional().describe("Route de login/redirect si non authentifié."),
48
49
  host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
49
50
  resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
@@ -336,7 +337,7 @@ const securityConfigSchema = z.strictObject({
336
337
  encoders: z.record(z.string(), encoderSchema).default(() => ({ default: encoderSchema.parse({ type: "argon2id" }) })).describe("Chaîne d'encodeurs de mot de passe. 1re entrée = principal (hash à la création), suivantes = legacy en lecture seule (migration au login). Défaut : un argon2id sûr — le firewall en dérive le service `passwordEncoder`."),
337
338
  roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
338
339
  areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
339
- realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats = ROLE_ADMIN). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
340
+ realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats = ROLE_NODEFONY_ADMIN, l'échelle PLATEFORME : ces canaux exposent l'état du processus, pas celui d'une application). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
340
341
  cors: corsSchema.default(() => corsSchema.parse({})),
341
342
  csrf: csrfSchema.default(() => csrfSchema.parse({})),
342
343
  headers: headersSchema.default(() => headersSchema.parse({})),
@@ -16,6 +16,12 @@ var SecuredArea = class {
16
16
  host;
17
17
  realtime;
18
18
  resource;
19
+ /**
20
+ * Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
21
+ * aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
22
+ * et aucune zone ne porte de tableau qu'elle n'utilise pas.
23
+ */
24
+ roles;
19
25
  constructor(name, config) {
20
26
  this.name = name;
21
27
  this.pattern = new RegExp(config.pattern, "u");
@@ -26,6 +32,7 @@ var SecuredArea = class {
26
32
  this.host = config.host;
27
33
  this.realtime = config.realtime;
28
34
  this.resource = config.resource;
35
+ this.roles = config.roles.length > 0 ? config.roles : null;
29
36
  }
30
37
  /**
31
38
  * Cœur du match — pathname (+ host éventuel) déjà extraits, SANS `context`.
@@ -2,13 +2,26 @@ import { NODEFONY_CHANNEL_NAMESPACE, PLATFORM_CHANNELS, PLATFORM_INBOUND, starts
2
2
  //#region nodefony/src/realtime/frameAuthorizer.ts
3
3
  /**
4
4
  * Politique système par défaut des canaux d'**introspection serveur** : réservés
5
- * aux administrateurs. DURCISSEMENT Zero Trust (P6) : avant, « authentifié
6
- * suffisait » (tout `ROLE_USER` lisait `nodefony:syslog`) ; désormais `ROLE_ADMIN`.
7
- * Surchargeable finement par `realtimeChannels` (ex. `ROLE_SECURITY_AUDITOR`).
5
+ * à l'exploitant de l'INSTANCE, pas à l'administrateur d'une application.
6
+ *
7
+ * Ce que ces canaux exposent est l'état du **processus** — journaux, base,
8
+ * métriques, supervision, et jusqu'au déclenchement d'un ramasse-miettes
9
+ * bloquant. C'est l'échelle PLATEFORME, celle que le préfixe `ROLE_NODEFONY_*`
10
+ * désigne : le seul rôle qui traverse l'isolation entre organisations
11
+ * clientes. Un `ROLE_ADMIN` est, lui, destiné à être scopé à une organisation ;
12
+ * le jour où plusieurs partagent une instance, il aurait lu la supervision du
13
+ * processus qui les héberge toutes.
14
+ *
15
+ * Le canal d'audit ({@link SECURITY_CHANNEL_POLICY}) partage désormais cette
16
+ * échelle — il ne se distingue plus par le rôle, mais par ce qu'il contient.
17
+ *
18
+ * Surchargeable finement par `realtimeChannels` : une application qui veut
19
+ * ouvrir un de ces canaux à un rôle d'organisation le DÉCLARE, elle ne l'hérite
20
+ * pas.
8
21
  */
9
22
  const SYSTEM_CHANNEL_POLICY = {
10
23
  authenticated: true,
11
- roles: ["ROLE_ADMIN"]
24
+ roles: ["ROLE_NODEFONY_ADMIN"]
12
25
  };
13
26
  /**
14
27
  * Namespace d'introspection serveur (observabilité) — s'y abonner expose l'état
@@ -24,11 +37,12 @@ const SYSTEM_CHANNEL_POLICY = {
24
37
  */
25
38
  const DEFAULT_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
26
39
  /**
27
- * Plancher des canaux de **sécurité** (`nodefony:audit`, P6.14 lot 4) : réservé au
28
- * super-admin Nodefony (`ROLE_NODEFONY_ADMIN`) — un cran AU-DESSUS du plancher
29
- * d'observabilité générique (`ROLE_ADMIN`). Le journal d'audit du pod ne se lit
30
- * pas avec un simple rôle admin applicatif. Cohérent avec le data plane HTTP de
31
- * l'audit (`SecurityAdminApi`, lot 3, même rôle).
40
+ * Plancher des canaux de **sécurité** (`nodefony:audit`) : réservé à l'exploitant
41
+ * de l'instance (`ROLE_NODEFONY_ADMIN`). Même échelle que
42
+ * {@link SYSTEM_CHANNEL_POLICY} — le journal d'audit du pod n'est pas plus
43
+ * exposé que sa supervision, et aucun des deux ne se lit avec un rôle
44
+ * d'organisation. Cohérent avec le plan d'administration HTTP de l'audit
45
+ * (`SecurityAdminApi`, même rôle).
32
46
  *
33
47
  * Multi-tenant (futur) : `nodefony:audit` reste un canal **plateforme** (pod),
34
48
  * jamais exposé à un user tenant ; l'événement portera le `tenantId` (via l'ALS)
@@ -8,6 +8,7 @@ declare const areaSchema: z.ZodObject<{
8
8
  first: "first";
9
9
  }>>;
10
10
  authenticators: z.ZodDefault<z.ZodArray<z.ZodString>>;
11
+ roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
11
12
  entryPoint: z.ZodOptional<z.ZodString>;
12
13
  host: z.ZodOptional<z.ZodString>;
13
14
  resource: z.ZodOptional<z.ZodString>;
@@ -34,6 +35,7 @@ export declare const securityConfigSchema: z.ZodObject<{
34
35
  first: "first";
35
36
  }>>;
36
37
  authenticators: z.ZodDefault<z.ZodArray<z.ZodString>>;
38
+ roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
37
39
  entryPoint: z.ZodOptional<z.ZodString>;
38
40
  host: z.ZodOptional<z.ZodString>;
39
41
  resource: z.ZodOptional<z.ZodString>;
@@ -52,6 +52,21 @@ export interface ISecuredArea {
52
52
  * verrou WS consulte la même zone que HTTP (invariant `api.request` ≤ `GET`).
53
53
  */
54
54
  readonly realtime: boolean;
55
+ /**
56
+ * Rôles exigés PAR DÉFAUT dans la zone — en OU, hiérarchie comprise —, ou
57
+ * `null` quand la zone n'exige qu'une identité.
58
+ *
59
+ * C'est le **filet** d'une surface d'administration : une route que personne
60
+ * n'a pensé à garder hérite du rôle de sa zone, au lieu d'être ouverte à
61
+ * n'importe quel compte authentifié. Une route qui décide elle-même de son
62
+ * autorisation — garde déclarée, ou pont du plan d'administration qui résout
63
+ * un rôle par point d'entrée — n'en dépend pas : c'est ce qui laisse exister
64
+ * un point d'entrée ouvert à son propriétaire (`me`, `sessions/mine`) dans
65
+ * une zone par ailleurs fermée.
66
+ *
67
+ * Un refus est un **403** (authentifié mais pas autorisé), jamais un 401.
68
+ */
69
+ readonly roles: readonly string[] | null;
55
70
  /** La requête tombe-t-elle dans cette zone ? (pattern + host éventuel). */
56
71
  match(context: ContextType): boolean;
57
72
  }
@@ -18,6 +18,12 @@ export declare class SecuredArea implements ISecuredArea {
18
18
  readonly host?: string;
19
19
  readonly realtime: boolean;
20
20
  readonly resource?: string;
21
+ /**
22
+ * Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
23
+ * aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
24
+ * et aucune zone ne porte de tableau qu'elle n'utilise pas.
25
+ */
26
+ readonly roles: readonly string[] | null;
21
27
  constructor(name: string, config: ISecurityAreaConfig);
22
28
  /**
23
29
  * Cœur du match — pathname (+ host éventuel) déjà extraits, SANS `context`.
@@ -48,9 +48,22 @@ export interface ISystemChannelRule {
48
48
  export type FrameDenyReporter = (surface: "api.request" | "channel", target: string, reason: string, token: IRealtimeToken) => void;
49
49
  /**
50
50
  * Politique système par défaut des canaux d'**introspection serveur** : réservés
51
- * aux administrateurs. DURCISSEMENT Zero Trust (P6) : avant, « authentifié
52
- * suffisait » (tout `ROLE_USER` lisait `nodefony:syslog`) ; désormais `ROLE_ADMIN`.
53
- * Surchargeable finement par `realtimeChannels` (ex. `ROLE_SECURITY_AUDITOR`).
51
+ * à l'exploitant de l'INSTANCE, pas à l'administrateur d'une application.
52
+ *
53
+ * Ce que ces canaux exposent est l'état du **processus** — journaux, base,
54
+ * métriques, supervision, et jusqu'au déclenchement d'un ramasse-miettes
55
+ * bloquant. C'est l'échelle PLATEFORME, celle que le préfixe `ROLE_NODEFONY_*`
56
+ * désigne : le seul rôle qui traverse l'isolation entre organisations
57
+ * clientes. Un `ROLE_ADMIN` est, lui, destiné à être scopé à une organisation ;
58
+ * le jour où plusieurs partagent une instance, il aurait lu la supervision du
59
+ * processus qui les héberge toutes.
60
+ *
61
+ * Le canal d'audit ({@link SECURITY_CHANNEL_POLICY}) partage désormais cette
62
+ * échelle — il ne se distingue plus par le rôle, mais par ce qu'il contient.
63
+ *
64
+ * Surchargeable finement par `realtimeChannels` : une application qui veut
65
+ * ouvrir un de ces canaux à un rôle d'organisation le DÉCLARE, elle ne l'hérite
66
+ * pas.
54
67
  */
55
68
  export declare const SYSTEM_CHANNEL_POLICY: IChannelPolicy;
56
69
  /**
@@ -67,11 +80,12 @@ export declare const SYSTEM_CHANNEL_POLICY: IChannelPolicy;
67
80
  */
68
81
  export declare const DEFAULT_SYSTEM_PREFIXES: readonly ["nodefony:"];
69
82
  /**
70
- * Plancher des canaux de **sécurité** (`nodefony:audit`, P6.14 lot 4) : réservé au
71
- * super-admin Nodefony (`ROLE_NODEFONY_ADMIN`) — un cran AU-DESSUS du plancher
72
- * d'observabilité générique (`ROLE_ADMIN`). Le journal d'audit du pod ne se lit
73
- * pas avec un simple rôle admin applicatif. Cohérent avec le data plane HTTP de
74
- * l'audit (`SecurityAdminApi`, lot 3, même rôle).
83
+ * Plancher des canaux de **sécurité** (`nodefony:audit`) : réservé à l'exploitant
84
+ * de l'instance (`ROLE_NODEFONY_ADMIN`). Même échelle que
85
+ * {@link SYSTEM_CHANNEL_POLICY} — le journal d'audit du pod n'est pas plus
86
+ * exposé que sa supervision, et aucun des deux ne se lit avec un rôle
87
+ * d'organisation. Cohérent avec le plan d'administration HTTP de l'audit
88
+ * (`SecurityAdminApi`, même rôle).
75
89
  *
76
90
  * Multi-tenant (futur) : `nodefony:audit` reste un canal **plateforme** (pod),
77
91
  * jamais exposé à un user tenant ; l'événement portera le `tenantId` (via l'ALS)
package/docs/audit.md CHANGED
@@ -584,8 +584,7 @@ Ces deux absences n'ont pas le même statut. Redis n'en aura pas, et la raison n
584
584
  que le journal d'audit **croît sans borne**, se conserve des mois pour la conformité, et se
585
585
  **consulte** (recherche, filtres, pagination) : garder tout ça en mémoire vive coûte cher pour un
586
586
  motif d'accès qui n'est pas le sien. Mongo, lui, est un chemin **durable** :
587
- l'audit y **manque**, et c'est un manque à combler (objectif « full NoSQL », `MIGRATION_STATUS.md`
588
- P7.11) — un utilisateur choisit sa base de données, pas de perdre sa traçabilité.
587
+ l'audit y **manque**, et c'est un manque à combler (objectif « full NoSQL ») — un utilisateur choisit sa base de données, pas de perdre sa traçabilité.
589
588
 
590
589
  En attendant, une application MongoDB qui veut un journal durable a trois voies : brancher un store
591
590
  maison (voir l'extension ci-dessous), charger `@nodefony/drizzle` à côté de Mongo — même en SQLite
@@ -87,6 +87,35 @@ Nodefony **centralise** la décision dans un service unique, appelé par les dé
87
87
  (`@IsGranted`, `@RequireScope`) sur tous les transports, avec une posture **fail-closed** : au
88
88
  moindre doute (voter qui plante, silence du jury, moteur absent), c'est refusé — jamais accordé.
89
89
 
90
+ ## Où se situe Nodefony parmi les modèles de contrôle d'accès
91
+
92
+ Le contrôle d'accès a plusieurs façons de répondre à « as-tu le droit ? », selon **ce sur quoi**
93
+ la décision porte : un rôle porté par le compte (RBAC), des attributs du sujet, de la ressource et
94
+ du contexte (ABAC), une relation dans un graphe (ReBAC), ou une politique écrite hors du code
95
+ (XACML, et les moteurs qui s'en inspirent). Ce ne sont pas des concurrents : les systèmes réels en
96
+ empilent plusieurs.
97
+
98
+ Nodefony fait du **RBAC hiérarchique** — des rôles, et une hiérarchie qui dit lesquels en couvrent
99
+ d'autres, au sens des composantes _Core_ et _Hierarchical_ du modèle NIST (ANSI INCITS 359-2004,
100
+ révisée 2012). Le reste se **branche** au jury, décrit plus bas : un voter maison qui lit des
101
+ attributs fait de l'ABAC, un voter qui interroge un moteur de graphe (OpenFGA, SpiceDB, Ory Keto —
102
+ famille issue de l'article _Zanzibar_, Google, 2019) fait du ReBAC.
103
+
104
+ **Ce qui n'est PAS couvert**, et qu'il vaut mieux savoir avant de concevoir :
105
+
106
+ - **la séparation des devoirs** (deux rôles incompatibles sur un même compte) n'est pas outillée ;
107
+ elle se joue à l'affectation des rôles, pas à la décision ;
108
+ - **le filtrage d'une liste** — « montre-moi seulement les lignes que j'ai le droit de voir » — ne
109
+ passe pas par le jury : chaque décision porte sur **une** ressource. Ce filtrage appartient à ta
110
+ requête de dépôt ;
111
+ - **le contexte de la requête** (heure, adresse, en-têtes, corps) n'est pas au contrat d'un voter ;
112
+ ce qui lui parvient, c'est le jeton et le sujet ;
113
+ - **aucune décision « accordé, à condition que »** (les _obligations_ de XACML) : le verdict est
114
+ binaire.
115
+
116
+ En pratique, et c'est le cas dans 95 % des applications : des rôles, plus **un** voter d'ownership
117
+ pour les cas où le rôle ne suffit pas.
118
+
90
119
  ## La vision Nodefony — un jury découplé et fail-closed
91
120
 
92
121
  `Authorization.decide(token, attribute, subject?)` (`authorization.ts:70`) itère les voters, teste
@@ -302,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
302
331
 
303
332
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
304
333
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
305
- (`Resolver.ts:587-607`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
334
+ (`Resolver.ts:689-702`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
306
335
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
307
336
  `@IsGranted("doc.edit", { subject: "id" })`.
308
337
 
@@ -327,16 +356,16 @@ se voit au premier test.
327
356
  ## 🧰 Déclarer l'exigence — `@IsGranted`, `@RequireScope`, `@Anonymous`
328
357
 
329
358
  Les décorateurs n'écrivent **que des métadonnées** (0 import `@nodefony/security`, 0 cycle) ; le
330
- moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:577-578`) :
359
+ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`) :
331
360
 
332
361
  | Déclaration | Sémantique |
333
362
  | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
334
- | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:839`) |
363
+ | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:878`) |
335
364
  | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:407-412`) |
336
365
  | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:426`) |
337
366
  | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1500`) |
338
- | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:613-617`) |
339
- | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:760`) |
367
+ | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:743-747`) |
368
+ | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:975`) |
340
369
  | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:887`) |
341
370
  | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1236`) |
342
371
 
@@ -344,7 +373,7 @@ La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI
344
373
  403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:331-336`). Le
345
374
  même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
346
375
  transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
347
- entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:576-606`).
376
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:672-702`).
348
377
 
349
378
  > [!IMPORTANT]
350
379
  > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
@@ -373,6 +402,25 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
373
402
  (`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:206`).
374
403
  - Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
375
404
 
405
+ #### Deux échelles de rôles — plateforme et organisation
406
+
407
+ Une **convention de nom** sépare deux échelles, et c'est elle qui prépare le jour où plusieurs
408
+ organisations partageront une instance :
409
+
410
+ | Préfixe | Échelle | Qui le porte | Scopable ? |
411
+ | ----------------- | ---------------- | -------------------------- | ---------- |
412
+ | `ROLE_NODEFONY_*` | **plateforme** | l'exploitant de l'instance | **jamais** |
413
+ | `ROLE_*` | **organisation** | un compte de l'application | oui |
414
+
415
+ L'échelle plateforme est la seule qui traverse l'isolation entre organisations. Les ressources du
416
+ **processus** — journaux, base, supervision, ramasse-miettes — sont donc gardées par elle, jamais
417
+ par un rôle d'organisation : un administrateur applicatif n'a pas à lire l'état du serveur qui
418
+ héberge aussi les autres.
419
+
420
+ > ⚠️ **Ne donne jamais un `ROLE_NODEFONY_*` à un compte client.** Un « administrateur » chez le
421
+ > client, c'est `ROLE_ADMIN`. C'est une convention : **rien dans le code ne l'impose** pour tes
422
+ > propres rôles — seules les politiques de plateforme du framework sont vérifiées par un test.
423
+
376
424
  ### `scope` — l'axe « ce qu'une clé déléguée peut faire »
377
425
 
378
426
  Frère du `role` sur l'autre axe. Capte la forme conventionnée `api:action` — un `:`, jamais
@@ -400,6 +448,49 @@ voir [firewall](./firewall.md)) et fait deux choses au boot :
400
448
  le boot **jette avec le chemin complet** `A → B → A` (`RoleHierarchyWalker.ts:69-95`) : jamais
401
449
  de boucle infinie silencieuse en production.
402
450
 
451
+ ## 🛡️ Le rôle par défaut d'une zone — et ce qu'il n'écrase pas
452
+
453
+ Une zone du pare-feu peut exiger un rôle **par défaut** chez elle (`roles`, voir
454
+ [firewall](./firewall.md)). C'est le filet d'un espace d'administration : une route que personne
455
+ n'a pensé à garder hérite du rôle de sa zone, au lieu d'être ouverte à tout compte connecté.
456
+
457
+ La précédence est un **remplacement, jamais un cumul de rôles** : si l'action déclare sa propre
458
+ garde de rôle, c'est elle qui décide — y compris pour ouvrir plus largement que la zone. C'est
459
+ voulu : une page réservée à l'exploitant (`ROLE_SUPERVISOR`) doit rester atteignable dans une zone
460
+ fermée à l'administrateur de plateforme.
461
+
462
+ Quatre cas gardent la main, et chacun est une déclaration explicite :
463
+
464
+ | Ce qui décide à la place | Pourquoi |
465
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
466
+ | une garde de **rôle** (`@IsGranted`) | l'action a dit ce qu'elle exige |
467
+ | 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` |
468
+ | une route qui **est** le mécanisme d'auth | lui imposer un rôle serait un verrou dont la clé est à l'intérieur |
469
+ | 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 |
470
+
471
+ > 🔴 **`@RequireScope` seul n'est pas une décision d'identité.** Un scope dit ce qu'une clé
472
+ > déléguée peut faire en ton nom ; sur une session humaine, le voter de scope accorde sans
473
+ > condition. Une action gardée par un scope seul reste donc soumise au rôle de sa zone — les deux
474
+ > axes se **cumulent**. Les traiter comme interchangeables ouvrait la route à tout compte connecté.
475
+
476
+ Le dernier cas mérite d'être compris, parce qu'il ressemble à un trou sans en être un : une frame
477
+ `api.request {path}` repasse par la **même** décision que la requête HTTP équivalente. L'invariant
478
+ tenu est `api.request {path}` ≤ `GET {path}`.
479
+
480
+ ## 🚪 Trois portes décident — et deux ne passent pas par le jury
481
+
482
+ C'est la chose la plus utile à savoir avant d'écrire un voter, et la plus facile à ignorer :
483
+
484
+ | Porte | Qui tranche | Le jury est-il consulté ? |
485
+ | ------------------------------------------ | ----------------------------------- | ------------------------- |
486
+ | une action gardée (`@IsGranted`), la zone | `Authorization.decide()` | **oui** |
487
+ | un point d'entrée du plan d'administration | son rôle déclaré (`isAdminGranted`) | non |
488
+ | une frame WebSocket (canal, `api.request`) | la politique de canal | non |
489
+
490
+ Les trois appliquent la **hiérarchie** de rôles, donc un même rôle y rend le même verdict. Mais un
491
+ voter maison — ABAC, ReBAC, ownership — n'est consulté que par la première. Une règle métier qui
492
+ doit valoir partout se place donc dans le code de l'action, pas seulement dans un voter.
493
+
403
494
  ## 🧩 Étendre le jury — le contrat et le registre
404
495
 
405
496
  Le contrat `IAccessVoter` (`IAccessVoter.ts:20-26`) tient en deux méthodes :
@@ -432,11 +523,20 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
432
523
 
433
524
  ## 📜 Normes appliquées
434
525
 
435
- | Domaine | Norme / posture | Ancrage |
436
- | -------------------------- | -------------------------------------- | --------------------------------------------------------- |
437
- | Contrôle d'accès | OWASP Top 10 **A01** (IDOR, élévation) | défaut `DENY` du jury (`authorization.ts:100-108`) |
438
- | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:582-584`) |
439
- | Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
526
+ | Domaine | Norme / posture | Ancrage |
527
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
528
+ | Contrôle d'accès | OWASP Top 10 **A01** (IDOR, élévation) | défaut `DENY` du jury (`authorization.ts:100-108`) |
529
+ | 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()` |
530
+ | Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
531
+ | Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
532
+ | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:582-584`) |
533
+ | Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
534
+
535
+ > Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
536
+ > ANSI 359 » (ni sessions de rôles, ni séparation des devoirs), pas « conforme XACML » (un seul
537
+ > algorithme de combinaison, aucune obligation), et pas « compatible Zanzibar » (pas d'expansion,
538
+ > pas de liste, pas de jeton de cohérence). Ce qu'il offre, c'est de quoi **brancher** ces moteurs
539
+ > pour une décision ponctuelle.
440
540
 
441
541
  ## ⚡ Performance & mémoire
442
542
 
@@ -452,6 +552,11 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
452
552
 
453
553
  ## ⚠️ Pièges (symptôme → cause → correction)
454
554
 
555
+ | Symptôme | Cause | Correction |
556
+ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
557
+ | 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 |
558
+ | `@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 |
559
+
455
560
  | Symptôme | Cause (dans le code) | Correction |
456
561
  | -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
457
562
  | Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
package/docs/firewall.md CHANGED
@@ -431,6 +431,57 @@ muets (volume, pas un signal). Les voters sont instanciés **une fois au boot**
431
431
  ou futur (`mtls`, `agent`…) — est traité comme scopable, donc **bridé par défaut**.
432
432
  - En une ligne : rôles = qui tu es ; scopes = ce qu'une **clé** a le droit de faire.
433
433
 
434
+ ### `roles` — le rôle exigé PAR DÉFAUT dans une zone
435
+
436
+ Une zone qui ne liste que des authenticators exige une **identité**, rien de plus : toute route
437
+ qu'elle couvre est alors ouverte à **n'importe quel compte connecté**. C'est acceptable pour une
438
+ application ; c'est un défaut ouvert pour une surface d'administration, où l'oubli d'une garde ne
439
+ devrait jamais se solder par une fuite.
440
+
441
+ `roles` renverse ce défaut. Il se déclare **une fois sur la zone**, au lieu d'être recopié sur
442
+ chaque route :
443
+
444
+ ```typescript
445
+ use("@nodefony/security", {
446
+ areas: {
447
+ admin: {
448
+ pattern: "^/back-office(/|$)",
449
+ authenticators: ["session"],
450
+ roles: ["ROLE_ADMIN"], // ← exigé par défaut dans toute la zone
451
+ },
452
+ },
453
+ });
454
+ ```
455
+
456
+ Les rôles listés sont en **OU** — un seul suffit — et la **hiérarchie s'applique** : un
457
+ `ROLE_NODEFONY_ADMIN` satisfait `ROLE_ADMIN` si la hiérarchie le dit. Un refus est un **403**
458
+ (authentifié, mais pas autorisé), jamais un 401. Omettre la clé laisse le comportement historique
459
+ inchangé : la zone n'exige qu'une identité.
460
+
461
+ #### Ce que le rôle de zone n'écrase PAS
462
+
463
+ C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
464
+ explicite du code — jamais un contournement :
465
+
466
+ | Ce qui décide à la place | Pourquoi |
467
+ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
468
+ | Une **garde d'action** (`@IsGranted`, `@RequireScope`) | L'action a déjà dit ce qu'elle exige. Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
469
+ | Une route **`selfGuarded`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
470
+ | Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
471
+ | L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
472
+
473
+ Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
474
+ une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
475
+ L'invariant tenu est donc `api.request {path}` ≤ `GET {path}` — on n'obtient jamais par la socket ce
476
+ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rôle d'administration ne
477
+ puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
478
+ destinataire.
479
+
480
+ > ⚠️ **`selfGuarded` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
481
+ > qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
482
+ > que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
483
+ > Dans le doute, une garde d'action explicite est toujours préférable.
484
+
434
485
  ### La hiérarchie de rôles
435
486
 
436
487
  `RoleHierarchyWalker` (`src/RoleHierarchyWalker.ts`) : `ROLE_ADMIN` hérite `ROLE_USER`, etc.
package/docs/oauth2.md CHANGED
@@ -163,6 +163,13 @@ reste de l'application démarre, le bouton correspondant n'apparaît simplement
163
163
  Un fournisseur n'est monté **que si ses deux secrets sont présents** : pas de bouton mort sur l'écran
164
164
  de login quand la variable manque.
165
165
 
166
+ > **Pourquoi ces deux noms n'ont pas de préfixe `NF_`, contrairement à la règle.** Le préfixe dit à
167
+ > qui appartient la valeur, pas qui la lit — et `GITHUB_CLIENT_ID` vous est **délivré par GitHub**.
168
+ > Son nom est celui que la documentation du fournisseur, Auth.js et Passport emploient déjà : si
169
+ > votre application en a une, Nodefony la lit telle quelle plutôt que de vous faire recopier la même
170
+ > valeur sous un second nom, qui finirait par diverger. La base des callbacks, elle, n'est émise par
171
+ > personne : c'est un réglage que votre application se donne, donc `NF_OAUTH_REDIRECT_BASE`.
172
+
166
173
  ```typescript
167
174
  // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
168
175
  // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
@@ -173,7 +180,7 @@ export const env = defineEnv({
173
180
  GITHUB_CLIENT_SECRET: envString({ optional: true }),
174
181
  // Base des callbacks : doit correspondre EXACTEMENT à l'URL enregistrée chez
175
182
  // le fournisseur (RFC 9700 — comparaison de chaînes, pas de préfixe).
176
- OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
183
+ NF_OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
177
184
  });
178
185
 
179
186
  export default defineConfig<typeof env>((ctx) => ({
@@ -194,7 +201,7 @@ export default defineConfig<typeof env>((ctx) => ({
194
201
  github: {
195
202
  clientId: ctx.env.GITHUB_CLIENT_ID,
196
203
  clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
197
- redirectUri: `${ctx.env.OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
204
+ redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
198
205
  },
199
206
  }
200
207
  : {}),
package/docs/totp.md CHANGED
@@ -577,7 +577,7 @@ Deux pièges de lecture, signalés dans l'entité elle-même :
577
577
  | `mongoose` | — | — | ⏳ manquant, à venir |
578
578
  | `redis` | — | — | ⏳ manquant, à venir |
579
579
 
580
- Ces deux absences sont des **manques**, pas des choix de périmètre (`MIGRATION_STATUS.md`, P7.11) —
580
+ Ces deux absences sont des **manques**, pas des choix de périmètre —
581
581
  mais elles se comblent à deux régimes différents.
582
582
 
583
583
  `redis` le portera **en opt-in explicite, jamais choisi par `auto`** — exactement le régime des
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-alpha.6",
3
+ "version": "10.0.0-alpha.8",
4
4
  "description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -47,26 +47,26 @@
47
47
  "@simplewebauthn/server": "14.0.2",
48
48
  "jose": "6.2.12",
49
49
  "tslib": "2.8.1",
50
- "zod": "^4.6.1"
50
+ "zod": "^4.6.5"
51
51
  },
52
52
  "devDependencies": {
53
- "@nodefony/framework": "^10.0.0-alpha.6",
54
- "@nodefony/http": "^10.0.0-alpha.6",
55
- "@nodefony/user": "^10.0.0-alpha.6",
56
- "@types/node": "26.5.1",
57
- "@vitest/coverage-v8": "5.0.0",
58
- "nodefony": "^10.0.0-alpha.6",
53
+ "@nodefony/framework": "^10.0.0-alpha.8",
54
+ "@nodefony/http": "^10.0.0-alpha.8",
55
+ "@nodefony/user": "^10.0.0-alpha.8",
56
+ "@types/node": "26.6.2",
57
+ "@vitest/coverage-v8": "5.0.1",
58
+ "nodefony": "^10.0.0-alpha.8",
59
59
  "rimraf": "6.1.3",
60
- "vitest": "5.0.0"
60
+ "vitest": "5.0.1"
61
61
  },
62
62
  "license": "Apache-2.0",
63
63
  "readmeFilename": "README.md",
64
64
  "contributors": [],
65
65
  "peerDependencies": {
66
- "@nodefony/framework": "^10.0.0-alpha.6",
67
- "@nodefony/http": "^10.0.0-alpha.6",
68
- "@nodefony/user": "^10.0.0-alpha.6",
69
- "nodefony": "^10.0.0-alpha.6"
66
+ "@nodefony/framework": "^10.0.0-alpha.8",
67
+ "@nodefony/http": "^10.0.0-alpha.8",
68
+ "@nodefony/user": "^10.0.0-alpha.8",
69
+ "nodefony": "^10.0.0-alpha.8"
70
70
  },
71
71
  "files": [
72
72
  "dist",