From 4b0f13316f320317c28d7594519c626d1ee74706 Mon Sep 17 00:00:00 2001 From: donchelo Date: Fri, 30 Oct 2026 12:15:20 -0500 Subject: [PATCH 01/15] feat(platform): P1 un 500 no operacional ya no devuelve err.message al cliente toErrorResponse responde {error:"Error interno", code, requestId} cuando el error no es operacional y el status es >= 500; el mensaje real solo va al log (redactado). Los 4xx y los AppError operacionales conservan su mensaje. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015nTgTv7S3gQwaoNFw5fZeG --- packages/platform/dist/errors/index.d.ts | 14 +++- packages/platform/dist/errors/index.d.ts.map | 2 +- packages/platform/dist/errors/index.js | 18 ++++- packages/platform/src/errors/index.ts | 17 ++++- .../platform/tests/errors-500-opaco.test.ts | 70 +++++++++++++++++++ 5 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 packages/platform/tests/errors-500-opaco.test.ts diff --git a/packages/platform/dist/errors/index.d.ts b/packages/platform/dist/errors/index.d.ts index 2ad0a1e..af00b02 100644 --- a/packages/platform/dist/errors/index.d.ts +++ b/packages/platform/dist/errors/index.d.ts @@ -64,6 +64,9 @@ export declare function isAppError(err: unknown): err is AppError; /** * Convierte cualquier `unknown` en un AppError. Lo que no reconozca se trata como * fallo de infraestructura inesperado (500), nunca se ignora. + * + * El `message` del AppError devuelto conserva el texto original SOLO para logging; lo que se + * envía al cliente lo decide `toErrorResponse` (que lo reemplaza por "Error interno"). */ export declare function classifyUnknownError(err: unknown): AppError; export interface ErrorResponseBody { @@ -73,7 +76,16 @@ export interface ErrorResponseBody { requestId?: string; details?: unknown; } -/** Cuerpo JSON uniforme y seguro para responder al cliente. */ +/** Mensaje genérico que ve el cliente cuando el error no es operacional y es 5xx. */ +export declare const GENERIC_ERROR_MESSAGE = "Error interno"; +/** + * Cuerpo JSON uniforme y seguro para responder al cliente. + * + * Desde 0.8.0: un error NO operacional con status >= 500 (excepciones desconocidas, fallos de + * infraestructura) responde `error: "Error interno"` en vez de su mensaje: ese texto suele traer + * internals (hosts, SQL, URLs con claves). El mensaje real queda en el log (redactado) y el + * cliente correlaciona por `requestId`. Los errores operacionales y los 4xx conservan su mensaje. + */ export declare function toErrorResponse(err: unknown, requestId?: string): { status: number; body: ErrorResponseBody; diff --git a/packages/platform/dist/errors/index.d.ts.map b/packages/platform/dist/errors/index.d.ts.map index 36c4a28..292f03d 100644 --- a/packages/platform/dist/errors/index.d.ts.map +++ b/packages/platform/dist/errors/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/errors/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,UAAU,GAAG,gBAAgB,CAAA;AAExE,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8BAA8B;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,mFAAmF;AACnF,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;gBAEZ,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,IAAI,GAAE,eAAoB;CAYjF;AAoBD,qBAAa,eAAgB,SAAQ,QAAQ;gBAC/B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,aAAc,SAAQ,QAAQ;gBAC7B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,mBAAoB,SAAQ,QAAQ;gBACnC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAID,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAA0B,EAAE,IAAI,GAAE,eAAoB;CAG1E;AAED,qBAAa,iBAAkB,SAAQ,QAAQ;gBACjC,OAAO,SAAmB,EAAE,IAAI,GAAE,eAAoB;CAGnE;AAED,qBAAa,cAAe,SAAQ,QAAQ;gBAC9B,OAAO,SAAkB,EAAE,IAAI,GAAE,eAAoB;CAGlE;AAED,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAAwB,EAAE,IAAI,GAAE,eAAoB;CAGxE;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;gBAC/C,OAAO,SAAmC,EAAE,IAAI,GAAE,eAAoB;CAGnF;AAID,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAExD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,QAAQ,CAS3D;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,+DAA+D;AAC/D,wBAAgB,eAAe,CAC7B,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,GACjB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAY7C"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/errors/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,UAAU,GAAG,gBAAgB,CAAA;AAExE,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8BAA8B;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,wEAAwE;IACxE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,oEAAoE;IACpE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,mFAAmF;AACnF,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;gBAEZ,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,IAAI,GAAE,eAAoB;CAYjF;AAoBD,qBAAa,eAAgB,SAAQ,QAAQ;gBAC/B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,aAAc,SAAQ,QAAQ;gBAC7B,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAED,qBAAa,mBAAoB,SAAQ,QAAQ;gBACnC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB;CAGxD;AAID,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAA0B,EAAE,IAAI,GAAE,eAAoB;CAG1E;AAED,qBAAa,iBAAkB,SAAQ,QAAQ;gBACjC,OAAO,SAAmB,EAAE,IAAI,GAAE,eAAoB;CAGnE;AAED,qBAAa,cAAe,SAAQ,QAAQ;gBAC9B,OAAO,SAAkB,EAAE,IAAI,GAAE,eAAoB;CAGlE;AAED,qBAAa,aAAc,SAAQ,aAAa;gBAClC,OAAO,SAAwB,EAAE,IAAI,GAAE,eAAoB;CAGxE;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;gBAC/C,OAAO,SAAmC,EAAE,IAAI,GAAE,eAAoB;CAGnF;AAID,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAExD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,QAAQ,CAS3D;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,qFAAqF;AACrF,eAAO,MAAM,qBAAqB,kBAAkB,CAAA;AAEpD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,GACjB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAY7C"} \ No newline at end of file diff --git a/packages/platform/dist/errors/index.js b/packages/platform/dist/errors/index.js index 03725ea..d33d4f0 100644 --- a/packages/platform/dist/errors/index.js +++ b/packages/platform/dist/errors/index.js @@ -11,7 +11,7 @@ * responda errores de forma uniforme y NUNCA falle en silencio. */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.ExternalServiceError = exports.ConflictError = exports.ForbiddenError = exports.UnauthorizedError = exports.NotFoundError = exports.InfrastructureError = exports.BusinessError = exports.ValidationError = exports.AppError = void 0; +exports.GENERIC_ERROR_MESSAGE = exports.ExternalServiceError = exports.ConflictError = exports.ForbiddenError = exports.UnauthorizedError = exports.NotFoundError = exports.InfrastructureError = exports.BusinessError = exports.ValidationError = exports.AppError = void 0; exports.isAppError = isAppError; exports.classifyUnknownError = classifyUnknownError; exports.toErrorResponse = toErrorResponse; @@ -102,6 +102,9 @@ function isAppError(err) { /** * Convierte cualquier `unknown` en un AppError. Lo que no reconozca se trata como * fallo de infraestructura inesperado (500), nunca se ignora. + * + * El `message` del AppError devuelto conserva el texto original SOLO para logging; lo que se + * envía al cliente lo decide `toErrorResponse` (que lo reemplaza por "Error interno"). */ function classifyUnknownError(err) { if (isAppError(err)) @@ -114,13 +117,22 @@ function classifyUnknownError(err) { cause: err, }); } -/** Cuerpo JSON uniforme y seguro para responder al cliente. */ +/** Mensaje genérico que ve el cliente cuando el error no es operacional y es 5xx. */ +exports.GENERIC_ERROR_MESSAGE = "Error interno"; +/** + * Cuerpo JSON uniforme y seguro para responder al cliente. + * + * Desde 0.8.0: un error NO operacional con status >= 500 (excepciones desconocidas, fallos de + * infraestructura) responde `error: "Error interno"` en vez de su mensaje: ese texto suele traer + * internals (hosts, SQL, URLs con claves). El mensaje real queda en el log (redactado) y el + * cliente correlaciona por `requestId`. Los errores operacionales y los 4xx conservan su mensaje. + */ function toErrorResponse(err, requestId) { const appErr = classifyUnknownError(err); return { status: appErr.httpStatus, body: { - error: appErr.message, + error: !appErr.isOperational && appErr.httpStatus >= 500 ? exports.GENERIC_ERROR_MESSAGE : appErr.message, code: appErr.code, category: appErr.category, ...(requestId ? { requestId } : {}), diff --git a/packages/platform/src/errors/index.ts b/packages/platform/src/errors/index.ts index 91ffbde..470a23b 100644 --- a/packages/platform/src/errors/index.ts +++ b/packages/platform/src/errors/index.ts @@ -129,6 +129,9 @@ export function isAppError(err: unknown): err is AppError { /** * Convierte cualquier `unknown` en un AppError. Lo que no reconozca se trata como * fallo de infraestructura inesperado (500), nunca se ignora. + * + * El `message` del AppError devuelto conserva el texto original SOLO para logging; lo que se + * envía al cliente lo decide `toErrorResponse` (que lo reemplaza por "Error interno"). */ export function classifyUnknownError(err: unknown): AppError { if (isAppError(err)) return err @@ -149,7 +152,17 @@ export interface ErrorResponseBody { details?: unknown } -/** Cuerpo JSON uniforme y seguro para responder al cliente. */ +/** Mensaje genérico que ve el cliente cuando el error no es operacional y es 5xx. */ +export const GENERIC_ERROR_MESSAGE = "Error interno" + +/** + * Cuerpo JSON uniforme y seguro para responder al cliente. + * + * Desde 0.8.0: un error NO operacional con status >= 500 (excepciones desconocidas, fallos de + * infraestructura) responde `error: "Error interno"` en vez de su mensaje: ese texto suele traer + * internals (hosts, SQL, URLs con claves). El mensaje real queda en el log (redactado) y el + * cliente correlaciona por `requestId`. Los errores operacionales y los 4xx conservan su mensaje. + */ export function toErrorResponse( err: unknown, requestId?: string, @@ -158,7 +171,7 @@ export function toErrorResponse( return { status: appErr.httpStatus, body: { - error: appErr.message, + error: !appErr.isOperational && appErr.httpStatus >= 500 ? GENERIC_ERROR_MESSAGE : appErr.message, code: appErr.code, category: appErr.category, ...(requestId ? { requestId } : {}), diff --git a/packages/platform/tests/errors-500-opaco.test.ts b/packages/platform/tests/errors-500-opaco.test.ts new file mode 100644 index 0000000..d1a209f --- /dev/null +++ b/packages/platform/tests/errors-500-opaco.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest" +import { withApiHandler } from "../src/http" +import { toErrorResponse, ValidationError, NotFoundError, ExternalServiceError, AppError } from "../src/errors" +import * as logger from "../src/logger" + +const LEAK = 'connect ECONNREFUSED 10.0.0.5:5432 password authentication failed for user "postgres"' + +beforeAll(() => { + process.env.LOG_FORMAT = "json" +}) +afterEach(() => vi.restoreAllMocks()) + +describe("P1: un 500 no operacional no filtra internals al cliente", () => { + it("Error genérico → {error:'Error interno', code, requestId} sin el mensaje real", async () => { + vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never) + const route = withApiHandler(async () => { + throw new Error(LEAK) + }) + const res = await route(new Request("https://x/api/test", { headers: { "x-request-id": "r500" } })) + const text = await res.text() + expect(res.status).toBe(500) + expect(text).not.toContain("ECONNREFUSED") + expect(text).not.toContain("postgres") + const body = JSON.parse(text) + expect(body.error).toBe("Error interno") + expect(body.code).toBe("UNEXPECTED_ERROR") + expect(body.requestId).toBe("r500") + }) + + it("el mensaje real SÍ va al log (con el detalle para diagnosticar)", async () => { + const lines: string[] = [] + vi.spyOn(process.stderr, "write").mockImplementation(((c: unknown) => (lines.push(String(c)), true)) as never) + const route = withApiHandler(async () => { + throw new Error("falla interna sin secretos") + }) + await route(new Request("https://x/api/test")) + expect(lines.join("")).toContain("falla interna sin secretos") + }) + + it("en el log el mensaje real sale redactado (token en URL)", async () => { + const lines: string[] = [] + vi.spyOn(process.stderr, "write").mockImplementation(((c: unknown) => (lines.push(String(c)), true)) as never) + const route = withApiHandler(async () => { + throw new Error("fetch https://api.x.com/v1?key=SUPERSECRETO123 falló") + }) + await route(new Request("https://x/api/test")) + expect(lines.join("")).toContain("falló") + expect(lines.join("")).not.toContain("SUPERSECRETO123") + }) + + it("toErrorResponse oculta también un AppError no operacional 5xx", () => { + const { status, body } = toErrorResponse(new ExternalServiceError(LEAK), "rid") + expect(status).toBe(502) + expect(body.error).toBe("Error interno") + expect(body.code).toBe("EXTERNAL_SERVICE_ERROR") + expect(body.requestId).toBe("rid") + }) + + it("errores operacionales conservan su mensaje (4xx y AppError operacional)", () => { + expect(toErrorResponse(new ValidationError("falta nombre")).body.error).toBe("falta nombre") + expect(toErrorResponse(new NotFoundError("no existe")).body.error).toBe("no existe") + const op5xx = new AppError("servicio en mantenimiento", "infrastructure", { httpStatus: 503, isOperational: true }) + expect(toErrorResponse(op5xx).body.error).toBe("servicio en mantenimiento") + }) + + it("un 4xx no operacional explícito conserva su mensaje (es del cliente)", () => { + const e = new AppError("payload inválido", "validation", { httpStatus: 422, isOperational: false }) + expect(toErrorResponse(e).body.error).toBe("payload inválido") + }) +}) From ebfc29248cc7f84797d4b520c6b67684b6929814 Mon Sep 17 00:00:00 2001 From: donchelo Date: Fri, 30 Oct 2026 12:16:22 -0500 Subject: [PATCH 02/15] =?UTF-8?q?feat(platform):=20P2=20readIdentity=20con?= =?UTF-8?q?=20serviceId/allowedTenants/acceptLegacy=20opt-in=20y=20modo=20?= =?UTF-8?q?estricto=20de=20m=C3=B3dulos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readIdentity delega en readMcSession de mc-sso 1.3.0 y acepta {serviceId, allowedTenants, normalizeTenant, acceptLegacy}; configureIdentity fija el default por app. Identity expone serviceId. Sin serviceId configurado emite un WARN por proceso. No se deriva nada de SERVICE_ID ni cambia el comportamiento por defecto. Se agregan isModuleAllowedStrict, requireModule con {strict:true} y la opción strictModules de withApiHandler (null/[] = ningún módulo); isModuleAllowed conserva su semántica. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015nTgTv7S3gQwaoNFw5fZeG --- packages/platform/dist/auth/index.d.ts | 35 +++++- packages/platform/dist/auth/index.d.ts.map | 2 +- packages/platform/dist/auth/index.js | 45 ++++++- packages/platform/dist/http/index.d.ts | 5 + packages/platform/dist/http/index.d.ts.map | 2 +- packages/platform/dist/http/index.js | 2 +- packages/platform/src/auth/index.ts | 80 ++++++++++--- packages/platform/src/http/index.ts | 7 +- packages/platform/tests/auth-scope.test.ts | 130 +++++++++++++++++++++ 9 files changed, 285 insertions(+), 23 deletions(-) create mode 100644 packages/platform/tests/auth-scope.test.ts diff --git a/packages/platform/dist/auth/index.d.ts b/packages/platform/dist/auth/index.d.ts index c71fdd7..ba55bf7 100644 --- a/packages/platform/dist/auth/index.d.ts +++ b/packages/platform/dist/auth/index.d.ts @@ -4,13 +4,37 @@ export interface Identity { roles?: string[]; allowedModules?: string[] | null; displayName?: string; + /** App para la que se emitió la sesión (mc-sso >= 1.3.0). undefined = sesión "legacy". */ + serviceId?: string; } -export interface SessionAuthConfig { +/** + * Restricciones de app/tenant sobre la sesión (se pasan tal cual a `readMcSession` de mc-sso). + * El secreto de firma se comparte entre apps: sin estas opciones una cookie de otra app/tenant + * con firma válida PASA. Son opt-in; ver "Cómo adoptar el endurecimiento de auth" en CHANGELOG. + */ +export interface IdentityScope { + /** `serviceId` de ESTA app (el mismo de su `createMcAuthHandler`). Sesión de otro servicio => null. */ + serviceId?: string; + /** Tenants aceptados (apps de un solo tenant). Fuera de la lista => null; `[]` rechaza todo. */ + allowedTenants?: readonly string[]; + /** Normalizador de tenant para comparar `allowedTenants` (p. ej. `normalizeTenant` de @ai4u/config). */ + normalizeTenant?: (tenantId: string) => string; + /** Aceptar sesiones sin `serviceId` (emitidas por mc-sso <= 1.2.0). Default de mc-sso: true. */ + acceptLegacy?: boolean; +} +export interface SessionAuthConfig extends IdentityScope { /** Nombre de la cookie de sesión (default "mc_session"). */ cookieName?: string; /** Secreto de firma (default process.env.MISSION_CONTROL_SECRET). */ secret?: string; } +/** + * Fija el alcance de identidad por defecto de ESTA app (una vez, p. ej. en instrumentation.ts o + * en un módulo de config). Las opciones pasadas a `readIdentity`/`withApiHandler({sessionAuth})` + * lo pisan campo a campo. No se deriva nada de `SERVICE_ID`: puede no coincidir con el serviceId + * de su handler SSO y cortaría sesiones válidas. + */ +export declare function configureIdentity(scope: IdentityScope): void; type HeaderCarrier = { headers: { get(name: string): string | null; @@ -19,13 +43,20 @@ type HeaderCarrier = { /** Lee y verifica la identidad del usuario desde la cookie de sesión. null si no hay/no es válida. */ export declare function readIdentity(req: HeaderCarrier, cfg?: SessionAuthConfig): Identity | null; export declare function isModuleAllowed(moduleId: string, allowedModules: string[] | null | undefined): boolean; +/** + * Variante ESTRICTA: sin lista (null/undefined/[]) => NINGÚN módulo. Úsala en apps que no son + * Mission Control, donde una sesión sin `allowedModules` no debería ver todo. + */ +export declare function isModuleAllowedStrict(moduleId: string, allowedModules: string[] | null | undefined): boolean; export declare function hasRole(identity: Identity | null, role: string): boolean; export declare function isAdmin(identity: Identity | null): boolean; /** * Exige identidad y acceso a un módulo (acepta varios: any-of). Lanza errores * tipados que el wrapper HTTP convierte en 401/403 uniformes. */ -export declare function requireModule(identity: Identity | null, moduleId: string | string[]): Identity; +export declare function requireModule(identity: Identity | null, moduleId: string | string[], opts?: { + strict?: boolean; +}): Identity; export declare function requireRole(identity: Identity | null, role: string | string[]): Identity; export interface ServiceAuthConfig { /** Secreto compartido aceptado en x-mc-secret (default process.env.MISSION_CONTROL_SECRET). */ diff --git a/packages/platform/dist/auth/index.d.ts.map b/packages/platform/dist/auth/index.d.ts.map index e028b96..0753bd6 100644 --- a/packages/platform/dist/auth/index.d.ts.map +++ b/packages/platform/dist/auth/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAWD,MAAM,WAAW,iBAAiB;IAChC,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,CAAA;AA0BtE,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,QAAQ,GAAG,IAAI,CAe7F;AAID,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAGtG;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,QAAQ,CAO9F;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,QAAQ,CAOxF;AAID,MAAM,WAAW,iBAAiB;IAChC,+FAA+F;IAC/F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAA;IACX,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8GAA8G;AAC9G,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,iBAAiB,CAevG"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,uGAAuG;IACvG,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gGAAgG;IAChG,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,wGAAwG;IACxG,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAA;IAC9C,gGAAgG;IAChG,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,iBAAkB,SAAQ,aAAa;IACtD,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAID;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAE5D;AAID,KAAK,aAAa,GAAG;IAAE,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAAE,CAAA;AA0BtE,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,QAAQ,GAAG,IAAI,CA+B7F;AAID,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAGtG;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5G;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAExE;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,QAAQ,GAAG,IAAI,EACzB,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,EAC3B,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GAC9B,QAAQ,CAQV;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,QAAQ,CAOxF;AAID,MAAM,WAAW,iBAAiB;IAChC,+FAA+F;IAC/F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAA;IACX,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8GAA8G;AAC9G,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,GAAE,iBAAsB,GAAG,iBAAiB,CAevG"} \ No newline at end of file diff --git a/packages/platform/dist/auth/index.js b/packages/platform/dist/auth/index.js index 242d1c3..a577d27 100644 --- a/packages/platform/dist/auth/index.js +++ b/packages/platform/dist/auth/index.js @@ -1,7 +1,9 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.configureIdentity = configureIdentity; exports.readIdentity = readIdentity; exports.isModuleAllowed = isModuleAllowed; +exports.isModuleAllowedStrict = isModuleAllowedStrict; exports.hasRole = hasRole; exports.isAdmin = isAdmin; exports.requireModule = requireModule; @@ -19,11 +21,24 @@ exports.verifyServiceRequest = verifyServiceRequest; * esquemas actuales (x-mc-secret compartido / X-API-Key por tenant). * * La regla de visibilidad de módulos es la misma de mission-control-main/lib/access.ts: - * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. + * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. Existe además el modo estricto + * (`requireModule(..., { strict: true })` / `isModuleAllowedStrict`): sin lista ⇒ ninguno. */ const mc_sso_1 = require("@ai4u/mc-sso"); const errors_1 = require("../errors"); +const logger_1 = require("../logger"); const security_1 = require("../security"); +let appScope = {}; +/** + * Fija el alcance de identidad por defecto de ESTA app (una vez, p. ej. en instrumentation.ts o + * en un módulo de config). Las opciones pasadas a `readIdentity`/`withApiHandler({sessionAuth})` + * lo pisan campo a campo. No se deriva nada de `SERVICE_ID`: puede no coincidir con el serviceId + * de su handler SSO y cortaría sesiones válidas. + */ +function configureIdentity(scope) { + appScope = { ...scope }; +} +let warnedNoServiceId = false; /** * Parsea el header Cookie. Nunca lanza: una cookie con percent-encoding inválido * (p.ej. `%E0%A4%A`, que hace lanzar URIError a decodeURIComponent) se IGNORA y se @@ -59,7 +74,20 @@ function readIdentity(req, cfg = {}) { const token = parseCookies(req.headers.get("cookie"))[cookieName]; if (!token) return null; - const data = (0, mc_sso_1.verifySession)(token, secret); + const scope = { + serviceId: cfg.serviceId ?? appScope.serviceId, + allowedTenants: cfg.allowedTenants ?? appScope.allowedTenants, + normalizeTenant: cfg.normalizeTenant ?? appScope.normalizeTenant, + acceptLegacy: cfg.acceptLegacy ?? appScope.acceptLegacy, + }; + if (scope.serviceId === undefined && !warnedNoServiceId) { + warnedNoServiceId = true; + (0, logger_1.getLogger)("platform/auth").warn("readIdentity sin serviceId configurado: una sesión firmada para otra app con el mismo secreto sería aceptada. " + + "Pasa { serviceId } a readIdentity/sessionAuth o llama configureIdentity({ serviceId }) (platform 0.8.0)."); + } + // Se reenvía solo el token ya parseado (parseCookies ignora duplicados mal codificados) y mc-sso + // aplica firma, expiración y las restricciones de app/tenant. + const data = (0, mc_sso_1.readMcSession)(`${cookieName}=${encodeURIComponent(token)}`, secret, { cookieName, ...scope }); if (!data) return null; return { @@ -68,6 +96,7 @@ function readIdentity(req, cfg = {}) { roles: data.roles, allowedModules: data.allowedModules ?? null, displayName: data.displayName, + serviceId: data.serviceId, }; } /* ── Permisos (misma regla que access.ts) ────────────────────────────────── */ @@ -76,6 +105,13 @@ function isModuleAllowed(moduleId, allowedModules) { return true; return allowedModules.includes(moduleId); } +/** + * Variante ESTRICTA: sin lista (null/undefined/[]) => NINGÚN módulo. Úsala en apps que no son + * Mission Control, donde una sesión sin `allowedModules` no debería ver todo. + */ +function isModuleAllowedStrict(moduleId, allowedModules) { + return Array.isArray(allowedModules) && allowedModules.includes(moduleId); +} function hasRole(identity, role) { return !!identity?.roles?.includes(role); } @@ -86,11 +122,12 @@ function isAdmin(identity) { * Exige identidad y acceso a un módulo (acepta varios: any-of). Lanza errores * tipados que el wrapper HTTP convierte en 401/403 uniformes. */ -function requireModule(identity, moduleId) { +function requireModule(identity, moduleId, opts = {}) { if (!identity) throw new errors_1.UnauthorizedError(); const ids = Array.isArray(moduleId) ? moduleId : [moduleId]; - if (!ids.some((id) => isModuleAllowed(id, identity.allowedModules))) { + const allowed = opts.strict ? isModuleAllowedStrict : isModuleAllowed; + if (!ids.some((id) => allowed(id, identity.allowedModules))) { throw new errors_1.ForbiddenError("No autorizado para este módulo"); } return identity; diff --git a/packages/platform/dist/http/index.d.ts b/packages/platform/dist/http/index.d.ts index f4c9fa9..047abfb 100644 --- a/packages/platform/dist/http/index.d.ts +++ b/packages/platform/dist/http/index.d.ts @@ -26,6 +26,11 @@ export interface WithApiHandlerOptions { label?: string; /** Exige sesión + acceso a este módulo (any-of si es array). */ requireModule?: string | string[]; + /** + * Con `requireModule`: modo estricto, una sesión sin `allowedModules` (null/[]) NO pasa. + * Default false (compatibilidad: null/[] = acceso a todo). + */ + strictModules?: boolean; /** Exige sesión + este rol (any-of si es array). */ requireRole?: string | string[]; /** Exige auth de servicio (x-mc-secret / X-API-Key). */ diff --git a/packages/platform/dist/http/index.d.ts.map b/packages/platform/dist/http/index.d.ts.map index 2cbb4a4..4636bc8 100644 --- a/packages/platform/dist/http/index.d.ts.map +++ b/packages/platform/dist/http/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAwE,MAAM,EAAE,MAAM,WAAW,CAAA;AAoCxG,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,kGAAkG;IAClG,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC/B,wDAAwD;IACxD,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,mCAAmC;IACnC,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,kCAAkC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CAChC;AAED,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AASxF,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EACtD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,OAAO,GAAE,qBAA0B,GAClC,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6EnD"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAwE,MAAM,EAAE,MAAM,WAAW,CAAA;AAoCxG,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,kGAAkG;IAClG,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC/B,wDAAwD;IACxD,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,mCAAmC;IACnC,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,kCAAkC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CAChC;AAED,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AASxF,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EACtD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,OAAO,GAAE,qBAA0B,GAClC,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6EnD"} \ No newline at end of file diff --git a/packages/platform/dist/http/index.js b/packages/platform/dist/http/index.js index 77075a3..cb4cef3 100644 --- a/packages/platform/dist/http/index.js +++ b/packages/platform/dist/http/index.js @@ -83,7 +83,7 @@ function withApiHandler(handler, options = {}) { if (identity) (0, logger_1.setContextFields)({ tenant: identity.tenantId, userId: identity.userId }); if (options.requireModule) - (0, auth_1.requireModule)(identity, options.requireModule); + (0, auth_1.requireModule)(identity, options.requireModule, { strict: options.strictModules }); if (options.requireRole) (0, auth_1.requireRole)(identity, options.requireRole); } diff --git a/packages/platform/src/auth/index.ts b/packages/platform/src/auth/index.ts index 56b0993..822e3dd 100644 --- a/packages/platform/src/auth/index.ts +++ b/packages/platform/src/auth/index.ts @@ -10,10 +10,12 @@ * esquemas actuales (x-mc-secret compartido / X-API-Key por tenant). * * La regla de visibilidad de módulos es la misma de mission-control-main/lib/access.ts: - * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. + * sin lista (null/[]) ⇒ ve todo; con lista ⇒ solo esos ids. Existe además el modo estricto + * (`requireModule(..., { strict: true })` / `isModuleAllowedStrict`): sin lista ⇒ ninguno. */ -import { verifySession } from "@ai4u/mc-sso" +import { readMcSession } from "@ai4u/mc-sso" import { ForbiddenError, UnauthorizedError } from "../errors" +import { getLogger } from "../logger" import { safeEqual } from "../security" export interface Identity { @@ -22,24 +24,47 @@ export interface Identity { roles?: string[] allowedModules?: string[] | null displayName?: string + /** App para la que se emitió la sesión (mc-sso >= 1.3.0). undefined = sesión "legacy". */ + serviceId?: string } -/** Forma extendida de la sesión (mc-sso lleva estos campos opcionales tras el handoff). */ -interface ExtendedSession { - tenantId: string - userId?: string - roles?: string[] - allowedModules?: string[] | null - displayName?: string +/** + * Restricciones de app/tenant sobre la sesión (se pasan tal cual a `readMcSession` de mc-sso). + * El secreto de firma se comparte entre apps: sin estas opciones una cookie de otra app/tenant + * con firma válida PASA. Son opt-in; ver "Cómo adoptar el endurecimiento de auth" en CHANGELOG. + */ +export interface IdentityScope { + /** `serviceId` de ESTA app (el mismo de su `createMcAuthHandler`). Sesión de otro servicio => null. */ + serviceId?: string + /** Tenants aceptados (apps de un solo tenant). Fuera de la lista => null; `[]` rechaza todo. */ + allowedTenants?: readonly string[] + /** Normalizador de tenant para comparar `allowedTenants` (p. ej. `normalizeTenant` de @ai4u/config). */ + normalizeTenant?: (tenantId: string) => string + /** Aceptar sesiones sin `serviceId` (emitidas por mc-sso <= 1.2.0). Default de mc-sso: true. */ + acceptLegacy?: boolean } -export interface SessionAuthConfig { +export interface SessionAuthConfig extends IdentityScope { /** Nombre de la cookie de sesión (default "mc_session"). */ cookieName?: string /** Secreto de firma (default process.env.MISSION_CONTROL_SECRET). */ secret?: string } +let appScope: IdentityScope = {} + +/** + * Fija el alcance de identidad por defecto de ESTA app (una vez, p. ej. en instrumentation.ts o + * en un módulo de config). Las opciones pasadas a `readIdentity`/`withApiHandler({sessionAuth})` + * lo pisan campo a campo. No se deriva nada de `SERVICE_ID`: puede no coincidir con el serviceId + * de su handler SSO y cortaría sesiones válidas. + */ +export function configureIdentity(scope: IdentityScope): void { + appScope = { ...scope } +} + +let warnedNoServiceId = false + type HeaderCarrier = { headers: { get(name: string): string | null } } /** @@ -73,7 +98,22 @@ export function readIdentity(req: HeaderCarrier, cfg: SessionAuthConfig = {}): I if (!secret) return null const token = parseCookies(req.headers.get("cookie"))[cookieName] if (!token) return null - const data = verifySession(token, secret) as ExtendedSession | null + const scope: IdentityScope = { + serviceId: cfg.serviceId ?? appScope.serviceId, + allowedTenants: cfg.allowedTenants ?? appScope.allowedTenants, + normalizeTenant: cfg.normalizeTenant ?? appScope.normalizeTenant, + acceptLegacy: cfg.acceptLegacy ?? appScope.acceptLegacy, + } + if (scope.serviceId === undefined && !warnedNoServiceId) { + warnedNoServiceId = true + getLogger("platform/auth").warn( + "readIdentity sin serviceId configurado: una sesión firmada para otra app con el mismo secreto sería aceptada. " + + "Pasa { serviceId } a readIdentity/sessionAuth o llama configureIdentity({ serviceId }) (platform 0.8.0).", + ) + } + // Se reenvía solo el token ya parseado (parseCookies ignora duplicados mal codificados) y mc-sso + // aplica firma, expiración y las restricciones de app/tenant. + const data = readMcSession(`${cookieName}=${encodeURIComponent(token)}`, secret, { cookieName, ...scope }) if (!data) return null return { tenantId: data.tenantId, @@ -81,6 +121,7 @@ export function readIdentity(req: HeaderCarrier, cfg: SessionAuthConfig = {}): I roles: data.roles, allowedModules: data.allowedModules ?? null, displayName: data.displayName, + serviceId: data.serviceId, } } @@ -91,6 +132,14 @@ export function isModuleAllowed(moduleId: string, allowedModules: string[] | nul return allowedModules.includes(moduleId) } +/** + * Variante ESTRICTA: sin lista (null/undefined/[]) => NINGÚN módulo. Úsala en apps que no son + * Mission Control, donde una sesión sin `allowedModules` no debería ver todo. + */ +export function isModuleAllowedStrict(moduleId: string, allowedModules: string[] | null | undefined): boolean { + return Array.isArray(allowedModules) && allowedModules.includes(moduleId) +} + export function hasRole(identity: Identity | null, role: string): boolean { return !!identity?.roles?.includes(role) } @@ -103,10 +152,15 @@ export function isAdmin(identity: Identity | null): boolean { * Exige identidad y acceso a un módulo (acepta varios: any-of). Lanza errores * tipados que el wrapper HTTP convierte en 401/403 uniformes. */ -export function requireModule(identity: Identity | null, moduleId: string | string[]): Identity { +export function requireModule( + identity: Identity | null, + moduleId: string | string[], + opts: { strict?: boolean } = {}, +): Identity { if (!identity) throw new UnauthorizedError() const ids = Array.isArray(moduleId) ? moduleId : [moduleId] - if (!ids.some((id) => isModuleAllowed(id, identity.allowedModules))) { + const allowed = opts.strict ? isModuleAllowedStrict : isModuleAllowed + if (!ids.some((id) => allowed(id, identity.allowedModules))) { throw new ForbiddenError("No autorizado para este módulo") } return identity diff --git a/packages/platform/src/http/index.ts b/packages/platform/src/http/index.ts index 37bf1d2..4d507ac 100644 --- a/packages/platform/src/http/index.ts +++ b/packages/platform/src/http/index.ts @@ -71,6 +71,11 @@ export interface WithApiHandlerOptions { label?: string /** Exige sesión + acceso a este módulo (any-of si es array). */ requireModule?: string | string[] + /** + * Con `requireModule`: modo estricto, una sesión sin `allowedModules` (null/[]) NO pasa. + * Default false (compatibilidad: null/[] = acceso a todo). + */ + strictModules?: boolean /** Exige sesión + este rol (any-of si es array). */ requireRole?: string | string[] /** Exige auth de servicio (x-mc-secret / X-API-Key). */ @@ -122,7 +127,7 @@ export function withApiHandler( if (options.requireModule || options.requireRole) { identity = readIdentity(req, options.sessionAuth) if (identity) setContextFields({ tenant: identity.tenantId, userId: identity.userId }) - if (options.requireModule) requireModule(identity, options.requireModule) + if (options.requireModule) requireModule(identity, options.requireModule, { strict: options.strictModules }) if (options.requireRole) requireRole(identity, options.requireRole) } else { // Lee identidad si está disponible, pero no la exige. diff --git a/packages/platform/tests/auth-scope.test.ts b/packages/platform/tests/auth-scope.test.ts new file mode 100644 index 0000000..82118fe --- /dev/null +++ b/packages/platform/tests/auth-scope.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { createSession } from "@ai4u/mc-sso" + +const SECRET = "test-secret-auth-scope-0123456789abcdef" + +function reqWith(token: string | null): Request { + return new Request("https://x/api/test", { headers: token ? { cookie: `mc_session=${token}` } : {} }) +} +const session = (tenant: string, serviceId?: string, modules?: string[] | null) => + createSession(tenant, SECRET, 60_000, { userId: "u1", roles: ["admin"], allowedModules: modules, serviceId }) + +// Cada test carga un módulo fresco: el WARN "una vez por proceso" y configureIdentity son estado de módulo. +async function load() { + vi.resetModules() + const logger = await import("../src/logger") + const warn = vi.fn() + vi.spyOn(logger, "getLogger").mockReturnValue({ warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() } as never) + const auth = await import("../src/auth") + return { auth, warn } +} + +beforeEach(() => { + delete process.env.SERVICE_ID +}) +afterEach(() => vi.restoreAllMocks()) + +describe("P2 readIdentity con serviceId / allowedTenants / acceptLegacy", () => { + it("expone serviceId de la sesión en Identity", async () => { + const { auth } = await load() + const id = auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(id?.serviceId).toBe("kpis") + }) + + it("serviceId distinto al de la sesión => null (cookie de otra app)", async () => { + const { auth } = await load() + const t = session("flexoimpresos", "otra-app") + expect(auth.readIdentity(reqWith(t), { secret: SECRET, serviceId: "kpis" })).toBeNull() + expect(auth.readIdentity(reqWith(t), { secret: SECRET, serviceId: "otra-app" })?.tenantId).toBe("flexoimpresos") + }) + + it("allowedTenants rechaza otro tenant y [] es fail-closed", async () => { + const { auth } = await load() + const t = session("tamaprint", "kpis") + expect(auth.readIdentity(reqWith(t), { secret: SECRET, allowedTenants: ["flexoimpresos"] })).toBeNull() + expect(auth.readIdentity(reqWith(t), { secret: SECRET, allowedTenants: [] })).toBeNull() + expect(auth.readIdentity(reqWith(t), { secret: SECRET, allowedTenants: ["Tamaprint"] })?.tenantId).toBe("tamaprint") + }) + + it("acceptLegacy:false rechaza sesiones sin serviceId; por defecto se aceptan", async () => { + const { auth } = await load() + const legacy = session("flexoimpresos") + expect(auth.readIdentity(reqWith(legacy), { secret: SECRET })?.serviceId).toBeUndefined() + expect(auth.readIdentity(reqWith(legacy), { secret: SECRET, acceptLegacy: false })).toBeNull() + }) + + it("SIN opciones el comportamiento no cambia, aunque SERVICE_ID del entorno no coincida", async () => { + process.env.SERVICE_ID = "no-coincide-con-el-handler-sso" + const { auth } = await load() + const id = auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(id?.tenantId).toBe("flexoimpresos") + }) + + it("configureIdentity fija el default de la app; las opciones por llamada lo pisan", async () => { + const { auth } = await load() + auth.configureIdentity({ serviceId: "kpis", allowedTenants: ["flexoimpresos"] }) + expect(auth.readIdentity(reqWith(session("flexoimpresos", "otra")), { secret: SECRET })).toBeNull() + expect(auth.readIdentity(reqWith(session("tamaprint", "kpis")), { secret: SECRET })).toBeNull() + expect(auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET })?.serviceId).toBe("kpis") + expect( + auth.readIdentity(reqWith(session("flexoimpresos", "otra")), { secret: SECRET, serviceId: "otra" }), + ).not.toBeNull() + }) + + it("emite UN solo WARN por proceso cuando no hay serviceId configurado", async () => { + const { auth, warn } = await load() + auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(warn).toHaveBeenCalledTimes(1) + }) + + it("no emite WARN si serviceId está configurado (por opción o por configureIdentity)", async () => { + const a = await load() + a.auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET, serviceId: "kpis" }) + expect(a.warn).not.toHaveBeenCalled() + const b = await load() + b.auth.configureIdentity({ serviceId: "kpis" }) + b.auth.readIdentity(reqWith(session("flexoimpresos", "kpis")), { secret: SECRET }) + expect(b.warn).not.toHaveBeenCalled() + }) +}) + +describe("P2 modo estricto de módulos (null/[] = ninguno)", () => { + it("isModuleAllowed conserva la semántica actual y isModuleAllowedStrict la invierte", async () => { + const { auth } = await load() + expect(auth.isModuleAllowed("kpis", null)).toBe(true) + expect(auth.isModuleAllowed("kpis", [])).toBe(true) + expect(auth.isModuleAllowedStrict("kpis", null)).toBe(false) + expect(auth.isModuleAllowedStrict("kpis", undefined)).toBe(false) + expect(auth.isModuleAllowedStrict("kpis", [])).toBe(false) + expect(auth.isModuleAllowedStrict("kpis", ["kpis"])).toBe(true) + expect(auth.isModuleAllowedStrict("kpis", ["otro"])).toBe(false) + }) + + it("requireModule({strict:true}) lanza 403 con allowedModules null; sin strict pasa", async () => { + const { auth } = await load() + const id = { tenantId: "t", allowedModules: null } + expect(auth.requireModule(id, "kpis")).toBe(id) + expect(() => auth.requireModule(id, "kpis", { strict: true })).toThrow(/módulo/) + expect(auth.requireModule({ tenantId: "t", allowedModules: ["kpis"] }, ["x", "kpis"], { strict: true })).toBeTruthy() + }) +}) + +describe("P2 withApiHandler({ strictModules })", () => { + it("con strictModules una sesión sin allowedModules => 403; sin la opción pasa (compat)", async () => { + vi.resetModules() + const { withApiHandler } = await import("../src/http") + const req = () => reqWith(session("flexoimpresos", "kpis", null)) + const strict = withApiHandler(async () => ({ ok: true }), { + requireModule: "kpis", + strictModules: true, + sessionAuth: { secret: SECRET, serviceId: "kpis" }, + }) + expect((await strict(req())).status).toBe(403) + const lax = withApiHandler(async () => ({ ok: true }), { + requireModule: "kpis", + sessionAuth: { secret: SECRET, serviceId: "kpis" }, + }) + expect((await lax(req())).status).toBe(200) + }) +}) From 14dea1b58c765b3d8477ad8ecb940f4e3ded949b Mon Sep 17 00:00:00 2001 From: donchelo Date: Fri, 30 Oct 2026 12:18:10 -0500 Subject: [PATCH 03/15] =?UTF-8?q?feat(platform):=20P3=20logger=20endurecid?= =?UTF-8?q?o=20(redacci=C3=B3n=20de=20msg/extras,=20claves=20reservadas,?= =?UTF-8?q?=20timeout=20y=20flush=20con=20reintento)=20y=20x-request-id=20?= =?UTF-8?q?validado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redactSecrets sobre msg y extras (recursivo, profundidad 4, tolera ciclos). - tenant/userId/requestId del contexto no se pisan desde los extras. - fetch al ingest con AbortSignal.timeout(2000). - flush con promesa en vuelo compartida; el lote fallido se reencola con tope de 1000 y los envíos automáticos respetan un enfriamiento de 5 s tras un fallo. - x-request-id entrante validado con /^[A-Za-z0-9._-]{1,128}$/ o se regenera. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015nTgTv7S3gQwaoNFw5fZeG --- packages/platform/dist/http/index.d.ts.map | 2 +- packages/platform/dist/http/index.js | 7 +- packages/platform/dist/logger/index.d.ts.map | 2 +- packages/platform/dist/logger/index.js | 98 ++++++++-- packages/platform/src/http/index.ts | 8 +- packages/platform/src/logger/index.ts | 93 ++++++++- .../platform/tests/logger-hardening.test.ts | 177 ++++++++++++++++++ 7 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 packages/platform/tests/logger-hardening.test.ts diff --git a/packages/platform/dist/http/index.d.ts.map b/packages/platform/dist/http/index.d.ts.map index 4636bc8..ac1c692 100644 --- a/packages/platform/dist/http/index.d.ts.map +++ b/packages/platform/dist/http/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAwE,MAAM,EAAE,MAAM,WAAW,CAAA;AAoCxG,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,kGAAkG;IAClG,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC/B,wDAAwD;IACxD,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,mCAAmC;IACnC,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,kCAAkC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CAChC;AAED,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AASxF,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EACtD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,OAAO,GAAE,qBAA0B,GAClC,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6EnD"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAwE,MAAM,EAAE,MAAM,WAAW,CAAA;AAoCxG,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,kGAAkG;IAClG,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAA;CAC1B;AAED,MAAM,WAAW,qBAAqB;IACpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACjC;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC/B,wDAAwD;IACxD,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,mCAAmC;IACnC,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,kCAAkC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAA;CAChC;AAED,KAAK,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AAexF,wBAAgB,cAAc,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EACtD,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,EACxB,OAAO,GAAE,qBAA0B,GAClC,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6EnD"} \ No newline at end of file diff --git a/packages/platform/dist/http/index.js b/packages/platform/dist/http/index.js index cb4cef3..9bb833c 100644 --- a/packages/platform/dist/http/index.js +++ b/packages/platform/dist/http/index.js @@ -58,10 +58,15 @@ function jsonResponse(body, status, requestId) { headers: { "content-type": "application/json", "x-request-id": requestId }, }); } +/** x-request-id entrante: solo charset/longitud acotados; si no, se regenera (evita inflado e inyección en logs). */ +const REQUEST_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; +function safeIncomingRequestId(v) { + return v !== null && REQUEST_ID_RE.test(v) ? v : null; +} function withApiHandler(handler, options = {}) { const label = options.label ?? "api"; return async (req, routeCtx) => { - const requestId = req.headers.get("x-request-id") ?? (0, logger_1.newRequestId)(); + const requestId = safeIncomingRequestId(req.headers.get("x-request-id")) ?? (0, logger_1.newRequestId)(); const log = (0, logger_1.getLogger)(label); return (0, logger_1.runWithContext)({ requestId }, async () => { const start = Date.now(); diff --git a/packages/platform/dist/logger/index.d.ts.map b/packages/platform/dist/logger/index.d.ts.map index c348129..d9713c6 100644 --- a/packages/platform/dist/logger/index.d.ts.map +++ b/packages/platform/dist/logger/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/logger/index.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAavD,MAAM,WAAW,UAAU;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAID,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEjE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAGzD;AAED,wBAAgB,UAAU,IAAI,UAAU,CAEvC;AAID,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAOjD;AA0GD,0GAA0G;AAC1G,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAelD;AA+BD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,QAAQ,EAAE,MAAM,CAAA;IAChB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AA4BD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAM7D;AA0BD,kFAAkF;AAClF,wBAAgB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAEzC;AAWD,KAAK,SAAS,GAAG;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACrD,CAAA;AAwDD,qBAAa,MAAM;IACL,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM;IAE9C,KAAK,EAAE,SAAS,CAA2F;IAC3G,IAAI,EAAE,SAAS,CAA0F;IACzG,IAAI,EAAE,SAAS,CAA0F;IACzG,KAAK,EAAE,SAAS,CAA2F;IAE3G,KAAK,CAAC,QAAQ,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM;CAGhD;AAED,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,MAAM,CAErC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/logger/index.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAavD,MAAM,WAAW,UAAU;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAID,6EAA6E;AAC7E,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEjE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAGzD;AAED,wBAAgB,UAAU,IAAI,UAAU,CAEvC;AAID,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAOjD;AA8HD,0GAA0G;AAC1G,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAelD;AA+BD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,QAAQ,EAAE,MAAM,CAAA;IAChB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd,uCAAuC;IACvC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAsCD,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAM7D;AA6DD,kFAAkF;AAClF,wBAAgB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAEzC;AAYD,KAAK,SAAS,GAAG;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACrD,CAAA;AA+DD,qBAAa,MAAM;IACL,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM;IAE9C,KAAK,EAAE,SAAS,CAA2F;IAC3G,IAAI,EAAE,SAAS,CAA0F;IACzG,IAAI,EAAE,SAAS,CAA0F;IACzG,KAAK,EAAE,SAAS,CAA2F;IAE3G,KAAK,CAAC,QAAQ,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM;CAGhD;AAED,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,IAAI,MAAM,CAErC"} \ No newline at end of file diff --git a/packages/platform/dist/logger/index.js b/packages/platform/dist/logger/index.js index 7060993..d8e150e 100644 --- a/packages/platform/dist/logger/index.js +++ b/packages/platform/dist/logger/index.js @@ -65,12 +65,34 @@ function resolveServiceName() { /* ── Redacción de secretos (red de seguridad de sap-b1-backend) ──────────── */ const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|authorization|cookie|credential/i; function sanitize(fields) { + return sanitizeObject(fields, 0, new WeakSet()); +} +/** Profundidad máxima al recorrer los extras de un log (lo más hondo se reemplaza por un marcador). */ +const MAX_EXTRA_DEPTH = 4; +function sanitizeObject(obj, depth, seen) { const out = {}; - for (const [k, v] of Object.entries(fields)) { - out[k] = SENSITIVE_KEY.test(k) ? "[REDACTED]" : v; + for (const [k, v] of Object.entries(obj)) { + out[k] = SENSITIVE_KEY.test(k) ? "[REDACTED]" : sanitizeValue(v, depth + 1, seen); } return out; } +/** Redacta strings (con tope) y recorre objetos/arrays hasta una profundidad acotada; tolera ciclos. */ +function sanitizeValue(v, depth, seen) { + if (typeof v === "string") + return redactAndCap(v, MAX_MESSAGE); + if (v === null || typeof v !== "object") + return v; + if (v instanceof Date) + return v; + if (v instanceof Error) + return serializeErr(v); + if (depth >= MAX_EXTRA_DEPTH || seen.has(v)) + return "[truncado]"; + seen.add(v); + const res = Array.isArray(v) ? v.map((x) => sanitizeValue(x, depth + 1, seen)) : sanitizeObject(v, depth, seen); + seen.delete(v); + return res; +} /* * Secretos que viajan DENTRO de textos de error (p. ej. googleapis pone `?key=AIza…` * en la URL del mensaje; node-fetch dice `request to failed`). Se enmascara solo @@ -200,6 +222,16 @@ function serializeErr(err) { let transport = null; let buffer = []; let timer = null; +/** Envío en curso: los flush concurrentes lo esperan en vez de retornar con el buffer ya vaciado. */ +let inflight = null; +/** Tope de registros retenidos (reencolados tras un fallo o acumulados): se descartan los más viejos. */ +const MAX_BUFFER = 1000; +/** Timeout del POST al ingest: un ingest lento no puede retener la respuesta de la API. */ +const FLUSH_TIMEOUT_MS = 2000; +/** Tras un fallo, los envíos automáticos (timer / lote lleno) esperan esto; flushLogs() siempre intenta. */ +const FAIL_COOLDOWN_MS = 5000; +let lastFailAt = 0; +let lastFailed = false; /** * Bundlers como Turbopack pueden instanciar este módulo más de una vez dentro * del mismo proceso (una copia por bundle de función serverless: una para @@ -227,35 +259,69 @@ function configureTransport(cfg) { transport = cfg; if (timer) clearInterval(timer); - timer = setInterval(() => void flush(), cfg.flushMs ?? 2000); + timer = setInterval(autoFlush, cfg.flushMs ?? 2000); // No mantener vivo el proceso solo por el logger. timer.unref?.(); } -async function flush() { - const cfg = resolveTransport(); - if (!cfg || buffer.length === 0) - return; - const batch = buffer; - buffer = []; +/** Devuelve los registros de un lote fallido al frente del buffer, respetando el tope. */ +function requeue(batch) { + buffer = batch.concat(buffer); + if (buffer.length > MAX_BUFFER) + buffer = buffer.slice(buffer.length - MAX_BUFFER); +} +async function send(cfg, batch) { try { const res = await fetch(cfg.endpoint, { method: "POST", headers: { "content-type": "application/json", "x-ingest-secret": cfg.secret }, body: JSON.stringify({ logs: batch }), keepalive: true, + signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS), }); // fetch() NO lanza por status HTTP no-2xx (p.ej. una redirección de Vercel // Deployment Protection hacia su login) — hay que chequearlo explícitamente // o el fallo pasa inadvertido igual que el bug de arriba. if (!res.ok) { process.stderr.write(`[platform/logger] ingest flush respondió ${res.status}: ${cfg.endpoint}\n`); + // Transitorio (5xx / 408 / 429): se reintenta. Otro 4xx (secreto malo) no mejora reintentando. + if (res.status >= 500 || res.status === 408 || res.status === 429) { + requeue(batch); + lastFailed = true; + lastFailAt = Date.now(); + return; + } } + lastFailed = false; } catch (err) { - // El ingest cayó: no perder del todo el dato ni romper la app. + // El ingest cayó o tardó demasiado: el lote vuelve al buffer (con tope) y la app no se rompe. process.stderr.write(`[platform/logger] ingest flush failed: ${String(err)}\n`); + requeue(batch); + lastFailed = true; + lastFailAt = Date.now(); } } +function flush() { + if (inflight) { + // Espera el envío en curso; si mientras tanto entró algo nuevo (y el envío no falló), lo manda. + return inflight.then(() => (buffer.length > 0 && !lastFailed ? flush() : undefined)); + } + const cfg = resolveTransport(); + if (!cfg || buffer.length === 0) + return Promise.resolve(); + const batch = buffer; + buffer = []; + inflight = send(cfg, batch).finally(() => { + inflight = null; + }); + return inflight; +} +/** Envío automático (timer / lote lleno): respeta el enfriamiento tras un fallo. */ +function autoFlush() { + if (lastFailed && Date.now() - lastFailAt < FAIL_COOLDOWN_MS) + return; + void flush(); +} /** Fuerza el envío inmediato del buffer (útil en cron/scripts antes de salir). */ function flushLogs() { return flush(); @@ -265,8 +331,10 @@ function enqueue(record) { if (!cfg) return; buffer.push(record); + if (buffer.length > MAX_BUFFER) + buffer = buffer.slice(buffer.length - MAX_BUFFER); if (buffer.length >= (cfg.batchMax ?? 50)) - void flush(); + autoFlush(); } function emit(level, component, fieldsOrMsg, msg) { // ERROR siempre se emite y persiste, aunque MIN_LEVEL sea más alto (nada de @@ -290,12 +358,20 @@ function emit(level, component, fieldsOrMsg, msg) { // `extra` (primer argumento de log.info/warn/error/debug). Bug real: un // caller pasando `{ service: mod.id }` como campo de negocio silenciaba el // nombre real del servicio en cada log (mission-control/app/api/handoff). + // tenant/userId/requestId: el contexto de la request manda (evita logs mal atribuidos entre + // tenants); si no hay contexto, el extra sigue valiendo como dato informativo. const safeExtra = sanitize(extra); delete safeExtra.ts; delete safeExtra.level; delete safeExtra.service; delete safeExtra.component; delete safeExtra.msg; + for (const k of ["tenant", "userId", "requestId"]) { + if (ctx[k] !== undefined) + delete safeExtra[k]; + } + // El mensaje también puede traer secretos/PII (p. ej. texto de error de un navegador o de un tercero). + message = redactAndCap(message, MAX_MESSAGE); const record = { ts: new Date().toISOString(), level, diff --git a/packages/platform/src/http/index.ts b/packages/platform/src/http/index.ts index 4d507ac..c046617 100644 --- a/packages/platform/src/http/index.ts +++ b/packages/platform/src/http/index.ts @@ -95,13 +95,19 @@ function jsonResponse(body: unknown, status: number, requestId: string): Respons }) } +/** x-request-id entrante: solo charset/longitud acotados; si no, se regenera (evita inflado e inyección en logs). */ +const REQUEST_ID_RE = /^[A-Za-z0-9._-]{1,128}$/ +function safeIncomingRequestId(v: string | null): string | null { + return v !== null && REQUEST_ID_RE.test(v) ? v : null +} + export function withApiHandler( handler: RouteHandler, options: WithApiHandlerOptions = {}, ): (req: Request, routeCtx?: R) => Promise { const label = options.label ?? "api" return async (req: Request, routeCtx?: R): Promise => { - const requestId = req.headers.get("x-request-id") ?? newRequestId() + const requestId = safeIncomingRequestId(req.headers.get("x-request-id")) ?? newRequestId() const log = getLogger(label) return runWithContext({ requestId }, async () => { diff --git a/packages/platform/src/logger/index.ts b/packages/platform/src/logger/index.ts index adf5f73..95bdcf4 100644 --- a/packages/platform/src/logger/index.ts +++ b/packages/platform/src/logger/index.ts @@ -73,13 +73,33 @@ function resolveServiceName(): string { const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|authorization|cookie|credential/i function sanitize(fields: Record): Record { + return sanitizeObject(fields, 0, new WeakSet()) +} + +/** Profundidad máxima al recorrer los extras de un log (lo más hondo se reemplaza por un marcador). */ +const MAX_EXTRA_DEPTH = 4 + +function sanitizeObject(obj: object, depth: number, seen: WeakSet): Record { const out: Record = {} - for (const [k, v] of Object.entries(fields)) { - out[k] = SENSITIVE_KEY.test(k) ? "[REDACTED]" : v + for (const [k, v] of Object.entries(obj)) { + out[k] = SENSITIVE_KEY.test(k) ? "[REDACTED]" : sanitizeValue(v, depth + 1, seen) } return out } +/** Redacta strings (con tope) y recorre objetos/arrays hasta una profundidad acotada; tolera ciclos. */ +function sanitizeValue(v: unknown, depth: number, seen: WeakSet): unknown { + if (typeof v === "string") return redactAndCap(v, MAX_MESSAGE) + if (v === null || typeof v !== "object") return v + if (v instanceof Date) return v + if (v instanceof Error) return serializeErr(v) + if (depth >= MAX_EXTRA_DEPTH || seen.has(v)) return "[truncado]" + seen.add(v) + const res = Array.isArray(v) ? v.map((x) => sanitizeValue(x, depth + 1, seen)) : sanitizeObject(v, depth, seen) + seen.delete(v) + return res +} + /* * Secretos que viajan DENTRO de textos de error (p. ej. googleapis pone `?key=AIza…` * en la URL del mensaje; node-fetch dice `request to failed`). Se enmascara solo @@ -229,6 +249,16 @@ export interface TransportConfig { let transport: TransportConfig | null = null let buffer: Record[] = [] let timer: ReturnType | null = null +/** Envío en curso: los flush concurrentes lo esperan en vez de retornar con el buffer ya vaciado. */ +let inflight: Promise | null = null +/** Tope de registros retenidos (reencolados tras un fallo o acumulados): se descartan los más viejos. */ +const MAX_BUFFER = 1000 +/** Timeout del POST al ingest: un ingest lento no puede retener la respuesta de la API. */ +const FLUSH_TIMEOUT_MS = 2000 +/** Tras un fallo, los envíos automáticos (timer / lote lleno) esperan esto; flushLogs() siempre intenta. */ +const FAIL_COOLDOWN_MS = 5000 +let lastFailAt = 0 +let lastFailed = false /** * Bundlers como Turbopack pueden instanciar este módulo más de una vez dentro @@ -256,35 +286,70 @@ function resolveTransport(): TransportConfig | null { export function configureTransport(cfg: TransportConfig): void { transport = cfg if (timer) clearInterval(timer) - timer = setInterval(() => void flush(), cfg.flushMs ?? 2000) + timer = setInterval(autoFlush, cfg.flushMs ?? 2000) // No mantener vivo el proceso solo por el logger. timer.unref?.() } -async function flush(): Promise { - const cfg = resolveTransport() - if (!cfg || buffer.length === 0) return - const batch = buffer - buffer = [] +/** Devuelve los registros de un lote fallido al frente del buffer, respetando el tope. */ +function requeue(batch: Record[]): void { + buffer = batch.concat(buffer) + if (buffer.length > MAX_BUFFER) buffer = buffer.slice(buffer.length - MAX_BUFFER) +} + +async function send(cfg: TransportConfig, batch: Record[]): Promise { try { const res = await fetch(cfg.endpoint, { method: "POST", headers: { "content-type": "application/json", "x-ingest-secret": cfg.secret }, body: JSON.stringify({ logs: batch }), keepalive: true, + signal: AbortSignal.timeout(FLUSH_TIMEOUT_MS), }) // fetch() NO lanza por status HTTP no-2xx (p.ej. una redirección de Vercel // Deployment Protection hacia su login) — hay que chequearlo explícitamente // o el fallo pasa inadvertido igual que el bug de arriba. if (!res.ok) { process.stderr.write(`[platform/logger] ingest flush respondió ${res.status}: ${cfg.endpoint}\n`) + // Transitorio (5xx / 408 / 429): se reintenta. Otro 4xx (secreto malo) no mejora reintentando. + if (res.status >= 500 || res.status === 408 || res.status === 429) { + requeue(batch) + lastFailed = true + lastFailAt = Date.now() + return + } } + lastFailed = false } catch (err) { - // El ingest cayó: no perder del todo el dato ni romper la app. + // El ingest cayó o tardó demasiado: el lote vuelve al buffer (con tope) y la app no se rompe. process.stderr.write(`[platform/logger] ingest flush failed: ${String(err)}\n`) + requeue(batch) + lastFailed = true + lastFailAt = Date.now() } } +function flush(): Promise { + if (inflight) { + // Espera el envío en curso; si mientras tanto entró algo nuevo (y el envío no falló), lo manda. + return inflight.then(() => (buffer.length > 0 && !lastFailed ? flush() : undefined)) + } + const cfg = resolveTransport() + if (!cfg || buffer.length === 0) return Promise.resolve() + const batch = buffer + buffer = [] + inflight = send(cfg, batch).finally(() => { + inflight = null + }) + return inflight +} + +/** Envío automático (timer / lote lleno): respeta el enfriamiento tras un fallo. */ +function autoFlush(): void { + if (lastFailed && Date.now() - lastFailAt < FAIL_COOLDOWN_MS) return + void flush() +} + /** Fuerza el envío inmediato del buffer (útil en cron/scripts antes de salir). */ export function flushLogs(): Promise { return flush() @@ -294,7 +359,8 @@ function enqueue(record: Record): void { const cfg = resolveTransport() if (!cfg) return buffer.push(record) - if (buffer.length >= (cfg.batchMax ?? 50)) void flush() + if (buffer.length > MAX_BUFFER) buffer = buffer.slice(buffer.length - MAX_BUFFER) + if (buffer.length >= (cfg.batchMax ?? 50)) autoFlush() } /* ── Logger ──────────────────────────────────────────────────────────────── */ @@ -326,12 +392,19 @@ function emit(level: Level, component: string, fieldsOrMsg: Record = { ts: new Date().toISOString(), diff --git a/packages/platform/tests/logger-hardening.test.ts b/packages/platform/tests/logger-hardening.test.ts new file mode 100644 index 0000000..db97f81 --- /dev/null +++ b/packages/platform/tests/logger-hardening.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +/** + * P3 (platform 0.8.0): redacción de msg/extras, claves reservadas, timeout y flush del transporte, + * x-request-id hostil. Cada test usa un módulo logger fresco (el transporte es estado de módulo). + */ +type Logger = typeof import("../src/logger") + +let lines: string[] +async function fresh(): Promise { + vi.resetModules() + return (await import("../src/logger")) as Logger +} + +beforeEach(() => { + process.env.LOG_FORMAT = "json" + process.env.LOG_LEVEL = "DEBUG" + delete process.env.PLATFORM_INGEST_URL + delete process.env.INGEST_SECRET + lines = [] + const sink = ((c: unknown) => (lines.push(String(c)), true)) as never + vi.spyOn(process.stdout, "write").mockImplementation(sink) + vi.spyOn(process.stderr, "write").mockImplementation(sink) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + delete process.env.PLATFORM_INGEST_URL + delete process.env.INGEST_SECRET +}) + +const records = () => lines.map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean) + +describe("P3 redacción de msg y extras", () => { + it("msg y extras anidados no salen con Bearer, token ni correo", async () => { + const { getLogger } = await fresh() + getLogger("c").error( + { headers: { authorization: "Bearer abcdefgh12345678" }, nested: { deep: { url: "https://a.com/x?key=ZZZSECRET99" } }, list: ["token=abcdef123456"] }, + "token=abcdef123456 mail ana@x.com", + ) + const out = lines.join("") + expect(out).not.toContain("abcdefgh12345678") + expect(out).not.toContain("abcdef123456") + expect(out).not.toContain("ana@x.com") + expect(out).not.toContain("ZZZSECRET99") + expect(out).toContain("[correo]@x.com") + }) + + it("claves sensibles anidadas se enmascaran y no se rompe con objetos circulares", async () => { + const { getLogger } = await fresh() + const circ: Record = { password: "p4ss", ok: 1 } + circ.self = circ + expect(() => getLogger("c").info({ circ, a: { b: { c: { d: { e: { f: "deep" } } } } } }, "m")).not.toThrow() + const r = records()[0] + expect(r.circ.password).toBe("[REDACTED]") + expect(r.circ.ok).toBe(1) + }) +}) + +describe("P3 claves reservadas no pisables por extras", () => { + it("el contexto manda sobre tenant/userId/requestId/service pasados como extras", async () => { + const { getLogger, runWithContext, setServiceName } = await fresh() + setServiceName("svc-real") + runWithContext({ tenant: "real", userId: "u-real", requestId: "rid-real" }, () => + getLogger("c").info({ tenant: "fake", userId: "u-fake", requestId: "rid-fake", service: "fake" }, "m"), + ) + expect(records()[0]).toMatchObject({ tenant: "real", userId: "u-real", requestId: "rid-real", service: "svc-real" }) + }) + + it("sin contexto, un extra tenant sigue siendo informativo (no se pierde)", async () => { + const { getLogger } = await fresh() + getLogger("c").info({ tenant: "t1" }, "m") + expect(records()[0].tenant).toBe("t1") + }) +}) + +describe("P3 transporte al ingest", () => { + function setup(fetchImpl: (url: string, init: RequestInit) => Promise) { + process.env.PLATFORM_INGEST_URL = "http://ingest.test/x" + process.env.INGEST_SECRET = "s" + const f = vi.fn(fetchImpl) + vi.stubGlobal("fetch", f) + return f + } + + it("el fetch lleva una señal de timeout (AbortSignal)", async () => { + const f = setup(async () => new Response("ok")) + const { getLogger, flushLogs } = await fresh() + getLogger("c").error("uno") + await flushLogs() + expect(f).toHaveBeenCalledTimes(1) + expect(f.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal) + }) + + it("un ingest colgado no retiene la respuesta más allá del timeout", async () => { + setup((_u, init) => + new Promise((_res, rej) => { + // Sin señal de timeout el fetch nunca termina (ingest colgado). + init.signal?.addEventListener("abort", () => rej(new Error("aborted"))) + }), + ) + const { getLogger, flushLogs } = await fresh() + getLogger("c").error("uno") + const t = Date.now() + await flushLogs() + expect(Date.now() - t).toBeLessThan(3500) + }, 10_000) + + it("un flush concurrente ESPERA al que está en vuelo (no retorna con buffer vacío)", async () => { + let release!: () => void + const gate = new Promise((r) => (release = r)) + const f = setup(async () => (await gate, new Response("ok"))) + const { getLogger, flushLogs } = await fresh() + getLogger("c").error("A") + const p1 = flushLogs() + let done2 = false + const p2 = flushLogs().then(() => (done2 = true)) // buffer vacío, pero hay un envío en curso + await new Promise((r) => setTimeout(r, 30)) + expect(done2).toBe(false) + release() + await Promise.all([p1, p2]) + expect(done2).toBe(true) + expect(f).toHaveBeenCalledTimes(1) + }) + + it("si el envío falla, el lote se reencola y se reenvía en el siguiente flush", async () => { + let n = 0 + const f = setup(async () => { + n++ + if (n === 1) throw new Error("ingest caído") + return new Response("ok") + }) + const { getLogger, flushLogs } = await fresh() + getLogger("c").error("perdible") + await flushLogs() + await flushLogs() + expect(f).toHaveBeenCalledTimes(2) + expect(String(f.mock.calls[1][1].body)).toContain("perdible") + }) + + it("el reencolado tiene tope: no crece sin límite con el ingest caído", async () => { + const f = setup(async () => new Response("no", { status: 503 })) + const { getLogger, flushLogs } = await fresh() + const log = getLogger("c") + for (let i = 0; i < 3000; i++) log.error(`m${i}`) + await flushLogs() + await flushLogs() + const last = JSON.parse(String(f.mock.calls.at(-1)![1].body)).logs as unknown[] + expect(last.length).toBeLessThanOrEqual(1000) + expect(last.length).toBeGreaterThan(200) // se retuvo lo pendiente (antes: el lote se perdía) + }) +}) + +describe("P3 x-request-id entrante", () => { + async function ridFor(value: string): Promise { + vi.resetModules() + const { withApiHandler } = await import("../src/http") + const res = await withApiHandler(async () => ({}))(new Request("https://x/a", { headers: { "x-request-id": value } })) + return res.headers.get("x-request-id")! + } + + it("un id de 5000 caracteres se regenera", async () => { + const rid = await ridFor("A".repeat(5000)) + expect(rid.length).toBeLessThanOrEqual(128) + expect(rid).not.toContain("AAAAAAAAAA") + }) + + it("un id con caracteres fuera de [A-Za-z0-9._-] se regenera", async () => { + expect(await ridFor("abc def")).not.toBe("abc def") + expect(await ridFor("a