@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.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.
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +6 -5
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +1 -1
- package/dist/nodefony/service/tokenService.js +30 -20
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +41 -33
- package/dist/nodefony/service/webhooks.js +55 -44
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +67 -65
- package/docs/audit.md +87 -82
- package/docs/authenticators.md +61 -49
- package/docs/authorization.md +21 -21
- package/docs/cors.md +30 -30
- package/docs/csrf.md +28 -28
- package/docs/external-jwt.md +21 -13
- package/docs/firewall.md +32 -32
- package/docs/headers.md +29 -29
- package/docs/index.md +3 -1
- package/docs/oauth2.md +61 -61
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +56 -56
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +85 -85
- package/docs/webhooks.md +50 -47
- package/package.json +19 -16
package/docs/audit.md
CHANGED
|
@@ -30,8 +30,8 @@ source: "src/packages/@nodefony/security/docs/audit.md"
|
|
|
30
30
|
> Chaque fois qu'une **décision de sécurité** est prise — un login réussit, un accès est refusé, une
|
|
31
31
|
> clé d'API est révoquée, un jeton volé resurgit — Nodefony écrit une ligne dans un journal
|
|
32
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:
|
|
34
|
-
> `IAuditEvent` (`IAuditEvent.ts:
|
|
33
|
+
> les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:51`), le contrat
|
|
34
|
+
> `IAuditEvent` (`IAuditEvent.ts:61`) et les stores de `nodefony/src/audit/`.
|
|
35
35
|
|
|
36
36
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Journal d'audit**
|
|
37
37
|
|
|
@@ -70,7 +70,7 @@ flowchart LR
|
|
|
70
70
|
API --> UI["Studio — Journal d'audit"]
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`AuditService.record()` (`auditService.ts:
|
|
73
|
+
`AuditService.record()` (`auditService.ts:187`) est le point de passage unique : il pose l'identité et
|
|
74
74
|
l'horodatage, écrit **sans attendre**, et notifie les abonnés live. Tout le reste — stores, pont WS,
|
|
75
75
|
webhooks, console — se branche autour de lui.
|
|
76
76
|
|
|
@@ -123,7 +123,7 @@ requête authentifiée qui passe n'écrit rien. Prouvé par les tests « SUCCÈS
|
|
|
123
123
|
signal, pas du bruit à filtrer.
|
|
124
124
|
|
|
125
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:
|
|
126
|
+
part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:199`). Un
|
|
127
127
|
store en panne dégrade la traçabilité, il ne renvoie pas 500 à l'utilisateur.
|
|
128
128
|
|
|
129
129
|
**3. Le store est pluggable, jamais câblé en dur.** Le service résout un **nom** via un registre
|
|
@@ -196,7 +196,7 @@ export default ExportController;
|
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
> [!IMPORTANT]
|
|
199
|
-
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:
|
|
199
|
+
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:71`) : il n'existe
|
|
200
200
|
> pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
|
|
201
201
|
> événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
|
|
202
202
|
> données) et laisse `action` porter le vocabulaire métier.
|
|
@@ -254,7 +254,7 @@ et ratées, sur une fenêtre précise.
|
|
|
254
254
|
|
|
255
255
|
Aucune configuration : les événements d'authentification sont émis par défaut. Il suffit de filtrer.
|
|
256
256
|
Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
|
|
257
|
-
(`SecurityAdminApi.ts:
|
|
257
|
+
(`SecurityAdminApi.ts:184`) :
|
|
258
258
|
|
|
259
259
|
| Paramètre | Effet | Exemple |
|
|
260
260
|
| ----------- | ----------------------------------------------- | --------------------------- |
|
|
@@ -312,12 +312,12 @@ use("@nodefony/security", {
|
|
|
312
312
|
```
|
|
313
313
|
|
|
314
314
|
Ce que ça déclenche : le service arme un `GcScheduler` de purge **toutes les heures**
|
|
315
|
-
(`auditService.ts:
|
|
315
|
+
(`auditService.ts:159`, intervalle `GC_INTERVAL_MS` — `auditService.ts:34`), avec gigue
|
|
316
316
|
anti-avalanche en cluster. Chaque
|
|
317
317
|
tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
|
|
318
|
-
(`IAuditStore.ts:
|
|
319
|
-
côté SQL (`DrizzleAuditStore.ts:
|
|
320
|
-
`threshold` côté mémoire (`MemoryAuditStore.ts:
|
|
318
|
+
(`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
|
|
319
|
+
côté SQL (`DrizzleAuditStore.ts:256`), un défilement de file tant que l'événement de tête dépasse le
|
|
320
|
+
`threshold` côté mémoire (`MemoryAuditStore.ts:133`).
|
|
321
321
|
|
|
322
322
|
Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
|
|
323
323
|
|
|
@@ -331,8 +331,8 @@ qu'il arrive.
|
|
|
331
331
|
La base est indisponible pendant trente secondes. Que se passe-t-il ?
|
|
332
332
|
|
|
333
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:
|
|
335
|
-
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:
|
|
334
|
+
fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:199`) ; côté SQL, si l'ORM n'est
|
|
335
|
+
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:152`). Un login n'échoue jamais
|
|
336
336
|
parce que le journal est cassé.
|
|
337
337
|
|
|
338
338
|
> [!WARNING]
|
|
@@ -342,34 +342,39 @@ parce que le journal est cassé.
|
|
|
342
342
|
> disponibilité — le contrat `IAuditStore` (`IAuditStore.ts:48`) est le point d'extension pour ça.
|
|
343
343
|
|
|
344
344
|
Deux garde-fous limitent la casse au démarrage : un store **explicitement** configuré mais inconnu
|
|
345
|
-
**avorte le boot en production** (`auditService.ts:
|
|
345
|
+
**avorte le boot en production** (`auditService.ts:115`), et un store `memory` en production déclenche
|
|
346
346
|
un `WARNING` nommant précisément le risque — volatil, per-pod, rétention réglementaire impossible
|
|
347
|
-
(`auditService.ts:
|
|
347
|
+
(`auditService.ts:128`).
|
|
348
348
|
|
|
349
349
|
## 🔐 Le catalogue des événements audités
|
|
350
350
|
|
|
351
|
-
Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:
|
|
351
|
+
Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:24`) : c'est
|
|
352
352
|
l'axe de filtrage principal de la console. L'**action** est une chaîne **ouverte**
|
|
353
|
-
(`IAuditEvent.ts:
|
|
353
|
+
(`IAuditEvent.ts:74`) : la liste grandit sans jamais casser le contrat.
|
|
354
354
|
|
|
355
355
|
### Vue d'ensemble — choisir son filtre en cinq secondes
|
|
356
356
|
|
|
357
357
|
<!-- prettier-ignore -->
|
|
358
358
|
| Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
|
|
359
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` |
|
|
360
|
+
| `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
|
|
361
|
+
| `authz` | autorisation (voters, `@IsGranted`), administration des comptes | `access.denied` · `user.created` · `user.updated` · `user.password_changed` · `user.deleted` (ces quatre-là par `@nodefony/user`) |
|
|
362
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` |
|
|
363
|
+
| `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
|
|
364
364
|
| `webauthn` | passkeys | `user.passkey_revoked` |
|
|
365
365
|
| `ws` | verrou de frame WebSocket | `frame.denied` |
|
|
366
366
|
| `webhook` | webhooks sortants | `webhook.created` · `webhook.updated` · `webhook.deleted` · `webhook.rotated` · `webhook.revealed` · `webhook.disabled` |
|
|
367
367
|
| `oauth` | login social OAuth2 | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
368
368
|
| `csrf` | défense CSRF | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
369
369
|
| `cors` | politique CORS | _catégorie déclarée, aucune action émise aujourd'hui_ |
|
|
370
|
-
| `config` | mutation de config runtime depuis Studio |
|
|
370
|
+
| `config` | mutation de config runtime depuis Studio | `config.update` · `log.debug.set` · `log.debug.clear` (par `@nodefony/framework`) |
|
|
371
371
|
|
|
372
|
-
|
|
372
|
+
Toute catégorie émise appartient au type `AuditCategory`, y compris depuis un paquet qui ne peut pas
|
|
373
|
+
l'importer (`@nodefony/user`, `@nodefony/framework`) : `tests/unit/auditCategoryEmitters.test.ts`
|
|
374
|
+
relit les sources de tous les paquets et refuse un littéral hors contrat. Un filtre sur les
|
|
375
|
+
catégories déclarées voit donc tout le journal.
|
|
376
|
+
|
|
377
|
+
Les trois issues possibles (`IAuditEvent.ts:43`) ne sont pas interchangeables : `failure` = **l'acteur
|
|
373
378
|
a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
|
|
374
379
|
refusé** un acteur pourtant bien formé (Zero Trust, rôle manquant) ; `success` = l'action a abouti.
|
|
375
380
|
Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non autorisé.
|
|
@@ -377,19 +382,21 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
|
|
|
377
382
|
### `auth` — la chaîne d'authentification
|
|
378
383
|
|
|
379
384
|
Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
|
|
380
|
-
(`firewall.ts:
|
|
385
|
+
(`firewall.ts:907`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
|
|
381
386
|
|
|
382
|
-
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:
|
|
383
|
-
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:
|
|
387
|
+
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
|
|
388
|
+
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
|
|
389
|
+
- `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
|
|
390
|
+
(`firewall.ts:795`) ;
|
|
384
391
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
385
|
-
(`firewall.ts:
|
|
392
|
+
(`firewall.ts:822`) ;
|
|
386
393
|
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
|
|
387
394
|
|
|
388
395
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
389
396
|
`login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
|
|
390
397
|
`login.throttled` (`authFlow.ts:139`), `login.mfa_required` quand un second facteur est réclamé
|
|
391
398
|
(`authFlow.ts:175`), et `login.success` après la preuve complète (`authFlow.ts:191`,
|
|
392
|
-
`authFlow.ts:
|
|
399
|
+
`authFlow.ts:232`).
|
|
393
400
|
|
|
394
401
|
### `authz` — le refus d'autorisation
|
|
395
402
|
|
|
@@ -399,10 +406,10 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
|
|
|
399
406
|
### `token` — la vie et la mort des jetons longue durée
|
|
400
407
|
|
|
401
408
|
- `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:
|
|
409
|
+
d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:535`) ;
|
|
403
410
|
- `token.reuse_detected` — **le signal d'attaque le plus fort du journal** : un refresh déjà révoqué
|
|
404
411
|
a été re-présenté, donc quelqu'un détient un jeton volé. Toute la famille est coupée
|
|
405
|
-
(`tokenService.ts:
|
|
412
|
+
(`tokenService.ts:585`, RFC 9700 §4.14) ;
|
|
406
413
|
- `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts:153`,
|
|
407
414
|
`apiKeys.ts:212`).
|
|
408
415
|
|
|
@@ -427,18 +434,18 @@ depuis l'utilisateur de la requête — avec repli `"admin"`, jamais une décisi
|
|
|
427
434
|
|
|
428
435
|
## 🧰 Le contrat d'une entrée
|
|
429
436
|
|
|
430
|
-
Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:
|
|
431
|
-
brouillon `IAuditEventDraft` (`IAuditEvent.ts:
|
|
437
|
+
Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:61`). L'émetteur ne fournit qu'un
|
|
438
|
+
brouillon `IAuditEventDraft` (`IAuditEvent.ts:113`) : `id` et `ts` sont posés par le service, ce qui
|
|
432
439
|
garantit un seul appel d'horloge, centralisé hors des points d'émission.
|
|
433
440
|
|
|
434
441
|
| Champ | Type | Posé par | Rôle |
|
|
435
442
|
| ----------- | -------------------------- | -------- | ------------------------------------------------------------ |
|
|
436
443
|
| `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur |
|
|
437
|
-
| `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:
|
|
444
|
+
| `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:195`) |
|
|
438
445
|
| `category` | `AuditCategory` | émetteur | sous-système — union fermée |
|
|
439
446
|
| `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte |
|
|
440
447
|
| `outcome` | `success\|failure\|denied` | émetteur | le verdict |
|
|
441
|
-
| `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:
|
|
448
|
+
| `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:82`) |
|
|
442
449
|
| `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger** |
|
|
443
450
|
| `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit |
|
|
444
451
|
| `ip` | `string \| null` | contexte | provenance réseau |
|
|
@@ -458,19 +465,19 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
458
465
|
|
|
459
466
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
460
467
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
461
|
-
(`firewall.ts:
|
|
462
|
-
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:
|
|
468
|
+
(`firewall.ts:907`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:358`).
|
|
469
|
+
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
|
|
463
470
|
|
|
464
471
|
**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:
|
|
472
|
+
avant tout appel d'horloge si le service est inactif (`auditService.ts:187`). Aucun objet créé,
|
|
466
473
|
aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens` n'est pas journalisé »
|
|
467
474
|
(`auditEmissionHotPath.test.ts:499`).
|
|
468
475
|
|
|
469
476
|
**3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
|
|
470
|
-
(`auditService.ts:
|
|
477
|
+
(`auditService.ts:199`). La latence du store n'entre jamais dans la latence de la requête.
|
|
471
478
|
|
|
472
479
|
**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:
|
|
480
|
+
personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:228`). Le pont
|
|
474
481
|
WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
|
|
475
482
|
alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est armé à la demande et
|
|
476
483
|
`unref` (`auditBridge.ts:93`).
|
|
@@ -478,13 +485,13 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
|
|
|
478
485
|
Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
|
|
479
486
|
(`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
|
|
480
487
|
d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
|
|
481
|
-
(`auditBridge.ts:
|
|
488
|
+
(`auditBridge.ts:86`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
|
|
482
489
|
jamais faire tomber ce qu'on supervise.
|
|
483
490
|
|
|
484
491
|
## ⚙️ Configuration
|
|
485
492
|
|
|
486
|
-
Table dérivée du schéma Zod `auditSchema` (`config.ts:
|
|
487
|
-
(`config.ts:
|
|
493
|
+
Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la racine sous la clé `audit`
|
|
494
|
+
(`config.ts:1158`).
|
|
488
495
|
|
|
489
496
|
| Option | Type | Défaut | Effet |
|
|
490
497
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
@@ -498,18 +505,18 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
|
|
|
498
505
|
> `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
|
|
499
506
|
> Les mettre à `false` ne change rien. L'immuabilité vient du **contrat** `IAuditStore`, qui n'expose
|
|
500
507
|
> 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:
|
|
508
|
+
> **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:55`).
|
|
502
509
|
|
|
503
510
|
### Comment `store: "auto"` décide
|
|
504
511
|
|
|
505
512
|
Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
|
|
506
|
-
enregistrés (`auditService.ts:
|
|
513
|
+
enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:289`).
|
|
507
514
|
|
|
508
515
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
509
516
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
510
517
|
3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
|
|
511
518
|
|
|
512
|
-
La résolution finale est publiée au kernel (`auditService.ts:
|
|
519
|
+
La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
|
|
513
520
|
de Studio : configuré, résolu, disponible, motif, emplacement physique.
|
|
514
521
|
|
|
515
522
|
## 🏗️ Architecture interne
|
|
@@ -536,14 +543,14 @@ Quatre pièces, quatre responsabilités :
|
|
|
536
543
|
- **`recordAudit()`** (`recordAudit.ts:15`) — le point d'émission côté appelant. Une résolution par le
|
|
537
544
|
container, sur le cold-path uniquement. C'est ce qui rend l'audit **découplé** : module absent →
|
|
538
545
|
aucun effet, jamais d'exception qui remonterait dans le flux métier.
|
|
539
|
-
- **`AuditService`** (`auditService.ts:
|
|
540
|
-
au container sous le nom `auditStore` (`auditService.ts:
|
|
546
|
+
- **`AuditService`** (`auditService.ts:51`) — le propriétaire. Il construit le store au boot, le pose
|
|
547
|
+
au container sous le nom `auditStore` (`auditService.ts:144`), arme la purge, et implémente
|
|
541
548
|
`IAuditSink` (`IAuditStore.ts:77`).
|
|
542
549
|
- **`IAuditStore`** (`IAuditStore.ts:48`) — le contrat de persistance : `append`, `listPage`, `gc`.
|
|
543
550
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
544
551
|
d'audit.
|
|
545
552
|
- **`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:
|
|
553
|
+
(`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:387`) et donc
|
|
547
554
|
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
|
|
548
555
|
|
|
549
556
|
### La lecture paginée — pourquoi un curseur et pas un décalage
|
|
@@ -565,30 +572,28 @@ tiennent identiquement :
|
|
|
565
572
|
|
|
566
573
|
Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
|
|
567
574
|
paie (`IAuditStore.ts:59`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
|
|
568
|
-
ligne de garde `limit + 1` (`DrizzleAuditStore.ts:
|
|
575
|
+
ligne de garde `limit + 1` (`DrizzleAuditStore.ts:225`).
|
|
569
576
|
|
|
570
577
|
## Entité de persistance et dialectes pris en charge
|
|
571
578
|
|
|
572
|
-
**
|
|
579
|
+
**Les deux backends durables portent l'audit** : `@nodefony/drizzle` et `@nodefony/mongoose`. Le
|
|
580
|
+
décompte honnête :
|
|
573
581
|
|
|
574
|
-
| Backend | Store d'audit |
|
|
582
|
+
| Backend | Store d'audit | Moteurs prouvés |
|
|
575
583
|
| -------------------- | ------------- | ----------------------------------- |
|
|
576
584
|
| builtin `memory` | ✅ fourni | — (mémoire du process, per-pod) |
|
|
577
585
|
| `@nodefony/drizzle` | ✅ fourni | SQLite · PostgreSQL · MySQL/MariaDB |
|
|
578
|
-
| `@nodefony/mongoose` |
|
|
586
|
+
| `@nodefony/mongoose` | ✅ fourni | MongoDB |
|
|
579
587
|
| `@nodefony/redis` | ❌ absent | — |
|
|
580
588
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 ») — un utilisateur choisit sa base de données, pas de perdre sa traçabilité.
|
|
589
|
+
Une application peut donc tourner **entièrement sur MongoDB** sans perdre sa traçabilité : c'était
|
|
590
|
+
la condition pour que Mongo soit un chemin complet, et pas un chemin qui oblige à rapatrier une
|
|
591
|
+
base SQL pour une seule brique.
|
|
588
592
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
593
|
+
**Redis n'en aura pas, et la raison n'est pas « c'est un cache »** — il porte déjà des données
|
|
594
|
+
durables comme les passkeys, en opt-in assumé. C'est que le journal d'audit **croît sans borne**,
|
|
595
|
+
se conserve des mois pour la conformité, et se **consulte** (recherche, filtres, pagination) :
|
|
596
|
+
garder tout ça en mémoire vive coûte cher pour un motif d'accès qui n'est pas le sien.
|
|
592
597
|
|
|
593
598
|
### La table `audit_event`
|
|
594
599
|
|
|
@@ -630,9 +635,9 @@ La règle d'or est écrite en tête du contrat (`IAuditEvent.ts:8`) : **un secre
|
|
|
630
635
|
Ce que le code garantit, concrètement :
|
|
631
636
|
|
|
632
637
|
- **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
|
|
633
|
-
d'identité_ (`IAuditEvent.ts:
|
|
634
|
-
les en-têtes de la requête » (`IAuditEvent.ts:
|
|
635
|
-
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:
|
|
638
|
+
d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
|
|
639
|
+
les en-têtes de la requête » (`IAuditEvent.ts:87`).
|
|
640
|
+
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:53`) ne porte que deux
|
|
636
641
|
booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
|
|
637
642
|
les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
|
|
638
643
|
copiée.
|
|
@@ -641,7 +646,7 @@ Ce que le code garantit, concrètement :
|
|
|
641
646
|
complète de l'événement **ne contient pas** le secret créé
|
|
642
647
|
(`auditEmissionHotPath.test.ts:545`).
|
|
643
648
|
- **Le motif reste machine.** `reason` est une valeur stable et filtrable, pas un message d'erreur
|
|
644
|
-
libre (`IAuditEvent.ts:
|
|
649
|
+
libre (`IAuditEvent.ts:92`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
|
|
645
650
|
permet de la garder **côté audit** alors que le client, lui, reçoit un message d'échec uniforme
|
|
646
651
|
(anti-énumération).
|
|
647
652
|
|
|
@@ -660,11 +665,11 @@ Deux autres propriétés de sécurité valent d'être connues :
|
|
|
660
665
|
| ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
|
|
661
666
|
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:693`) |
|
|
662
667
|
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
|
|
663
|
-
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:
|
|
664
|
-
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:
|
|
668
|
+
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
|
|
669
|
+
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
|
|
665
670
|
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
|
|
666
|
-
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:
|
|
667
|
-
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:
|
|
671
|
+
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:912`) |
|
|
672
|
+
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
|
|
668
673
|
| Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:773`) |
|
|
669
674
|
|
|
670
675
|
## 📡 Observabilité — Studio
|
|
@@ -674,7 +679,7 @@ L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx:62`)
|
|
|
674
679
|
|
|
675
680
|
- un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx:227`) ;
|
|
676
681
|
- un indicateur du **store réellement résolu** pour la brique `audit`, alimenté par la publication de
|
|
677
|
-
résolution du service (`auditService.ts:
|
|
682
|
+
résolution du service (`auditService.ts:145`) ;
|
|
678
683
|
- un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx:20`) — pensé
|
|
679
684
|
pour les pics d'activité : un journal d'audit se consulte, il ne se regarde pas défiler ;
|
|
680
685
|
- un renvoi vers la **trace de requête** quand l'événement porte un `requestId`, ce qui recolle
|
|
@@ -692,19 +697,19 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
|
|
|
692
697
|
|
|
693
698
|
## ⚠️ Pièges
|
|
694
699
|
|
|
695
|
-
| Symptôme | Cause (dans le code)
|
|
696
|
-
| --------------------------------------------------- |
|
|
697
|
-
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`)
|
|
698
|
-
| Un pod voit des événements, l'autre non | Store `memory` non partagé
|
|
699
|
-
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:
|
|
700
|
-
| `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`)
|
|
701
|
-
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset
|
|
702
|
-
| Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`)
|
|
703
|
-
| Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`)
|
|
704
|
-
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:
|
|
705
|
-
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:
|
|
706
|
-
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF
|
|
707
|
-
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`)
|
|
700
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
701
|
+
| --------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
702
|
+
| 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`) |
|
|
703
|
+
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
704
|
+
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
|
|
705
|
+
| `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
706
|
+
| 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 |
|
|
707
|
+
| Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
708
|
+
| Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
709
|
+
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
|
|
710
|
+
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
|
|
711
|
+
| 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 |
|
|
712
|
+
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
|
|
708
713
|
|
|
709
714
|
## 🧪 Tests & couverture
|
|
710
715
|
|