@nodefony/security 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { messageNonRestreint, modeNonRestreint, readIfPresentSync, writeSecretSync } from "../src/token/secretFile.js";
|
|
2
|
+
import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
//#region nodefony/command/security-token.ts
|
|
7
|
+
const options = {
|
|
8
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
9
|
+
showBanner: false,
|
|
10
|
+
kernelEvent: "onReady",
|
|
11
|
+
quietBoot: true
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* La table des agents, leurs emplacements de secret et la façon de leur
|
|
15
|
+
* déclarer la porte vivent dans le CŒUR (`nodefony/cli/agentTargets`) : ce sont
|
|
16
|
+
* `ai:mcp` et `create app` — des commandes du cœur — qui déclarent la porte,
|
|
17
|
+
* quand cette commande-ci pose le jeton. Deux tables recopiées auraient divergé
|
|
18
|
+
* au premier agent ajouté d'un seul côté, et la divergence se serait vue chez
|
|
19
|
+
* l'utilisateur, sous la forme d'un agent servi par l'une et ignoré par l'autre.
|
|
20
|
+
*/
|
|
21
|
+
/** Plafond d'une durée demandée en ligne de commande : 30 jours. */
|
|
22
|
+
const TTL_MAX_MINUTES = 43200;
|
|
23
|
+
/**
|
|
24
|
+
* Traduit `--ttl` en secondes, ou rend l'erreur à afficher.
|
|
25
|
+
*
|
|
26
|
+
* Exportée pour être ÉPROUVÉE : c'est une fonction pure dont chaque verdict est
|
|
27
|
+
* binaire, et dont l'échec — une durée acceptée alors qu'elle est aberrante —
|
|
28
|
+
* ne se verrait qu'au moment où un jeton refuse de mourir.
|
|
29
|
+
*
|
|
30
|
+
* @param raw - la valeur telle que tapée, ou rien
|
|
31
|
+
* @returns les secondes, `undefined` si rien n'est demandé, une `Error` sinon
|
|
32
|
+
*/
|
|
33
|
+
function ttlSeconds(raw) {
|
|
34
|
+
if (raw === void 0) return void 0;
|
|
35
|
+
const minutes = Number.parseInt(raw, 10);
|
|
36
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return /* @__PURE__ */ new Error(`--ttl attend un nombre de MINUTES supérieur à zéro (reçu « ${raw} »)`);
|
|
37
|
+
if (minutes > TTL_MAX_MINUTES) return /* @__PURE__ */ new Error(`--ttl est borné à ${TTL_MAX_MINUTES} minutes (30 jours) — un jeton posé dans un fichier est une clé, et une clé se remplace`);
|
|
38
|
+
return minutes * 60;
|
|
39
|
+
}
|
|
40
|
+
const GREEN = "\x1B[32m";
|
|
41
|
+
const YELLOW = "\x1B[33m";
|
|
42
|
+
const DIM = "\x1B[2m";
|
|
43
|
+
const BOLD = "\x1B[1m";
|
|
44
|
+
const RESET = "\x1B[0m";
|
|
45
|
+
/**
|
|
46
|
+
* `nodefony security:token` — émet un jeton d'accès pour une porte de cette
|
|
47
|
+
* application (la porte MCP par défaut).
|
|
48
|
+
*
|
|
49
|
+
* **Pourquoi une commande, et pas un `curl`.** Le jeton s'obtenait par un appel
|
|
50
|
+
* au grant : trouver l'URL, composer un JSON, y mettre un mot de passe en clair
|
|
51
|
+
* dans l'historique du shell, et surtout AVOIR un serveur en marche. Personne ne
|
|
52
|
+
* fait ça deux fois. Ici l'application SIGNE elle-même — elle possède la clé —
|
|
53
|
+
* donc : pas de serveur, pas de mot de passe, pas de réseau.
|
|
54
|
+
*
|
|
55
|
+
* **L'audience est celle de la porte, et ce n'est pas un détail** (RFC 8707) :
|
|
56
|
+
* un jeton d'audience différente est refusé, à juste titre — c'est toute la
|
|
57
|
+
* raison d'être de la liaison d'audience. La commande la vise d'elle-même,
|
|
58
|
+
* `--resource` ne sert qu'à en viser une autre.
|
|
59
|
+
*
|
|
60
|
+
* Suit `security:secrets` : `--write` pose la valeur là où l'AGENT la lit
|
|
61
|
+
* (gitignoré), jamais dans le `.env` commité, et ne remplace jamais une valeur
|
|
62
|
+
* existante — une rotation est un geste explicite.
|
|
63
|
+
*/
|
|
64
|
+
var SecurityToken = class extends Command {
|
|
65
|
+
constructor(cli) {
|
|
66
|
+
super("security:token", `émet un jeton d'accès pour la porte MCP`, cli, options);
|
|
67
|
+
this.addArgument("[identifier]", "compte porteur du jeton (défaut : admin)");
|
|
68
|
+
this.addOption("-s, --scope <scopes>", `scopes demandés, séparés par des espaces (défaut : « ${ADMIN_SCOPE_READ} » ; ajouter « ${ADMIN_SCOPE_WRITE} » pour les mutations)`);
|
|
69
|
+
this.addOption("-r, --resource <uri>", "audience visée (défaut : la porte MCP de cette application)");
|
|
70
|
+
this.addOption("-a, --agent <noms>", "agents à servir, séparés par des virgules (défaut : ceux détectés ; « none » pour aucun)");
|
|
71
|
+
this.addOption("-t, --ttl <duree>", "durée de validité, en minutes (défaut : celle de la config, 15 min)");
|
|
72
|
+
this.addOption("-w, --write", `pose ${MCP_TOKEN_ENV} dans la configuration des agents présents`);
|
|
73
|
+
this.addOption("-j, --json", "sortie JSON (scripts/CI)");
|
|
74
|
+
}
|
|
75
|
+
/** Racine du projet (le kernel la connaît ; repli sur le cwd). */
|
|
76
|
+
#root() {
|
|
77
|
+
return this.kernel?.path ?? process.cwd();
|
|
78
|
+
}
|
|
79
|
+
/** Ce fichier est-il couvert par un `.gitignore` ? */
|
|
80
|
+
#gitIgnored(file) {
|
|
81
|
+
try {
|
|
82
|
+
return spawnSync("git", [
|
|
83
|
+
"check-ignore",
|
|
84
|
+
"-q",
|
|
85
|
+
file
|
|
86
|
+
], {
|
|
87
|
+
cwd: this.#root(),
|
|
88
|
+
stdio: "ignore"
|
|
89
|
+
}).status === 0;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Ce fichier est-il SUIVI par git ? Un secret n'entre pas dans un suivi. */
|
|
95
|
+
#tracked(file) {
|
|
96
|
+
try {
|
|
97
|
+
return spawnSync("git", [
|
|
98
|
+
"ls-files",
|
|
99
|
+
"--error-unmatch",
|
|
100
|
+
file
|
|
101
|
+
], {
|
|
102
|
+
cwd: this.#root(),
|
|
103
|
+
stdio: "ignore"
|
|
104
|
+
}).status === 0;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Racine où vit la configuration d'une cible (projet, ou dossier maison).
|
|
111
|
+
*
|
|
112
|
+
* La résolution elle-même vit dans le cœur, avec la table : recopiée ici,
|
|
113
|
+
* elle aurait cessé d'honorer `CODEX_HOME`/`VIBE_HOME` le jour où l'une des
|
|
114
|
+
* deux copies aurait bougé — et le symptôme aurait été un jeton posé dans un
|
|
115
|
+
* dossier que l'agent ne lit pas, donc un 401 qui accuse le jeton.
|
|
116
|
+
*/
|
|
117
|
+
#rootOf(target) {
|
|
118
|
+
return agentRoot(target, { projectRoot: this.#root() });
|
|
119
|
+
}
|
|
120
|
+
/** Contenu du fichier d'une cible, "" s'il n'existe pas. */
|
|
121
|
+
#contentOf(target) {
|
|
122
|
+
try {
|
|
123
|
+
const abs = path.resolve(this.#rootOf(target), target.file);
|
|
124
|
+
return readIfPresentSync(abs) ?? "";
|
|
125
|
+
} catch {
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Agents dont la présence est CONSTATÉE — on ne crée pas la configuration
|
|
131
|
+
* d'un outil que personne n'utilise ici.
|
|
132
|
+
*/
|
|
133
|
+
#agentsPresents() {
|
|
134
|
+
return agentsPresents({
|
|
135
|
+
projectRoot: this.#root(),
|
|
136
|
+
exists: existsSync
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Pose le jeton dans la configuration des agents PRÉSENTS dans ce projet.
|
|
141
|
+
*
|
|
142
|
+
* Un agent n'est servi que si son marqueur existe : on ne crée pas la
|
|
143
|
+
* configuration d'un outil que personne n'utilise ici. Et jamais dans un
|
|
144
|
+
* fichier SUIVI par git — un jeton commité est un jeton publié, et c'est la
|
|
145
|
+
* seule faute de cette commande qui serait irrattrapable.
|
|
146
|
+
*
|
|
147
|
+
* @param token - le jeton à poser
|
|
148
|
+
* @param w - la sortie où rendre compte
|
|
149
|
+
* @param targets - agents à servir (déjà filtrés par le choix de l'appelant)
|
|
150
|
+
* @returns le nombre d'agents effectivement servis
|
|
151
|
+
*/
|
|
152
|
+
#writeForAgents(token, w, targets) {
|
|
153
|
+
let servis = 0;
|
|
154
|
+
for (const target of targets) {
|
|
155
|
+
const root = this.#rootOf(target);
|
|
156
|
+
if (target.scope === "projet" && this.#tracked(target.file)) {
|
|
157
|
+
w(`${YELLOW}⚠ ${target.file} est SUIVI par git — rien n'est écrit.${RESET}\n${DIM} Un jeton commité est un jeton publié.${RESET}\n\n`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const abs = path.resolve(root, target.file);
|
|
161
|
+
const pose = poseVariable(target.forme, readIfPresentSync(abs) ?? "", MCP_TOKEN_ENV, token);
|
|
162
|
+
if (pose instanceof Error) {
|
|
163
|
+
w(`${YELLOW}⚠ ${target.file} : ${pose.message} — rien n'est écrit.${RESET}\n\n`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
writeSecretSync(abs, pose);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
w(`${YELLOW}⚠ ${target.file} : écriture impossible — ${error.message}${RESET}\n\n`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const resolvedMode = modeNonRestreint(abs);
|
|
173
|
+
if (typeof resolvedMode === "number") w(`${YELLOW}⚠ ${messageNonRestreint(abs, resolvedMode)}${RESET}\n`);
|
|
174
|
+
w(`${GREEN}✓ ${MCP_TOKEN_ENV} posé pour ${target.name}${RESET} ${DIM}(${target.scope === "projet" ? target.file : abs})${RESET}\n${DIM} RELANCE-le : il lit sa configuration au démarrage.${RESET}\n`);
|
|
175
|
+
if (target.scope === "projet" && !this.#gitIgnored(target.file)) w(`${YELLOW} ⚠ ${target.file} n'est PAS couvert par .gitignore — un « git add -A » l'emporterait.${RESET}\n`);
|
|
176
|
+
w("\n");
|
|
177
|
+
servis += 1;
|
|
178
|
+
}
|
|
179
|
+
return servis;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Audience par défaut : la porte MCP de CETTE application.
|
|
183
|
+
*
|
|
184
|
+
* Lue de la configuration quand elle y est écrite — c'est elle qui fait foi,
|
|
185
|
+
* et elle doit l'être : dérivée d'un en-tête `Host`, un `Host` forgé
|
|
186
|
+
* obtiendrait un jeton d'audience arbitraire. À défaut, on compose l'adresse
|
|
187
|
+
* locale, qui est celle du développement.
|
|
188
|
+
*/
|
|
189
|
+
#defaultResource() {
|
|
190
|
+
const declaree = ((this.kernel?.modules)?.devkit?.options)?.mcp?.authorization?.resource;
|
|
191
|
+
if (typeof declaree === "string" && declaree.length > 0) return declaree;
|
|
192
|
+
return `http://localhost:${process.env.NF_PORT ?? "5151"}${MCP_ENDPOINT_PATH}`;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* `true` si l'application signe avec une clé ÉPHÉMÈRE (ni `keySetJson` ni
|
|
196
|
+
* `dir` déclarés dans `security.jwt.keystore`).
|
|
197
|
+
*
|
|
198
|
+
* 🔴 C'est le piège que cette commande doit annoncer : le jeton produit est
|
|
199
|
+
* parfaitement valide et n'est vérifiable par PERSONNE d'autre que le process
|
|
200
|
+
* qui vient de le signer — celui-ci. Le serveur en marche a généré la sienne
|
|
201
|
+
* au démarrage, et refusera ce jeton en « autorisation requise ». Mesuré :
|
|
202
|
+
* deux `kid` distincts pour la même application, et un troisième après un
|
|
203
|
+
* redémarrage.
|
|
204
|
+
*/
|
|
205
|
+
#ephemeralKey() {
|
|
206
|
+
const ks = (((this.kernel?.modules)?.security?.options)?.jwt)?.keystore;
|
|
207
|
+
return !ks?.keySetJson && !ks?.dir;
|
|
208
|
+
}
|
|
209
|
+
async generate(identifierArg, opts) {
|
|
210
|
+
const tokens = this.kernel?.container?.get("tokenService");
|
|
211
|
+
const users = this.kernel?.container?.get("users");
|
|
212
|
+
if (!tokens || !users) {
|
|
213
|
+
this.log(`service « ${!tokens ? "tokenService" : "users"} » absent — cette application ne provisionne pas d'émetteur de jetons.`, "ERROR");
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
let identifier = identifierArg?.trim() ?? "";
|
|
218
|
+
if (!identifier && process.stdin.isTTY) {
|
|
219
|
+
const page = await users.listPage({ limit: 25 });
|
|
220
|
+
if (page.items.length > 0) {
|
|
221
|
+
await this.loadPrompts();
|
|
222
|
+
identifier = await this.prompts.select({
|
|
223
|
+
message: "Compte porteur du jeton :",
|
|
224
|
+
choices: page.items.map((u) => ({
|
|
225
|
+
name: `${u.identifier}${(u.roles ?? []).length ? ` ${DIM}(${(u.roles ?? []).join(", ")})${RESET}` : ""}`,
|
|
226
|
+
value: u.identifier
|
|
227
|
+
}))
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!identifier) identifier = "admin";
|
|
232
|
+
const user = await users.findByIdentifier(identifier);
|
|
233
|
+
if (!user) {
|
|
234
|
+
this.log(`compte « ${identifier} » introuvable — crée-le : nodefony security:user:add ${identifier}`, "ERROR");
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
const resource = opts.resource ?? this.#defaultResource();
|
|
239
|
+
const requestedScopes = (opts.scope ?? "").split(/\s+/u).filter(Boolean);
|
|
240
|
+
const scopes = requestedScopes.length > 0 ? requestedScopes : [ADMIN_SCOPE_READ];
|
|
241
|
+
const ttlS = ttlSeconds(opts.ttl);
|
|
242
|
+
if (ttlS instanceof Error) {
|
|
243
|
+
this.log(ttlS.message, "ERROR");
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
return this;
|
|
246
|
+
}
|
|
247
|
+
let issued;
|
|
248
|
+
try {
|
|
249
|
+
issued = await tokens.issueTokens(user, scopes, resource, ttlS);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e.oauthError === "invalid_target") {
|
|
252
|
+
const env = this.kernel?.environment ?? "?";
|
|
253
|
+
const enDev = env === "development";
|
|
254
|
+
this.log(`impossible d'émettre un jeton pour cette porte ici.
|
|
255
|
+
|
|
256
|
+
Ce n'est PAS un problème de serveur : cette commande n'en a pas
|
|
257
|
+
besoin, elle signe le jeton elle-même.
|
|
258
|
+
|
|
259
|
+
La porte visée : ${resource}\n Environnement CONSTATÉ : ${env}\n\n` + (enDev ? ` Cette application n'accepte pas cette audience. Une audience se
|
|
260
|
+
DÉCLARE — c'est une liste blanche (RFC 8707), sans quoi tout
|
|
261
|
+
porteur obtiendrait un jeton pour la ressource de son choix :
|
|
262
|
+
|
|
263
|
+
use("@nodefony/security", {
|
|
264
|
+
jwt: { audiences: ["${resource}"] },\n })\n\n → puis npm run build (le runtime lit le dist)\n` : ` La porte est servie par un module de DÉVELOPPEMENT, absent en\n « ${env} » : un jeton pour une porte absente n'aurait personne\n pour l'accepter.\n\n → NODE_ENV=development nodefony security:token${opts.write ? " --write" : ""}\n`) + ` → ou vise une autre porte : --resource <uri>`, "ERROR");
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
throw e;
|
|
269
|
+
}
|
|
270
|
+
const token = issued.access_token;
|
|
271
|
+
const accordes = (issued.scope ?? "").split(/\s+/u).filter(Boolean);
|
|
272
|
+
const nonAccordes = scopes.filter((s) => !accordes.includes(s));
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
process.stdout.write(`${JSON.stringify({
|
|
275
|
+
access_token: token,
|
|
276
|
+
resource,
|
|
277
|
+
scopes: accordes,
|
|
278
|
+
requested: scopes,
|
|
279
|
+
expires_in: issued.expires_in
|
|
280
|
+
}, null, 2)}\n`);
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
const w = (s) => {
|
|
284
|
+
process.stdout.write(s);
|
|
285
|
+
};
|
|
286
|
+
let write = opts.write === true;
|
|
287
|
+
if (!write && process.stdin.isTTY && !opts.json) {
|
|
288
|
+
await this.loadPrompts();
|
|
289
|
+
write = await this.prompts.confirm({
|
|
290
|
+
message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
|
|
291
|
+
default: true
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (this.#ephemeralKey()) w(`\n${YELLOW}⚠ Clé de signature ÉPHÉMÈRE — ce jeton sera REFUSÉ.${RESET}\n${DIM} Cette application n'a pas de clé persistante : chaque process en génère\n une au démarrage. Le jeton ci-dessous n'est vérifiable que par le process\n qui vient de le signer — pas par le serveur en marche, qui a la sienne.\n → déclare une source de clés dans nodefony.config.ts :\n use("@nodefony/security", { jwt: { keystore: { dir: "var/keys" } } })\n ou, en production, keySetJson depuis l'environnement.${RESET}\n`);
|
|
295
|
+
const minutes = Math.round((issued.expires_in ?? 0) / 60);
|
|
296
|
+
w(`\n${BOLD}🔑 Jeton d'accès${RESET} ${DIM}— compte ${identifier}, audience ${resource}${RESET}\n${DIM} valable ${minutes} min${accordes.length ? `, scopes : ${accordes.join(" ")}` : ", aucun scope"}${RESET}\n\n`);
|
|
297
|
+
if (nonAccordes.length > 0) w(`${DIM} ⚠️ non accordé(s) : ${nonAccordes.join(" ")} — réservé(s) au rôle d'administration, que le compte « ${identifier} » ne porte pas.${RESET}\n\n`);
|
|
298
|
+
if (write) {
|
|
299
|
+
const requested = requestedAgents(opts.agent);
|
|
300
|
+
if (requested instanceof Error) {
|
|
301
|
+
this.log(requested.message, "ERROR");
|
|
302
|
+
process.exitCode = 1;
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
let targets = requested ?? [];
|
|
306
|
+
if (requested === void 0) {
|
|
307
|
+
const presents = this.#agentsPresents();
|
|
308
|
+
const porteurs = presents.filter((c) => alreadyHasKey(c.forme, this.#contentOf(c), MCP_TOKEN_ENV));
|
|
309
|
+
const added = presents.filter((c) => !porteurs.includes(c));
|
|
310
|
+
targets = porteurs;
|
|
311
|
+
if (porteurs.length === 0 && added.length > 0 && process.stdin.isTTY) {
|
|
312
|
+
const { checkbox } = await chargePrompts();
|
|
313
|
+
const chosen = await checkbox({
|
|
314
|
+
message: "Poser le jeton chez quels agents ?",
|
|
315
|
+
choices: added.map((c) => ({
|
|
316
|
+
name: `${c.name} — ${c.scope === "projet" ? c.file : `$${c.home}/${c.file}`}`,
|
|
317
|
+
value: c.key,
|
|
318
|
+
checked: true
|
|
319
|
+
}))
|
|
320
|
+
});
|
|
321
|
+
targets = added.filter((c) => chosen.includes(c.key));
|
|
322
|
+
} else if (porteurs.length === 0) targets = added;
|
|
323
|
+
else if (added.length > 0) w(`${DIM} ${added.map((c) => c.name).join(", ")} ${added.length > 1 ? "sont présents" : "est présent"} mais ne porte${added.length > 1 ? "nt" : ""} pas encore le jeton — ajoute --agent ${added.map((c) => c.key).join(",")}.${RESET}\n\n`);
|
|
324
|
+
}
|
|
325
|
+
if (this.#writeForAgents(token, w, targets) === 0) w(`${YELLOW}⚠ aucun agent reconnu dans ce projet — rien n'est écrit.${RESET}\n${DIM} Les agents connus rangent leur configuration ici :${RESET}\n` + AGENT_TARGETS.map((c) => `${DIM} ${c.name} : ${c.scope === "projet" ? c.file : `$${c.home ?? "HOME"}/${c.file}`}${RESET}\n`).join("") + `\n Le geste qui vaut pour TOUS — dans le shell d'où tu lances l'agent :\n\n ${BOLD}export ${MCP_TOKEN_ENV}=${token}${RESET}\n\n`);
|
|
326
|
+
w(`${DIM} Vibe et Codex prennent le NOM de la variable, pas le secret — déclare la porte une fois :${RESET}\n\n ${DIM}vibe mcp add nodefony --transport streamable-http \\${RESET}\n ${DIM} --url ${resource} --api-key-env ${MCP_TOKEN_ENV}${RESET}\n ${DIM}codex mcp add nodefony --url ${resource} \\${RESET}\n ${DIM} --bearer-token-env-var ${MCP_TOKEN_ENV}${RESET}\n\n`);
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
w(` export ${MCP_TOKEN_ENV}=${token}\n\n`);
|
|
330
|
+
w(`${DIM} --write pose la valeur chez les agents présents · nodefony ai:mcp --auth câble .mcp.json${RESET}\n\n`);
|
|
331
|
+
return this;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
//#endregion
|
|
335
|
+
export { SecurityToken as default, ttlSeconds };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Command, askPasswordMasked } from "nodefony";
|
|
2
|
+
//#region nodefony/command/security-user-add.ts
|
|
3
|
+
const options = {
|
|
4
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
5
|
+
showBanner: false,
|
|
6
|
+
kernelEvent: "onPostReady"
|
|
7
|
+
};
|
|
8
|
+
const GREEN = "\x1B[32m";
|
|
9
|
+
const YELLOW = "\x1B[33m";
|
|
10
|
+
const DIM = "\x1B[2m";
|
|
11
|
+
const BOLD = "\x1B[1m";
|
|
12
|
+
const RESET = "\x1B[0m";
|
|
13
|
+
/** Rôles du raccourci `--admin` — À PLAT (ne dépend pas d'une hiérarchie). */
|
|
14
|
+
const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
|
|
15
|
+
/** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
|
|
16
|
+
const ROLE_BASE = "ROLE_USER";
|
|
17
|
+
/**
|
|
18
|
+
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
19
|
+
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
20
|
+
* jamais de mot de passe stocké en clair).
|
|
21
|
+
*
|
|
22
|
+
* Identifiant : en argument, ou DEMANDÉ quand un terminal est là — la commande
|
|
23
|
+
* est proposée au menu, où personne ne peut taper d'argument. Hors terminal
|
|
24
|
+
* (CI, script), l'absence reste une erreur, avec la ligne exacte à taper.
|
|
25
|
+
*
|
|
26
|
+
* Mot de passe : `--password` (visible dans l'historique shell — accepté pour
|
|
27
|
+
* les scripts) ou PROMPT MASQUÉ en TTY (recommandé). Rôles : `--roles a,b,c`
|
|
28
|
+
* (CSV) ou `--admin` (raccourci `ROLE_ADMIN + ROLE_NODEFONY_ADMIN` → accès
|
|
29
|
+
* console Studio). Défaut : `ROLE_USER`.
|
|
30
|
+
*
|
|
31
|
+
* Le service `users` est posé par l'APPLICATION (cf `provisionUsers` du
|
|
32
|
+
* template d'app) — absent = message actionnable, pas de stack.
|
|
33
|
+
*/
|
|
34
|
+
var SecurityUserAdd = class extends Command {
|
|
35
|
+
constructor(cli) {
|
|
36
|
+
super("security:user:add", "crée un compte (mot de passe demandé masqué)", cli, options);
|
|
37
|
+
this.addArgument("[identifier]", "identifiant (login) du compte");
|
|
38
|
+
this.addOption("-p, --password <password>", "mot de passe (sinon : prompt masqué en TTY)");
|
|
39
|
+
this.addOption("-r, --roles <roles>", "rôles CSV (ex: ROLE_USER,ROLE_DEV) — défaut ROLE_USER");
|
|
40
|
+
this.addOption("-a, --admin", `compte administrateur (${ADMIN_ROLES.join(" + ")} — accès Studio)`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Rôles que CETTE application connaît, lus de sa hiérarchie déclarée.
|
|
44
|
+
*
|
|
45
|
+
* 🔴 Jamais une liste recopiée ici : une constante en dur diverge dès que
|
|
46
|
+
* l'application déclare un rôle métier, et la commande proposerait alors des
|
|
47
|
+
* rôles qui n'existent pas tout en taisant ceux qui existent. La hiérarchie
|
|
48
|
+
* (`security.roleHierarchy`) est la seule source — ses clés sont les rôles
|
|
49
|
+
* qui couvrent, ses valeurs ceux qui sont couverts.
|
|
50
|
+
*
|
|
51
|
+
* Un rôle inconnu du RBAC n'est pas une faute : il est simplement inerte.
|
|
52
|
+
* On ne refuse donc rien, on PROPOSE ce qu'on sait.
|
|
53
|
+
*/
|
|
54
|
+
#knownRoles() {
|
|
55
|
+
const hierarchy = ((this.kernel?.modules)?.security?.options)?.roleHierarchy;
|
|
56
|
+
const all = /* @__PURE__ */ new Set([ROLE_BASE]);
|
|
57
|
+
for (const [porteur, couverts] of Object.entries(hierarchy ?? {})) {
|
|
58
|
+
all.add(porteur);
|
|
59
|
+
for (const c of couverts) all.add(c);
|
|
60
|
+
}
|
|
61
|
+
return [ROLE_BASE, ...[...all].filter((r) => r !== ROLE_BASE).sort()];
|
|
62
|
+
}
|
|
63
|
+
async generate(identifierArg, opts) {
|
|
64
|
+
let identifier;
|
|
65
|
+
try {
|
|
66
|
+
identifier = await this.askArgument(identifierArg, {
|
|
67
|
+
name: "identifier",
|
|
68
|
+
message: "Identifiant (login) du compte :"
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
this.log(e.message, "ERROR");
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
const users = this.kernel?.container?.get("users");
|
|
76
|
+
if (!users) {
|
|
77
|
+
this.log("service \"users\" absent — l'application ne provisionne pas son annuaire utilisateurs. Ajoute-le (cf nodefony/security/provisionUsers.ts d'une app générée : container.set(\"users\", new UserService(repo, encoder)) à onKernelReady).", "ERROR");
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return this;
|
|
80
|
+
}
|
|
81
|
+
if (await users.findByIdentifier(identifier)) {
|
|
82
|
+
this.log(`le compte « ${identifier} » existe déjà.\n · le voir : nodefony security:user:list -q ${identifier}\n · le supprimer : nodefony security:user:delete ${identifier}\n · mot de passe oublié : par Studio (/nodefony) — la commande n'existe pas encore.`, "ERROR");
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
let password = opts.password;
|
|
87
|
+
if (!password) {
|
|
88
|
+
if (!process.stdin.isTTY) {
|
|
89
|
+
this.log("mot de passe requis : --password <pwd> (pas de prompt hors terminal).", "ERROR");
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
password = await askPasswordMasked(`${BOLD}Mot de passe de « ${identifier} »${RESET} ${DIM}(frappe masquée)${RESET} : `);
|
|
94
|
+
const confirmed = await askPasswordMasked(`${BOLD}Confirme le mot de passe${RESET} : `);
|
|
95
|
+
if (password !== confirmed) {
|
|
96
|
+
this.log("les deux saisies diffèrent — rien n'a été créé.", "ERROR");
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!password) {
|
|
102
|
+
this.log("mot de passe vide — rien n'a été créé.", "ERROR");
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
let roles;
|
|
107
|
+
if (opts.admin) roles = ADMIN_ROLES;
|
|
108
|
+
else if (opts.roles) roles = opts.roles.split(",").map((r) => r.trim()).filter(Boolean);
|
|
109
|
+
else if (process.stdin.isTTY) {
|
|
110
|
+
await this.loadPrompts();
|
|
111
|
+
const chosen = await this.prompts.checkbox({
|
|
112
|
+
message: `Rôles de « ${identifier} » :`,
|
|
113
|
+
choices: this.#knownRoles().map((r) => ({
|
|
114
|
+
name: r,
|
|
115
|
+
value: r,
|
|
116
|
+
checked: r === ROLE_BASE
|
|
117
|
+
}))
|
|
118
|
+
});
|
|
119
|
+
roles = chosen.length > 0 ? chosen : [ROLE_BASE];
|
|
120
|
+
} else roles = [ROLE_BASE];
|
|
121
|
+
const user = await users.createUser({
|
|
122
|
+
identifier,
|
|
123
|
+
plainPassword: password,
|
|
124
|
+
roles
|
|
125
|
+
});
|
|
126
|
+
process.stdout.write(`\n${GREEN}✓ compte créé${RESET} — ${BOLD}${user.identifier}${RESET} ${DIM}(id ${user.id})${RESET}\n rôles : ${roles.join(" · ")}\n` + (opts.admin ? ` ${DIM}accès console Studio : /nodefony${RESET}\n` : "") + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : ""));
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
export { SecurityUserAdd as default };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Command } from "nodefony";
|
|
2
|
+
//#region nodefony/command/security-user-delete.ts
|
|
3
|
+
const options = {
|
|
4
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
5
|
+
showBanner: false,
|
|
6
|
+
kernelEvent: "onPostReady",
|
|
7
|
+
quietBoot: true
|
|
8
|
+
};
|
|
9
|
+
const GREEN = "\x1B[32m";
|
|
10
|
+
const YELLOW = "\x1B[33m";
|
|
11
|
+
const DIM = "\x1B[2m";
|
|
12
|
+
const BOLD = "\x1B[1m";
|
|
13
|
+
const RESET = "\x1B[0m";
|
|
14
|
+
/**
|
|
15
|
+
* Rôle qui donne la main sur l'instance — celui dont il faut toujours garder un
|
|
16
|
+
* porteur actif, sinon plus personne ne peut administrer l'application.
|
|
17
|
+
*/
|
|
18
|
+
const ADMIN_ROLE = "ROLE_NODEFONY_ADMIN";
|
|
19
|
+
/**
|
|
20
|
+
* `nodefony security:user:delete <identifiant>` — retire un compte.
|
|
21
|
+
*
|
|
22
|
+
* **Un geste destructeur ne se fait pas sur une frappe.** La commande MONTRE ce
|
|
23
|
+
* qu'elle va supprimer (identifiant, rôles, identifiant interne), puis demande
|
|
24
|
+
* une confirmation. Hors terminal, elle exige `--yes` : un script qui supprime
|
|
25
|
+
* doit le dire dans sa ligne, jamais l'obtenir d'un prompt sauté.
|
|
26
|
+
*
|
|
27
|
+
* **Garde-fou anti-lockout** : le DERNIER administrateur actif ne se supprime
|
|
28
|
+
* pas. Sans lui, plus personne n'accède à la console d'administration, et le
|
|
29
|
+
* seul recours est une écriture directe en base. Le même garde-fou existe côté
|
|
30
|
+
* data plane (`UserAdminApi`) — ici il est constaté, pas supposé.
|
|
31
|
+
*
|
|
32
|
+
* La révocation des sessions et jetons du compte suit d'elle-même : la
|
|
33
|
+
* suppression émet `onUserRevoked`, auquel `@nodefony/security` réagit en
|
|
34
|
+
* éjectant sessions et jetons. Rien à faire de plus ici.
|
|
35
|
+
*/
|
|
36
|
+
var SecurityUserDelete = class extends Command {
|
|
37
|
+
constructor(cli) {
|
|
38
|
+
super("security:user:delete", "supprime un compte, après confirmation", cli, options);
|
|
39
|
+
this.addArgument("[identifier]", "identifiant (login) du compte à retirer");
|
|
40
|
+
this.addOption("-y, --yes", "ne pas demander confirmation (obligatoire hors terminal)");
|
|
41
|
+
}
|
|
42
|
+
async generate(identifierArg, opts) {
|
|
43
|
+
const users = this.kernel?.container?.get("users");
|
|
44
|
+
if (!users) {
|
|
45
|
+
this.log("service « users » absent — l'application ne provisionne pas son annuaire utilisateurs.", "ERROR");
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
let identifier;
|
|
50
|
+
try {
|
|
51
|
+
identifier = await this.askArgument(identifierArg, {
|
|
52
|
+
name: "identifier",
|
|
53
|
+
message: "Compte à SUPPRIMER :"
|
|
54
|
+
});
|
|
55
|
+
} catch (e) {
|
|
56
|
+
this.log(e.message, "ERROR");
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
const user = await users.findByIdentifier(identifier);
|
|
61
|
+
if (!user) {
|
|
62
|
+
this.log(`aucun compte « ${identifier} » — nodefony security:user:list`, "ERROR");
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
if ((user.roles ?? []).includes(ADMIN_ROLE)) {
|
|
67
|
+
if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) {
|
|
68
|
+
this.log(`« ${identifier} » est le DERNIER administrateur actif — refus.\n Sans lui, plus personne n'administre cette application.\n Crée d'abord un autre admin : nodefony security:user:add <id> --admin`, "ERROR");
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const w = (t) => {
|
|
74
|
+
process.stdout.write(t);
|
|
75
|
+
};
|
|
76
|
+
w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${(user.roles ?? []).join(", ") || "—"}\n id interne : ${DIM}${user.id}${RESET}\n\n`);
|
|
77
|
+
if (!opts.yes) {
|
|
78
|
+
if (!process.stdin.isTTY) {
|
|
79
|
+
this.log("confirmation impossible sans terminal — relance avec --yes si c'est voulu.", "ERROR");
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
await this.loadPrompts();
|
|
84
|
+
if (!await this.prompts.confirm({
|
|
85
|
+
message: `Supprimer définitivement « ${identifier} » ?`,
|
|
86
|
+
default: false
|
|
87
|
+
})) {
|
|
88
|
+
w(`${YELLOW}annulé — rien n'a été supprimé.${RESET}\n\n`);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (await users.delete({ id: user.id }) === 0) {
|
|
93
|
+
this.log(`aucune ligne supprimée pour « ${identifier} » — le compte a-t-il disparu entre-temps ?`, "ERROR");
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
w(`${GREEN}✓ compte supprimé${RESET} — ${BOLD}${identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués (event onUserRevoked).${RESET}\n\n`);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
//#endregion
|
|
102
|
+
export { SecurityUserDelete as default };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Command } from "nodefony";
|
|
2
|
+
//#region nodefony/command/security-user-list.ts
|
|
3
|
+
const options = {
|
|
4
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
5
|
+
showBanner: false,
|
|
6
|
+
kernelEvent: "onReady",
|
|
7
|
+
quietBoot: true
|
|
8
|
+
};
|
|
9
|
+
const DIM = "\x1B[2m";
|
|
10
|
+
const BOLD = "\x1B[1m";
|
|
11
|
+
const RESET = "\x1B[0m";
|
|
12
|
+
/** Défaut volontairement BORNÉ — une liste se borne, toujours. */
|
|
13
|
+
const DEFAULT_LIMIT = 50;
|
|
14
|
+
/**
|
|
15
|
+
* `nodefony security:user:list` — qui a un compte dans cette application.
|
|
16
|
+
*
|
|
17
|
+
* **Lister n'est pas une fuite, et c'était la question.** Cette commande
|
|
18
|
+
* s'exécute sur la machine qui possède déjà la base : elle ne divulgue rien que
|
|
19
|
+
* son utilisateur ne puisse lire avec un client SQL. Ce qui serait dangereux est
|
|
20
|
+
* ailleurs, et gardé ici : **jamais le hachage du mot de passe**, jamais les
|
|
21
|
+
* secrets de second facteur, jamais les jetons d'un fournisseur social. La
|
|
22
|
+
* sortie est composée champ par champ — un `console.table(user)` livrerait le
|
|
23
|
+
* credential, parce que le repository, lui, le voit.
|
|
24
|
+
*
|
|
25
|
+
* Le contraire — ne pas pouvoir lister — a un coût réel : on crée un compte, on
|
|
26
|
+
* ne voit pas ce qu'on a créé, et on recommence en doublon.
|
|
27
|
+
*
|
|
28
|
+
* La pagination est NATIVE (`users.listPage`) : jamais un `find()` complet
|
|
29
|
+
* ramené en mémoire, indolore sur trois comptes et fatal sur cent mille.
|
|
30
|
+
*/
|
|
31
|
+
var SecurityUserList = class extends Command {
|
|
32
|
+
constructor(cli) {
|
|
33
|
+
super("security:user:list", "liste les comptes : identifiant, rôles, état", cli, options);
|
|
34
|
+
this.addOption("-q, --query <texte>", "filtre sur l'identifiant (sous-chaîne, insensible à la casse)");
|
|
35
|
+
this.addOption("-r, --role <role>", "n'affiche que les porteurs d'un rôle");
|
|
36
|
+
this.addOption("-l, --limit <n>", `nombre maximum de comptes (défaut ${DEFAULT_LIMIT})`);
|
|
37
|
+
this.addOption("-j, --json", "sortie JSON (scripts/CI)");
|
|
38
|
+
}
|
|
39
|
+
async generate(opts) {
|
|
40
|
+
const users = this.kernel?.container?.get("users");
|
|
41
|
+
if (!users) {
|
|
42
|
+
this.log("service « users » absent — l'application ne provisionne pas son annuaire utilisateurs.", "ERROR");
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
const limit = Number.parseInt(opts.limit ?? "", 10);
|
|
47
|
+
const page = await users.listPage({
|
|
48
|
+
limit: Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_LIMIT,
|
|
49
|
+
...opts.query ? { q: opts.query } : {},
|
|
50
|
+
...opts.role ? { role: opts.role } : {}
|
|
51
|
+
});
|
|
52
|
+
const rows = page.items.map((u) => ({
|
|
53
|
+
identifiant: u.identifier,
|
|
54
|
+
rôles: (u.roles ?? []).join(", ") || "—",
|
|
55
|
+
actif: u.isActive() ? "oui" : "non",
|
|
56
|
+
verrouillé: u.isLocked() ? "OUI" : "—",
|
|
57
|
+
id: u.id
|
|
58
|
+
}));
|
|
59
|
+
if (opts.json) {
|
|
60
|
+
process.stdout.write(`${JSON.stringify({
|
|
61
|
+
items: rows,
|
|
62
|
+
hasNext: page.hasNext
|
|
63
|
+
}, null, 2)}\n`);
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
if (rows.length === 0) {
|
|
67
|
+
process.stdout.write(`aucun compte${opts.query || opts.role ? " pour ce filtre" : ""} — nodefony security:user:add <identifiant>\n`);
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(`\n${BOLD}👤 Comptes${RESET}\n`);
|
|
71
|
+
console.table(rows);
|
|
72
|
+
process.stdout.write(`${rows.length} compte(s)` + (page.hasNext ? `${DIM} — page bornée, il y en a d'autres (--limit)${RESET}` : "") + `\n\n`);
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { SecurityUserList as default };
|