@nodefony/security 10.0.0-alpha.7 → 10.0.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +4 -3
- package/dist/nodefony/service/auditService.js +3 -2
- package/dist/nodefony/service/tokenService.js +3 -2
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +3 -2
- package/dist/nodefony/service/webhooks.js +5 -3
- package/dist/nodefony/src/SecuredArea.js +7 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +23 -9
- package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/config/config.d.ts +2 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +15 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +6 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +22 -8
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +62 -60
- package/docs/audit.md +77 -78
- package/docs/authenticators.md +55 -43
- package/docs/authorization.md +128 -23
- package/docs/cors.md +29 -29
- package/docs/csrf.md +26 -26
- package/docs/external-jwt.md +4 -4
- package/docs/firewall.md +80 -29
- package/docs/headers.md +26 -26
- package/docs/oauth2.md +69 -62
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +55 -55
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +81 -81
- package/docs/webhooks.md +43 -37
- package/package.json +16 -13
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
|
|
|
77
77
|
import SecurityToken from "./nodefony/command/security-token.js";
|
|
78
78
|
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
79
79
|
import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
80
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
81
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
80
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
81
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
82
82
|
import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
|
|
83
83
|
import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
|
|
84
84
|
import "./nodefony/errors/index.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
1
|
+
import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
2
2
|
import { WeakPasswordError } from "@nodefony/user";
|
|
3
3
|
//#region nodefony/command/security-user-add.ts
|
|
4
4
|
const options = {
|
|
@@ -17,22 +17,6 @@ const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
|
|
|
17
17
|
/** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
|
|
18
18
|
const ROLE_BASE = "ROLE_USER";
|
|
19
19
|
/**
|
|
20
|
-
* La route qui échange un compte contre une SESSION — le geste qui manquait.
|
|
21
|
-
*
|
|
22
|
-
* Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
|
|
23
|
-
* 33 minutes d'un essai réel se sont passées entre un premier refus et
|
|
24
|
-
* l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
|
|
25
|
-
* suivante au moment où elle a un sens.
|
|
26
|
-
*
|
|
27
|
-
* ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
|
|
28
|
-
* framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
|
|
29
|
-
* conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
|
|
30
|
-
* exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
|
|
31
|
-
* controller : deux copies qui divergent en silence, c'est précisément ce
|
|
32
|
-
* qu'on refuse.
|
|
33
|
-
*/
|
|
34
|
-
const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
|
|
35
|
-
/**
|
|
36
20
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
37
21
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
38
22
|
* jamais de mot de passe stocké en clair).
|
|
@@ -163,4 +147,4 @@ var SecurityUserAdd = class extends Command {
|
|
|
163
147
|
}
|
|
164
148
|
};
|
|
165
149
|
//#endregion
|
|
166
|
-
export {
|
|
150
|
+
export { SecurityUserAdd as default };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
1
|
+
import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
2
2
|
import { USER_REVOKED_EVENT } from "@nodefony/user";
|
|
3
3
|
//#region nodefony/command/security-user-password.ts
|
|
4
4
|
const options = {
|
|
@@ -106,7 +106,7 @@ var SecurityUserPassword = class extends Command {
|
|
|
106
106
|
tenantId: null,
|
|
107
107
|
reason: "password_changed"
|
|
108
108
|
});
|
|
109
|
-
process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST
|
|
109
|
+
process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST ${AUTH_LOGIN_PATH}${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
|
|
110
110
|
return this;
|
|
111
111
|
}
|
|
112
112
|
};
|
|
@@ -44,6 +44,7 @@ const areaSchema = z.strictObject({
|
|
|
44
44
|
stateless: z.boolean().default(false).describe("Stratégie d'identité AU-DESSUS du protocole (HTTP reste stateless par nature). false (défaut) : la zone PEUT tenir un registre serveur — la session n'est créée qu'AU LOGIN (jamais pour un anonyme, zéro alloc), cookie opaque révocable (BFF). true : aucun registre — chaque requête porte sa preuve complète (JWT/clé API), la session est ignorée même si un cookie est présent. ⚠️ Une zone destinée à un appelant NON-NAVIGATEUR (service partenaire, script, agent) doit être `true` : laissée à `false`, elle marche à l'essai — un navigateur ou un `curl -c` renvoie le cookie posé — puis échoue chez le vrai client, qui ne stocke rien et repart anonyme à chaque requête. Le symptôme est un 401 intermittent en production, que rien dans le code ne montre."),
|
|
45
45
|
mode: z.enum(["first", "all"]).default("first").describe("Chaîne d'authenticators : 'first' = le premier qui reconnaît la requête authentifie (ex. cookie OU bearer) ; 'all' = tous doivent passer (ex. mtls+jwt zone admin)."),
|
|
46
46
|
authenticators: z.array(z.string()).default([]).describe("Authenticators de la zone (sémantique selon `mode`). Validés au boot contre le registre."),
|
|
47
|
+
roles: z.array(z.string()).default([]).describe("Rôle(s) exigés PAR DÉFAUT dans la zone, en OU (l'un suffit ; hiérarchie comprise). Vide (défaut) = la zone n'exige qu'une identité, et toute route qu'elle couvre est ouverte à n'importe quel compte authentifié — c'est un défaut OUVERT, qu'une surface d'administration ne doit pas garder. Renseigné, le rôle s'applique à toute route de la zone qui ne décide PAS elle-même de son autorisation : une route gardée (`@IsGranted`, `@RequireScope`) et le pont du plan d'administration (qui résout un rôle par point d'entrée) gardent la main — sans quoi une zone fermée à l'administrateur écraserait un point d'entrée voulu accessible à son propriétaire (`me`, `sessions/mine`). Un refus ici est un 403 (authentifié, pas autorisé), jamais un 401."),
|
|
47
48
|
entryPoint: z.string().optional().describe("Route de login/redirect si non authentifié."),
|
|
48
49
|
host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
|
|
49
50
|
resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
|
|
@@ -207,7 +208,7 @@ const totpSchema = z.strictObject({
|
|
|
207
208
|
window: z.number().int().nonnegative().default(1).describe("Tolérance de dérive d'horloge (± N pas). RFC 6238 §5.2 : « at most one time step » → 1 [défaut]. Plus = surface d'attaque accrue."),
|
|
208
209
|
recoveryCodes: z.number().int().positive().default(10).describe("Nombre de codes de récupération générés à l'activation (NIST SP 800-63B « look-up secrets », usage unique, affichés 1×)."),
|
|
209
210
|
encryptionKey: z.string().optional().describe("Clé de chiffrement AES-256-GCM du secret TOTP au repos (fournie par l'app depuis env, ≥ 32 octets après décodage). Omise = clé éphémère dérivée + WARNING (dev mono-process) ; en prod multi-pod, REQUISE (un secret 2FA déchiffrable doit l'être par tous les pods, et jamais en clair)."),
|
|
210
|
-
store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte).
|
|
211
|
+
store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle | mongoose — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). Les deux backends DURABLES le portent : sur une infra SQL comme sur une infra Mongo, `auto` trouve un store persistant. ⚠️ @nodefony/redis n'en enregistre pas : avec Redis pour seule infra, `auto` se replie sur `memory` (repli tracé au boot, et WARNING en production) — les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur.")
|
|
211
212
|
}).describe("2FA TOTP (RFC 6238) — second facteur step-up au login. Secret chiffré au repos, codes de récupération hachés.");
|
|
212
213
|
const remoteJwtAlgSchema = z.enum([
|
|
213
214
|
"RS256",
|
|
@@ -275,7 +276,7 @@ const webhooksSchema = z.strictObject({
|
|
|
275
276
|
}).describe("Webhooks sortants signés (Standard Webhooks).");
|
|
276
277
|
const auditSchema = z.strictObject({
|
|
277
278
|
enabled: z.boolean().default(true),
|
|
278
|
-
store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` =
|
|
279
|
+
store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` et `mongoose` = persistants + partagés multi-pod (auto-register par l'adapter chargé). @nodefony/redis n'en fournit pas : un journal croît sans borne et se consulte, ce n'est pas le motif d'accès d'un cache. Vocabulaire unifié : données = `store`."),
|
|
279
280
|
immutable: z.boolean().default(true).meta({
|
|
280
281
|
reserved: true,
|
|
281
282
|
description: "INERTE — l'immuabilité du journal vient du contrat `IAuditStore` (aucune méthode `update`/`delete`), pas de ce drapeau. Le passer à `false` n'ouvre aucune mutation."
|
|
@@ -336,7 +337,7 @@ const securityConfigSchema = z.strictObject({
|
|
|
336
337
|
encoders: z.record(z.string(), encoderSchema).default(() => ({ default: encoderSchema.parse({ type: "argon2id" }) })).describe("Chaîne d'encodeurs de mot de passe. 1re entrée = principal (hash à la création), suivantes = legacy en lecture seule (migration au login). Défaut : un argon2id sûr — le firewall en dérive le service `passwordEncoder`."),
|
|
337
338
|
roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
|
|
338
339
|
areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
|
|
339
|
-
realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats =
|
|
340
|
+
realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats = ROLE_NODEFONY_ADMIN, l'échelle PLATEFORME : ces canaux exposent l'état du processus, pas celui d'une application). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
|
|
340
341
|
cors: corsSchema.default(() => corsSchema.parse({})),
|
|
341
342
|
csrf: csrfSchema.default(() => csrfSchema.parse({})),
|
|
342
343
|
headers: headersSchema.default(() => headersSchema.parse({})),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
2
|
import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
|
|
3
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
3
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
//#region nodefony/service/auditService.ts
|
|
6
6
|
const serviceName = "auditService";
|
|
@@ -78,7 +78,8 @@ var AuditService = class extends Service {
|
|
|
78
78
|
available: listAuditStores(),
|
|
79
79
|
reason,
|
|
80
80
|
configPath: "security.audit.store",
|
|
81
|
-
location: readStoreLocation(this.#store)
|
|
81
|
+
location: readStoreLocation(this.#store),
|
|
82
|
+
connector: readStoreConnector(this.#store)
|
|
82
83
|
});
|
|
83
84
|
this.#gc = new GcScheduler({
|
|
84
85
|
intervalS: GC_INTERVAL_MS / 1e3,
|
|
@@ -6,7 +6,7 @@ import { recordAudit } from "../src/audit/recordAudit.js";
|
|
|
6
6
|
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
7
7
|
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
8
8
|
import { JwtKeystore } from "../src/token/JwtKeystore.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
10
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
//#region nodefony/service/tokenService.ts
|
|
12
12
|
const serviceName = "tokenService";
|
|
@@ -84,7 +84,8 @@ var TokenService = class extends Service {
|
|
|
84
84
|
available: listTokenStores(),
|
|
85
85
|
reason,
|
|
86
86
|
configPath: "security.tokenStore.store",
|
|
87
|
-
location: readStoreLocation(this.#store)
|
|
87
|
+
location: readStoreLocation(this.#store),
|
|
88
|
+
connector: readStoreConnector(this.#store)
|
|
88
89
|
});
|
|
89
90
|
this.container?.set("tokenStore", this.#store);
|
|
90
91
|
if (jwtEnabled) {
|
|
@@ -3,7 +3,7 @@ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStore
|
|
|
3
3
|
import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
4
4
|
import { deriveTotpKey } from "../src/totp/totpCipher.js";
|
|
5
5
|
import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
|
|
6
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
7
7
|
//#region nodefony/service/totp.ts
|
|
8
8
|
const serviceName = "totp";
|
|
9
9
|
function isFlushable(s) {
|
|
@@ -83,7 +83,8 @@ var TotpService = class extends Service {
|
|
|
83
83
|
available: listTotpStores(),
|
|
84
84
|
reason: "adapter posé au container (infra database déclarée)",
|
|
85
85
|
configPath: "security.totp.store",
|
|
86
|
-
location: readStoreLocation(existing)
|
|
86
|
+
location: readStoreLocation(existing),
|
|
87
|
+
connector: readStoreConnector(existing)
|
|
87
88
|
});
|
|
88
89
|
return existing;
|
|
89
90
|
}
|
|
@@ -116,7 +117,8 @@ var TotpService = class extends Service {
|
|
|
116
117
|
available: listTotpStores(),
|
|
117
118
|
reason,
|
|
118
119
|
configPath: "security.totp.store",
|
|
119
|
-
location: readStoreLocation(store)
|
|
120
|
+
location: readStoreLocation(store),
|
|
121
|
+
connector: readStoreConnector(store)
|
|
120
122
|
});
|
|
121
123
|
return store;
|
|
122
124
|
}
|
|
@@ -2,7 +2,7 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
|
2
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
3
|
import { WebAuthnError } from "../errors/WebAuthnError.js";
|
|
4
4
|
import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
5
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
5
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
7
|
//#region nodefony/service/webAuthn.ts
|
|
8
8
|
const serviceName = "webauthn";
|
|
@@ -102,7 +102,8 @@ var WebAuthnService = class extends Service {
|
|
|
102
102
|
available: listWebAuthnStores(),
|
|
103
103
|
reason,
|
|
104
104
|
configPath: "security.passkeys.store",
|
|
105
|
-
location: readStoreLocation(this.#store)
|
|
105
|
+
location: readStoreLocation(this.#store),
|
|
106
|
+
connector: readStoreConnector(this.#store)
|
|
106
107
|
});
|
|
107
108
|
if (config.passkeys.attestation !== "none") this.log(`passkeys.attestation "${config.passkeys.attestation}" — l'attestation est demandée au navigateur mais N'EST PAS VÉRIFIÉE : ni métadonnées FIDO (MDS), ni certificats racines fabricant, et l'AAGUID n'est pas conservé. Ce réglage ne tient donc pas un AAL3 régulé. Repasser à "none" si la garantie n'est pas requise.`, "WARNING");
|
|
108
109
|
if (config.passkeys.residentKey === "discouraged") this.log("passkeys.residentKey \"discouraged\" — les credentials enrôlés ne sont pas découvrables, or le login par passkey est usernameless (aucun ciblage depuis la requête, anti-énumération). Ces porteurs ne pourront pas se connecter : repasser à \"preferred\" ou \"required\".", "WARNING");
|
|
@@ -6,7 +6,7 @@ import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
|
|
|
6
6
|
import { assertPublicUrl } from "../src/net/ssrfGuard.js";
|
|
7
7
|
import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
|
|
8
8
|
import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { schemaMismatchOf } from "@nodefony/http";
|
|
@@ -146,7 +146,8 @@ var WebhookService = class extends Service {
|
|
|
146
146
|
available: listWebhookStores(),
|
|
147
147
|
reason: "adapter posé au container (infra database déclarée)",
|
|
148
148
|
configPath: "security.webhooks.store",
|
|
149
|
-
location: readStoreLocation(existing)
|
|
149
|
+
location: readStoreLocation(existing),
|
|
150
|
+
connector: readStoreConnector(existing)
|
|
150
151
|
});
|
|
151
152
|
return existing;
|
|
152
153
|
}
|
|
@@ -179,7 +180,8 @@ var WebhookService = class extends Service {
|
|
|
179
180
|
available: listWebhookStores(),
|
|
180
181
|
reason,
|
|
181
182
|
configPath: "security.webhooks.store",
|
|
182
|
-
location: readStoreLocation(store)
|
|
183
|
+
location: readStoreLocation(store),
|
|
184
|
+
connector: readStoreConnector(store)
|
|
183
185
|
});
|
|
184
186
|
return store;
|
|
185
187
|
}
|
|
@@ -16,6 +16,12 @@ var SecuredArea = class {
|
|
|
16
16
|
host;
|
|
17
17
|
realtime;
|
|
18
18
|
resource;
|
|
19
|
+
/**
|
|
20
|
+
* Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
|
|
21
|
+
* aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
|
|
22
|
+
* et aucune zone ne porte de tableau qu'elle n'utilise pas.
|
|
23
|
+
*/
|
|
24
|
+
roles;
|
|
19
25
|
constructor(name, config) {
|
|
20
26
|
this.name = name;
|
|
21
27
|
this.pattern = new RegExp(config.pattern, "u");
|
|
@@ -26,6 +32,7 @@ var SecuredArea = class {
|
|
|
26
32
|
this.host = config.host;
|
|
27
33
|
this.realtime = config.realtime;
|
|
28
34
|
this.resource = config.resource;
|
|
35
|
+
this.roles = config.roles.length > 0 ? config.roles : null;
|
|
29
36
|
}
|
|
30
37
|
/**
|
|
31
38
|
* Cœur du match — pathname (+ host éventuel) déjà extraits, SANS `context`.
|
|
@@ -2,13 +2,26 @@ import { NODEFONY_CHANNEL_NAMESPACE, PLATFORM_CHANNELS, PLATFORM_INBOUND, starts
|
|
|
2
2
|
//#region nodefony/src/realtime/frameAuthorizer.ts
|
|
3
3
|
/**
|
|
4
4
|
* Politique système par défaut des canaux d'**introspection serveur** : réservés
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* à l'exploitant de l'INSTANCE, pas à l'administrateur d'une application.
|
|
6
|
+
*
|
|
7
|
+
* Ce que ces canaux exposent est l'état du **processus** — journaux, base,
|
|
8
|
+
* métriques, supervision, et jusqu'au déclenchement d'un ramasse-miettes
|
|
9
|
+
* bloquant. C'est l'échelle PLATEFORME, celle que le préfixe `ROLE_NODEFONY_*`
|
|
10
|
+
* désigne : le seul rôle qui traverse l'isolation entre organisations
|
|
11
|
+
* clientes. Un `ROLE_ADMIN` est, lui, destiné à être scopé à une organisation ;
|
|
12
|
+
* le jour où plusieurs partagent une instance, il aurait lu la supervision du
|
|
13
|
+
* processus qui les héberge toutes.
|
|
14
|
+
*
|
|
15
|
+
* Le canal d'audit ({@link SECURITY_CHANNEL_POLICY}) partage désormais cette
|
|
16
|
+
* échelle — il ne se distingue plus par le rôle, mais par ce qu'il contient.
|
|
17
|
+
*
|
|
18
|
+
* Surchargeable finement par `realtimeChannels` : une application qui veut
|
|
19
|
+
* ouvrir un de ces canaux à un rôle d'organisation le DÉCLARE, elle ne l'hérite
|
|
20
|
+
* pas.
|
|
8
21
|
*/
|
|
9
22
|
const SYSTEM_CHANNEL_POLICY = {
|
|
10
23
|
authenticated: true,
|
|
11
|
-
roles: ["
|
|
24
|
+
roles: ["ROLE_NODEFONY_ADMIN"]
|
|
12
25
|
};
|
|
13
26
|
/**
|
|
14
27
|
* Namespace d'introspection serveur (observabilité) — s'y abonner expose l'état
|
|
@@ -24,11 +37,12 @@ const SYSTEM_CHANNEL_POLICY = {
|
|
|
24
37
|
*/
|
|
25
38
|
const DEFAULT_SYSTEM_PREFIXES = [NODEFONY_CHANNEL_NAMESPACE];
|
|
26
39
|
/**
|
|
27
|
-
* Plancher des canaux de **sécurité** (`nodefony:audit
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
40
|
+
* Plancher des canaux de **sécurité** (`nodefony:audit`) : réservé à l'exploitant
|
|
41
|
+
* de l'instance (`ROLE_NODEFONY_ADMIN`). Même échelle que
|
|
42
|
+
* {@link SYSTEM_CHANNEL_POLICY} — le journal d'audit du pod n'est pas plus
|
|
43
|
+
* exposé que sa supervision, et aucun des deux ne se lit avec un rôle
|
|
44
|
+
* d'organisation. Cohérent avec le plan d'administration HTTP de l'audit
|
|
45
|
+
* (`SecurityAdminApi`, même rôle).
|
|
32
46
|
*
|
|
33
47
|
* Multi-tenant (futur) : `nodefony:audit` reste un canal **plateforme** (pod),
|
|
34
48
|
* jamais exposé à un user tenant ; l'événement portera le `tenantId` (via l'ALS)
|
|
@@ -17,6 +17,14 @@ function matchesTokenQuery(record, query, now) {
|
|
|
17
17
|
return true;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
+
* Copie PROFONDE d'un record lu : `scopes`, `audience`, `resources`, `cnf` et
|
|
21
|
+
* `metadata` sont mutables — rendre la référence laisserait l'appelant muter le
|
|
22
|
+
* store en place, ce que les backends persistants rendent impossible.
|
|
23
|
+
*/
|
|
24
|
+
function cloneOrNull(record) {
|
|
25
|
+
return record ? structuredClone(record) : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
20
28
|
* Store de jetons **en mémoire** — implémentation de référence d'{@link ITokenStore}.
|
|
21
29
|
*
|
|
22
30
|
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
@@ -59,6 +67,11 @@ var MemoryTokenStore = class {
|
|
|
59
67
|
this.#retentionRevokedMs = retentionRevokedMs;
|
|
60
68
|
}
|
|
61
69
|
put(record) {
|
|
70
|
+
const owner = this.#idByHash.get(record.secretHash);
|
|
71
|
+
if (owner !== void 0 && owner !== record.id) return Promise.reject(/* @__PURE__ */ new Error(`secretHash déjà porté par le jeton ${owner} : un secret ne désigne qu'un jeton`));
|
|
72
|
+
const previous = this.#byId.get(record.id);
|
|
73
|
+
if (previous) this.#removeRecord(record.id, previous);
|
|
74
|
+
record = structuredClone(record);
|
|
62
75
|
this.#byId.set(record.id, record);
|
|
63
76
|
this.#idByHash.set(record.secretHash, record.id);
|
|
64
77
|
this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
|
|
@@ -66,11 +79,11 @@ var MemoryTokenStore = class {
|
|
|
66
79
|
return Promise.resolve();
|
|
67
80
|
}
|
|
68
81
|
findById(id) {
|
|
69
|
-
return Promise.resolve(this.#byId.get(id)
|
|
82
|
+
return Promise.resolve(cloneOrNull(this.#byId.get(id)));
|
|
70
83
|
}
|
|
71
84
|
findByHash(secretHash) {
|
|
72
85
|
const id = this.#idByHash.get(secretHash);
|
|
73
|
-
return Promise.resolve(id !== void 0 ? this.#byId.get(id)
|
|
86
|
+
return Promise.resolve(id !== void 0 ? cloneOrNull(this.#byId.get(id)) : null);
|
|
74
87
|
}
|
|
75
88
|
findBySubject(subjectId) {
|
|
76
89
|
const ids = this.#idsBySubject.get(subjectId);
|
|
@@ -78,12 +91,12 @@ var MemoryTokenStore = class {
|
|
|
78
91
|
const out = [];
|
|
79
92
|
for (const id of ids) {
|
|
80
93
|
const record = this.#byId.get(id);
|
|
81
|
-
if (record) out.push(record);
|
|
94
|
+
if (record) out.push(structuredClone(record));
|
|
82
95
|
}
|
|
83
96
|
return Promise.resolve(out);
|
|
84
97
|
}
|
|
85
98
|
listAll() {
|
|
86
|
-
return Promise.resolve([...this.#byId.values()]);
|
|
99
|
+
return Promise.resolve([...this.#byId.values()].map((r) => structuredClone(r)));
|
|
87
100
|
}
|
|
88
101
|
listPage(query) {
|
|
89
102
|
assertPageQuery(query, "offset");
|
|
@@ -93,7 +106,7 @@ var MemoryTokenStore = class {
|
|
|
93
106
|
const filtered = [...this.#byId.values()].filter((r) => matchesTokenQuery(r, query, now));
|
|
94
107
|
const order = pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER);
|
|
95
108
|
filtered.sort(compareByOrder(order, (r, field) => r[field]));
|
|
96
|
-
const items = filtered.slice(offset, offset + limit);
|
|
109
|
+
const items = filtered.slice(offset, offset + limit).map((r) => structuredClone(r));
|
|
97
110
|
const total = query.withTotal === false ? void 0 : filtered.length;
|
|
98
111
|
return Promise.resolve({
|
|
99
112
|
items,
|
|
@@ -37,21 +37,33 @@ function matchesTotpQuery(secret, query) {
|
|
|
37
37
|
* un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
|
|
38
38
|
* (2FA activé), jamais sur le hot path par requête.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* Copie d'un secret : `recoveryCodes` est un tableau mutable — rendre ou garder
|
|
42
|
+
* la référence laisserait l'appelant rendre un code de secours réutilisable, ou
|
|
43
|
+
* en effacer un, sans passer par le store.
|
|
44
|
+
*/
|
|
45
|
+
function cloneSecret(s) {
|
|
46
|
+
return {
|
|
47
|
+
...s,
|
|
48
|
+
recoveryCodes: [...s.recoveryCodes]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
40
51
|
var MemoryTotpSecretStore = class {
|
|
41
52
|
/** userId → secret (source de vérité). */
|
|
42
53
|
#byUser = /* @__PURE__ */ new Map();
|
|
43
54
|
findByUser(userId) {
|
|
44
|
-
|
|
55
|
+
const secret = this.#byUser.get(userId);
|
|
56
|
+
return Promise.resolve(secret ? cloneSecret(secret) : null);
|
|
45
57
|
}
|
|
46
58
|
save(secret) {
|
|
47
|
-
this.#byUser.set(secret.userId, secret);
|
|
59
|
+
this.#byUser.set(secret.userId, cloneSecret(secret));
|
|
48
60
|
return Promise.resolve();
|
|
49
61
|
}
|
|
50
62
|
update(userId, patch) {
|
|
51
63
|
const secret = this.#byUser.get(userId);
|
|
52
64
|
if (secret) {
|
|
53
65
|
if (patch.confirmedAt !== void 0) secret.confirmedAt = patch.confirmedAt;
|
|
54
|
-
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = patch.recoveryCodes;
|
|
66
|
+
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = [...patch.recoveryCodes];
|
|
55
67
|
if (patch.lastUsedStep !== void 0) secret.lastUsedStep = patch.lastUsedStep;
|
|
56
68
|
if (patch.lastUsedAt !== void 0) secret.lastUsedAt = patch.lastUsedAt;
|
|
57
69
|
}
|
|
@@ -18,13 +18,25 @@ function toWebAuthnSummary(c) {
|
|
|
18
18
|
...c.nickname !== void 0 ? { nickname: c.nickname } : {}
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Copie d'une passkey : `transports` est un tableau mutable — rendre ou garder
|
|
23
|
+
* la référence laisserait l'appelant muter le store en place, ce que les
|
|
24
|
+
* backends persistants rendent impossible par sérialisation.
|
|
25
|
+
*/
|
|
26
|
+
function cloneCredential(c) {
|
|
27
|
+
return {
|
|
28
|
+
...c,
|
|
29
|
+
transports: [...c.transports]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
21
32
|
var MemoryWebAuthnCredentialStore = class {
|
|
22
33
|
/** id (base64url) → credential (source de vérité). */
|
|
23
34
|
#byId = /* @__PURE__ */ new Map();
|
|
24
35
|
/** userId → ids (allowCredentials + « mes appareils »). */
|
|
25
36
|
#idsByUser = /* @__PURE__ */ new Map();
|
|
26
37
|
findById(credentialId) {
|
|
27
|
-
|
|
38
|
+
const cred = this.#byId.get(credentialId);
|
|
39
|
+
return Promise.resolve(cred ? cloneCredential(cred) : null);
|
|
28
40
|
}
|
|
29
41
|
findByUser(userId) {
|
|
30
42
|
const ids = this.#idsByUser.get(userId);
|
|
@@ -32,7 +44,7 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
32
44
|
const out = [];
|
|
33
45
|
for (const id of ids) {
|
|
34
46
|
const cred = this.#byId.get(id);
|
|
35
|
-
if (cred) out.push(cred);
|
|
47
|
+
if (cred) out.push(cloneCredential(cred));
|
|
36
48
|
}
|
|
37
49
|
return Promise.resolve(out);
|
|
38
50
|
}
|
|
@@ -40,7 +52,9 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
40
52
|
return Promise.resolve(this.#idsByUser.get(userId)?.size ?? 0);
|
|
41
53
|
}
|
|
42
54
|
save(credential) {
|
|
43
|
-
this.#byId.
|
|
55
|
+
const previous = this.#byId.get(credential.id);
|
|
56
|
+
if (previous && previous.userId !== credential.userId) this.#unindex(previous.userId, credential.id);
|
|
57
|
+
this.#byId.set(credential.id, cloneCredential(credential));
|
|
44
58
|
let set = this.#idsByUser.get(credential.userId);
|
|
45
59
|
if (!set) {
|
|
46
60
|
set = /* @__PURE__ */ new Set();
|
|
@@ -63,12 +77,16 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
63
77
|
const cred = this.#byId.get(credentialId);
|
|
64
78
|
if (!cred) return Promise.resolve();
|
|
65
79
|
this.#byId.delete(credentialId);
|
|
66
|
-
|
|
80
|
+
this.#unindex(cred.userId, credentialId);
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
/** Retire un id de l'index d'un porteur ; l'entrée vide disparaît. */
|
|
84
|
+
#unindex(userId, credentialId) {
|
|
85
|
+
const set = this.#idsByUser.get(userId);
|
|
67
86
|
if (set) {
|
|
68
87
|
set.delete(credentialId);
|
|
69
|
-
if (set.size === 0) this.#idsByUser.delete(
|
|
88
|
+
if (set.size === 0) this.#idsByUser.delete(userId);
|
|
70
89
|
}
|
|
71
|
-
return Promise.resolve();
|
|
72
90
|
}
|
|
73
91
|
/** Credentials filtrés, dans l'ordre contractuel (createdAt DESC, id ASC). */
|
|
74
92
|
#filtered(query) {
|
|
@@ -27,6 +27,18 @@ function matchesWebhookQuery(e, query) {
|
|
|
27
27
|
* Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
|
|
28
28
|
* store détient la vérité, un consommateur ne peut pas muter un record en place.
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* Copie PROFONDE d'un endpoint : `events` et `metadata` sont des structures
|
|
32
|
+
* mutables — une copie de surface laisserait l'appelant muter le store en place,
|
|
33
|
+
* ce que les backends persistants rendent impossible par sérialisation.
|
|
34
|
+
*/
|
|
35
|
+
function cloneEndpoint(e) {
|
|
36
|
+
return {
|
|
37
|
+
...e,
|
|
38
|
+
events: [...e.events],
|
|
39
|
+
metadata: structuredClone(e.metadata)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
30
42
|
var MemoryWebhookStore = class {
|
|
31
43
|
/**
|
|
32
44
|
* {@inheritDoc IWebhookStore.sortableFields}
|
|
@@ -37,25 +49,25 @@ var MemoryWebhookStore = class {
|
|
|
37
49
|
sortableFields = WEBHOOK_SORTABLE_FIELDS;
|
|
38
50
|
#byId = /* @__PURE__ */ new Map();
|
|
39
51
|
async save(endpoint) {
|
|
40
|
-
this.#byId.set(endpoint.id,
|
|
52
|
+
this.#byId.set(endpoint.id, cloneEndpoint(endpoint));
|
|
41
53
|
}
|
|
42
54
|
async findById(id) {
|
|
43
55
|
const found = this.#byId.get(id);
|
|
44
|
-
return found ?
|
|
56
|
+
return found ? cloneEndpoint(found) : null;
|
|
45
57
|
}
|
|
46
58
|
async update(id, patch) {
|
|
47
59
|
const current = this.#byId.get(id);
|
|
48
60
|
if (!current) return;
|
|
49
|
-
this.#byId.set(id, {
|
|
61
|
+
this.#byId.set(id, cloneEndpoint({
|
|
50
62
|
...current,
|
|
51
63
|
...patch
|
|
52
|
-
});
|
|
64
|
+
}));
|
|
53
65
|
}
|
|
54
66
|
async delete(id) {
|
|
55
67
|
this.#byId.delete(id);
|
|
56
68
|
}
|
|
57
69
|
async listAll() {
|
|
58
|
-
return [...this.#byId.values()].map(
|
|
70
|
+
return [...this.#byId.values()].map(cloneEndpoint);
|
|
59
71
|
}
|
|
60
72
|
async listPage(query) {
|
|
61
73
|
assertPageQuery(query, "offset");
|
|
@@ -64,11 +76,7 @@ var MemoryWebhookStore = class {
|
|
|
64
76
|
const filtered = [...this.#byId.values()].filter((e) => matchesWebhookQuery(e, query));
|
|
65
77
|
const order = pickOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
|
|
66
78
|
filtered.sort(compareByOrder(order, (e, field) => e[field]));
|
|
67
|
-
const items = filtered.slice(offset, offset + limit).map(
|
|
68
|
-
...e,
|
|
69
|
-
events: [...e.events],
|
|
70
|
-
metadata: { ...e.metadata }
|
|
71
|
-
}));
|
|
79
|
+
const items = filtered.slice(offset, offset + limit).map(cloneEndpoint);
|
|
72
80
|
return {
|
|
73
81
|
items,
|
|
74
82
|
total: query.withTotal === false ? void 0 : filtered.length,
|
|
@@ -1,20 +1,4 @@
|
|
|
1
1
|
import { CliKernel, Command } from "nodefony";
|
|
2
|
-
/**
|
|
3
|
-
* La route qui échange un compte contre une SESSION — le geste qui manquait.
|
|
4
|
-
*
|
|
5
|
-
* Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
|
|
6
|
-
* 33 minutes d'un essai réel se sont passées entre un premier refus et
|
|
7
|
-
* l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
|
|
8
|
-
* suivante au moment où elle a un sens.
|
|
9
|
-
*
|
|
10
|
-
* ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
|
|
11
|
-
* framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
|
|
12
|
-
* conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
|
|
13
|
-
* exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
|
|
14
|
-
* controller : deux copies qui divergent en silence, c'est précisément ce
|
|
15
|
-
* qu'on refuse.
|
|
16
|
-
*/
|
|
17
|
-
export declare const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
|
|
18
2
|
/**
|
|
19
3
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
20
4
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
@@ -8,6 +8,7 @@ declare const areaSchema: z.ZodObject<{
|
|
|
8
8
|
first: "first";
|
|
9
9
|
}>>;
|
|
10
10
|
authenticators: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
11
|
+
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
11
12
|
entryPoint: z.ZodOptional<z.ZodString>;
|
|
12
13
|
host: z.ZodOptional<z.ZodString>;
|
|
13
14
|
resource: z.ZodOptional<z.ZodString>;
|
|
@@ -34,6 +35,7 @@ export declare const securityConfigSchema: z.ZodObject<{
|
|
|
34
35
|
first: "first";
|
|
35
36
|
}>>;
|
|
36
37
|
authenticators: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
38
|
+
roles: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
37
39
|
entryPoint: z.ZodOptional<z.ZodString>;
|
|
38
40
|
host: z.ZodOptional<z.ZodString>;
|
|
39
41
|
resource: z.ZodOptional<z.ZodString>;
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
* Sous-système de sécurité concerné — axe de filtrage principal de la console
|
|
13
13
|
* d'audit (Studio P6.15). Fermé : la liste des sous-systèmes est stable.
|
|
14
14
|
*
|
|
15
|
-
* ⚠️
|
|
15
|
+
* ⚠️ Trois catégories sont RÉSERVÉES — déclarées au contrat mais **sans émetteur à
|
|
16
16
|
* ce jour** (aucun `record({ category: … })` ne les produit) : `oauth`, `csrf`,
|
|
17
|
-
* `cors
|
|
17
|
+
* `cors`. La console Studio ne propose donc PAS de filtre pour elles (un
|
|
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.
|
|
21
|
+
* session, webauthn, ws, webhook, config (`config.update`).
|
|
22
22
|
*/
|
|
23
23
|
export type AuditCategory = "auth" | "authz" | "token" | "session" | "oauth" | "webauthn" | "csrf" | "cors" | "ws" | "webhook" | "config";
|
|
24
24
|
/**
|
|
@@ -52,6 +52,21 @@ export interface ISecuredArea {
|
|
|
52
52
|
* verrou WS consulte la même zone que HTTP (invariant `api.request` ≤ `GET`).
|
|
53
53
|
*/
|
|
54
54
|
readonly realtime: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Rôles exigés PAR DÉFAUT dans la zone — en OU, hiérarchie comprise —, ou
|
|
57
|
+
* `null` quand la zone n'exige qu'une identité.
|
|
58
|
+
*
|
|
59
|
+
* C'est le **filet** d'une surface d'administration : une route que personne
|
|
60
|
+
* n'a pensé à garder hérite du rôle de sa zone, au lieu d'être ouverte à
|
|
61
|
+
* n'importe quel compte authentifié. Une route qui décide elle-même de son
|
|
62
|
+
* autorisation — garde déclarée, ou pont du plan d'administration qui résout
|
|
63
|
+
* un rôle par point d'entrée — n'en dépend pas : c'est ce qui laisse exister
|
|
64
|
+
* un point d'entrée ouvert à son propriétaire (`me`, `sessions/mine`) dans
|
|
65
|
+
* une zone par ailleurs fermée.
|
|
66
|
+
*
|
|
67
|
+
* Un refus est un **403** (authentifié mais pas autorisé), jamais un 401.
|
|
68
|
+
*/
|
|
69
|
+
readonly roles: readonly string[] | null;
|
|
55
70
|
/** La requête tombe-t-elle dans cette zone ? (pattern + host éventuel). */
|
|
56
71
|
match(context: ContextType): boolean;
|
|
57
72
|
}
|