@envisiongroup/create-alakazam 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envisiongroup/create-alakazam",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "CLI para generar proyectos Mastra cliente sobre @envisiongroup/alakazam (agents hub con auth, storage y rutas ya cableadas).",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -37,6 +37,9 @@ AUTH_DISABLED=false
37
37
  # true → bypass de auth solo para Mastra Studio (same-origin, sin Bearer).
38
38
  # Recomendado en local. En producción debe quedar sin setear (o false).
39
39
  STUDIO_AUTH_BYPASS=true
40
+ # ¿Llamadas servicio-a-servicio (M2M) sin usuario interactivo? No uses estos
41
+ # flags: ver las variantes comentadas en src/mastra/index.ts (endpoints
42
+ # públicos puntuales / provider app-only en src/mastra/auth/).
40
43
 
41
44
  # ── CORS ────────────────────────────────────────────────────────────────────
42
45
  # Lista de orígenes permitidos separados por coma (tu SPA en local):
@@ -19,6 +19,10 @@ import type { RoleRule } from '@envisiongroup/alakazam';
19
19
  * { when: { provider: 'google', domain: 'envisiongroup.com' }, grant: 'employee' },
20
20
  * // Email directo → rol (útil para admins puntuales):
21
21
  * { when: { email: 'admin@envisiongroup.com' }, grant: 'hub-admin' },
22
+ * // Servicio M2M (app-only Entra, provider 'entra-service' del ejemplo
23
+ * // `auth/service-identity-provider.ts`; el `user` es el Object ID del
24
+ * // service principal llamante):
25
+ * { when: { provider: 'entra-service', user: '<OBJECT-ID-DEL-SP>' }, grant: 'mi-rol-servicio' },
22
26
  * ```
23
27
  *
24
28
  * Requiere que la App Registration de la API tenga configurado
@@ -0,0 +1,217 @@
1
+ /**
2
+ * EJEMPLO: autenticación servicio-a-servicio (M2M) junto al login de usuario.
3
+ *
4
+ * Por default el hub solo acepta tokens de USUARIO delegados de Entra ID
5
+ * (flujo interactivo del SPA, claim `scp` con `access_as_user`). Este archivo
6
+ * muestra cómo aceptar ADEMÁS tokens **app-only** emitidos por Entra para
7
+ * otra aplicación (client credentials flow), de modo que un servicio X
8
+ * (un backend, un job, una Azure Function, …) pueda invocar agents con su
9
+ * propia identidad, sin usuario interactivo y SIN desactivar la auth.
10
+ *
11
+ * ─── ¿Por qué UN provider compuesto y no dos providers? ────────────────────
12
+ *
13
+ * Con varios providers, el hub elige cuál valida cada token por su claim
14
+ * `iss` (routing multi-IdP). Pero un token app-only del MISMO tenant tiene
15
+ * exactamente el mismo `iss` que un token de usuario — el routing no puede
16
+ * distinguirlos. Por eso este provider se registra como ÚNICO provider y
17
+ * discrimina por la FORMA del token:
18
+ *
19
+ * - token SIN claim `scp` (o con `idtyp: 'app'`) → app-only: se valida
20
+ * aquí (firma + issuer + audience + app role requerido).
21
+ * - cualquier otro token → se delega intacto al provider de usuario de
22
+ * siempre (`EntraIdentityProvider`), conservando la semántica 401/403
23
+ * original para el SPA.
24
+ *
25
+ * ─── Alta en Microsoft Entra (una vez, por servicio llamante) ──────────────
26
+ *
27
+ * 1. En la App Registration DE ESTA API (la de `AZURE_API_CLIENT_ID`):
28
+ * "App roles" → crear rol con `allowedMemberTypes: ['Applications']`,
29
+ * value `Hub.Invoke` (o el que pases como `requiredAppRole`).
30
+ * 2. En la App Registration DEL SERVICIO llamante: "API permissions" →
31
+ * "Application permissions" → agregar el rol `Hub.Invoke` de tu API →
32
+ * conceder admin consent.
33
+ * 3. El servicio pide token con client credentials:
34
+ * POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token
35
+ * grant_type=client_credentials
36
+ * client_id=<app-del-servicio>&client_secret=<...>
37
+ * scope=api://<AZURE_API_CLIENT_ID>/.default
38
+ * y llama al hub con `Authorization: Bearer <token>`.
39
+ *
40
+ * ─── Identidad resultante y roles ──────────────────────────────────────────
41
+ *
42
+ * El `subject` de la identidad es el Object ID del service principal llamante
43
+ * (claim `sub`; fallback `azp`). Ese subject pasa a ser el
44
+ * `MASTRA_RESOURCE_ID_KEY` de la request: memoria, threads y ownership de
45
+ * runs quedan aislados POR SERVICIO, igual que por usuario.
46
+ *
47
+ * La identidad se reporta con `provider: 'entra-service'` para que el
48
+ * `roleResolver` pueda otorgar roles distintos a servicios que a usuarios
49
+ * (ver `access/role-rules.ts`):
50
+ *
51
+ * { when: { provider: 'entra-service', user: '<OBJECT-ID-DEL-SP>' }, grant: 'mi-rol-servicio' },
52
+ *
53
+ * Nota: si lo que buscas es NO exigir credencial alguna (red privada, dev),
54
+ * no necesitas un provider — usa los flags `AUTH_DISABLED` /
55
+ * `config.publicPaths` (ver los comentarios de `src/mastra/index.ts`).
56
+ */
57
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
58
+ import {
59
+ AuthError,
60
+ entraIdentityProvider,
61
+ type EntraIdentityProvider,
62
+ type HubIdentity,
63
+ type IdentityProvider,
64
+ } from '@envisiongroup/alakazam';
65
+
66
+ /** Nombre del provider en la identidad resultante (lo usan las RoleRules). */
67
+ export const ENTRA_SERVICE_PROVIDER_NAME = 'entra-service' as const;
68
+
69
+ /**
70
+ * JWKS del tenant (mismo endpoint que el flujo de usuario). `new URL` no
71
+ * puede fallar con un tenant sano, pero validamos para reportar config
72
+ * rota como error de servidor y no como excepción suelta en el arranque.
73
+ */
74
+ const buildTenantJwks = (tenantId: string) => {
75
+ try {
76
+ return createRemoteJWKSet(
77
+ new URL(
78
+ `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`,
79
+ ),
80
+ );
81
+ } catch (error) {
82
+ throw new AuthError(
83
+ `Invalid tenant id for JWKS URL: ${error instanceof Error ? error.message : error}`,
84
+ 500,
85
+ );
86
+ }
87
+ };
88
+
89
+ export type EntraServiceIdentityProviderOptions = {
90
+ /** Tenant de Entra ID. Default: env AZURE_API_TENANT_ID. */
91
+ tenantId?: string;
92
+ /**
93
+ * Audience exigido: la App Registration de esta API.
94
+ * Default: env AZURE_API_CLIENT_ID (se acepta GUID y `api://<GUID>`,
95
+ * igual que en el flujo de usuario).
96
+ */
97
+ apiClientId?: string;
98
+ /**
99
+ * App role (application permission) que el token del servicio debe
100
+ * incluir en su claim `roles`. Default: 'Hub.Invoke'.
101
+ */
102
+ requiredAppRole?: string;
103
+ };
104
+
105
+ export class EntraServiceIdentityProvider implements IdentityProvider {
106
+ /**
107
+ * Se registra como provider único: el routing por `iss` no entra en
108
+ * juego (con un solo provider el hub lo usa directamente). El nombre
109
+ * del provider de USUARIOS sigue siendo 'entra' en sus identidades;
110
+ * las identidades app-only salen de aquí con 'entra-service'.
111
+ */
112
+ readonly provider = 'entra';
113
+ readonly issuers: readonly string[];
114
+
115
+ readonly #apiClientId: string;
116
+ readonly #requiredAppRole: string;
117
+ readonly #jwks: ReturnType<typeof createRemoteJWKSet>;
118
+ readonly #userProvider: EntraIdentityProvider;
119
+
120
+ constructor(options: EntraServiceIdentityProviderOptions = {}) {
121
+ const tenantId =
122
+ options.tenantId ?? process.env.AZURE_API_TENANT_ID ?? '';
123
+ this.#apiClientId =
124
+ options.apiClientId ?? process.env.AZURE_API_CLIENT_ID ?? '';
125
+ this.#requiredAppRole = options.requiredAppRole ?? 'Hub.Invoke';
126
+
127
+ // Mismos issuers y JWKS que el flujo de usuario (mismo tenant):
128
+ // v2.0 y v1.0, según `accessTokenAcceptedVersion` de cada app.
129
+ this.issuers = tenantId
130
+ ? [
131
+ `https://login.microsoftonline.com/${tenantId}/v2.0`,
132
+ `https://sts.windows.net/${tenantId}/`,
133
+ ]
134
+ : [];
135
+ this.#jwks = buildTenantJwks(tenantId);
136
+ this.#userProvider = entraIdentityProvider(
137
+ options.tenantId ? { tenantId: options.tenantId } : {},
138
+ );
139
+ }
140
+
141
+ async validateToken(token: string): Promise<HubIdentity> {
142
+ const serviceIdentity = await this.#tryValidateServiceToken(token);
143
+ if (serviceIdentity) {
144
+ return serviceIdentity;
145
+ }
146
+ // Token de usuario delegado (o token inválido): flujo de siempre,
147
+ // que produce los mismos 401/403 que sin este provider.
148
+ return this.#userProvider.validateToken(token);
149
+ }
150
+
151
+ /**
152
+ * Valida el token como app-only (client credentials). Devuelve `null`
153
+ * cuando el token NO es app-only (tiene `scp` → es de usuario, o no
154
+ * verifica → el flujo de usuario reportará el error adecuado).
155
+ */
156
+ async #tryValidateServiceToken(token: string): Promise<HubIdentity | null> {
157
+ if (!this.#apiClientId || this.issuers.length === 0) {
158
+ return null;
159
+ }
160
+
161
+ let payload;
162
+ try {
163
+ ({ payload } = await jwtVerify(token, this.#jwks, {
164
+ issuer: [...this.issuers],
165
+ audience: [this.#apiClientId, `api://${this.#apiClientId}`],
166
+ }));
167
+ } catch {
168
+ return null;
169
+ }
170
+
171
+ // Los tokens de usuario delegados SIEMPRE traen `scp` (scopes);
172
+ // los app-only traen `roles` (app roles) y, en v2.0, `idtyp: 'app'`.
173
+ const isAppOnly =
174
+ payload.idtyp === 'app' || typeof payload.scp !== 'string';
175
+ if (!isAppOnly) {
176
+ return null;
177
+ }
178
+
179
+ const roles = Array.isArray(payload.roles)
180
+ ? payload.roles.filter(
181
+ (role: unknown): role is string => typeof role === 'string',
182
+ )
183
+ : [];
184
+ if (!roles.includes(this.#requiredAppRole)) {
185
+ throw new AuthError(
186
+ `Token missing required app role: ${this.#requiredAppRole}`,
187
+ 403,
188
+ );
189
+ }
190
+
191
+ // En tokens app-only, `sub` es el Object ID del service principal
192
+ // llamante; `azp` es el Client ID de su app (fallback).
193
+ const subject = payload.sub ?? (payload.azp as string | undefined);
194
+ if (!subject) {
195
+ throw new AuthError('Token missing sub claim', 401);
196
+ }
197
+ const tenantId = payload.tid as string | undefined;
198
+ if (!tenantId) {
199
+ throw new AuthError('Token missing tid claim', 401);
200
+ }
201
+
202
+ return {
203
+ provider: ENTRA_SERVICE_PROVIDER_NAME,
204
+ subject,
205
+ email: '', // app-only: no hay email de usuario
206
+ principals: [
207
+ { kind: 'user', id: subject },
208
+ { kind: 'domain', id: tenantId },
209
+ ],
210
+ raw: payload,
211
+ };
212
+ }
213
+ }
214
+
215
+ export const entraServiceIdentityProvider = (
216
+ options: EntraServiceIdentityProviderOptions = {},
217
+ ): EntraServiceIdentityProvider => new EntraServiceIdentityProvider(options);
@@ -45,3 +45,54 @@ export const mastra = await createAgentsHub({
45
45
  roleResolver: staticRoleResolver(ROLE_RULES),
46
46
  },
47
47
  });
48
+
49
+ // --- Autenticación: variantes para clientes sin usuario interactivo ---------
50
+ // Por defecto, todo lo bajo `/api/*` (rutas nativas de Mastra) y `/app/*`
51
+ // (rutas custom) exige un Bearer token de usuario de Entra ID. El hub
52
+ // soporta tres variantes para escenarios distintos (p. ej. que un servicio
53
+ // X — otro backend, un job, una Function— llame a los agents):
54
+ //
55
+ // A) APAGADO TOTAL — solo emergencias/debug, NUNCA en producción.
56
+ // En `.env`: AUTH_DISABLED=true
57
+ // Ninguna ruta exige token y el catálogo (`/app/agents/catalog`) lista
58
+ // TODOS los recursos sin filtrar por roles. No hay usuario autenticado:
59
+ // se degrada el aislamiento por usuario (threads/runs sin ownership,
60
+ // pinned agents, uploads/downloads). No requiere AZURE_API_*.
61
+ //
62
+ // B) ENDPOINTS PÚBLICOS PUNTUALES — un caller sin token accede solo a lo
63
+ // que declares; el resto del hub sigue exigiendo Entra. Es el mismo
64
+ // patrón que usa `/app/files` (donde el downloadId opaco es el credential):
65
+ //
66
+ // export const mastra = await createAgentsHub({
67
+ // // ... agents, tools, workflows, auth, ...
68
+ // config: {
69
+ // // Path exacto público (p. ej. el stream nativo de UN agente):
70
+ // publicPaths: ['/api/agents/weather-agent/stream'],
71
+ // // …o prefijos completos (todo lo del agente):
72
+ // // publicPathPrefixes: ['/api/agents/weather-agent/'],
73
+ // },
74
+ // });
75
+ //
76
+ // Ojo: lo que publiques así queda SIN credencial — compensa a nivel de
77
+ // red (VNet, firewall, API Management) si lo expones fuera de localhost.
78
+ //
79
+ // C) SERVICIO A SERVICIO CON CREDENCIAL (recomendado para M2M) — el servicio
80
+ // X se autentica con client credentials de Entra (token app-only) y el hub
81
+ // lo valida como una identidad propia, con roles y aislamiento por
82
+ // servicio. Implementación de ejemplo lista para adaptar en
83
+ // `./auth/service-identity-provider.ts` (incluye el alta paso a paso en
84
+ // Entra). Se registra como provider ÚNICO — él mismo distingue tokens
85
+ // app-only de tokens de usuario del mismo tenant:
86
+ //
87
+ // import { entraServiceIdentityProvider } from './auth/service-identity-provider';
88
+ // ...
89
+ // auth: {
90
+ // providers: [entraServiceIdentityProvider()],
91
+ // roleResolver: staticRoleResolver(ROLE_RULES),
92
+ // },
93
+ //
94
+ // Roles para la identidad del servicio: ver `./access/role-rules.ts`.
95
+ //
96
+ // No uses STUDIO_AUTH_BYPASS para esto: ese flag es solo para Mastra Studio
97
+ // en local (un caller server-to-server sin header Origin también pasaría el
98
+ // bypass — en producción debe quedar sin setear).