@nodefony/security 10.0.0-alpha.9 → 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/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -3
- 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 +27 -18
- package/dist/nodefony/service/webAuthn.js +38 -31
- package/dist/nodefony/service/webhooks.js +50 -41
- 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 +1 -1
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
- 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/docs/api-keys.md +15 -15
- package/docs/audit.md +18 -13
- package/docs/authenticators.md +6 -6
- package/docs/authorization.md +15 -15
- package/docs/cors.md +6 -6
- package/docs/csrf.md +7 -7
- package/docs/external-jwt.md +17 -9
- package/docs/firewall.md +7 -7
- package/docs/headers.md +13 -13
- package/docs/index.md +3 -1
- package/docs/oauth2.md +2 -2
- package/docs/tokens.md +6 -6
- package/docs/webauthn.md +13 -13
- package/docs/webhooks.md +9 -12
- package/package.json +13 -13
|
@@ -27,8 +27,8 @@ function base32Encode(buf) {
|
|
|
27
27
|
let bits = 0;
|
|
28
28
|
let value = 0;
|
|
29
29
|
let out = "";
|
|
30
|
-
for (
|
|
31
|
-
value = value << 8 |
|
|
30
|
+
for (const byte of buf) {
|
|
31
|
+
value = value << 8 | byte;
|
|
32
32
|
bits += 8;
|
|
33
33
|
while (bits >= 5) {
|
|
34
34
|
out += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
@@ -48,9 +48,9 @@ function base32Decode(input) {
|
|
|
48
48
|
let bits = 0;
|
|
49
49
|
let value = 0;
|
|
50
50
|
const out = [];
|
|
51
|
-
for (
|
|
52
|
-
const idx = BASE32_ALPHABET.indexOf(
|
|
53
|
-
if (idx === -1) throw new Error(`base32: caractère invalide « ${
|
|
51
|
+
for (const char of clean) {
|
|
52
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
53
|
+
if (idx === -1) throw new Error(`base32: caractère invalide « ${char} »`);
|
|
54
54
|
value = value << 5 | idx;
|
|
55
55
|
bits += 5;
|
|
56
56
|
if (bits >= 8) {
|
|
@@ -85,8 +85,8 @@ function counterBuffer(counter) {
|
|
|
85
85
|
*/
|
|
86
86
|
function hotp(secret, counter, digits = TOTP_DEFAULTS.digits, algorithm = TOTP_DEFAULTS.algorithm) {
|
|
87
87
|
const hs = createHmac(hmacName(algorithm), secret).update(counterBuffer(counter)).digest();
|
|
88
|
-
const offset = hs
|
|
89
|
-
return ((
|
|
88
|
+
const offset = hs.readUInt8(hs.length - 1) & 15;
|
|
89
|
+
return ((hs.readUInt32BE(offset) & 2147483647) % 10 ** digits).toString().padStart(digits, "0");
|
|
90
90
|
}
|
|
91
91
|
/** Numéro de tranche temporelle `T` (RFC 6238 §4.2) pour un instant donné. */
|
|
92
92
|
function totpCounter(epochSec, step = TOTP_DEFAULTS.step, t0 = TOTP_DEFAULTS.t0) {
|
|
@@ -216,8 +216,8 @@ function hashRecoveryCode(code) {
|
|
|
216
216
|
function matchRecoveryCode(presented, hashes) {
|
|
217
217
|
const target = Buffer.from(hashRecoveryCode(presented));
|
|
218
218
|
let found = -1;
|
|
219
|
-
for (
|
|
220
|
-
const candidate = Buffer.from(
|
|
219
|
+
for (const [i, hash] of hashes.entries()) {
|
|
220
|
+
const candidate = Buffer.from(hash);
|
|
221
221
|
if (candidate.length === target.length && timingSafeEqual(candidate, target)) found = i;
|
|
222
222
|
}
|
|
223
223
|
return found;
|
|
@@ -78,7 +78,7 @@ var WebhookDispatcher = class {
|
|
|
78
78
|
/** Empile une livraison ; **abandonne** (best-effort) si la file est pleine. */
|
|
79
79
|
#enqueue(job) {
|
|
80
80
|
if (this.#stopped) return;
|
|
81
|
-
|
|
81
|
+
this.#queue ??= [];
|
|
82
82
|
if (this.#queue.length >= this.#deps.policy.maxQueue) {
|
|
83
83
|
this.#dropped++;
|
|
84
84
|
if (this.#dropped === 1 || this.#dropped % 100 === 0) this.#deps.log(`webhooks: file pleine (${this.#deps.policy.maxQueue}) — ${this.#dropped} livraison(s) abandonnée(s) (best-effort)`);
|
|
@@ -100,8 +100,9 @@ var WebhookDispatcher = class {
|
|
|
100
100
|
#pump() {
|
|
101
101
|
if (this.#queue === null || this.#stopped) return;
|
|
102
102
|
const max = this.#deps.policy.maxConcurrent;
|
|
103
|
-
while (this.#inFlight < max
|
|
103
|
+
while (this.#inFlight < max) {
|
|
104
104
|
const job = this.#queue.shift();
|
|
105
|
+
if (job === void 0) break;
|
|
105
106
|
this.#inFlight++;
|
|
106
107
|
this.#process(job).finally(() => {
|
|
107
108
|
this.#inFlight--;
|
|
@@ -178,7 +179,7 @@ var WebhookDispatcher = class {
|
|
|
178
179
|
/** Replanifie la livraison après backoff (repasse par la file bornée). */
|
|
179
180
|
#scheduleRetry(job) {
|
|
180
181
|
if (this.#stopped) return;
|
|
181
|
-
|
|
182
|
+
this.#timers ??= /* @__PURE__ */ new Set();
|
|
182
183
|
const cancel = this.#deps.schedule(() => {
|
|
183
184
|
this.#timers?.delete(cancel);
|
|
184
185
|
this.#enqueue({
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
* filtre qui ne rend jamais rien induit en erreur — cf `studio/.../auditModel.ts`).
|
|
19
19
|
* En câbler une = ajouter son émetteur ICI (l'émetteur), puis la métadonnée
|
|
20
20
|
* d'affichage dans `AUDIT_CATEGORIES`. Émettent réellement : auth, authz, token,
|
|
21
|
-
* session, webauthn, ws, webhook, config (`config.update`).
|
|
21
|
+
* session, webauthn, ws, webhook, config (`config.update`, `log.debug.*`).
|
|
22
|
+
*
|
|
23
|
+
* Un émetteur hors de `@nodefony/security` ne peut pas importer ce type et passe
|
|
24
|
+
* la catégorie en `string` : `tests/unit/auditCategoryEmitters.test.ts` relit
|
|
25
|
+
* les sources de tous les paquets pour refuser une catégorie hors contrat.
|
|
22
26
|
*/
|
|
23
27
|
export type AuditCategory = "auth" | "authz" | "token" | "session" | "oauth" | "webauthn" | "csrf" | "cors" | "ws" | "webhook" | "config";
|
|
24
28
|
/**
|
|
@@ -85,7 +89,7 @@ export interface IAuditEvent {
|
|
|
85
89
|
*/
|
|
86
90
|
flags?: IAuditEventFlags;
|
|
87
91
|
/** Extras applicatifs libres (anti-migration) ; absents par défaut. */
|
|
88
|
-
metadata?: Record<string, unknown
|
|
92
|
+
metadata?: Record<string, unknown> | undefined;
|
|
89
93
|
}
|
|
90
94
|
/**
|
|
91
95
|
* Champs fournis par l'**émetteur** d'un événement — l'`AuditService` y ajoute
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextType } from "@nodefony/http";
|
|
1
|
+
import type { ContextType, IFirewallGate } from "@nodefony/http";
|
|
2
2
|
import type { IAuthenticator } from "./IAuthenticator.js";
|
|
3
3
|
import type { ISecuredArea } from "./ISecuredArea.js";
|
|
4
4
|
import type { CspFragment } from "../src/csp.js";
|
|
@@ -11,7 +11,7 @@ import type { IProtectedResourceInput } from "nodefony";
|
|
|
11
11
|
* `context.security`. `handleSecurity()` (lazy, seulement si la requête est dans
|
|
12
12
|
* une zone) exécute CORS → headers → authenticators → Zero Trust → CSRF.
|
|
13
13
|
*/
|
|
14
|
-
export interface IFirewall {
|
|
14
|
+
export interface IFirewall extends IFirewallGate {
|
|
15
15
|
/** Match rapide de zone (pose `context.security`). `true` si zone capturée. */
|
|
16
16
|
isSecure(context: ContextType): boolean;
|
|
17
17
|
/** Pipeline complet d'authentification de la zone. Rejette (401/403) ou résout. */
|
|
@@ -78,11 +78,11 @@ export interface IFirewallDefensesDescription {
|
|
|
78
78
|
frameguard: "deny" | "sameorigin";
|
|
79
79
|
noSniff: boolean;
|
|
80
80
|
referrerPolicy: string;
|
|
81
|
-
coop?: string;
|
|
82
|
-
coep?: string;
|
|
83
|
-
corp?: string;
|
|
84
|
-
originAgentCluster?: boolean;
|
|
85
|
-
permissionsPolicy?: string;
|
|
81
|
+
coop?: string | undefined;
|
|
82
|
+
coep?: string | undefined;
|
|
83
|
+
corp?: string | undefined;
|
|
84
|
+
originAgentCluster?: boolean | undefined;
|
|
85
|
+
permissionsPolicy?: string | undefined;
|
|
86
86
|
};
|
|
87
87
|
rateLimit: {
|
|
88
88
|
enabled: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextType } from "@nodefony/http";
|
|
1
|
+
import type { ContextType, ISecurityZone } from "@nodefony/http";
|
|
2
2
|
/**
|
|
3
3
|
* Zone sécurisée — un pattern d'URL + la liste des authenticators à exécuter.
|
|
4
4
|
*
|
|
@@ -7,7 +7,7 @@ import type { ContextType } from "@nodefony/http";
|
|
|
7
7
|
* `defineSecurityConfig({ areas })` — patterns compilés et triés au boot, conflits
|
|
8
8
|
* détectés au boot (pas au premier match runtime).
|
|
9
9
|
*/
|
|
10
|
-
export interface ISecuredArea {
|
|
10
|
+
export interface ISecuredArea extends ISecurityZone {
|
|
11
11
|
/** Nom de la zone (`"main_api"`, `"admin"`…). */
|
|
12
12
|
readonly name: string;
|
|
13
13
|
/** Pattern d'URL compilé. */
|
|
@@ -29,7 +29,7 @@ export interface ISecuredArea {
|
|
|
29
29
|
/** Noms des authenticators à exécuter (sémantique selon {@link mode}). */
|
|
30
30
|
readonly authenticators: readonly string[];
|
|
31
31
|
/** Domaine/vhost de la zone (ex. `admin.exemple.com`). Omis = tous domaines. */
|
|
32
|
-
readonly host?: string;
|
|
32
|
+
readonly host?: string | undefined;
|
|
33
33
|
/**
|
|
34
34
|
* URI canonique de la ressource protégée par cette zone — l'**audience**
|
|
35
35
|
* qu'un jeton doit porter pour y être accepté (RFC 8707 §2).
|
|
@@ -44,7 +44,7 @@ export interface ISecuredArea {
|
|
|
44
44
|
* Omise, une zone reste parfaitement utilisable par les authenticators qui
|
|
45
45
|
* n'en ont pas besoin (session, mot de passe, jetons maison).
|
|
46
46
|
*/
|
|
47
|
-
readonly resource?: string;
|
|
47
|
+
readonly resource?: string | undefined;
|
|
48
48
|
/**
|
|
49
49
|
* Zone valable AUSSI pour le WebSocket (frames `api.request` + `subscribe`),
|
|
50
50
|
* pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS
|
|
@@ -27,7 +27,7 @@ export interface IResourcePermission {
|
|
|
27
27
|
/** Type de ressource (ex. `"repo"`, `"project"`). */
|
|
28
28
|
type: string;
|
|
29
29
|
/** Identifiants ciblés (omis = toutes les ressources de ce type). */
|
|
30
|
-
ids?: string[];
|
|
30
|
+
ids?: string[] | undefined;
|
|
31
31
|
/** Permissions accordées sur ces ressources. */
|
|
32
32
|
perms: Array<"read" | "write">;
|
|
33
33
|
}
|
|
@@ -36,9 +36,9 @@ export interface ITokenUsage {
|
|
|
36
36
|
/** Instant d'usage (epoch ms). */
|
|
37
37
|
at: number;
|
|
38
38
|
/** IP source, si disponible. */
|
|
39
|
-
ip?: string | null;
|
|
39
|
+
ip?: string | null | undefined;
|
|
40
40
|
/** User-Agent source, si disponible. */
|
|
41
|
-
userAgent?: string | null;
|
|
41
|
+
userAgent?: string | null | undefined;
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Enregistrement d'un jeton persistant — un PAT (clé API style GitHub) ou un
|
|
@@ -46,13 +46,13 @@ export interface ITotpListQuery extends IPageQuery {
|
|
|
46
46
|
/** Champs mutables d'un secret TOTP (patch partiel). */
|
|
47
47
|
export interface TotpSecretUpdate {
|
|
48
48
|
/** Confirmation de l'enrôlement (epoch ms) — passe le secret en « actif ». */
|
|
49
|
-
confirmedAt?: number | null;
|
|
49
|
+
confirmedAt?: number | null | undefined;
|
|
50
50
|
/** Stock résiduel de codes de récupération hachés (après consommation). */
|
|
51
|
-
recoveryCodes?: string[];
|
|
51
|
+
recoveryCodes?: string[] | undefined;
|
|
52
52
|
/** Dernière tranche `T` validée (anti-rejeu RFC 6238 §5.2). */
|
|
53
|
-
lastUsedStep?: number;
|
|
53
|
+
lastUsedStep?: number | undefined;
|
|
54
54
|
/** Horodatage du dernier usage réussi (epoch ms). */
|
|
55
|
-
lastUsedAt?: number;
|
|
55
|
+
lastUsedAt?: number | undefined;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Store **pluggable** du secret TOTP par utilisateur — découple le cœur du backend
|
|
@@ -28,7 +28,7 @@ export interface IWebAuthnCredentialSummary {
|
|
|
28
28
|
/** Compteur de signatures (anti-clone WebAuthn §6.1.1). */
|
|
29
29
|
readonly signCount: number;
|
|
30
30
|
/** Nom donné à l'appareil par l'utilisateur, si renseigné. */
|
|
31
|
-
readonly nickname?: string;
|
|
31
|
+
readonly nickname?: string | undefined;
|
|
32
32
|
/** Enrôlement (epoch ms). */
|
|
33
33
|
readonly createdAt: number;
|
|
34
34
|
/** Dernière authentification réussie (epoch ms), ou `null` = jamais servie. */
|
|
@@ -23,7 +23,7 @@ export declare class InvalidTargetError extends nodefonyError {
|
|
|
23
23
|
/** Code d'erreur OAuth à rendre au client (RFC 6749 §5.2 / RFC 8707 §2). */
|
|
24
24
|
readonly oauthError = "invalid_target";
|
|
25
25
|
/** Ce que le client a demandé, tel qu'il l'a écrit — pour le journal. */
|
|
26
|
-
readonly requested?: string;
|
|
26
|
+
readonly requested?: string | undefined;
|
|
27
27
|
/**
|
|
28
28
|
* @param description - raison, destinée à `error_description` (aucune fuite :
|
|
29
29
|
* elle qualifie la DEMANDE, jamais la configuration du serveur)
|
|
@@ -27,7 +27,7 @@ import { nodefonyError } from "nodefony";
|
|
|
27
27
|
*/
|
|
28
28
|
export declare class UnverifiableTokenError extends nodefonyError {
|
|
29
29
|
/** Cause technique, destinée au JOURNAL — jamais au client. */
|
|
30
|
-
readonly detail?: string;
|
|
30
|
+
readonly detail?: string | undefined;
|
|
31
31
|
/**
|
|
32
32
|
* @param detail - cause technique pour le journal ; n'apparaît jamais dans le
|
|
33
33
|
* message rendu au client
|
|
@@ -25,8 +25,9 @@ import type { IFirewallDescription, IRoleHierarchyDescription } from "../contrac
|
|
|
25
25
|
* d'identité down, câblage manquant) → log ERROR serveur + 401 générique
|
|
26
26
|
* (aucun détail ne fuite au client).
|
|
27
27
|
*
|
|
28
|
-
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC
|
|
29
|
-
*
|
|
28
|
+
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
|
|
29
|
+
* §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
|
|
30
|
+
* le défi de repli {@link FALLBACK_CHALLENGE}.
|
|
30
31
|
*
|
|
31
32
|
* CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
|
|
32
33
|
* Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
|
|
@@ -13,11 +13,11 @@ export interface ISecurityHeadersOptions {
|
|
|
13
13
|
csp: string;
|
|
14
14
|
cspNonces: boolean;
|
|
15
15
|
referrerPolicy: string;
|
|
16
|
-
coop?: string;
|
|
17
|
-
coep?: string;
|
|
18
|
-
corp?: string;
|
|
19
|
-
originAgentCluster?: boolean;
|
|
20
|
-
permissionsPolicy?: string;
|
|
16
|
+
coop?: string | undefined;
|
|
17
|
+
coep?: string | undefined;
|
|
18
|
+
corp?: string | undefined;
|
|
19
|
+
originAgentCluster?: boolean | undefined;
|
|
20
|
+
permissionsPolicy?: string | undefined;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* En-têtes de sécurité **applicatifs** de Nodefony (couche `@nodefony/security`,
|
|
@@ -15,9 +15,9 @@ export declare class SecuredArea implements ISecuredArea {
|
|
|
15
15
|
readonly stateless: boolean;
|
|
16
16
|
readonly mode: "first" | "all";
|
|
17
17
|
readonly authenticators: readonly string[];
|
|
18
|
-
readonly host?: string;
|
|
18
|
+
readonly host?: string | undefined;
|
|
19
19
|
readonly realtime: boolean;
|
|
20
|
-
readonly resource?: string;
|
|
20
|
+
readonly resource?: string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
|
|
23
23
|
* aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
|
|
@@ -14,9 +14,11 @@ import type { IToken } from "../../contracts/IToken.js";
|
|
|
14
14
|
* démarre jamais rien lui-même. L'identité est re-résolue à CHAQUE requête
|
|
15
15
|
* via {@link resolveSessionIdentity} (rôles frais, révocation immédiate).
|
|
16
16
|
*
|
|
17
|
-
* Pas de `challenge()` : une
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Pas de `challenge()` : sur une zone à session seule, le firewall pose son
|
|
18
|
+
* défi de repli `Session realm="nodefony"` (RFC 9110 §15.5.2 : tout 401 en
|
|
19
|
+
* porte un) — schéma inconnu des navigateurs, donc jamais de popup Basic ; le
|
|
20
|
+
* client web redirige vers son écran de login. Si la zone liste aussi
|
|
21
|
+
* `userpassword`, le firewall pose SON challenge.
|
|
20
22
|
*/
|
|
21
23
|
export declare class SessionAuthenticator implements IAuthenticator {
|
|
22
24
|
#private;
|
|
@@ -137,9 +137,9 @@ export interface IOAuth2ClientOptions {
|
|
|
137
137
|
* Implémentation de `fetch` à employer — la voie pour éprouver l'échange sans
|
|
138
138
|
* réseau, plutôt que de remplacer le `fetch` global du processus.
|
|
139
139
|
*/
|
|
140
|
-
readonly fetch?: typeof globalThis.fetch;
|
|
140
|
+
readonly fetch?: typeof globalThis.fetch | undefined;
|
|
141
141
|
/** Délai d'attente de l'échange, en millisecondes. */
|
|
142
|
-
readonly timeoutMs?: number;
|
|
142
|
+
readonly timeoutMs?: number | undefined;
|
|
143
143
|
}
|
|
144
144
|
/**
|
|
145
145
|
* Client d'un serveur d'autorisation donné : construit l'URL d'autorisation puis
|
|
@@ -33,7 +33,7 @@ export interface IOAuthProviderContext {
|
|
|
33
33
|
* champ, la forme serait ouverte dans le client et INATTEIGNABLE depuis une
|
|
34
34
|
* application — donc absente.
|
|
35
35
|
*/
|
|
36
|
-
readonly clientAuthMethod?: OAuth2ClientAuthMethod;
|
|
36
|
+
readonly clientAuthMethod?: OAuth2ClientAuthMethod | undefined;
|
|
37
37
|
/** URL de callback exacte (RFC 9700). */
|
|
38
38
|
readonly redirectUri: string;
|
|
39
39
|
/**
|
|
@@ -41,7 +41,7 @@ export interface IOAuthProviderContext {
|
|
|
41
41
|
* `https://kc.example/realms/app`) — `undefined` pour les fournisseurs dont
|
|
42
42
|
* l'émetteur est connu d'avance (Google) ou qui n'en publient pas (GitHub).
|
|
43
43
|
*/
|
|
44
|
-
readonly issuer?: string;
|
|
44
|
+
readonly issuer?: string | undefined;
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* Fabrique d'un fournisseur OAuth pour un nom donné.
|
|
@@ -83,11 +83,11 @@ export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean
|
|
|
83
83
|
*/
|
|
84
84
|
export interface IChannelPolicy {
|
|
85
85
|
/** Exige une connexion authentifiée (token non anonyme). */
|
|
86
|
-
readonly authenticated?: boolean;
|
|
86
|
+
readonly authenticated?: boolean | undefined;
|
|
87
87
|
/** Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles du firewall). */
|
|
88
|
-
readonly roles?: readonly string[];
|
|
88
|
+
readonly roles?: readonly string[] | undefined;
|
|
89
89
|
/** Un de ces scopes suffit (axe API : JWT/clé API ; session BFF n'en porte pas). */
|
|
90
|
-
readonly scopes?: readonly string[];
|
|
90
|
+
readonly scopes?: readonly string[] | undefined;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
93
|
* Surface consommée du `realtimeService` (miroir partiel de `RealtimeService`) —
|
|
@@ -5,9 +5,9 @@ type LogFn = (message: string, severity: string) => void;
|
|
|
5
5
|
/** Source de clé configurée (`config.jwt.keystore`). */
|
|
6
6
|
interface KeystoreSource {
|
|
7
7
|
/** JWK Set (clés privées) injecté depuis l'env — source `env` (prod). */
|
|
8
|
-
readonly keySetJson?: string;
|
|
8
|
+
readonly keySetJson?: string | undefined;
|
|
9
9
|
/** Dossier de persistance `keyset.json` — source `fichier` (opt-in dev/VPS). */
|
|
10
|
-
readonly dir?: string;
|
|
10
|
+
readonly dir?: string | undefined;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Le trousseau va-t-il partir dans l'image de conteneur ?
|
|
@@ -20,7 +20,7 @@ export interface ITrustedIssuer {
|
|
|
20
20
|
* une requête au démarrage à froid. Déclaré, il fait autorité : rien n'est
|
|
21
21
|
* découvert.
|
|
22
22
|
*/
|
|
23
|
-
jwksUri?: string;
|
|
23
|
+
jwksUri?: string | undefined;
|
|
24
24
|
/**
|
|
25
25
|
* Jeu de clés fourni LOCALEMENT — aucune requête, aucune découverte.
|
|
26
26
|
*
|
|
@@ -58,7 +58,7 @@ export interface ITrustedIssuer {
|
|
|
58
58
|
* réglé finement. La séparation entre jetons est déjà assurée par l'audience,
|
|
59
59
|
* qui, elle, n'est pas facultative.
|
|
60
60
|
*/
|
|
61
|
-
typ?: string;
|
|
61
|
+
typ?: string | undefined;
|
|
62
62
|
/** Claims dont la PRÉSENCE est exigée, en plus de `iss`/`aud`/`sub`. */
|
|
63
63
|
requiredClaims?: readonly string[];
|
|
64
64
|
}
|
|
@@ -26,3 +26,13 @@ export interface IJwtRuntime {
|
|
|
26
26
|
* propre audience (`[issuer]`).
|
|
27
27
|
*/
|
|
28
28
|
export declare function resolveJwtRuntime(jwt: ISecurityConfig["jwt"]): IJwtRuntime;
|
|
29
|
+
/**
|
|
30
|
+
* Audience inscrite dans un jeton quand aucune ressource n'est demandée : la
|
|
31
|
+
* première de la liste blanche. La liste n'est jamais vide après
|
|
32
|
+
* {@link resolveJwtRuntime} ; un runtime construit à la main qui la laisserait
|
|
33
|
+
* vide retombe sur la même règle — l'application est sa propre audience.
|
|
34
|
+
*
|
|
35
|
+
* @param runtime - paramètres JWT résolus
|
|
36
|
+
* @returns l'audience par défaut
|
|
37
|
+
*/
|
|
38
|
+
export declare function defaultAudience(runtime: IJwtRuntime): string;
|
package/docs/api-keys.md
CHANGED
|
@@ -229,7 +229,7 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
|
|
|
229
229
|
> [!WARNING]
|
|
230
230
|
> Le champ `token` de la réponse 201 est la **seule** occasion de lire le secret : il n'est pas
|
|
231
231
|
> stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:44`). Le listing ultérieur ne
|
|
232
|
-
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:
|
|
232
|
+
> renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:386`). Perdue = ré-émise.
|
|
233
233
|
|
|
234
234
|
## 🔐 Anatomie d'une clé — ce que chaque morceau paie
|
|
235
235
|
|
|
@@ -276,16 +276,16 @@ jamais par le préfixe.
|
|
|
276
276
|
|
|
277
277
|
### Émission — `ApiKeyService.createForSubject()`
|
|
278
278
|
|
|
279
|
-
`ApiKeyService.createForSubject()` (`apiKeys.ts:
|
|
279
|
+
`ApiKeyService.createForSubject()` (`apiKeys.ts:121`) est le seul chemin d'émission. Dans l'ordre :
|
|
280
280
|
|
|
281
281
|
1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
|
|
282
|
-
`apiKeys.ts:
|
|
282
|
+
`apiKeys.ts:330`).
|
|
283
283
|
2. **Validation des scopes** — tableau de chaînes non vides, dédupliquées, et **⊆ catalogue** si
|
|
284
|
-
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:
|
|
284
|
+
`allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:341`).
|
|
285
285
|
3. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
|
|
286
|
-
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:
|
|
286
|
+
sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:363`).
|
|
287
287
|
4. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
|
|
288
|
-
`#isActive()` (`apiKeys.ts:
|
|
288
|
+
`#isActive()` (`apiKeys.ts:379`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:48`).
|
|
289
289
|
5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
|
|
290
290
|
(`generateApiKey()`, `apiKeyFormat.ts:92`).
|
|
291
291
|
6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:177`).
|
|
@@ -293,7 +293,7 @@ jamais par le préfixe.
|
|
|
293
293
|
secret** (`apiKeys.ts:183-189`).
|
|
294
294
|
|
|
295
295
|
Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
|
|
296
|
-
au premier usage (`#resolveStore()`, `apiKeys.ts:
|
|
296
|
+
au premier usage (`#resolveStore()`, `apiKeys.ts:317`) — indépendant de l'ordre de boot. Store
|
|
297
297
|
absent = **503 explicite**, jamais une 500 opaque.
|
|
298
298
|
|
|
299
299
|
### Vérification — `ApiKeyAuthenticator.authenticate()`
|
|
@@ -353,7 +353,7 @@ apiKeys: {
|
|
|
353
353
|
**Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
|
|
354
354
|
en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
|
|
355
355
|
`{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
|
|
356
|
-
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:
|
|
356
|
+
l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:341`).
|
|
357
357
|
|
|
358
358
|
> [!TIP]
|
|
359
359
|
> Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
|
|
@@ -368,7 +368,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
|
|
|
368
368
|
|
|
369
369
|
**Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
|
|
370
370
|
soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure. Le motif est le
|
|
371
|
-
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:
|
|
371
|
+
**recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:48`) :
|
|
372
372
|
|
|
373
373
|
1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
|
|
374
374
|
2. Déployer le nouveau secret dans le CI.
|
|
@@ -388,8 +388,8 @@ Deux chemins, selon qui agit :
|
|
|
388
388
|
|
|
389
389
|
| Qui | Endpoint | Portée | Ancrage |
|
|
390
390
|
| ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
|
|
391
|
-
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:
|
|
392
|
-
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:
|
|
391
|
+
| Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:296`) |
|
|
392
|
+
| Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:250`) |
|
|
393
393
|
|
|
394
394
|
**Ce qu'on observe** : la révocation est **idempotente** et prend effet à la requête suivante —
|
|
395
395
|
l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:111-118`).
|
|
@@ -417,7 +417,7 @@ Trois sources, et il faut connaître les limites de chacune :
|
|
|
417
417
|
|
|
418
418
|
1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
|
|
419
419
|
/nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
|
|
420
|
-
(`apiKeys.ts:
|
|
420
|
+
(`apiKeys.ts:209`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
|
|
421
421
|
`order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
|
|
422
422
|
(`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
|
|
423
423
|
|
|
@@ -596,7 +596,7 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
|
|
|
596
596
|
| Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
|
|
597
597
|
| Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
|
|
598
598
|
| Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
|
|
599
|
-
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:
|
|
599
|
+
| Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:48`) |
|
|
600
600
|
|
|
601
601
|
## ⚡ Performance & mémoire
|
|
602
602
|
|
|
@@ -614,7 +614,7 @@ Le coût par requête authentifiée par clé est **maîtrisé par construction**
|
|
|
614
614
|
container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
|
|
615
615
|
ne paie rien si aucune clé n'est jamais présentée.
|
|
616
616
|
- **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
|
|
617
|
-
(`listPagePat()`, `apiKeys.ts:
|
|
617
|
+
(`listPagePat()`, `apiKeys.ts:209`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
|
|
618
618
|
|
|
619
619
|
Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
|
|
620
620
|
(`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
|
|
@@ -649,7 +649,7 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
|
|
|
649
649
|
| 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
|
|
650
650
|
| Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
|
|
651
651
|
| 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
|
|
652
|
-
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:
|
|
652
|
+
| 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
|
|
653
653
|
| Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:142`) | Garder le `prefix` STABLE après la première émission |
|
|
654
654
|
| Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
|
|
655
655
|
| Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
|
package/docs/audit.md
CHANGED
|
@@ -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.
|
|
@@ -317,7 +317,7 @@ anti-avalanche en cluster. Chaque
|
|
|
317
317
|
tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
|
|
318
318
|
(`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
|
|
319
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:
|
|
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
|
|
|
@@ -332,7 +332,7 @@ 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
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:
|
|
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]
|
|
@@ -357,17 +357,22 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
|
|
|
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.unverifiable` · `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
|
+
|
|
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.
|
|
371
376
|
|
|
372
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
|
|
@@ -377,7 +382,7 @@ 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
387
|
- `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
|
|
383
388
|
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
|
|
@@ -460,7 +465,7 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
460
465
|
|
|
461
466
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
462
467
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
463
|
-
(`firewall.ts:
|
|
468
|
+
(`firewall.ts:907`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:358`).
|
|
464
469
|
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
|
|
465
470
|
|
|
466
471
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
@@ -480,7 +485,7 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
|
|
|
480
485
|
Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
|
|
481
486
|
(`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
|
|
482
487
|
d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
|
|
483
|
-
(`auditBridge.ts:
|
|
488
|
+
(`auditBridge.ts:86`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
|
|
484
489
|
jamais faire tomber ce qu'on supervise.
|
|
485
490
|
|
|
486
491
|
## ⚙️ Configuration
|
|
@@ -545,7 +550,7 @@ Quatre pièces, quatre responsabilités :
|
|
|
545
550
|
**`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
|
|
546
551
|
d'audit.
|
|
547
552
|
- **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
|
|
548
|
-
(`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
|
|
549
554
|
gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
|
|
550
555
|
|
|
551
556
|
### La lecture paginée — pourquoi un curseur et pas un décalage
|
|
@@ -567,7 +572,7 @@ tiennent identiquement :
|
|
|
567
572
|
|
|
568
573
|
Le `total` est **refusable** (`withTotal: false`) : un `COUNT` filtré sur une rétention longue se
|
|
569
574
|
paie (`IAuditStore.ts:59`). `hasNext` et `nextCursor` restent fiables dans les deux cas, grâce à une
|
|
570
|
-
ligne de garde `limit + 1` (`DrizzleAuditStore.ts:
|
|
575
|
+
ligne de garde `limit + 1` (`DrizzleAuditStore.ts:225`).
|
|
571
576
|
|
|
572
577
|
## Entité de persistance et dialectes pris en charge
|
|
573
578
|
|
|
@@ -632,7 +637,7 @@ Ce que le code garantit, concrètement :
|
|
|
632
637
|
- **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
|
|
633
638
|
d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
|
|
634
639
|
les en-têtes de la requête » (`IAuditEvent.ts:87`).
|
|
635
|
-
- **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:
|
|
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.
|