@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
package/docs/audit.md ADDED
@@ -0,0 +1,751 @@
1
+ ---
2
+ title: "Journal d'audit — la mémoire des décisions de sécurité"
3
+ navTitle: Journal d'audit
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: audit
7
+ coverageModule: security
8
+ coverageFiles: "audit"
9
+ section: "Sécurité"
10
+ audience: [developer, devops]
11
+ tags:
12
+ [
13
+ audit,
14
+ journal,
15
+ securite,
16
+ tracabilite,
17
+ conformite,
18
+ retention,
19
+ pagination,
20
+ webhooks,
21
+ ]
22
+ version: "doc"
23
+ status: stable
24
+ updated: 2026-07-19
25
+ source: "src/packages/@nodefony/security/docs/audit.md"
26
+ ---
27
+
28
+ # Journal d'audit — la mémoire des décisions de sécurité
29
+
30
+ > Chaque fois qu'une **décision de sécurité** est prise — un login réussit, un accès est refusé, une
31
+ > clé d'API est révoquée, un jeton volé resurgit — Nodefony écrit une ligne dans un journal
32
+ > **append-only** : qui, quoi, quand, d'où, avec quel verdict. Pas le trafic (ça, c'est le log HTTP) :
33
+ > les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:48`), le contrat
34
+ > `IAuditEvent` (`IAuditEvent.ts:53`) et les stores de `nodefony/src/audit/`.
35
+
36
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Journal d'audit**
37
+
38
+ ## 🧠 Le modèle mental — deux journaux, deux métiers
39
+
40
+ Un serveur produit **deux** flux d'écriture, et les confondre coûte cher. Le log de trafic répond à
41
+ « que s'est-il passé sur le réseau ? » ; le journal d'audit répond à « **qui a obtenu quoi, et
42
+ pourquoi** ? ».
43
+
44
+ | | Log de trafic (`@nodefony/http`) | Journal d'audit (`@nodefony/security`) |
45
+ | -------------- | -------------------------------- | ------------------------------------------------ |
46
+ | Volume | **1 entrée par requête** | 1 entrée par **transition de sécurité** |
47
+ | Contenu | méthode, URL, statut, durée | acteur, action, verdict, motif machine |
48
+ | Sur un succès | écrit toujours | **muet** (le volume nominal n'est pas un signal) |
49
+ | Destination | stdout → collecteur | store durable, requêtable, à rétention |
50
+ | Question posée | « le service tient-il ? » | « qui s'est connecté cette nuit ? » |
51
+
52
+ ```mermaid
53
+ flowchart LR
54
+ subgraph EM["Points d'émission (cold-path)"]
55
+ FW["Firewall<br/>401 / 429 / Zero Trust"]
56
+ AF["AuthFlow<br/>login / logout / MFA"]
57
+ AZ["Authorization<br/>access.denied"]
58
+ TK["TokenService · ApiKeys<br/>émission / révocation / rejeu"]
59
+ WS["Verrou de frame WS<br/>frame.denied"]
60
+ APP["Ton code applicatif<br/>recordAudit"]
61
+ end
62
+ EM --> REC["recordAudit(container, draft)<br/>no-op si audit absent"]
63
+ REC --> SVC["AuditService.record()<br/>pose id + ts, fire-and-forget"]
64
+ SVC --> ST["IAuditStore.append()<br/>memory | drizzle"]
65
+ SVC --> LIVE["subscribe()<br/>abonnés live"]
66
+ LIVE --> BR["createAuditBridge<br/>batch coalescé 250 ms"]
67
+ BR --> CH["canal WS nodefony:audit<br/>ROLE_NODEFONY_ADMIN"]
68
+ LIVE --> WH["WebhookDispatcher<br/>webhooks sortants"]
69
+ ST --> API["GET /nodefony/security/api/audit/events<br/>page filtrée par curseur"]
70
+ API --> UI["Studio — Journal d'audit"]
71
+ ```
72
+
73
+ `AuditService.record()` (`auditService.ts:180`) est le point de passage unique : il pose l'identité et
74
+ l'horodatage, écrit **sans attendre**, et notifie les abonnés live. Tout le reste — stores, pont WS,
75
+ webhooks, console — se branche autour de lui.
76
+
77
+ ## 📖 Lexique
78
+
79
+ | Terme | Sens |
80
+ | ----------------- | -------------------------------------------------------------------------------------------------- |
81
+ | Événement d'audit | Une ligne du journal : acteur + action + verdict + provenance (`IAuditEvent`). |
82
+ | Acteur (_actor_) | Le **libellé d'identité** de qui a agi (`token.getUserIdentifier()`), ou `null` si anonyme. |
83
+ | Issue (_outcome_) | Le verdict : `success`, `failure` (l'acteur a raté une preuve), `denied` (une politique a refusé). |
84
+ | Catégorie | Le sous-système concerné (`auth`, `authz`, `token`…) — union **fermée**. |
85
+ | Action | Le fait, conventionné `<sujet>.<verbe>` (`login.success`) — chaîne **ouverte**. |
86
+ | Raison (_reason_) | Motif **machine** stable et filtrable (`invalid_credentials`, `throttled`), jamais une phrase. |
87
+ | Append-only | On n'ajoute que des lignes ; rien n'est modifié ni supprimé, sauf la purge de rétention. |
88
+ | Rétention | Durée pendant laquelle un événement reste consultable avant purge (`retentionDays`). |
89
+ | GC | _Garbage collection_ : la purge périodique des événements hors rétention. |
90
+ | Curseur | Jeton opaque « donne-moi la suite après celui-ci » — la façon de paginer un flux qui grandit. |
91
+ | Cold-path | Chemin d'exception (échec, refus). Par opposition au **hot-path** : le chemin nominal, à coût nul. |
92
+ | Fire-and-forget | On lance l'écriture sans l'attendre : le flux métier ne dépend pas de son succès. |
93
+ | Redaction | Le fait de ne jamais laisser entrer un secret dans une trace. |
94
+
95
+ ## Qu'est-ce qu'un journal d'audit — et quelle faille il ferme
96
+
97
+ Un journal d'audit est le **magnétoscope des décisions de sécurité**. Il n'empêche aucune attaque :
98
+ il rend l'attaque **racontable après coup**.
99
+
100
+ La faille qu'il ferme n'est pas une injection, c'est le **trou noir** : sans journal, une intrusion
101
+ est indétectable et indémontrable. Concrètement, sans lui :
102
+
103
+ - une **attaque par force brute** ressemble à du trafic normal — personne ne voit les 4 000
104
+ `login.failure` de la nuit ;
105
+ - un **jeton volé** rejoué passe inaperçu, alors que c'est le signal d'attaque le plus fort qui soit ;
106
+ - une **escalade de privilèges** ne laisse aucune trace exploitable : impossible de dire quel compte
107
+ a tenté quoi, ni depuis quelle IP ;
108
+ - face à un auditeur (ISO 27001, SOC 2, RGPD), tu ne peux **rien prouver** — ni que les accès sont
109
+ contrôlés, ni qu'un refus a bien eu lieu.
110
+
111
+ C'est la raison d'être des exigences de journalisation de l'**OWASP Logging Cheat Sheet** et de
112
+ l'entrée **A09:2021 – Security Logging and Monitoring Failures** du Top 10 : l'absence de trace est
113
+ elle-même classée comme une vulnérabilité.
114
+
115
+ ## La vision Nodefony
116
+
117
+ Trois partis pris, tous vérifiables dans le code.
118
+
119
+ **1. Le journal trace les transitions, jamais le trafic.** Le succès nominal est **muet** : une
120
+ requête authentifiée qui passe n'écrit rien. Prouvé par les tests « SUCCÈS authentifié → AUCUNE
121
+ émission » (`auditEmissionHotPath.test.ts:229`) et « SUCCÈS anonyme EXPLICITE → AUCUNE émission »
122
+ (`auditEmissionHotPath.test.ts:243`). Conséquence directe : ce que tu lis dans le journal **est** un
123
+ signal, pas du bruit à filtrer.
124
+
125
+ **2. L'audit ne peut jamais casser le métier.** `record()` est synchrone, sans `await`, et l'écriture
126
+ part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:192`). Un
127
+ store en panne dégrade la traçabilité, il ne renvoie pas 500 à l'utilisateur.
128
+
129
+ **3. Le store est pluggable, jamais câblé en dur.** Le service résout un **nom** via un registre
130
+ (`getAuditStoreFactory()`, `auditStoreRegistry.ts:48`) ; le socle n'embarque que le builtin mémoire,
131
+ posé par `registerAuditStore("memory")` à l'import (`auditStoreRegistry.ts:64`), et les backends
132
+ lourds s'enregistrent depuis **leur** module. Un
133
+ `if (name === "drizzle")` dans le service trahirait la promesse.
134
+
135
+ ## 🚀 Démarrage rapide
136
+
137
+ Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
138
+ défaut, `config.ts:729`) sur un store mémoire. Voici le parcours complet : configurer, émettre,
139
+ relire.
140
+
141
+ ### 1. Choisir où le journal est écrit
142
+
143
+ ```typescript
144
+ // nodefony.config.ts (extrait) — le journal survit au redémarrage et se partage entre pods
145
+ use("@nodefony/security", {
146
+ audit: {
147
+ // "auto" (défaut) suit l'infra database déclarée ; "drizzle" force le SQL.
148
+ // "memory" = volatile et per-pod : parfait en dev, jamais en production.
149
+ store: "drizzle",
150
+ // Rétention : au-delà, la purge horaire supprime. Défaut 365 jours.
151
+ retentionDays: 90,
152
+ },
153
+ });
154
+ ```
155
+
156
+ Rien d'autre à écrire : l'adapter `@nodefony/drizzle` déclare l'entité **et** la fabrique de store
157
+ tout seul au démarrage (`registerStores.ts:241`).
158
+
159
+ ### 2. Émettre un événement depuis ton code
160
+
161
+ `recordAudit()` (`recordAudit.ts:15`) est exporté par le module. Il est **no-op** si l'audit est
162
+ absent ou désactivé — tu peux l'appeler sans garde.
163
+
164
+ ```typescript
165
+ // nodefony/controllers/ExportController.ts — complet, compile tel quel
166
+ import {
167
+ controller,
168
+ Controller,
169
+ Post,
170
+ IsGranted,
171
+ CurrentUser,
172
+ } from "@nodefony/framework";
173
+ import { recordAudit } from "@nodefony/security";
174
+ import type { IUser } from "@nodefony/user";
175
+
176
+ @controller("/api/back/export")
177
+ class ExportController extends Controller {
178
+ @IsGranted(["ROLE_ADMIN"])
179
+ @Post("/customers")
180
+ async exportCustomers(@CurrentUser() user: IUser) {
181
+ // L'export de données clients est une action sensible : elle DOIT laisser une trace.
182
+ recordAudit(this.container, {
183
+ category: "authz", // union fermée — voir le catalogue plus bas
184
+ action: "data.exported", // chaîne libre, convention <sujet>.<verbe>
185
+ outcome: "success",
186
+ actor: user.identifier, // un libellé d'identité, JAMAIS un secret
187
+ resource: "customers",
188
+ reason: "admin_export",
189
+ metadata: { rows: 4213 }, // extras applicatifs libres
190
+ });
191
+ return this.renderJson({ exported: 4213 });
192
+ }
193
+ }
194
+
195
+ export default ExportController;
196
+ ```
197
+
198
+ > [!IMPORTANT]
199
+ > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:16`) : il n'existe
200
+ > pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
201
+ > événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
202
+ > données) et laisse `action` porter le vocabulaire métier.
203
+
204
+ ### 3. Relire le journal
205
+
206
+ Le data plane d'admin sert une **page filtrée**, réservée à `ROLE_NODEFONY_ADMIN`
207
+ (`SecurityAdminApi.ts:331`) :
208
+
209
+ ```bash
210
+ # Les échecs d'authentification d'un compte, sur une fenêtre donnée
211
+ curl -s -b /tmp/jar \
212
+ 'http://localhost:5151/nodefony/security/api/audit/events?category=auth&outcome=failure&actor=alice&limit=50'
213
+ ```
214
+
215
+ ### Ce qu'on observe
216
+
217
+ ```json
218
+ {
219
+ "items": [
220
+ {
221
+ "id": "9f3ac21b-1",
222
+ "ts": 1763548800123,
223
+ "category": "auth",
224
+ "action": "auth.failure",
225
+ "outcome": "failure",
226
+ "actor": "alice",
227
+ "resource": "nodefony-admin",
228
+ "reason": "invalid_credentials",
229
+ "ip": "203.0.113.9",
230
+ "userAgent": "curl/8.6.0",
231
+ "requestId": "req-7c1e",
232
+ "flags": { "hasAuthorization": true, "hasCookie": false }
233
+ }
234
+ ],
235
+ "limit": 50,
236
+ "hasNext": true,
237
+ "nextCursor": "1763548800123:9f3ac21b-1",
238
+ "total": 137
239
+ }
240
+ ```
241
+
242
+ Trois choses à remarquer : le **motif machine** (`reason`) est filtrable, la **provenance** est là
243
+ sans qu'on l'ait demandée (IP, User-Agent, `requestId`), et les `flags` disent qu'un en-tête
244
+ `Authorization` **était présent** — sans jamais donner sa valeur.
245
+
246
+ Le même journal est consultable dans **Studio → Sécurité → Journal d'audit**.
247
+
248
+ ## ⚙️ Quatre situations — du besoin à la config
249
+
250
+ ### Situation 1 — « Qui s'est connecté à ce compte cette nuit ? »
251
+
252
+ Un utilisateur signale une activité suspecte sur son compte. Tu veux la liste des tentatives, réussies
253
+ et ratées, sur une fenêtre précise.
254
+
255
+ Aucune configuration : les événements d'authentification sont émis par défaut. Il suffit de filtrer.
256
+ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
257
+ (`SecurityAdminApi.ts:191`) :
258
+
259
+ | Paramètre | Effet | Exemple |
260
+ | ----------- | ----------------------------------------------- | --------------------------- |
261
+ | `category` | restreint à un sous-système | `category=auth` |
262
+ | `outcome` | restreint à un verdict | `outcome=failure` |
263
+ | `actor` | égalité **exacte** sur l'identifiant | `actor=alice` |
264
+ | `action` | égalité exacte sur l'action | `action=login.success` |
265
+ | `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
266
+ | `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
267
+ | `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
268
+ | `limit` | taille de page (défaut 100, **plafonné à 500**) | `limit=200` |
269
+ | `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
270
+
271
+ ```bash
272
+ # La nuit de 02:00 à 08:00, tout ce qui concerne alice
273
+ curl -s -b /tmp/jar 'http://localhost:5151/nodefony/security/api/audit/events\
274
+ ?actor=alice&since=1763517600000&until=1763539200000&limit=200'
275
+ ```
276
+
277
+ Ce qu'on observe : les `login.success` et `login.failure` **avec leur IP**, et un `auth.throttled`
278
+ si le backoff NIST s'est déclenché — la signature d'une attaque par essais répétés.
279
+
280
+ ### Situation 2 — « Prouver à un auditeur qu'un accès refusé a bien été tracé »
281
+
282
+ Un auditeur demande la preuve que le contrôle d'accès n'est pas décoratif.
283
+
284
+ Le jury d'autorisation journalise **tout refus** et **seulement** les refus, par un appel à
285
+ `recordAudit()` (`authorization.ts:130`) : les octrois restent muets, parce qu'un octroi est du
286
+ volume, pas un signal.
287
+
288
+ ```bash
289
+ curl -s -b /tmp/jar \
290
+ 'http://localhost:5151/nodefony/security/api/audit/events?outcome=denied&limit=100'
291
+ ```
292
+
293
+ Ce qu'on observe : des `access.denied` dont le champ `resource` porte **l'attribut exigé**
294
+ (`ROLE_ADMIN`, `doc.edit`…) et `reason` le motif du jury (`veto`, silence des voters). Le
295
+ `requestId` permet de recoller l'événement à la trace complète de la requête.
296
+
297
+ > [!TIP]
298
+ > Le refus au niveau du **firewall** (401) et le refus au niveau de l'**autorisation** (403) sont deux
299
+ > événements différents : `auth.denied` (catégorie `auth`) contre `access.denied` (catégorie `authz`).
300
+ > Filtrer sur `category=authz&outcome=denied` isole exactement « quelqu'un d'authentifié a tenté ce
301
+ > qu'il n'avait pas le droit de faire » — le signal d'escalade de privilèges.
302
+
303
+ ### Situation 3 — « Garder 90 jours sans faire exploser la base »
304
+
305
+ Ta politique de conservation impose 90 jours, pas plus (minimisation RGPD) et pas moins (obligation
306
+ de preuve).
307
+
308
+ ```typescript
309
+ use("@nodefony/security", {
310
+ audit: { store: "drizzle", retentionDays: 90 },
311
+ });
312
+ ```
313
+
314
+ Ce que ça déclenche : le service arme un `GcScheduler` de purge **toutes les heures**
315
+ (`auditService.ts:152`, intervalle `GC_INTERVAL_MS` — `auditService.ts:31`), avec gigue
316
+ anti-avalanche en cluster. Chaque
317
+ tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
318
+ (`IAuditStore.ts:67`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
319
+ côté SQL (`DrizzleAuditStore.ts:231`), un défilement de file tant que l'événement de tête dépasse le
320
+ `threshold` côté mémoire (`MemoryAuditStore.ts:127`).
321
+
322
+ Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
323
+
324
+ Le store mémoire porte une **seconde** protection, indépendante du temps : un plafond de
325
+ **10 000 entrées** en FIFO (`MemoryAuditStore.ts:10`, appliqué à `MemoryAuditStore.ts:59`). Ce n'est
326
+ pas une politique de rétention, c'est un garde-fou anti-fuite : la mémoire d'un pod est bornée quoi
327
+ qu'il arrive.
328
+
329
+ ### Situation 4 — « Ne rien perdre quand le store tombe »
330
+
331
+ La base est indisponible pendant trente secondes. Que se passe-t-il ?
332
+
333
+ Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
334
+ fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:192`) ; côté SQL, si l'ORM n'est
335
+ pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:131`). Un login n'échoue jamais
336
+ parce que le journal est cassé.
337
+
338
+ > [!WARNING]
339
+ > Le corollaire est une **perte possible d'événements** pendant une panne du store. Le contrat est
340
+ > best-effort, pas transactionnel : il n'existe pas de tampon de reprise ni de journal d'écriture
341
+ > anticipée. Si ta conformité exige « aucune action sans trace », il faut un store à haute
342
+ > disponibilité — le contrat `IAuditStore` (`IAuditStore.ts:48`) est le point d'extension pour ça.
343
+
344
+ Deux garde-fous limitent la casse au démarrage : un store **explicitement** configuré mais inconnu
345
+ **avorte le boot en production** (`auditService.ts:109`), et un store `memory` en production déclenche
346
+ un `WARNING` nommant précisément le risque — volatil, per-pod, rétention réglementaire impossible
347
+ (`auditService.ts:122`).
348
+
349
+ ## 🔐 Le catalogue des événements audités
350
+
351
+ Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:16`) : c'est
352
+ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouverte**
353
+ (`IAuditEvent.ts:66`) : la liste grandit sans jamais casser le contrat.
354
+
355
+ ### Vue d'ensemble — choisir son filtre en cinq secondes
356
+
357
+ <!-- prettier-ignore -->
358
+ | Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
359
+ | --- | --- | --- |
360
+ | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` |
361
+ | `authz` | autorisation (voters, `@IsGranted`) | `access.denied` |
362
+ | `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
363
+ | `session` | cycle de vie de session | `logout` |
364
+ | `webauthn` | passkeys | `user.passkey_revoked` |
365
+ | `ws` | verrou de frame WebSocket | `frame.denied` |
366
+ | `webhook` | webhooks sortants | `webhook.created` · `webhook.updated` · `webhook.deleted` · `webhook.rotated` · `webhook.revealed` · `webhook.disabled` |
367
+ | `oauth` | login social OAuth2 | _catégorie déclarée, aucune action émise aujourd'hui_ |
368
+ | `csrf` | défense CSRF | _catégorie déclarée, aucune action émise aujourd'hui_ |
369
+ | `cors` | politique CORS | _catégorie déclarée, aucune action émise aujourd'hui_ |
370
+ | `config` | mutation de config runtime depuis Studio | _catégorie déclarée, aucune action émise aujourd'hui_ |
371
+
372
+ Les trois issues possibles (`IAuditEvent.ts:35`) ne sont pas interchangeables : `failure` = **l'acteur
373
+ a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
374
+ refusé** un acteur pourtant bien formé (Zero Trust, rôle manquant) ; `success` = l'action a abouti.
375
+ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non autorisé.
376
+
377
+ ### `auth` — la chaîne d'authentification
378
+
379
+ Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
380
+ (`firewall.ts:693`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
381
+
382
+ - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:768`) ;
383
+ - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:794`) ;
384
+ - `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
385
+ (`firewall.ts:811`) ;
386
+ - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:638`).
387
+
388
+ Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
389
+ `login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
390
+ `login.throttled` (`authFlow.ts:139`), `login.mfa_required` quand un second facteur est réclamé
391
+ (`authFlow.ts:175`), et `login.success` après la preuve complète (`authFlow.ts:191`,
392
+ `authFlow.ts:229`).
393
+
394
+ ### `authz` — le refus d'autorisation
395
+
396
+ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le jury de voters
397
+ (`authorization.ts:130`). `resource` porte l'attribut refusé, `reason` le motif de la décision.
398
+
399
+ ### `token` — la vie et la mort des jetons longue durée
400
+
401
+ - `token.issued` — un couple access/refresh vient d'être émis, donc une surface d'attaque vient
402
+ d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:312`) ;
403
+ - `token.reuse_detected` — **le signal d'attaque le plus fort du journal** : un refresh déjà révoqué
404
+ a été re-présenté, donc quelqu'un détient un jeton volé. Toute la famille est coupée
405
+ (`tokenService.ts:353`, RFC 9700 §4.14) ;
406
+ - `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts:153`,
407
+ `apiKeys.ts:212`).
408
+
409
+ ### `ws` — le verrou de frame WebSocket
410
+
411
+ `frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts:302`) quand une
412
+ socket tente un `api.request` vers une zone protégée ou s'abonne à un canal interdit. La frame ne
413
+ porte ni IP ni `requestId` — acteur et cible suffisent.
414
+
415
+ ### `webhook` — les mutations d'endpoints et la mort d'un endpoint
416
+
417
+ Les mutations d'admin sont tracées une par une (`WebhookAdminApi.ts:305` à `WebhookAdminApi.ts:509`),
418
+ y compris `webhook.revealed` : révéler un secret en clair est une action à auditer. L'auto-désactivation
419
+ après échecs répétés émet **un seul** événement `webhook.disabled` par endpoint qui meurt, jamais un par
420
+ échec (`webhooks.ts:587`).
421
+
422
+ ### `config` et Studio — les actions d'admin
423
+
424
+ Les producteurs d'admin passent par `auditAdmin()` (`adminAudit.ts:37`), qui lit le sink
425
+ défensivement, et par `adminActor()` (`adminAudit.ts:19`) pour dériver un libellé d'identité stable
426
+ depuis l'utilisateur de la requête — avec repli `"admin"`, jamais une décision d'autorisation.
427
+
428
+ ## 🧰 Le contrat d'une entrée
429
+
430
+ Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:53`). L'émetteur ne fournit qu'un
431
+ brouillon `IAuditEventDraft` (`IAuditEvent.ts:105`) : `id` et `ts` sont posés par le service, ce qui
432
+ garantit un seul appel d'horloge, centralisé hors des points d'émission.
433
+
434
+ | Champ | Type | Posé par | Rôle |
435
+ | ----------- | -------------------------- | -------- | ------------------------------------------------------------ |
436
+ | `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur |
437
+ | `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:188`) |
438
+ | `category` | `AuditCategory` | émetteur | sous-système — union fermée |
439
+ | `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte |
440
+ | `outcome` | `success\|failure\|denied` | émetteur | le verdict |
441
+ | `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:74`) |
442
+ | `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger** |
443
+ | `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit |
444
+ | `ip` | `string \| null` | contexte | provenance réseau |
445
+ | `userAgent` | `string \| null` | contexte | provenance client |
446
+ | `requestId` | `string \| null` | contexte | corrélation log ↔ audit ↔ trace |
447
+ | `flags` | `IAuditEventFlags` | contexte | **présence** de matériel sensible, jamais la valeur |
448
+ | `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres, absents par défaut |
449
+
450
+ Les quatre derniers champs de provenance sont remplis d'un coup par `readAuditContext()`
451
+ (`readAuditContext.ts:33`), qui lit un contexte HTTP ou WS de façon défensive — tous les champs sont
452
+ optionnels, un contexte partiel ne casse rien.
453
+
454
+ ## ⚡ Performance & mémoire — pourquoi l'audit ne pèse pas sur la requête
455
+
456
+ C'est un point d'honneur du framework : **l'audit ne se paie que quand il se passe quelque chose**.
457
+ Quatre mécanismes, tous prouvés par les tests.
458
+
459
+ **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
460
+ n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
461
+ (`firewall.ts:884`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
462
+ Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:324`).
463
+
464
+ **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
465
+ avant tout appel d'horloge si le service est inactif (`auditService.ts:182`). Aucun objet créé,
466
+ aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens` n'est pas journalisé »
467
+ (`auditEmissionHotPath.test.ts:499`).
468
+
469
+ **3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
470
+ (`auditService.ts:192`). La latence du store n'entre jamais dans la latence de la requête.
471
+
472
+ **4. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
473
+ personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:221`). Le pont
474
+ WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
475
+ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est armé à la demande et
476
+ `unref` (`auditBridge.ts:93`).
477
+
478
+ Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
479
+ (`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
480
+ d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
481
+ (`auditBridge.ts:84`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
482
+ jamais faire tomber ce qu'on supervise.
483
+
484
+ ## ⚙️ Configuration
485
+
486
+ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la racine sous la clé `audit`
487
+ (`config.ts:1121`).
488
+
489
+ | Option | Type | Défaut | Effet |
490
+ | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
491
+ | `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
492
+ | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle` |
493
+ | `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
494
+ | `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
495
+ | `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
496
+
497
+ > [!WARNING]
498
+ > `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
499
+ > Les mettre à `false` ne change rien. L'immuabilité vient du **contrat** `IAuditStore`, qui n'expose
500
+ > ni `update` ni `delete` ciblé (`IAuditStore.ts:48`) ; la diffusion live est gouvernée par la
501
+ > **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:194`).
502
+
503
+ ### Comment `store: "auto"` décide
504
+
505
+ Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
506
+ enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:241`).
507
+
508
+ 1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
509
+ 2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
510
+ 3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
511
+
512
+ La résolution finale est publiée au kernel (`auditService.ts:139`), ce qui alimente l'écran des stores
513
+ de Studio : configuré, résolu, disponible, motif, emplacement physique.
514
+
515
+ ## 🏗️ Architecture interne
516
+
517
+ ```mermaid
518
+ sequenceDiagram
519
+ participant P as Point sensible
520
+ participant R as recordAudit()
521
+ participant S as AuditService
522
+ participant ST as IAuditStore
523
+ participant L as Abonnés live
524
+ P->>R: draft { category, action, outcome, actor, … }
525
+ R->>R: container.get("auditService")
526
+ Note over R: absent ou désactivé → retour immédiat
527
+ R->>S: record(draft)
528
+ S->>S: pose id + ts
529
+ S--)ST: append(event) sans await
530
+ Note over ST: échec → log ERROR, jamais de throw
531
+ S->>L: notifie si et seulement s'il y a des abonnés
532
+ ```
533
+
534
+ Quatre pièces, quatre responsabilités :
535
+
536
+ - **`recordAudit()`** (`recordAudit.ts:15`) — le point d'émission côté appelant. Une résolution par le
537
+ container, sur le cold-path uniquement. C'est ce qui rend l'audit **découplé** : module absent →
538
+ aucun effet, jamais d'exception qui remonterait dans le flux métier.
539
+ - **`AuditService`** (`auditService.ts:48`) — le propriétaire. Il construit le store au boot, le pose
540
+ au container sous le nom `auditStore` (`auditService.ts:138`), arme la purge, et implémente
541
+ `IAuditSink` (`IAuditStore.ts:77`).
542
+ - **`IAuditStore`** (`IAuditStore.ts:48`) — le contrat de persistance : `append`, `listPage`, `gc`.
543
+ **`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
544
+ d'audit.
545
+ - **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
546
+ (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:322`) et donc
547
+ gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
548
+
549
+ ### La lecture paginée — pourquoi un curseur et pas un décalage
550
+
551
+ Un journal reçoit des écritures **pendant** qu'on le parcourt. Un `OFFSET 200` glisserait d'une page
552
+ à l'autre : on reverrait des événements déjà lus, on en manquerait d'autres. Le contrat impose donc le
553
+ **curseur** (`IAuditStore.ts:22`).
554
+
555
+ Le curseur est **composite et auto-portant** : `<ts>:<id>`. Deux propriétés que les deux backends
556
+ tiennent identiquement :
557
+
558
+ - **ordre total** — l'horodatage porte la chronologie, l'identifiant départage les collisions à la
559
+ milliseconde. Sans ce tri `desc(ts), desc(id)`, trois échecs de login de la même milliseconde
560
+ pourraient se répéter ou disparaître (`MemoryAuditStore.ts:83`, `DrizzleAuditStore.ts:197`) ;
561
+ - **auto-portance** — le jeton contient tout ce qu'il faut pour se comparer, donc une page reste juste
562
+ même si l'événement qui l'a produite a été purgé entre-temps (`MemoryAuditStore.ts:115`,
563
+ `DrizzleAuditStore.ts:219`). Un curseur réduit à un identifiant nu aurait rembobiné en silence à la
564
+ première page — et fait boucler la console.
565
+
566
+ Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
567
+ paie (`IAuditStore.ts:59`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
568
+ ligne de garde `limit + 1` (`DrizzleAuditStore.ts:198`).
569
+
570
+ ## Entité de persistance et dialectes pris en charge
571
+
572
+ **Un seul backend durable** est fourni : `@nodefony/drizzle`. Le décompte honnête :
573
+
574
+ | Backend | Store d'audit | Dialectes prouvés |
575
+ | -------------------- | ------------- | ----------------------------------- |
576
+ | builtin `memory` | ✅ fourni | — (mémoire du process, per-pod) |
577
+ | `@nodefony/drizzle` | ✅ fourni | SQLite · PostgreSQL · MySQL/MariaDB |
578
+ | `@nodefony/mongoose` | ❌ absent | — |
579
+ | `@nodefony/redis` | ❌ absent | — |
580
+
581
+ Mongoose porte session, user, jetons, passkeys et webhooks, mais **pas** l'audit ; Redis non plus.
582
+ Ces deux absences n'ont pas le même statut. Redis n'en aura pas, et la raison n'est pas
583
+ « c'est un cache » — il porte déjà des données durables comme les passkeys, en opt-in assumé. C'est
584
+ que le journal d'audit **croît sans borne**, se conserve des mois pour la conformité, et se
585
+ **consulte** (recherche, filtres, pagination) : garder tout ça en mémoire vive coûte cher pour un
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é.
589
+
590
+ En attendant, une application MongoDB qui veut un journal durable a trois voies : brancher un store
591
+ maison (voir l'extension ci-dessous), charger `@nodefony/drizzle` à côté de Mongo — même en SQLite
592
+ local, les deux modules cohabitent — ou héberger le journal sur une base SQL.
593
+
594
+ ### La table `audit_event`
595
+
596
+ L'entité est déclarée en spécification logique (`auditEventEntity.ts:37`), déclinée par dialecte via
597
+ le `colKit` — mêmes **noms** de colonnes partout, donc un store dialect-agnostique. La forme de ligne
598
+ `AuditEventRow` (`auditEventEntity.ts:85`) est **identique** au contrat `IAuditEvent`, champ pour
599
+ champ : aucun mapping surprise.
600
+
601
+ | Colonne | SQLite | PostgreSQL | MySQL/MariaDB | Note |
602
+ | --------------------------------------- | ----------- | ---------- | ----------------- | -------------------------- |
603
+ | `id` | `text` PK | `text` PK | `varchar(512)` PK | identifiant de l'événement |
604
+ | `ts` | `integer` | `bigint` | `bigint` | epoch ms, exposé `number` |
605
+ | `category` | `text` | `text` | `varchar(512)` | non nul, **indexé** |
606
+ | `action`, `outcome` | `text` | `text` | `text` | non nuls |
607
+ | `actor`, `requestId` | `text` | `text` | `varchar(512)` | **NULLABLE**, **indexés** |
608
+ | `resource`, `reason`, `ip`, `userAgent` | `text` | `text` | `text` | **NULLABLE** |
609
+ | `flags`, `metadata` | `text` json | `jsonb` | `json` | **NULLABLE** |
610
+
611
+ En MySQL, toute colonne texte **indexée** (ou clé primaire) devient `varchar(512)` : InnoDB ne sait
612
+ pas indexer un `TEXT` sans longueur de préfixe. Même règle pour tout le framework, portée par le
613
+ `colKit` — jamais par l'entité.
614
+
615
+ Quatre index couvrent les axes de la console (`auditEventEntity.ts:61`) : `ts` pour la pagination
616
+ chronologique, `category`, `actor` et `requestId` pour les filtres. Ils sont lus par `drizzle-kit`
617
+ pour les migrations de production ; en dev et en test, le DDL dérivé les ignore — pure performance de
618
+ filtrage, jamais de sémantique.
619
+
620
+ L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
621
+ (`registerStores.ts:241`, entité via `registerAuditEntities()`, `auditEventEntity.ts:141`). Côté
622
+ implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts:64`) résout son handle de base **à chaque
623
+ appel**, pas à la construction : l'ordre de démarrage n'est pas garanti, et l'ORM se déconnecte au
624
+ `onTerminate` avant le drain des serveurs.
625
+
626
+ ## 🔐 Sécurité — ce qui n'entre JAMAIS dans le journal
627
+
628
+ La règle d'or est écrite en tête du contrat (`IAuditEvent.ts:8`) : **un secret n'entre jamais dans un
629
+ événement**. Ni mot de passe, ni jeton, ni cookie, ni corps de requête, ni en-têtes.
630
+
631
+ Ce que le code garantit, concrètement :
632
+
633
+ - **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
634
+ d'identité_ (`IAuditEvent.ts:74`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
635
+ les en-têtes de la requête » (`IAuditEvent.ts:79`).
636
+ - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:41`) ne porte que deux
637
+ booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
638
+ les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
639
+ copiée.
640
+ - **Les émetteurs journalisent l'identifiant public, pas le secret.** Une clé d'API émet son `id`
641
+ public en `resource`, jamais le jeton. Prouvé explicitement : le test vérifie que la sérialisation
642
+ complète de l'événement **ne contient pas** le secret créé
643
+ (`auditEmissionHotPath.test.ts:545`).
644
+ - **Le motif reste machine.** `reason` est une valeur stable et filtrable, pas un message d'erreur
645
+ libre (`IAuditEvent.ts:84`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
646
+ permet de la garder **côté audit** alors que le client, lui, reçoit un message d'échec uniforme
647
+ (anti-énumération).
648
+
649
+ Deux autres propriétés de sécurité valent d'être connues :
650
+
651
+ - **Pas d'événement fantôme.** Révoquer la clé d'API d'autrui n'émet **rien** : l'anti-énumération
652
+ vaut aussi pour le journal, sinon le journal lui-même deviendrait un oracle
653
+ (`auditEmissionHotPath.test.ts:561`).
654
+ - **Lecture réservée aux administrateurs.** L'endpoint est gardé `ROLE_NODEFONY_ADMIN`, le canal live
655
+ aussi via le plancher irréductible `security:` (`frameAuthorizer.ts:107`) — un utilisateur ordinaire
656
+ qui tente de s'y abonner produit lui-même un `frame.denied`.
657
+
658
+ ## 📜 Normes appliquées
659
+
660
+ | Exigence | Norme | Comment le code s'y conforme |
661
+ | ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
662
+ | Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:693`) |
663
+ | Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
664
+ | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:41`) |
665
+ | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:53`) |
666
+ | Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
667
+ | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:906`) |
668
+ | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:353`) |
669
+ | Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:773`) |
670
+
671
+ ## 📡 Observabilité — Studio
672
+
673
+ L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx:62`) consomme le data plane
674
+ `GET /nodefony/security/api/audit/events` en pagination **serveur**. Il offre :
675
+
676
+ - un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx:227`) ;
677
+ - un indicateur du **store réellement résolu** pour la brique `audit`, alimenté par la publication de
678
+ résolution du service (`auditService.ts:139`) ;
679
+ - un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx:20`) — pensé
680
+ pour les pics d'activité : un journal d'audit se consulte, il ne se regarde pas défiler ;
681
+ - un renvoi vers la **trace de requête** quand l'événement porte un `requestId`, ce qui recolle
682
+ l'événement de sécurité à toute la vie de la requête.
683
+
684
+ Quand l'audit est désactivé en configuration, l'endpoint répond **503** avec un message explicite
685
+ (`SecurityAdminApi.ts:341`) et la console l'affiche tel quel plutôt qu'un tableau vide ambigu.
686
+
687
+ ### Le journal alimente les webhooks
688
+
689
+ Le flux d'audit est la **source d'événements** du dispatcher de webhooks sortants : celui-ci s'abonne
690
+ au service au démarrage (`webhooks.ts:214`) et filtre les actions souscrites avant de livrer
691
+ (`WebhookDispatcher.ts:132`). Conséquence pratique : ce qui n'est pas audité ne peut pas déclencher de
692
+ webhook. Détail des souscriptions, de la signature et des relivraisons → [webhooks](webhooks.md).
693
+
694
+ ## ⚠️ Pièges
695
+
696
+ | Symptôme | Cause (dans le code) | Correction |
697
+ | --------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
698
+ | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
699
+ | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
700
+ | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:109`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
701
+ | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
702
+ | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
703
+ | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
704
+ | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
705
+ | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:194`) | Retirer le rôle admin, ou ne pas exposer le canal |
706
+ | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:192`) | Store à haute disponibilité si la conformité l'exige |
707
+ | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
708
+ | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
709
+
710
+ ## 🧪 Tests & couverture
711
+
712
+ Les chiffres exacts vivent dans la carte de l'aperçu (régénérée depuis vitest, jamais figée ici).
713
+ Cinq familles couvrent la brique :
714
+
715
+ - **unit — le socle** : `auditService` (store mémoire append-only, filtres, curseur, borne de volume,
716
+ purge, instantané ; et côté service : no-op à coût nul, estampille `id`+`ts`, diffusion live,
717
+ isolation d'un abonné qui lève) ; `auditStoreRegistry` (builtin enregistré à l'import, fabrique
718
+ défensive sur config partielle, backend inconnu) ; `auditBridge` (coalescing borné, nettoyage,
719
+ plancher `ROLE_NODEFONY_ADMIN` du canal).
720
+ - **unit — l'émission** : `auditEmission` prouve que `AuthFlow` et `Authorization` journalisent
721
+ **réellement** via le container partagé, exactement comme en production.
722
+ - **unit — le hot-path** : `auditEmissionHotPath` est le banc le plus instructif de la brique. Il
723
+ verrouille l'invariant de performance — succès authentifié et succès anonyme n'émettent **rien** —
724
+ et couvre les quatre sorties d'échec du firewall, le câblage réel du verrou WS, l'émission des
725
+ jetons et des clés, la non-fuite du secret, et l'absence d'événement fantôme.
726
+ - **intégration + e2e (base réelle)** : les stores Drizzle sur SQLite, plus des e2e PostgreSQL et
727
+ MySQL/MariaDB gatés par `NF_PG_URL` / `NF_MYSQL_URL` — round-trip JSON, curseur composite avec
728
+ collision à la milliseconde, et le compteur de purge normalisé par driver.
729
+ - **banc de contrat** : `auditPaginationContract` — **une seule** suite d'invariants de lecture,
730
+ déroulée sur le store mémoire **et** sur Drizzle en trois dialectes. Un écart de comportement entre
731
+ backends devient un échec de test, par construction.
732
+
733
+ Ce qui **manque** aujourd'hui : pas de test d'attaque dédié (`*.attack.test.ts`) sur le journal
734
+ lui-même — les scénarios hostiles passent par les bancs d'attaque des briques voisines
735
+ (autorisation, frames WS) ; et pas de banc de charge dédié à l'écriture d'audit.
736
+
737
+ > [!IMPORTANT]
738
+ > Un **skip est vert**. Les e2e PostgreSQL et MySQL ne s'exécutent qu'avec leurs variables d'infra ;
739
+ > sans elles, la suite passe sans avoir rien prouvé sur ces dialectes. Lancer les bases par
740
+ > `docker compose --profile postgres up -d postgres` avant de conclure.
741
+
742
+ Couverture : `npm run coverage` dans `@nodefony/security`.
743
+
744
+ ## 🔗 Pour aller plus loin
745
+
746
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
747
+ - Ce que le journal alimente → [webhooks](webhooks.md)
748
+ - D'où viennent les événements `auth.*` → [firewall](firewall.md) · les `login.*` → [authenticators](authenticators.md)
749
+ - D'où viennent les `access.denied` → [authorization](authorization.md)
750
+ - Les événements `token.*` → [tokens](tokens.md) · les `apikey.*` → [api-keys](api-keys.md)
751
+ - Vocabulaire transverse de la sécurité → [lexique](lexique.md)