@nodefony/security 10.0.0-alpha.1

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.
Files changed (258) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +182 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +151 -0
  6. package/dist/nodefony/command/security-secrets.js +158 -0
  7. package/dist/nodefony/command/security-token.js +335 -0
  8. package/dist/nodefony/command/security-user-add.js +131 -0
  9. package/dist/nodefony/command/security-user-delete.js +102 -0
  10. package/dist/nodefony/command/security-user-list.js +77 -0
  11. package/dist/nodefony/config/config.js +366 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +35 -0
  13. package/dist/nodefony/contracts/IAccessVoter.js +13 -0
  14. package/dist/nodefony/contracts/IApiKey.js +1 -0
  15. package/dist/nodefony/contracts/IAuditEvent.js +1 -0
  16. package/dist/nodefony/contracts/IAuditStore.js +1 -0
  17. package/dist/nodefony/contracts/IAuthenticator.js +1 -0
  18. package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
  19. package/dist/nodefony/contracts/IFirewall.js +1 -0
  20. package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
  21. package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
  22. package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
  23. package/dist/nodefony/contracts/ISecuredArea.js +1 -0
  24. package/dist/nodefony/contracts/IToken.js +1 -0
  25. package/dist/nodefony/contracts/ITokenStore.js +1 -0
  26. package/dist/nodefony/contracts/ITotpSecret.js +1 -0
  27. package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
  28. package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
  29. package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
  30. package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
  31. package/dist/nodefony/contracts/IWebhookStore.js +1 -0
  32. package/dist/nodefony/contracts/index.js +2 -0
  33. package/dist/nodefony/errors/AccessDeniedError.js +14 -0
  34. package/dist/nodefony/errors/ApiKeyError.js +21 -0
  35. package/dist/nodefony/errors/AuthenticationError.js +14 -0
  36. package/dist/nodefony/errors/CsrfError.js +23 -0
  37. package/dist/nodefony/errors/InvalidTargetError.js +39 -0
  38. package/dist/nodefony/errors/SsrfError.js +17 -0
  39. package/dist/nodefony/errors/ThrottledError.js +21 -0
  40. package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
  41. package/dist/nodefony/errors/WebAuthnError.js +21 -0
  42. package/dist/nodefony/errors/index.js +9 -0
  43. package/dist/nodefony/service/accessTokenVerifier.js +77 -0
  44. package/dist/nodefony/service/apiKeys.js +310 -0
  45. package/dist/nodefony/service/auditService.js +145 -0
  46. package/dist/nodefony/service/authFlow.js +332 -0
  47. package/dist/nodefony/service/authorization.js +95 -0
  48. package/dist/nodefony/service/cors.js +81 -0
  49. package/dist/nodefony/service/csrf.js +97 -0
  50. package/dist/nodefony/service/firewall.js +699 -0
  51. package/dist/nodefony/service/oauth2.js +153 -0
  52. package/dist/nodefony/service/securityHeaders.js +80 -0
  53. package/dist/nodefony/service/tokenService.js +486 -0
  54. package/dist/nodefony/service/totp.js +209 -0
  55. package/dist/nodefony/service/webAuthn.js +343 -0
  56. package/dist/nodefony/service/webhooks.js +539 -0
  57. package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
  58. package/dist/nodefony/src/SecuredArea.js +51 -0
  59. package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
  60. package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
  61. package/dist/nodefony/src/admin/adminAudit.js +37 -0
  62. package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
  63. package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
  64. package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
  65. package/dist/nodefony/src/audit/auditBridge.js +82 -0
  66. package/dist/nodefony/src/audit/auditFilters.js +60 -0
  67. package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
  68. package/dist/nodefony/src/audit/readAuditContext.js +24 -0
  69. package/dist/nodefony/src/audit/recordAudit.js +16 -0
  70. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
  71. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
  72. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
  73. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
  74. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
  75. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
  76. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
  77. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
  78. package/dist/nodefony/src/authenticator/bearer.js +2 -0
  79. package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
  80. package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
  81. package/dist/nodefony/src/crypto/secretCipher.js +79 -0
  82. package/dist/nodefony/src/csp.js +54 -0
  83. package/dist/nodefony/src/csrfToken.js +65 -0
  84. package/dist/nodefony/src/net/ssrfGuard.js +130 -0
  85. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +65 -0
  87. package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
  88. package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
  89. package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
  90. package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
  91. package/dist/nodefony/src/sessionIdentity.js +35 -0
  92. package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
  93. package/dist/nodefony/src/token/AnonymousToken.js +40 -0
  94. package/dist/nodefony/src/token/JwtKeystore.js +160 -0
  95. package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
  96. package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
  97. package/dist/nodefony/src/token/UserToken.js +67 -0
  98. package/dist/nodefony/src/token/jwtRuntime.js +19 -0
  99. package/dist/nodefony/src/token/secretFile.js +134 -0
  100. package/dist/nodefony/src/token/tokenCriteria.js +35 -0
  101. package/dist/nodefony/src/token/tokenFilters.js +72 -0
  102. package/dist/nodefony/src/token/tokenSort.js +40 -0
  103. package/dist/nodefony/src/token/tokenStatus.js +35 -0
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
  106. package/dist/nodefony/src/totp/totpCipher.js +30 -0
  107. package/dist/nodefony/src/totp/totpCrypto.js +226 -0
  108. package/dist/nodefony/src/totp/totpOperations.js +129 -0
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
  110. package/dist/nodefony/src/voter/RoleVoter.js +32 -0
  111. package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
  112. package/dist/nodefony/src/voter/voterRegistry.js +20 -0
  113. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
  114. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
  115. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
  116. package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
  117. package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
  118. package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
  119. package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
  120. package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
  121. package/dist/nodefony/src/webhook/webhookSort.js +48 -0
  122. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
  123. package/dist/types/index.d.ts +157 -0
  124. package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
  125. package/dist/types/nodefony/command/security-token.d.ts +44 -0
  126. package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
  127. package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
  128. package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
  129. package/dist/types/nodefony/config/config.d.ts +295 -0
  130. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  131. package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
  132. package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
  133. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
  134. package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
  135. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
  136. package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
  137. package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
  138. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
  139. package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
  140. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
  141. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
  142. package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
  143. package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
  144. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
  145. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
  146. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
  147. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
  148. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
  149. package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
  150. package/dist/types/nodefony/contracts/index.d.ts +9 -0
  151. package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
  152. package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
  153. package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
  154. package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
  155. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
  156. package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
  157. package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
  158. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
  159. package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
  160. package/dist/types/nodefony/errors/index.d.ts +8 -0
  161. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
  162. package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
  163. package/dist/types/nodefony/service/auditService.d.ts +30 -0
  164. package/dist/types/nodefony/service/authFlow.d.ts +123 -0
  165. package/dist/types/nodefony/service/authorization.d.ts +33 -0
  166. package/dist/types/nodefony/service/cors.d.ts +48 -0
  167. package/dist/types/nodefony/service/csrf.d.ts +57 -0
  168. package/dist/types/nodefony/service/firewall.d.ts +148 -0
  169. package/dist/types/nodefony/service/oauth2.d.ts +66 -0
  170. package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
  171. package/dist/types/nodefony/service/tokenService.d.ts +103 -0
  172. package/dist/types/nodefony/service/totp.d.ts +58 -0
  173. package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
  174. package/dist/types/nodefony/service/webhooks.d.ts +160 -0
  175. package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
  176. package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
  177. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
  178. package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
  179. package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
  180. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
  181. package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
  182. package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
  183. package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
  184. package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
  185. package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
  186. package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
  187. package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
  188. package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
  189. package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
  190. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
  191. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
  192. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
  193. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
  194. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
  195. package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
  196. package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
  197. package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
  198. package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
  199. package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
  200. package/dist/types/nodefony/src/csp.d.ts +39 -0
  201. package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
  202. package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
  203. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
  204. package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
  205. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
  206. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
  207. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
  208. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
  209. package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
  210. package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
  211. package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
  212. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
  213. package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
  214. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
  215. package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
  216. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
  217. package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
  218. package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
  219. package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
  220. package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
  221. package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
  222. package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
  223. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
  224. package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
  225. package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
  226. package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
  227. package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
  228. package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
  229. package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
  230. package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
  231. package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
  232. package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
  233. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
  234. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
  235. package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
  236. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
  237. package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
  238. package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
  239. package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
  240. package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
  241. package/docs/api-keys.md +691 -0
  242. package/docs/audit.md +751 -0
  243. package/docs/authenticators.md +487 -0
  244. package/docs/authorization.md +497 -0
  245. package/docs/cors.md +497 -0
  246. package/docs/csrf.md +392 -0
  247. package/docs/external-jwt.md +181 -0
  248. package/docs/firewall.md +546 -0
  249. package/docs/headers.md +616 -0
  250. package/docs/index.md +207 -0
  251. package/docs/lexique.md +190 -0
  252. package/docs/oauth2.md +575 -0
  253. package/docs/obtenir-un-jeton.md +225 -0
  254. package/docs/tokens.md +520 -0
  255. package/docs/totp.md +804 -0
  256. package/docs/webauthn.md +733 -0
  257. package/docs/webhooks.md +1016 -0
  258. package/package.json +83 -0
@@ -0,0 +1,497 @@
1
+ ---
2
+ title: "Autorisation — le jury de voters (rôles, scopes, ownership)"
3
+ navTitle: Autorisation
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: authorization
7
+ coverageModule: security
8
+ coverageFiles: "authorization.ts,RoleVoter,ScopeVoter,RoleHierarchyWalker"
9
+ section: "Sécurité"
10
+ audience: [developer]
11
+ tags:
12
+ [
13
+ security,
14
+ authorization,
15
+ rbac,
16
+ voters,
17
+ roles,
18
+ scopes,
19
+ zero-trust,
20
+ owasp-a01,
21
+ idor,
22
+ ]
23
+ version: "doc"
24
+ status: stable
25
+ updated: 2026-07-19
26
+ source: "src/packages/@nodefony/security/docs/authorization.md"
27
+ ---
28
+
29
+ # Autorisation — le jury de voters
30
+
31
+ > L'authentification établit **qui** tu es ; l'autorisation établit ce que tu as le **droit** de
32
+ > faire. Nodefony décide de chaque accès via un **jury de voters** : stratégie **affirmative +
33
+ > veto DENY**, et **défaut DENY** (Zero Trust — le silence ferme la porte). Deux voters intégrés
34
+ > (`role`, `scope`), un contrat ouvert pour la logique métier (ownership, multi-tenant). Ancré sur
35
+ > `src/packages/@nodefony/security/nodefony/service/authorization.ts` et `nodefony/src/voter/`.
36
+
37
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Autorisation**
38
+
39
+ ## 🧠 Le modèle mental — un jury qui vote
40
+
41
+ ```mermaid
42
+ flowchart TD
43
+ Q["decide(token, attribute, subject)"] --> L{"pour chaque voter<br/>supports(attribute) ?"}
44
+ L -->|non| L
45
+ L -->|oui| V["vote() → GRANT / DENY / ABSTAIN"]
46
+ V -->|DENY| DZ["❌ refus immédiat (veto)<br/>+ audit WARNING"]
47
+ V -->|GRANT| G["granted = true<br/>(on continue le jury)"]
48
+ V -->|ABSTAIN| L
49
+ G --> E{"fin du jury"}
50
+ L --> E
51
+ E -->|"au moins un GRANT, aucun DENY"| OK["✅ accès accordé (muet)"]
52
+ E -->|"aucun GRANT (tous ABSTAIN / 0 voter)"| DZ2["❌ refus par défaut<br/>(Zero Trust)"]
53
+ ```
54
+
55
+ Trois règles, et une seule ferme la porte par défaut :
56
+
57
+ 1. **Un `DENY` suffit** à bloquer (veto), et court-circuite le reste du jury.
58
+ 2. Sinon **un `GRANT` suffit** à accorder.
59
+ 3. **Silence total** (tous `ABSTAIN`, ou aucun voter compétent) → **`DENY`**. C'est le Zero
60
+ Trust : on n'accorde jamais « par absence d'objection ».
61
+
62
+ ## 📖 Lexique
63
+
64
+ | Terme | Sens |
65
+ | ------------------- | ---------------------------------------------------------------------------------------------------- |
66
+ | Autorisation | Décider des **droits** (≠ authentification, qui décide de l'**identité**). |
67
+ | Voter | Un juré : sait décider de certains attributs (`supports`) et vote `GRANT/DENY/ABSTAIN`. |
68
+ | Attribut | Le droit demandé : un rôle (`ROLE_ADMIN`), un scope (`api:action`), ou un verbe métier (`doc.edit`). |
69
+ | Clause | Un groupe d'attributs déclaré par `@IsGranted` — **OR interne**, clauses empilées en **AND**. |
70
+ | Subject (sujet) | La donnée sur laquelle porte la décision (un id de document, un tenant) — passée au voter. |
71
+ | RBAC | _Role-Based Access Control_ : droits selon le rôle. |
72
+ | Scope | Permission fine d'une **clé déléguée** (clé API, JWT d'agent) — « ce que la clé peut faire ». |
73
+ | Hiérarchie de rôles | `ROLE_ADMIN` hérite `ROLE_USER` — résolue et aplatie au boot. |
74
+ | IDOR | _Insecure Direct Object Reference_ : atteindre la ressource d'un autre en devinant son id. |
75
+ | OWASP A01 | _Broken Access Control_ — la faille n°1 du top 10 OWASP. |
76
+ | ALS | _AsyncLocalStorage_ : la « bulle » par requête qui porte identité et token. |
77
+ | Zero Trust | Fermé par défaut : sans `GRANT` explicite, c'est `DENY`. |
78
+
79
+ ## Qu'est-ce que l'autorisation — et quelle faille elle ferme
80
+
81
+ Le **contrôle d'accès défaillant** est la faille n°1 du top OWASP (A01) : un utilisateur atteint
82
+ une ressource qui n'est pas la sienne (IDOR), ou une action au-dessus de son niveau (élévation de
83
+ privilège). La cause récurrente est un contrôle **dispersé et optionnel** — un endpoint oublie de
84
+ vérifier.
85
+
86
+ Nodefony **centralise** la décision dans un service unique, appelé par les décorateurs
87
+ (`@IsGranted`, `@RequireScope`) sur tous les transports, avec une posture **fail-closed** : au
88
+ moindre doute (voter qui plante, silence du jury, moteur absent), c'est refusé — jamais accordé.
89
+
90
+ ## La vision Nodefony — un jury découplé et fail-closed
91
+
92
+ `Authorization.decide(token, attribute, subject?)` (`authorization.ts:70`) itère les voters, teste
93
+ `supports()` en place — zéro allocation par appel (`authorization.ts:78-80`) — et applique la
94
+ stratégie ci-dessus. Points structurants :
95
+
96
+ - **Fail-closed sur erreur** : un voter qui `throw` (lookup DB down, bug) ne fait ni accorder
97
+ l'accès ni planter la requête en 500 — on **refuse** cette décision + log `ERROR`
98
+ (`authorization.ts:85-93`). Même posture que le firewall sur une erreur interne.
99
+ - **Découverte par registre** : les voters sont instanciés **une fois au boot** par
100
+ `Authorization.#build()` (`authorization.ts:55-64`) depuis le registre — aucun nom en dur dans
101
+ le service. Les builtins `role`/`scope` s'enregistrent à l'import (`voterRegistry.ts:55-59`).
102
+ - **Transport-agnostique** : l'audit lit `getUserIdentifier()` (et non `getUser()`), commun au
103
+ token HTTP **et** au token WS `IRealtimeToken` (`authorization.ts:119-122`).
104
+ - **Audit asymétrique** : tout **refus** est audité par `Authorization.#auditDeny()` — WARNING +
105
+ `recordAudit` (`authorization.ts:113-142`) ; les octrois restent **muets** (volume, pas un
106
+ signal). Le refus porte sa raison : `veto` / `abstain` / `no-voter` / `error`.
107
+
108
+ ## 🚀 Démarrage rapide
109
+
110
+ ### Déclarer les droits sur tes actions — les trois axes
111
+
112
+ Dans une app `nodefony create app`, la zone `secure` du scaffold (`^/api/secure`, voir
113
+ [firewall](./firewall.md)) authentifie déjà ; ici on décide des **droits** :
114
+
115
+ ```typescript
116
+ // nodefony/controllers/DocumentController.ts — complet, compile tel quel
117
+ import {
118
+ controller,
119
+ Controller,
120
+ Get,
121
+ Post,
122
+ Param,
123
+ IsGranted,
124
+ RequireScope,
125
+ CurrentUser,
126
+ } from "@nodefony/framework";
127
+ import type { ContextType } from "@nodefony/http";
128
+ import type { IUser } from "@nodefony/user";
129
+
130
+ @controller("/api/secure/documents")
131
+ class DocumentController extends Controller {
132
+ constructor(context: ContextType) {
133
+ super("DocumentController", context);
134
+ }
135
+
136
+ // Axe RÔLE (« qui tu es ») : réservé aux admins — hiérarchie résolue
137
+ // (ROLE_NODEFONY_ADMIN hérite ROLE_ADMIN → passe aussi).
138
+ @IsGranted("ROLE_ADMIN")
139
+ @Post("/purge")
140
+ purge(@CurrentUser() user: IUser) {
141
+ return this.renderJson({ purgedBy: user.identifier });
142
+ }
143
+
144
+ // Axe SCOPE (« ce qu'une CLÉ peut faire ») : bride une clé API / un JWT ;
145
+ // no-op pour une session humaine (ses droits passent par ses rôles).
146
+ @RequireScope("documents:read")
147
+ @Get("/")
148
+ list(@CurrentUser() user: IUser) {
149
+ return this.renderJson({ reader: user.identifier, roles: user.roles });
150
+ }
151
+
152
+ // Axe MÉTIER : le param de route `id` part au voter comme `subject`.
153
+ @IsGranted("doc.edit", { subject: "id" })
154
+ @Post("/{id}")
155
+ edit(@Param("id") id: string) {
156
+ return this.renderJson({ edited: id });
157
+ }
158
+ }
159
+
160
+ export default DocumentController;
161
+ ```
162
+
163
+ (Wiring : `@controllers([DocumentController])` dans le module de l'app — `nodefony create
164
+ controller` le fait pour toi.)
165
+
166
+ ### Ce qu'on observe
167
+
168
+ ```bash
169
+ # 1) Sans session : le FIREWALL répond 401 — l'autorisation n'a même pas été consultée
170
+ curl -si http://localhost:5151/api/secure/documents/ | head -1
171
+ # HTTP/1.1 401 Unauthorized
172
+
173
+ # 2) Session d'un utilisateur ROLE_USER (cookie posé par le login BFF, cf. firewall) :
174
+ # le scope est un no-op pour un humain → 200
175
+ curl -s -b /tmp/jar http://localhost:5151/api/secure/documents/
176
+ # {"reader":"alice","roles":["ROLE_USER"]}
177
+
178
+ # 3) Même session sur l'action admin → 403 : authentifié MAIS pas autorisé
179
+ curl -si -b /tmp/jar -X POST http://localhost:5151/api/secure/documents/purge | head -1
180
+ # HTTP/1.1 403 Forbidden
181
+ ```
182
+
183
+ Le refus laisse une trace côté serveur (jamais côté client) :
184
+
185
+ ```
186
+ WARNING AUTHORIZATION access denied: "alice" → "ROLE_ADMIN" (abstain)
187
+ ```
188
+
189
+ **401 vs 403** : 401 = « prouve qui tu es » (authentification, firewall) ; 403 = « je sais qui tu
190
+ es, tu n'as pas le droit » (autorisation, jury).
191
+
192
+ ### Le voter métier — ta règle d'ownership branchée au jury
193
+
194
+ Pour l'attribut `doc.edit` déclaré ci-dessus, on enregistre un voter — découvert automatiquement
195
+ au boot, **aucun changement dans le cœur** :
196
+
197
+ ```typescript
198
+ // nodefony/security/DocumentVoter.ts — chargé par le module de l'app (avant le boot)
199
+ import { registerVoterFactory, VoterVote } from "@nodefony/security";
200
+ import type { IAccessVoter, IToken } from "@nodefony/security";
201
+
202
+ /** Le repository de TES documents (posé au container par ton module). */
203
+ interface IDocumentRepository {
204
+ find(id: string): Promise<{ ownerId: string; archived: boolean } | null>;
205
+ }
206
+
207
+ class DocumentVoter implements IAccessVoter {
208
+ constructor(private readonly repository: () => IDocumentRepository | null) {}
209
+
210
+ /** Ne capte QUE `doc.edit` — rôles et scopes restent aux voters intégrés. */
211
+ supports(attribute: string): boolean {
212
+ return attribute === "doc.edit";
213
+ }
214
+
215
+ async vote(
216
+ token: IToken,
217
+ _attribute: string,
218
+ subject?: unknown,
219
+ ): Promise<VoterVote> {
220
+ const repo = this.repository();
221
+ const doc =
222
+ repo && typeof subject === "string" ? await repo.find(subject) : null;
223
+ if (!doc) return VoterVote.ABSTAIN; // hors de mon domaine → les autres axes décident
224
+ if (doc.archived) return VoterVote.DENY; // veto EXPLICITE : personne n'édite un archivé
225
+ return doc.ownerId === token.getUserIdentifier()
226
+ ? VoterVote.GRANT
227
+ : VoterVote.ABSTAIN; // pas le sien → le default-DENY du jury ferme
228
+ }
229
+ }
230
+
231
+ // Découvert automatiquement par le service `authorization` au boot.
232
+ registerVoterFactory("documentVoter", ({ container }) => {
233
+ // Construction seule ici — la résolution du repository reste lazy.
234
+ return new DocumentVoter(() =>
235
+ container.get<IDocumentRepository>("documentRepository"),
236
+ );
237
+ });
238
+ ```
239
+
240
+ Observable : le propriétaire obtient 200 sur `POST /api/secure/documents/42` ; un autre
241
+ utilisateur connecté obtient **403** et le log dit `access denied: "bob" → "doc.edit" on 42
242
+ (abstain)`.
243
+
244
+ > [!WARNING]
245
+ > Dans un voter métier, renvoie **`ABSTAIN`** quand tu ne sais pas te prononcer (document
246
+ > introuvable, attribut hors domaine) — pour laisser les autres axes décider. Réserve **`DENY`**
247
+ > au **veto explicite** (ressource gelée/bannie) : un DENY bat tous les GRANT du même attribut.
248
+
249
+ ## 🧑‍⚖️ La stratégie du jury en situation
250
+
251
+ ### Situation 1 — rôle OU voter métier ? (l'IDOR ne se ferme pas par un rôle)
252
+
253
+ Ton app édite des documents : `POST /api/secure/documents/{id}` doit être réservé au
254
+ **propriétaire**. Or tous tes utilisateurs connectés portent `ROLE_USER` :
255
+
256
+ ```typescript
257
+ @IsGranted("ROLE_USER") // ❌ ferme la porte aux anonymes… mais PAS l'IDOR :
258
+ @Post("/{id}") edit() {} // alice peut éditer le document de bob
259
+
260
+ @IsGranted("doc.edit", { subject: "id" }) // ✅ le jury reçoit l'id → le voter tranche sur la DONNÉE
261
+ @Post("/{id}") edit() {}
262
+ ```
263
+
264
+ | La requête | ❌ avec `ROLE_USER` | ✅ avec `doc.edit` |
265
+ | ------------------------------ | ------------------- | ----------------------------- |
266
+ | alice édite **son** document | 200 | 200 (`GRANT` du propriétaire) |
267
+ | alice édite le document de bob | **200 — IDOR !** | 403 (`abstain` → défaut DENY) |
268
+ | anonyme | 401 (firewall) | 401 (firewall) |
269
+
270
+ **Règle de choix** : un **rôle** décide d'une _catégorie_ d'action (« qui peut purger ? ») ; un
271
+ **voter métier** décide sur la _donnée_ (« CE document est-il le sien ? »). Si la réponse exige un
272
+ lookup (ownership, tenant, état), c'est un voter.
273
+
274
+ ### Situation 2 — le veto DENY (gel légal : personne, même pas le propriétaire)
275
+
276
+ Conformité : un document sous **gel légal** (litige en cours) ne doit être édité par personne —
277
+ pas même son propriétaire. On ajoute un second voter qui capte le même attribut `doc.edit` :
278
+
279
+ ```typescript
280
+ class LegalHoldVoter implements IAccessVoter {
281
+ supports(attribute: string): boolean {
282
+ return attribute === "doc.edit";
283
+ }
284
+ async vote(
285
+ _token: IToken,
286
+ _attribute: string,
287
+ subject?: unknown,
288
+ ): Promise<VoterVote> {
289
+ return (await isUnderLegalHold(subject))
290
+ ? VoterVote.DENY
291
+ : VoterVote.ABSTAIN;
292
+ }
293
+ }
294
+ ```
295
+
296
+ | Le jury sur `doc.edit` | Verdict |
297
+ | --------------------------------------------------------------- | -------------------------------------- |
298
+ | `DocumentVoter` GRANT (propriétaire) + `LegalHoldVoter` ABSTAIN | ✅ 200 |
299
+ | `DocumentVoter` GRANT + `LegalHoldVoter` **DENY** | ❌ 403 (`veto`) — le DENY bat le GRANT |
300
+
301
+ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`authorization.ts:94-97`).
302
+
303
+ **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
304
+ `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
305
+ (`Resolver.ts:592-600`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
306
+ consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
307
+ `@IsGranted("doc.edit", { subject: "id" })`.
308
+
309
+ ### Situation 3 — le silence ferme la porte (la typo devient un 403, pas une faille)
310
+
311
+ Tu déploies `@IsGranted("doc.edti")` (faute de frappe), ou tu as oublié d'enregistrer ton voter.
312
+ **Aucun voter compétent** → refus par défaut (`!granted`, `authorization.ts:100-108`) : la route répond 403
313
+ systématiquement, et le log nomme la cause :
314
+
315
+ ```
316
+ WARNING AUTHORIZATION access denied: "alice" → "doc.edti" (no-voter)
317
+ ```
318
+
319
+ Un framework fail-open aurait laissé passer — la typo serait une **faille silencieuse**. Ici elle
320
+ se voit au premier test.
321
+
322
+ > [!TIP]
323
+ > La raison entre parenthèses dit quoi corriger : `no-voter` = aucun voter ne capte l'attribut
324
+ > (typo, voter non enregistré) · `abstain` = des voters ont regardé, aucun n'a accordé (droit
325
+ > manquant) · `veto` = un DENY explicite · `error` = un voter a planté (voir le log ERROR).
326
+
327
+ ## 🧰 Déclarer l'exigence — `@IsGranted`, `@RequireScope`, `@Anonymous`
328
+
329
+ 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`) :
331
+
332
+ | Déclaration | Sémantique |
333
+ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
334
+ | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:839`) |
335
+ | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:407-412`) |
336
+ | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:426`) |
337
+ | 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`) |
340
+ | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:887`) |
341
+ | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1236`) |
342
+
343
+ La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
344
+ 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:331-336`). Le
345
+ même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
346
+ transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
347
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:576-606`).
348
+
349
+ > [!IMPORTANT]
350
+ > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
351
+ > chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
352
+ > (`Resolver.ts:582-584`). Une route gardée doit être couverte par une zone — voir
353
+ > [firewall](./firewall.md).
354
+
355
+ ## 🧑‍⚖️ Les voters intégrés — deux axes, un même jury
356
+
357
+ | Voter (registre) | Axe | Capte | Non-satisfait → |
358
+ | ---------------- | ------------------------------ | ------------- | -------------------------------------- |
359
+ | `role` | qui es-tu ? | `ROLE_*` | `ABSTAIN` |
360
+ | `scope` | que peut faire cette **clé** ? | `api:action` | `ABSTAIN` (machine) / `GRANT` (humain) |
361
+ | le tien | est-ce **ta** ressource ? | `doc.edit`, … | `ABSTAIN` conseillé (veto = `DENY`) |
362
+
363
+ ### `role` — l'axe « qui tu es »
364
+
365
+ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et vote :
366
+
367
+ - **`GRANT`** si l'utilisateur possède le rôle, hiérarchie résolue ; **`ABSTAIN` sinon — jamais
368
+ `DENY`** (`RoleVoter.vote()`, `RoleVoter.ts:33-35`). L'absence d'un rôle ne doit pas opposer un
369
+ **veto** aux autres axes (un accès peut être légitime via un scope ou l'ownership) : c'est le
370
+ default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
371
+ (`@IsGranted(["A","B"])`) possible.
372
+ - La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
373
+ (`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:206`).
374
+ - Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
375
+
376
+ ### `scope` — l'axe « ce qu'une clé déléguée peut faire »
377
+
378
+ Frère du `role` sur l'autre axe. Capte la forme conventionnée `api:action` — un `:`, jamais
379
+ `ROLE_*` (`ScopeVoter.supports()`, `ScopeVoter.ts:46-48`) → aucune collision avec les rôles ni un
380
+ verbe métier. Le cœur est le **modèle de confiance** :
381
+
382
+ - **Jeton humain** (`session`, `userpassword`, `anonymous`) → `GRANT` no-op : un scope ne bride
383
+ **jamais** un humain, son autorisation passe par ses rôles (`ScopeVoter.ts:52-54`).
384
+ - **Jeton machine** (`apikey`, `jwt`, `oauth2`, ou tout type futur) → `GRANT` si le scope exact
385
+ est présent, sinon `ABSTAIN` (`ScopeVoter.ts:57-61`).
386
+ - **Fail-closed côté machine** : `NON_SCOPABLE_TOKEN_TYPES` est une **allowlist d'humains**
387
+ (`ScopeVoter.ts:17-21`) — tout type absent (`mtls`, `agent`…) est considéré **scopable**, donc
388
+ bridé par défaut. Un nouveau type de jeton délégué est **fermé par oubli**, jamais ouvert.
389
+ - Pur : aucune dépendance, aucune I/O — instancié une fois au boot.
390
+
391
+ ### La hiérarchie de rôles — aplatie et vérifiée au boot
392
+
393
+ `RoleHierarchyWalker` se déclare dans la config (`use("@nodefony/security", { roleHierarchy })`,
394
+ voir [firewall](./firewall.md)) et fait deux choses au boot :
395
+
396
+ - **Aplatissement DFS précalculé** (`#detectCycles()` puis `#precompute()`,
397
+ `RoleHierarchyWalker.ts:13-14`) → `RoleHierarchyWalker.hasRole()` est **O(1)** sur le hot path
398
+ (`RoleHierarchyWalker.ts:23-30`).
399
+ - **Détection de cycles** par DFS coloré — un arc vers un nœud « en cours de visite » = cycle, et
400
+ le boot **jette avec le chemin complet** `A → B → A` (`RoleHierarchyWalker.ts:69-95`) : jamais
401
+ de boucle infinie silencieuse en production.
402
+
403
+ ## 🧩 Étendre le jury — le contrat et le registre
404
+
405
+ Le contrat `IAccessVoter` (`IAccessVoter.ts:20-26`) tient en deux méthodes :
406
+
407
+ - `supports(attribute, subject?)` — test **bon marché** : ce voter sait-il décider de cet
408
+ attribut ? Appelé sur chaque voter à chaque `decide()`.
409
+ - `vote(token, attribute, subject?)` — **async** (les voters métier font des lookups DB) ; renvoie
410
+ un `VoterVote` : `GRANT` / `DENY` / `ABSTAIN` (`IAccessVoter.ts:7-11`).
411
+
412
+ L'enregistrement passe par le registre — `registerVoterFactory(name, factory)`
413
+ (`voterRegistry.ts:39-44`), consommé une fois au boot (`listVoterFactories()`,
414
+ `voterRegistry.ts:47-49`). La fabrique reçoit `{ container }` et ne fait **que construire** : les
415
+ résolutions coûteuses restent lazy dans l'instance (cf. le `DocumentVoter` du Démarrage rapide).
416
+
417
+ Pourquoi un registre et pas un scan DI des `@injectable` : les interfaces TS sont **effacées à la
418
+ compilation** — rien à scanner au runtime ; le registre **est** le marqueur explicite
419
+ (`voterRegistry.ts:10-16`). Convention-frère : `authenticatorRegistry`, `tokenStoreRegistry`.
420
+
421
+ ## 🔌 HTTP et WebSocket — une garde, N transports
422
+
423
+ - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:317`) est le point unique
424
+ d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
425
+ `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
426
+ `ws-isgranted-jwt`).
427
+ - **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
428
+ et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
429
+ - **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
430
+ hiérarchie** : `satisfies()` (`frameAuthorizer.ts:276`) délègue à `Firewall.hasRole()`
431
+ (`firewall.ts:466`) — les rôles exigés par un canal héritent comme partout ailleurs.
432
+
433
+ ## 📜 Normes appliquées
434
+
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`) |
440
+
441
+ ## ⚡ Performance & mémoire
442
+
443
+ - **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
444
+ alloc (`Resolver.ts:334-336`) ; l'exigence est **figée une fois** par route et partagée entre
445
+ requêtes (`SecurityRequirement`, `routerDecorators.ts:424`).
446
+ - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:78-80`),
447
+ instanciés **une seule fois** au boot (`authorization.ts:55-64`).
448
+ - **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
449
+ (`RoleHierarchyWalker.ts:23-30`).
450
+ - **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
451
+ `JSON.stringify` aveugle (`describeSubject()`, `authorization.ts:159-165`).
452
+
453
+ ## ⚠️ Pièges (symptôme → cause → correction)
454
+
455
+ | Symptôme | Cause (dans le code) | Correction |
456
+ | -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
457
+ | Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
458
+ | 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 |
459
+ | 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 |
460
+ | 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`) |
461
+ | 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` |
462
+ | `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 |
463
+ | Boot qui plante « cycle détecté » | Hiérarchie de rôles cyclique | Casser le cycle (le message nomme le chemin) |
464
+ | 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 |
465
+
466
+ ## 📡 Observabilité — Studio
467
+
468
+ Écran **Roles** (`studio/frontend/src/routes/Roles.tsx`) : la hiérarchie de rôles consommée par
469
+ les voters. Écran **Audit** : les refus du jury (catégorie `authz`, action `access.denied`, avec
470
+ la raison). Écran **Firewall** : zones et trace de décision — l'amont du jury.
471
+
472
+ ## 🧪 Tests & couverture
473
+
474
+ Quatre familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
475
+ (régénérée depuis vitest, jamais figée ici) :
476
+
477
+ - **unit** : `authorization.test` (le jury + la stratégie + le RoleVoter/hiérarchie),
478
+ `scopeVoter` (l'axe scope + fail-closed machine), `securityDecorators` et
479
+ `securityEnforcement` (framework : métadonnées + garde du Resolver), `realtimeFrameLock` (le
480
+ verrou de frame) ;
481
+ - **intégration** : `securityGuard.integration` (framework, la garde `@IsGranted` sur serveur
482
+ réel), `ws-data-plane-auth` (http, le pont WS authentifié) ;
483
+ - **e2e transport** : `ws-isgranted-jwt` (http — `@IsGranted` bout en bout sur WebSocket + JWT) ;
484
+ - **attaque** : `authorization.attack` (escalade verticale, cycle DoS, confusion d'attribut,
485
+ composition d'axes), `frameAuthorizer.attack` et `realtimeFramePollution.attack` (frames WS
486
+ hostiles).
487
+
488
+ Couverture : `npm run coverage` dans `@nodefony/security`.
489
+
490
+ ## 🔗 Pour aller plus loin
491
+
492
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
493
+ - 🧭 **Pages sœurs** : [Firewall](firewall.md) · [Jetons](tokens.md)
494
+
495
+ - L'authentification qui précède l'autorisation → [authenticators](./authenticators.md)
496
+ - Le firewall qui pose l'identité et appelle le jury (zones, WS) → [firewall](./firewall.md)
497
+ - Vue d'ensemble sécurité → [index](./index.md)