@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4
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.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +48 -16
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
- package/dist/nodefony/command/security-oauth-doctor.js +68 -0
- package/dist/nodefony/command/security-secrets.js +52 -29
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +10 -7
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +9 -14
- package/dist/nodefony/config/config.js +99 -8
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
- package/dist/nodefony/scaffold/contribute.js +19 -0
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +118 -16
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +116 -25
- package/dist/nodefony/service/keycloak.js +208 -0
- package/dist/nodefony/service/oauth2.js +403 -28
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +94 -27
- package/dist/nodefony/service/totp.js +55 -11
- package/dist/nodefony/service/webAuthn.js +24 -7
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +15 -5
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -3
- package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +38 -0
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +28 -12
- package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
- package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
- package/dist/nodefony/src/oauth/providerRoles.js +78 -0
- package/dist/nodefony/src/oauth/providers/github.js +48 -12
- package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
- package/dist/nodefony/src/openToSignup.js +46 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +25 -0
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +89 -48
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +60 -4
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +64 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +145 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +182 -175
- package/docs/audit.md +141 -141
- package/docs/authenticators.md +129 -96
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +77 -72
- package/docs/external-jwt.md +83 -11
- package/docs/firewall.md +119 -83
- package/docs/headers.md +82 -82
- package/docs/index.md +16 -8
- package/docs/keycloak.md +835 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +291 -166
- package/docs/obtenir-un-jeton.md +131 -34
- package/docs/tokens.md +197 -140
- package/docs/totp.md +177 -136
- package/docs/webauthn.md +157 -154
- package/docs/webhooks.md +128 -125
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +19 -17
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
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#AuditService.#store`), le contrat
|
|
34
|
+
> `IAuditEvent` (`IAuditEvent.ts#IAuditEvent`) 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#AuditService.record`) 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
|
|
|
@@ -118,24 +118,24 @@ Trois partis pris, tous vérifiables dans le code.
|
|
|
118
118
|
|
|
119
119
|
**1. Le journal trace les transitions, jamais le trafic.** Le succès nominal est **muet** : une
|
|
120
120
|
requête authentifiée qui passe n'écrit rien. Prouvé par les tests « SUCCÈS authentifié → AUCUNE
|
|
121
|
-
émission » (`auditEmissionHotPath.test.ts
|
|
122
|
-
(`auditEmissionHotPath.test.ts
|
|
121
|
+
émission » (`auditEmissionHotPath.test.ts`) et « SUCCÈS anonyme EXPLICITE → AUCUNE émission »
|
|
122
|
+
(`auditEmissionHotPath.test.ts`). Conséquence directe : ce que tu lis dans le journal **est** un
|
|
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#AuditService.record`). 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
|
|
130
|
-
(`getAuditStoreFactory()`, `auditStoreRegistry.ts
|
|
131
|
-
posé par `registerAuditStore("memory")` à l'import (`auditStoreRegistry.ts
|
|
130
|
+
(`getAuditStoreFactory()`, `auditStoreRegistry.ts#getAuditStoreFactory`) ; le socle n'embarque que le builtin mémoire,
|
|
131
|
+
posé par `registerAuditStore("memory")` à l'import (`auditStoreRegistry.ts#registerAuditStore`), et les backends
|
|
132
132
|
lourds s'enregistrent depuis **leur** module. Un
|
|
133
133
|
`if (name === "drizzle")` dans le service trahirait la promesse.
|
|
134
134
|
|
|
135
135
|
## 🚀 Démarrage rapide
|
|
136
136
|
|
|
137
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
|
|
138
|
+
défaut, `config.ts#resourceServerSchema`) sur le store que résout `auto` : la base SQLite locale de l'application
|
|
139
139
|
générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
|
|
140
140
|
relire.
|
|
141
141
|
|
|
@@ -155,11 +155,11 @@ use("@nodefony/security", {
|
|
|
155
155
|
```
|
|
156
156
|
|
|
157
157
|
Rien d'autre à écrire : l'adapter `@nodefony/drizzle` déclare l'entité **et** la fabrique de store
|
|
158
|
-
tout seul au démarrage (`registerStores.ts
|
|
158
|
+
tout seul au démarrage (`registerStores.ts#registerMongooseFrameworkStores`).
|
|
159
159
|
|
|
160
160
|
### 2. Émettre un événement depuis ton code
|
|
161
161
|
|
|
162
|
-
`recordAudit()` (`recordAudit.ts
|
|
162
|
+
`recordAudit()` (`recordAudit.ts#recordAudit`) est exporté par le module. Il est **no-op** si l'audit est
|
|
163
163
|
absent ou désactivé — tu peux l'appeler sans garde.
|
|
164
164
|
|
|
165
165
|
```typescript
|
|
@@ -197,7 +197,7 @@ export default ExportController;
|
|
|
197
197
|
```
|
|
198
198
|
|
|
199
199
|
> [!IMPORTANT]
|
|
200
|
-
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts
|
|
200
|
+
> `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts#IAuditEvent.category`) : il n'existe
|
|
201
201
|
> pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
|
|
202
202
|
> événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
|
|
203
203
|
> données) et laisse `action` porter le vocabulaire métier.
|
|
@@ -205,7 +205,7 @@ export default ExportController;
|
|
|
205
205
|
### 3. Relire le journal
|
|
206
206
|
|
|
207
207
|
Le data plane d'admin sert une **page filtrée**, réservée à `ROLE_NODEFONY_ADMIN`
|
|
208
|
-
(`SecurityAdminApi.ts
|
|
208
|
+
(`SecurityAdminApi.ts#createSecurityAdminApi`) :
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
211
|
# Les échecs d'authentification d'un compte, sur une fenêtre donnée
|
|
@@ -255,7 +255,7 @@ et ratées, sur une fenêtre précise.
|
|
|
255
255
|
|
|
256
256
|
Aucune configuration : les événements d'authentification sont émis par défaut. Il suffit de filtrer.
|
|
257
257
|
Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
|
|
258
|
-
(`SecurityAdminApi.ts
|
|
258
|
+
(`SecurityAdminApi.ts#parseAuditQuery`) :
|
|
259
259
|
|
|
260
260
|
| Paramètre | Effet | Exemple |
|
|
261
261
|
| ----------- | ----------------------------------------------- | --------------------------- |
|
|
@@ -283,7 +283,7 @@ si le backoff NIST s'est déclenché — la signature d'une attaque par essais r
|
|
|
283
283
|
Un auditeur demande la preuve que le contrôle d'accès n'est pas décoratif.
|
|
284
284
|
|
|
285
285
|
Le jury d'autorisation journalise **tout refus** et **seulement** les refus, par un appel à
|
|
286
|
-
`recordAudit()` (`authorization.ts
|
|
286
|
+
`recordAudit()` (`authorization.ts#Authorization.#auditDeny`) : les octrois restent muets, parce qu'un octroi est du
|
|
287
287
|
volume, pas un signal.
|
|
288
288
|
|
|
289
289
|
```bash
|
|
@@ -313,17 +313,17 @@ use("@nodefony/security", {
|
|
|
313
313
|
```
|
|
314
314
|
|
|
315
315
|
Ce que ça déclenche : le service arme un `GcScheduler` de purge **toutes les heures**
|
|
316
|
-
(`auditService.ts
|
|
316
|
+
(`auditService.ts#AuditService.#build`, intervalle `GC_INTERVAL_MS` — `auditService.ts#GC_INTERVAL_MS`), avec gigue
|
|
317
317
|
anti-avalanche en cluster. Chaque
|
|
318
318
|
tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
|
|
319
|
-
(`IAuditStore.ts
|
|
320
|
-
côté SQL (`DrizzleAuditStore.ts
|
|
321
|
-
`threshold` côté mémoire (`MemoryAuditStore.ts
|
|
319
|
+
(`IAuditStore.ts#IAuditStore.listPage`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
|
|
320
|
+
côté SQL (`DrizzleAuditStore.ts#DrizzleAuditStore.gc`), un défilement de file tant que l'événement de tête dépasse le
|
|
321
|
+
`threshold` côté mémoire (`MemoryAuditStore.ts#MemoryAuditStore.gc`).
|
|
322
322
|
|
|
323
323
|
Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
|
|
324
324
|
|
|
325
325
|
Le store mémoire porte une **seconde** protection, indépendante du temps : un plafond de
|
|
326
|
-
**10 000 entrées** en FIFO (`MemoryAuditStore.ts
|
|
326
|
+
**10 000 entrées** en FIFO (`MemoryAuditStore.ts#DEFAULT_MAX_ENTRIES`, appliqué à `MemoryAuditStore.ts#MemoryAuditStore.append`). Ce n'est
|
|
327
327
|
pas une politique de rétention, c'est un garde-fou anti-fuite : la mémoire d'un pod est bornée quoi
|
|
328
328
|
qu'il arrive.
|
|
329
329
|
|
|
@@ -332,33 +332,33 @@ qu'il arrive.
|
|
|
332
332
|
La base est indisponible pendant trente secondes. Que se passe-t-il ?
|
|
333
333
|
|
|
334
334
|
Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
|
|
335
|
-
fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts
|
|
336
|
-
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts
|
|
335
|
+
fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts#AuditService.record`) ; côté SQL, si l'ORM n'est
|
|
336
|
+
pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts#DrizzleAuditStore.append`). Un login n'échoue jamais
|
|
337
337
|
parce que le journal est cassé.
|
|
338
338
|
|
|
339
339
|
> [!WARNING]
|
|
340
340
|
> Le corollaire est une **perte possible d'événements** pendant une panne du store. Le contrat est
|
|
341
341
|
> best-effort, pas transactionnel : il n'existe pas de tampon de reprise ni de journal d'écriture
|
|
342
342
|
> anticipée. Si ta conformité exige « aucune action sans trace », il faut un store à haute
|
|
343
|
-
> disponibilité — le contrat `IAuditStore` (`IAuditStore.ts
|
|
343
|
+
> disponibilité — le contrat `IAuditStore` (`IAuditStore.ts#IAuditStore`) est le point d'extension pour ça.
|
|
344
344
|
|
|
345
345
|
Deux garde-fous limitent la casse au démarrage : un store **explicitement** configuré mais inconnu
|
|
346
|
-
**avorte le boot en production** (`auditService.ts
|
|
346
|
+
**avorte le boot en production** (`auditService.ts#AuditService.#build`), et un store `memory` en production déclenche
|
|
347
347
|
un `WARNING` nommant précisément le risque — volatil, per-pod, rétention réglementaire impossible
|
|
348
|
-
(`auditService.ts
|
|
348
|
+
(`auditService.ts#AuditService.#build`).
|
|
349
349
|
|
|
350
350
|
## 🔐 Le catalogue des événements audités
|
|
351
351
|
|
|
352
|
-
Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts
|
|
352
|
+
Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts#AuditCategory`) : c'est
|
|
353
353
|
l'axe de filtrage principal de la console. L'**action** est une chaîne **ouverte**
|
|
354
|
-
(`IAuditEvent.ts
|
|
354
|
+
(`IAuditEvent.ts#IAuditEvent.action`) : la liste grandit sans jamais casser le contrat.
|
|
355
355
|
|
|
356
356
|
### Vue d'ensemble — choisir son filtre en cinq secondes
|
|
357
357
|
|
|
358
358
|
<!-- prettier-ignore -->
|
|
359
359
|
| Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
|
|
360
360
|
| --- | --- | --- |
|
|
361
|
-
| `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
|
+
| `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_enabled` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
|
|
362
362
|
| `authz` | autorisation (voters, `@IsGranted`), administration des comptes | `access.denied` · `user.created` · `user.updated` · `user.password_changed` · `user.deleted` (ces quatre-là par `@nodefony/user`) |
|
|
363
363
|
| `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
|
|
364
364
|
| `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
|
|
@@ -375,7 +375,7 @@ l'importer (`@nodefony/user`, `@nodefony/framework`) : `tests/unit/auditCategory
|
|
|
375
375
|
relit les sources de tous les paquets et refuse un littéral hors contrat. Un filtre sur les
|
|
376
376
|
catégories déclarées voit donc tout le journal.
|
|
377
377
|
|
|
378
|
-
Les trois issues possibles (`IAuditEvent.ts
|
|
378
|
+
Les trois issues possibles (`IAuditEvent.ts#AuditOutcome`) ne sont pas interchangeables : `failure` = **l'acteur
|
|
379
379
|
a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
|
|
380
380
|
refusé** un acteur pourtant bien formé (Zero Trust, rôle manquant) ; `success` = l'action a abouti.
|
|
381
381
|
Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non autorisé.
|
|
@@ -383,80 +383,80 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
|
|
|
383
383
|
### `auth` — la chaîne d'authentification
|
|
384
384
|
|
|
385
385
|
Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
|
|
386
|
-
(`firewall.ts
|
|
386
|
+
(`firewall.ts#Firewall.#recordAuth`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
|
|
387
387
|
|
|
388
|
-
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts
|
|
389
|
-
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts
|
|
388
|
+
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts#Firewall.isSecure`) ;
|
|
389
|
+
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts#Firewall.isSecure`) ;
|
|
390
390
|
- `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
|
|
391
|
-
(`firewall.ts
|
|
391
|
+
(`firewall.ts#Firewall.isSecure`) ;
|
|
392
392
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
393
|
-
(`firewall.ts
|
|
394
|
-
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts
|
|
393
|
+
(`firewall.ts#Firewall.handleSecurity`) ;
|
|
394
|
+
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts#Firewall.handleSecurity`).
|
|
395
395
|
|
|
396
396
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
397
|
-
`login.failure` sur identité inconnue (`authFlow.ts
|
|
398
|
-
`login.throttled` (`authFlow.ts
|
|
399
|
-
(`authFlow.ts
|
|
400
|
-
`authFlow.ts
|
|
397
|
+
`login.failure` sur identité inconnue (`authFlow.ts#AuthFlow.#loginPage`) ou mot de passe faux (`authFlow.ts#AuthFlow.login`),
|
|
398
|
+
`login.throttled` (`authFlow.ts#AuthFlow.login`), `login.mfa_required` quand un second facteur est réclamé
|
|
399
|
+
(`authFlow.ts#AuthFlow.login`), et `login.success` après la preuve complète (`authFlow.ts#AuthFlow.login`,
|
|
400
|
+
`authFlow.ts#AuthFlow.login`).
|
|
401
401
|
|
|
402
402
|
### `authz` — le refus d'autorisation
|
|
403
403
|
|
|
404
404
|
Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le jury de voters
|
|
405
|
-
(`authorization.ts
|
|
405
|
+
(`authorization.ts#Authorization.#auditDeny`). `resource` porte l'attribut refusé, `reason` le motif de la décision.
|
|
406
406
|
|
|
407
407
|
### `token` — la vie et la mort des jetons longue durée
|
|
408
408
|
|
|
409
409
|
- `token.issued` — un couple access/refresh vient d'être émis, donc une surface d'attaque vient
|
|
410
|
-
d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts
|
|
410
|
+
d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts#TokenService.#grantableScopes`) ;
|
|
411
411
|
- `token.reuse_detected` — **le signal d'attaque le plus fort du journal** : un refresh déjà révoqué
|
|
412
412
|
a été re-présenté, donc quelqu'un détient un jeton volé. Toute la famille est coupée
|
|
413
|
-
(`tokenService.ts
|
|
414
|
-
- `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts
|
|
415
|
-
`apiKeys.ts
|
|
413
|
+
(`tokenService.ts#TokenService.issueTokens`, RFC 9700 §4.14) ;
|
|
414
|
+
- `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts#ApiKeyService.createForSubject`,
|
|
415
|
+
`apiKeys.ts#ApiKeyService.listPagePat`).
|
|
416
416
|
|
|
417
417
|
### `ws` — le verrou de frame WebSocket
|
|
418
418
|
|
|
419
|
-
`frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts
|
|
419
|
+
`frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts#Firewall.#wireRealtime`) quand une
|
|
420
420
|
socket tente un `api.request` vers une zone protégée ou s'abonne à un canal interdit. La frame ne
|
|
421
421
|
porte ni IP ni `requestId` — acteur et cible suffisent.
|
|
422
422
|
|
|
423
423
|
### `webhook` — les mutations d'endpoints et la mort d'un endpoint
|
|
424
424
|
|
|
425
|
-
Les mutations d'admin sont tracées une par une (`WebhookAdminApi.ts
|
|
425
|
+
Les mutations d'admin sont tracées une par une (`WebhookAdminApi.ts#webhookAdminEndpoints` à `WebhookAdminApi.ts#webhookAdminEndpoints`),
|
|
426
426
|
y compris `webhook.revealed` : révéler un secret en clair est une action à auditer. L'auto-désactivation
|
|
427
427
|
après échecs répétés émet **un seul** événement `webhook.disabled` par endpoint qui meurt, jamais un par
|
|
428
|
-
échec (`webhooks.ts
|
|
428
|
+
échec (`webhooks.ts#WebhookService.delete`).
|
|
429
429
|
|
|
430
430
|
### `config` et Studio — les actions d'admin
|
|
431
431
|
|
|
432
|
-
Les producteurs d'admin passent par `auditAdmin()` (`adminAudit.ts
|
|
433
|
-
défensivement, et par `adminActor()` (`adminAudit.ts
|
|
432
|
+
Les producteurs d'admin passent par `auditAdmin()` (`adminAudit.ts#auditAdmin`), qui lit le sink
|
|
433
|
+
défensivement, et par `adminActor()` (`adminAudit.ts#adminActor`) pour dériver un libellé d'identité stable
|
|
434
434
|
depuis l'utilisateur de la requête — avec repli `"admin"`, jamais une décision d'autorisation.
|
|
435
435
|
|
|
436
436
|
## 🧰 Le contrat d'une entrée
|
|
437
437
|
|
|
438
|
-
Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts
|
|
439
|
-
brouillon `IAuditEventDraft` (`IAuditEvent.ts
|
|
438
|
+
Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts#IAuditEvent`). L'émetteur ne fournit qu'un
|
|
439
|
+
brouillon `IAuditEventDraft` (`IAuditEvent.ts#IAuditEventDraft`) : `id` et `ts` sont posés par le service, ce qui
|
|
440
440
|
garantit un seul appel d'horloge, centralisé hors des points d'émission.
|
|
441
441
|
|
|
442
|
-
| Champ | Type | Posé par | Rôle
|
|
443
|
-
| ----------- | -------------------------- | -------- |
|
|
444
|
-
| `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur
|
|
445
|
-
| `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts
|
|
446
|
-
| `category` | `AuditCategory` | émetteur | sous-système — union fermée
|
|
447
|
-
| `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte
|
|
448
|
-
| `outcome` | `success\|failure\|denied` | émetteur | le verdict
|
|
449
|
-
| `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts
|
|
450
|
-
| `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger**
|
|
451
|
-
| `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit
|
|
452
|
-
| `ip` | `string \| null` | contexte | provenance réseau
|
|
453
|
-
| `userAgent` | `string \| null` | contexte | provenance client
|
|
454
|
-
| `requestId` | `string \| null` | contexte | corrélation log ↔ audit ↔ trace
|
|
455
|
-
| `flags` | `IAuditEventFlags` | contexte | **présence** de matériel sensible, jamais la valeur
|
|
456
|
-
| `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres, absents par défaut
|
|
442
|
+
| Champ | Type | Posé par | Rôle |
|
|
443
|
+
| ----------- | -------------------------- | -------- | -------------------------------------------------------------------------- |
|
|
444
|
+
| `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur |
|
|
445
|
+
| `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts#AuditService.record`) |
|
|
446
|
+
| `category` | `AuditCategory` | émetteur | sous-système — union fermée |
|
|
447
|
+
| `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte |
|
|
448
|
+
| `outcome` | `success\|failure\|denied` | émetteur | le verdict |
|
|
449
|
+
| `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts#IAuditEvent.actor`) |
|
|
450
|
+
| `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger** |
|
|
451
|
+
| `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit |
|
|
452
|
+
| `ip` | `string \| null` | contexte | provenance réseau |
|
|
453
|
+
| `userAgent` | `string \| null` | contexte | provenance client |
|
|
454
|
+
| `requestId` | `string \| null` | contexte | corrélation log ↔ audit ↔ trace |
|
|
455
|
+
| `flags` | `IAuditEventFlags` | contexte | **présence** de matériel sensible, jamais la valeur |
|
|
456
|
+
| `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres, absents par défaut |
|
|
457
457
|
|
|
458
458
|
Les quatre derniers champs de provenance sont remplis d'un coup par `readAuditContext()`
|
|
459
|
-
(`readAuditContext.ts
|
|
459
|
+
(`readAuditContext.ts#readAuditContext`), qui lit un contexte HTTP ou WS de façon défensive — tous les champs sont
|
|
460
460
|
optionnels, un contexte partiel ne casse rien.
|
|
461
461
|
|
|
462
462
|
## ⚡ Performance & mémoire — pourquoi l'audit ne pèse pas sur la requête
|
|
@@ -466,33 +466,33 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
466
466
|
|
|
467
467
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
468
468
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
469
|
-
(`firewall.ts
|
|
470
|
-
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts
|
|
469
|
+
(`firewall.ts#Firewall.#recordAuth`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts#Firewall.#wireRealtime`).
|
|
470
|
+
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts`).
|
|
471
471
|
|
|
472
472
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
473
|
-
avant tout appel d'horloge si le service est inactif (`auditService.ts
|
|
473
|
+
avant tout appel d'horloge si le service est inactif (`auditService.ts#AuditService.record`). Aucun objet créé,
|
|
474
474
|
aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens` n'est pas journalisé »
|
|
475
|
-
(`auditEmissionHotPath.test.ts
|
|
475
|
+
(`auditEmissionHotPath.test.ts`).
|
|
476
476
|
|
|
477
477
|
**3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
|
|
478
|
-
(`auditService.ts
|
|
478
|
+
(`auditService.ts#AuditService.record`). La latence du store n'entre jamais dans la latence de la requête.
|
|
479
479
|
|
|
480
480
|
**4. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
|
|
481
|
-
personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts
|
|
481
|
+
personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts#AuditService.subscribe`). Le pont
|
|
482
482
|
WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
|
|
483
|
-
alloué qu'au premier événement reçu (`auditBridge.ts
|
|
484
|
-
`unref` (`auditBridge.ts
|
|
483
|
+
alloué qu'au premier événement reçu (`auditBridge.ts#createAuditBridge`) ; son minuteur est armé à la demande et
|
|
484
|
+
`unref` (`auditBridge.ts#createAuditBridge`).
|
|
485
485
|
|
|
486
486
|
Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
|
|
487
|
-
(`auditBridge.ts
|
|
487
|
+
(`auditBridge.ts#createAuditBridge`), tampon plafonné à 200 événements (`auditBridge.ts#createAuditBridge`). Sous une rafale
|
|
488
488
|
d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
|
|
489
|
-
(`auditBridge.ts
|
|
489
|
+
(`auditBridge.ts#IAuditBatch.dropped`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
|
|
490
490
|
jamais faire tomber ce qu'on supervise.
|
|
491
491
|
|
|
492
492
|
## ⚙️ Configuration
|
|
493
493
|
|
|
494
|
-
Table dérivée du schéma Zod `auditSchema` (`config.ts
|
|
495
|
-
(`config.ts
|
|
494
|
+
Table dérivée du schéma Zod `auditSchema` (`config.ts#auditSchema`), rattaché à la racine sous la clé `audit`
|
|
495
|
+
(`config.ts#securityConfigSchema`).
|
|
496
496
|
|
|
497
497
|
| Option | Type | Défaut | Effet |
|
|
498
498
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
@@ -505,20 +505,20 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la
|
|
|
505
505
|
> [!WARNING]
|
|
506
506
|
> `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
|
|
507
507
|
> Les mettre à `false` ne change rien. L'immuabilité vient du **contrat** `IAuditStore`, qui n'expose
|
|
508
|
-
> ni `update` ni `delete` ciblé (`IAuditStore.ts
|
|
509
|
-
> **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts
|
|
508
|
+
> ni `update` ni `delete` ciblé (`IAuditStore.ts#IAuditStore`) ; la diffusion live est gouvernée par la
|
|
509
|
+
> **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts#AuditService.#listeners`).
|
|
510
510
|
|
|
511
511
|
### Comment `store: "auto"` décide
|
|
512
512
|
|
|
513
513
|
Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
|
|
514
|
-
enregistrés (`auditService.ts
|
|
514
|
+
enregistrés (`auditService.ts#AuditService.#build`, logique `resolveAutoStore()` dans `infra.ts#resolveAutoStore`).
|
|
515
515
|
|
|
516
516
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
517
517
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
518
518
|
3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
|
|
519
519
|
4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
|
|
520
520
|
|
|
521
|
-
La résolution finale est publiée au kernel (`auditService.ts
|
|
521
|
+
La résolution finale est publiée au kernel (`auditService.ts#AuditService.#build`), ce qui alimente l'écran des stores
|
|
522
522
|
de Studio : configuré, résolu, disponible, motif, emplacement physique.
|
|
523
523
|
|
|
524
524
|
## 🏗️ Architecture interne
|
|
@@ -542,39 +542,39 @@ sequenceDiagram
|
|
|
542
542
|
|
|
543
543
|
Quatre pièces, quatre responsabilités :
|
|
544
544
|
|
|
545
|
-
- **`recordAudit()`** (`recordAudit.ts
|
|
545
|
+
- **`recordAudit()`** (`recordAudit.ts#recordAudit`) — le point d'émission côté appelant. Une résolution par le
|
|
546
546
|
container, sur le cold-path uniquement. C'est ce qui rend l'audit **découplé** : module absent →
|
|
547
547
|
aucun effet, jamais d'exception qui remonterait dans le flux métier.
|
|
548
|
-
- **`AuditService`** (`auditService.ts
|
|
549
|
-
au container sous le nom `auditStore` (`auditService.ts
|
|
550
|
-
`IAuditSink` (`IAuditStore.ts
|
|
551
|
-
- **`IAuditStore`** (`IAuditStore.ts
|
|
548
|
+
- **`AuditService`** (`auditService.ts#AuditService.#store`) — le propriétaire. Il construit le store au boot, le pose
|
|
549
|
+
au container sous le nom `auditStore` (`auditService.ts#AuditService.#build`), arme la purge, et implémente
|
|
550
|
+
`IAuditSink` (`IAuditStore.ts#IAuditSink`).
|
|
551
|
+
- **`IAuditStore`** (`IAuditStore.ts#IAuditStore`) — le contrat de persistance : `append`, `listPage`, `gc`.
|
|
552
552
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
553
553
|
d'audit.
|
|
554
|
-
- **`createAuditBridge()`** (`auditBridge.ts
|
|
555
|
-
(`auditBridge.ts
|
|
556
|
-
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts
|
|
554
|
+
- **`createAuditBridge()`** (`auditBridge.ts#createAuditBridge`) — le pont vers le canal WS `nodefony:audit`
|
|
555
|
+
(`auditBridge.ts#SECURITY_AUDIT_CHANNEL`), enregistré comme canal **système** par le firewall (`firewall.ts#Firewall.#wireRealtime`) et donc
|
|
556
|
+
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#DEFAULT_SYSTEM_PREFIXES`).
|
|
557
557
|
|
|
558
558
|
### La lecture paginée — pourquoi un curseur et pas un décalage
|
|
559
559
|
|
|
560
560
|
Un journal reçoit des écritures **pendant** qu'on le parcourt. Un `OFFSET 200` glisserait d'une page
|
|
561
561
|
à l'autre : on reverrait des événements déjà lus, on en manquerait d'autres. Le contrat impose donc le
|
|
562
|
-
**curseur** (`IAuditStore.ts
|
|
562
|
+
**curseur** (`IAuditStore.ts#IAuditListQuery`).
|
|
563
563
|
|
|
564
564
|
Le curseur est **composite et auto-portant** : `<ts>:<id>`. Deux propriétés que les deux backends
|
|
565
565
|
tiennent identiquement :
|
|
566
566
|
|
|
567
567
|
- **ordre total** — l'horodatage porte la chronologie, l'identifiant départage les collisions à la
|
|
568
568
|
milliseconde. Sans ce tri `desc(ts), desc(id)`, trois échecs de login de la même milliseconde
|
|
569
|
-
pourraient se répéter ou disparaître (`MemoryAuditStore.ts
|
|
569
|
+
pourraient se répéter ou disparaître (`MemoryAuditStore.ts#MemoryAuditStore.listPage`, `DrizzleAuditStore.ts#DrizzleAuditStore.listPage`) ;
|
|
570
570
|
- **auto-portance** — le jeton contient tout ce qu'il faut pour se comparer, donc une page reste juste
|
|
571
|
-
même si l'événement qui l'a produite a été purgé entre-temps (`MemoryAuditStore.ts
|
|
572
|
-
`DrizzleAuditStore.ts
|
|
571
|
+
même si l'événement qui l'a produite a été purgé entre-temps (`MemoryAuditStore.ts#MemoryAuditStore.#parseCursor`,
|
|
572
|
+
`DrizzleAuditStore.ts#DrizzleAuditStore.listPage`). Un curseur réduit à un identifiant nu aurait rembobiné en silence à la
|
|
573
573
|
première page — et fait boucler la console.
|
|
574
574
|
|
|
575
575
|
Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
|
|
576
|
-
paie (`IAuditStore.ts
|
|
577
|
-
ligne de garde `limit + 1` (`DrizzleAuditStore.ts
|
|
576
|
+
paie (`IAuditStore.ts#IAuditStore.listPage`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
|
|
577
|
+
ligne de garde `limit + 1` (`DrizzleAuditStore.ts#DrizzleAuditStore.listPage`).
|
|
578
578
|
|
|
579
579
|
## Entité de persistance et dialectes pris en charge
|
|
580
580
|
|
|
@@ -599,9 +599,9 @@ garder tout ça en mémoire vive coûte cher pour un motif d'accès qui n'est pa
|
|
|
599
599
|
|
|
600
600
|
### La table `audit_event`
|
|
601
601
|
|
|
602
|
-
L'entité est déclarée en spécification logique (`auditEventEntity.ts
|
|
602
|
+
L'entité est déclarée en spécification logique (`auditEventEntity.ts#auditEventSchema`), déclinée par dialecte via
|
|
603
603
|
le `colKit` — mêmes **noms** de colonnes partout, donc un store dialect-agnostique. La forme de ligne
|
|
604
|
-
`AuditEventRow` (`auditEventEntity.ts
|
|
604
|
+
`AuditEventRow` (`auditEventEntity.ts#AuditEventRow`) est **identique** au contrat `IAuditEvent`, champ pour
|
|
605
605
|
champ : aucun mapping surprise.
|
|
606
606
|
|
|
607
607
|
| Colonne | SQLite | PostgreSQL | MySQL/MariaDB | Note |
|
|
@@ -618,37 +618,37 @@ En MySQL, toute colonne texte **indexée** (ou clé primaire) devient `varchar(5
|
|
|
618
618
|
pas indexer un `TEXT` sans longueur de préfixe. Même règle pour tout le framework, portée par le
|
|
619
619
|
`colKit` — jamais par l'entité.
|
|
620
620
|
|
|
621
|
-
Quatre index couvrent les axes de la console (`auditEventEntity.ts
|
|
621
|
+
Quatre index couvrent les axes de la console (`auditEventEntity.ts#AuditEventRow`) : `ts` pour la pagination
|
|
622
622
|
chronologique, `category`, `actor` et `requestId` pour les filtres. Ils sont lus par `drizzle-kit`
|
|
623
623
|
pour les migrations de production ; en dev et en test, le DDL dérivé les ignore — pure performance de
|
|
624
624
|
filtrage, jamais de sémantique.
|
|
625
625
|
|
|
626
626
|
L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
|
|
627
|
-
(`registerStores.ts
|
|
628
|
-
implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts
|
|
627
|
+
(`registerStores.ts#registerMongooseFrameworkStores`, entité via `registerAuditEntities()`, `auditEventEntity.ts#registerAuditEntities`). Côté
|
|
628
|
+
implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts#DrizzleAuditStore`) résout son handle de base **à chaque
|
|
629
629
|
appel**, pas à la construction : l'ordre de démarrage n'est pas garanti, et l'ORM se déconnecte au
|
|
630
630
|
`onTerminate` avant le drain des serveurs.
|
|
631
631
|
|
|
632
632
|
## 🔐 Sécurité — ce qui n'entre JAMAIS dans le journal
|
|
633
633
|
|
|
634
|
-
La règle d'or est écrite en tête du contrat (`IAuditEvent.ts
|
|
634
|
+
La règle d'or est écrite en tête du contrat (`IAuditEvent.ts#AuditCategory`) : **un secret n'entre jamais dans un
|
|
635
635
|
événement**. Ni mot de passe, ni jeton, ni cookie, ni corps de requête, ni en-têtes.
|
|
636
636
|
|
|
637
637
|
Ce que le code garantit, concrètement :
|
|
638
638
|
|
|
639
639
|
- **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
|
|
640
|
-
d'identité_ (`IAuditEvent.ts
|
|
641
|
-
les en-têtes de la requête » (`IAuditEvent.ts
|
|
642
|
-
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts
|
|
640
|
+
d'identité_ (`IAuditEvent.ts#IAuditEvent.actor`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
|
|
641
|
+
les en-têtes de la requête » (`IAuditEvent.ts#IAuditEvent.resource`).
|
|
642
|
+
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts#IAuditEventFlags`) ne porte que deux
|
|
643
643
|
booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
|
|
644
|
-
les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts
|
|
644
|
+
les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts#readAuditContext`) — la valeur n'est jamais
|
|
645
645
|
copiée.
|
|
646
646
|
- **Les émetteurs journalisent l'identifiant public, pas le secret.** Une clé d'API émet son `id`
|
|
647
647
|
public en `resource`, jamais le jeton. Prouvé explicitement : le test vérifie que la sérialisation
|
|
648
648
|
complète de l'événement **ne contient pas** le secret créé
|
|
649
|
-
(`auditEmissionHotPath.test.ts
|
|
649
|
+
(`auditEmissionHotPath.test.ts`).
|
|
650
650
|
- **Le motif reste machine.** `reason` est une valeur stable et filtrable, pas un message d'erreur
|
|
651
|
-
libre (`IAuditEvent.ts
|
|
651
|
+
libre (`IAuditEvent.ts#IAuditEvent.reason`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
|
|
652
652
|
permet de la garder **côté audit** alors que le client, lui, reçoit un message d'échec uniforme
|
|
653
653
|
(anti-énumération).
|
|
654
654
|
|
|
@@ -656,62 +656,62 @@ Deux autres propriétés de sécurité valent d'être connues :
|
|
|
656
656
|
|
|
657
657
|
- **Pas d'événement fantôme.** Révoquer la clé d'API d'autrui n'émet **rien** : l'anti-énumération
|
|
658
658
|
vaut aussi pour le journal, sinon le journal lui-même deviendrait un oracle
|
|
659
|
-
(`auditEmissionHotPath.test.ts
|
|
659
|
+
(`auditEmissionHotPath.test.ts`).
|
|
660
660
|
- **Lecture réservée aux administrateurs.** L'endpoint est gardé `ROLE_NODEFONY_ADMIN`, le canal live
|
|
661
|
-
aussi via le plancher irréductible `security:` (`frameAuthorizer.ts
|
|
661
|
+
aussi via le plancher irréductible `security:` (`frameAuthorizer.ts#SECURITY_CHANNEL_POLICY`) — un utilisateur ordinaire
|
|
662
662
|
qui tente de s'y abonner produit lui-même un `frame.denied`.
|
|
663
663
|
|
|
664
664
|
## 📜 Normes appliquées
|
|
665
665
|
|
|
666
|
-
| Exigence | Norme | Comment le code s'y conforme
|
|
667
|
-
| ----------------------------------------- | --------------------------------- |
|
|
668
|
-
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts
|
|
669
|
-
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts
|
|
670
|
-
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts
|
|
671
|
-
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts
|
|
672
|
-
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts
|
|
673
|
-
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts
|
|
674
|
-
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts
|
|
675
|
-
| Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts
|
|
666
|
+
| Exigence | Norme | Comment le code s'y conforme |
|
|
667
|
+
| ----------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
668
|
+
| Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts#Firewall.describeRoleHierarchy`) |
|
|
669
|
+
| Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts#Authorization.#auditDeny`) |
|
|
670
|
+
| Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts#AuditOutcome`) |
|
|
671
|
+
| Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts#IAuditEvent`) |
|
|
672
|
+
| Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts#IAuditStore`) |
|
|
673
|
+
| Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts#auditSchema`) |
|
|
674
|
+
| Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts#TokenService.issueTokens`) |
|
|
675
|
+
| Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts#Firewall.handleSecurity`) |
|
|
676
676
|
|
|
677
677
|
## 📡 Observabilité — Studio
|
|
678
678
|
|
|
679
|
-
L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx
|
|
679
|
+
L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx#ENDPOINT`) consomme le data plane
|
|
680
680
|
`GET /nodefony/security/api/audit/events` en pagination **serveur**. Il offre :
|
|
681
681
|
|
|
682
|
-
- un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx
|
|
682
|
+
- un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx#Audit`) ;
|
|
683
683
|
- un indicateur du **store réellement résolu** pour la brique `audit`, alimenté par la publication de
|
|
684
|
-
résolution du service (`auditService.ts
|
|
685
|
-
- un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx
|
|
684
|
+
résolution du service (`auditService.ts#AuditService.#build`) ;
|
|
685
|
+
- un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx#AuditLive`) — pensé
|
|
686
686
|
pour les pics d'activité : un journal d'audit se consulte, il ne se regarde pas défiler ;
|
|
687
687
|
- un renvoi vers la **trace de requête** quand l'événement porte un `requestId`, ce qui recolle
|
|
688
688
|
l'événement de sécurité à toute la vie de la requête.
|
|
689
689
|
|
|
690
690
|
Quand l'audit est désactivé en configuration, l'endpoint répond **503** avec un message explicite
|
|
691
|
-
(`SecurityAdminApi.ts
|
|
691
|
+
(`SecurityAdminApi.ts#createSecurityAdminApi`) et la console l'affiche tel quel plutôt qu'un tableau vide ambigu.
|
|
692
692
|
|
|
693
693
|
### Le journal alimente les webhooks
|
|
694
694
|
|
|
695
695
|
Le flux d'audit est la **source d'événements** du dispatcher de webhooks sortants : celui-ci s'abonne
|
|
696
|
-
au service au démarrage (`webhooks.ts
|
|
697
|
-
(`WebhookDispatcher.ts
|
|
696
|
+
au service au démarrage (`webhooks.ts#WebhookService.#attachDispatcher`) et filtre les actions souscrites avant de livrer
|
|
697
|
+
(`WebhookDispatcher.ts#WebhookDispatcher.onAuditEvent`). Conséquence pratique : ce qui n'est pas audité ne peut pas déclencher de
|
|
698
698
|
webhook. Détail des souscriptions, de la signature et des relivraisons → [webhooks](webhooks.md).
|
|
699
699
|
|
|
700
700
|
## ⚠️ Pièges
|
|
701
701
|
|
|
702
|
-
| Symptôme | Cause (dans le code)
|
|
703
|
-
| --------------------------------------------------- |
|
|
704
|
-
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts
|
|
705
|
-
| Un pod voit des événements, l'autre non | Store `memory` non partagé
|
|
706
|
-
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts
|
|
707
|
-
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts
|
|
708
|
-
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset
|
|
709
|
-
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`)
|
|
710
|
-
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal
|
|
711
|
-
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts
|
|
712
|
-
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts
|
|
713
|
-
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF
|
|
714
|
-
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts
|
|
702
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
703
|
+
| --------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
704
|
+
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts#MemoryAuditStore`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
|
|
705
|
+
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
706
|
+
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts#AuditService.#build`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
|
|
707
|
+
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts#toPageParams`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
708
|
+
| 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 |
|
|
709
|
+
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
710
|
+
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
711
|
+
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts#AuditService.#listeners`) | Retirer le rôle admin, ou ne pas exposer le canal |
|
|
712
|
+
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts#AuditService.record`) | Store à haute disponibilité si la conformité l'exige |
|
|
713
|
+
| 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 |
|
|
714
|
+
| Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts#createSecurityAdminApi`) | Réactiver l'audit en configuration |
|
|
715
715
|
|
|
716
716
|
## 🧪 Tests & couverture
|
|
717
717
|
|