// apps/admin/contexts/corridor-context.tsx const STORAGE_KEY = 'mogos-corridor'; // localStorage const COOKIE_KEY = 'mogos-corridor'; // y una cookie // apps/admin/app/(dashboard)/layout.tsx const initialCorridor = cookieStore.get('mogos-corridor')?.value ?? ''; // y el API acepta lo que le llegue resolveCorridorIdFilter(...) // 7 servicios lo usan
El navegador escribe el valor, el layout del servidor lo lee de la cookie y lo pasa como parámetro al API, que lo acepta. Andy abre las herramientas de desarrollo, cambia la cookie, y ve Venezuela.
Para una preferencia de vista entre colegas eso está perfectamente bien — es lo que es, y por eso existe la opción «sin ruta específica». Como mecanismo para mantener al personal de otra entidad legal fuera de tus datos, no lo está.
Ahí está toda la diferencia. El corredor es una preferencia: hoy miro China, mañana Estados Unidos. La empresa es una propiedad de la persona: Andy pertenece a la entidad de China, y eso no es un interruptor que él alterna.
Así que se reusa el modelo mental del corredor —un filtro alto que ordena todo lo de abajo— y se le cambia la fuente de verdad:
| Corredor · hoy | Empresa · propuesto | |
|---|---|---|
| De dónde sale el valor | La cookie del navegador | Las membresías del usuario, resueltas en el servidor desde CompanyUser, junto a permissionSet |
| ¿Lo manda el cliente? | Sí, como parámetro | Nunca. Un valor que llegue del cliente se ignora |
| ¿Se puede vaciar? | Sí — «sin ruta específica» | No. Si perteneces a una empresa, no existe la opción «todas» |
| Qué filtra | La vista | Las filas |
| El selector | Siempre visible, con todas las rutas | Solo ofrece lo que eres. Andy pertenece a una: no ve selector |
Es la misma tubería y el mismo hábito para quien la usa. Lo único que cambia es que el conjunto de valores permitidos lo decide el servidor, no el navegador. Por eso no se puede quitar: no hay nada que quitar.
permissions.guard.ts:80 · super-admin.guard.ts:34 ·
can-access.util.ts:34 · mcp/can-use-tool.ts:39. Los cuatro dicen lo
mismo: isSuperAdmin === true || role === MANAGER → salta la matriz de permisos
completa.
Si la empresa se implementa como «un permiso más», ese bypass la salta también. Y basta con que
alguien de la entidad de China sea MANAGER para que el aislamiento desaparezca sin
que nadie lo note.
Son dos preguntas ortogonales y hay que mantenerlas separadas:
El permiso decide qué puedes hacer
Leer, crear, actualizar, borrar. Ahí el bypass de MANAGER es deliberado y se queda como está.
La empresa decide sobre qué filas
Un MANAGER de la entidad de China puede hacer todo — sobre las filas de China. El bypass no toca el alcance, y el alcance no toca el bypass.
Casi todo el mundo diseña esto pensando en la fuga. El fallo más probable es el contrario: que Andy entre y vea una plataforma vacía, y concluya que está rota. Y como el filtro no se puede quitar, él no tiene forma de comprobar si algo existe. Por eso esta lista deja de ser un detalle y pasa a ser carga estructural.
| Se filtra por empresa | Nunca se filtra |
|---|---|
| Cotizaciones de servicio · prefacturas · pagos · órdenes · fletes · contenedores · productos y ventas | Datos de referencia: países, corredores, almacenes, ciudades, métodos de pago, tipos de cambio, tarifas |
| Lo que lleva dinero o carga | El maestro de proveedores — es conocimiento compartido a propósito: Pedro y Jesús se recomiendan fábricas, y partirlo destruiría el histórico de precios que acabamos de diseñar |
| El propio usuario y su sesión, o no puede ni entrar |
Y una consecuencia directa de que el filtro no se pueda quitar: companyId no puede ser anulable. Una fila en NULL o la ve todo el mundo o no la ve nadie — el error clásico de este tipo de filtro. Va NOT NULL después del relleno, para que ese estado no pueda existir.
La respuesta no hay que inventarla: este repo ya escribió la regla, en el interceptor que permite que una persona sea a la vez personal y cliente.
«Porque actuar como cliente solo puede ESTRECHAR el acceso… lo peor que logra un atacante falsificando esta cabecera es ver menos de sus propios datos. En el momento en que eso deje de ser cierto —el primer branch que conceda algo— esta cabecera tiene que pasar a formar parte del token.»
apps/api/src/auth/interceptors/client-scope.interceptor.ts
Elegir empresa concede acceso a otro conjunto de datos. Así que la pregunta se parte en dos, y cada mitad tiene una fuente distinta:
| La pregunta | De dónde sale la respuesta |
|---|---|
| ¿A qué empresas puedo ver? concede |
El servidor, siempre. Se resuelve de CompanyUser en JwtStrategy.validate, junto a permissionSet. Un valor que llegue del cliente se responde con 403, no se ignora en silencio — porque o es un bug o es un intento, y las dos cosas hay que verlas. |
| ¿Cuál de las mías estoy mirando? estrecha |
Una cookie, como el corredor. Es seguro precisamente porque solo puede estrechar dentro de lo que el servidor ya autorizó. Lo peor que logras falsificándola es ver menos de lo tuyo. |
Si perteneces a una empresa, estrechar dentro de un conjunto de un elemento da siempre el mismo elemento. Borrar las cookies, editarlas, mandar otro identificador por parámetro: todos los caminos terminan en China, porque el conjunto no viene del navegador.
El selector, cuando perteneces a varias
Una empresa · Andy, Pedro, casi todos
No hay selector. No es que esté deshabilitado: no se dibuja. El alcance es un hecho, no una elección, y una elección de una sola opción es ruido.
Varias · tú, Samuel
Un selector con exactamente tus empresas, más «Consolidado» — la palabra que el switcher de finanzas ya usa para «todas». Por defecto abre en Consolidado: si tienes las dos, normalmente quieres el cuadro completo.
Y para que sea imposible de verdad, no solo correcto
El diseño de arriba es correcto, pero se aplica con cláusulas where en muchos servicios — y basta con que una ruta nueva lo olvide. Hay tres niveles de garantía, y conviene elegir a sabiendas:
| Nivel | Qué garantiza · qué cuesta |
|---|---|
| Disciplina + guarda | Cada servicio aplica el alcance y lint:company-scope revienta la compilación si la regla se escribe dos veces. Bueno, pero no hermético: una consulta nueva dentro de un servicio existente puede pasar. |
| Extensión de Prisma recomendado |
Una capa que inyecta el filtro en toda consulta a los modelos declarados como alcanzados. El desarrollador no puede olvidarlo porque nunca lo escribe. Y la lista de modelos alcanzados vive en un archivo auditable en vez de repartida en veinte servicios. Hoy el repo no tiene ninguna extensión ni middleware de Prisma, así que sería la primera — un solo sitio que revisar. |
| Seguridad a nivel de fila en Postgres | La base de datos misma se niega. Es lo más fuerte que existe. Pero exige una variable de sesión por petición a través del pool de conexiones, y con el pooler que usa esta plataforma eso es delicado. Para el modelo de amenaza real —personal de una empresa hermana, no un atacante con acceso SQL— es desproporcionado. |
Una extensión de Prisma no cubre las consultas crudas. Este repo tiene varias — las
búsquedas por nombre usan $queryRaw con f_unaccent para poder apoyarse en
el índice. Esas hay que acotarlas a mano, y son exactamente el tipo de sitio donde se olvida.
Así que la guarda no sobra aunque haya extensión: se ocupa de lo que la extensión no ve. Las dos, no una.
Un filtro global se aplica en muchos servicios, y basta con que una ruta nueva lo olvide para que filtre todo. Este repo ya se quemó con eso y dejó escrita la lección — el encabezado de check-client-scope.ts:
«Falla la compilación cuando la regla de “¿esta petición es de cliente?” tiene una segunda copia. Una comparación de rol suelta parte la regla en dos, y las dos copias se desfasan — el mismo fallo que produjo un controlador de pagos sin decorador de matriz, una barra lateral que mostraba enlaces que el guardia negaba, y un feed de acciones que le entregaba conversaciones al personal de almacén.»
Tres fugas reales, causadas por la misma regla escrita dos veces. La empresa merece exactamente el mismo trato: un solo predicado y un lint:company-scope que reviente la compilación cuando aparezca una segunda copia, en el mismo commit que la migración — no después.
Y el hueco no está repartido: el bloque serviceQuotes.products no existe en chino. Es la pestaña de productos y proveedores — justo donde Andy trabajaría.
Su idioma vive en su navegador
User no tiene ni un campo de preferencia: cero. El idioma se guarda en localStorage con la clave mogos-admin-language.
Computadora nueva, español. Se arregla con una columna: User.locale.
Y el API no pregunta
10 sitios de producción mandan language: 'es' fijo. Así que cada notificación que Andy dispare sale en español aunque su pantalla esté en chino.
Con User.locale guardado, esos 10 sitios leen el idioma del destinatario en vez de suponerlo.
La cadena, y por qué se resuelve al leer
idioma = User.locale // lo que la persona eligió ?? Company.defaultUiLocale // el de su empresa · siembra al PERSONAL ?? 'es' // el de la plataforma
Se resuelve al leer, no se copia al crear
User.locale nace nulo, y nulo significa algo honesto: «esta persona todavía no ha elegido». Si mañana cambias el idioma por defecto de la entidad de China, todo el que nunca eligió lo sigue — sin migración ni relleno.
Copiarlo al dar de alta congelaría el valor y obligaría a un backfill cada vez que cambie.
El default de empresa siembra al personal, no a los clientes
Es la trampa. Los clientes de Andy son venezolanos — David lo dijo. Darle una interfaz en chino a un cliente venezolano solo porque lo factura la entidad de China sería un error inmediato y visible.
Para un cliente la cadena es más corta: User.locale ?? 'es'. La empresa que le factura no dice nada sobre el idioma en que lee.
Company.defaultLocale ya existe en el proyecto de cotizaciones, pero es
del documento — ES o EN_ZH, según quién lee la proforma. Este es
otro eje: la pantalla, es · en · zh, según quién trabaja.
Un chino con la interfaz en chino puede y debe emitir un documento en español a un cliente
venezolano. Fusionarlos haría imposible esa combinación, que es justo la más frecuente.
Se llama defaultUiLocale y vive aparte.
Dónde se enchufa
| Hoy | Después |
|---|---|
| Detección ['localStorage','navigator','htmlTag'] | El idioma resuelto viaja en /users/me, que ya devuelve el perfil y los permisos. localStorage pasa de ser la verdad a ser una caché para que la primera pintura no parpadee. |
| Elegir idioma solo toca el navegador | Escribe la caché y guarda User.locale. Computadora nueva, mismo idioma. |
| 10 sitios con language: 'es' | Resuelven el idioma del destinatario, no el de quien dispara. Es el error fácil: cuando Andy manda una proforma a un cliente venezolano, la notificación va en español — porque se lee del que recibe. |
Y hace falta una guarda, o el chino se vuelve a pudrir. Que inglés y chino tengan ~190 [TRANSLATE] cada uno es la prueba de que ya pasó dos veces. Una clave nueva sin traducción tiene que romper la compilación, igual que el alcance.
El menú está completamente traducido — las 39 etiquetas, en chino real. Así que la historia no es «la plataforma está en español». Es que el menú lo lleva hasta la pantalla donde trabaja, y ahí se cae.
El token se imprime, no se repliega
Las 192 claves existen en el archivo chino — con [TRANSLATE] dentro del valor. i18next encuentra la clave y pinta el valor tal cual.
Andy no ve español ahí: ve [TRANSLATE] Cotización en su barra de pestañas. Es peor que no traducir — parece que el programa está roto.
Y el bloque de productos no existe
Las 168 que faltan sí caen a español, porque el repliegue apunta a es. Y no están repartidas al azar: serviceQuotes.products no existe entero.
Es la pestaña de productos y proveedores — exactamente donde Andy pasaría el día.
Y la barra superior, según quién eres
Traducir 5 000 claves es la respuesta perezosa. El hueco está concentrado: el menú está completo y lo que se rompe es un módulo. Hace falta medir qué toca Andy de verdad y cerrar eso — más una guarda que impida que la próxima pantalla nueva vuelva a nacer con [TRANSLATE] dentro.
Un tercer selector en la barra
Ya hay dos —corredor y entidad de finanzas— y el propio código documenta el dolor de que choquen. El selector de empresa solo aparece si perteneces a más de una: Andy no ve ninguno, y tampoco Pedro.
Meter la empresa en la matriz de permisos
El bypass de MANAGER la saltaría. Permiso es qué puedes hacer; empresa es sobre qué filas. Juntarlas es la fuga.
Aceptar la empresa como parámetro o cabecera
El repo ya tiene la regla escrita para el caso del cliente: una cabecera es segura solo mientras estreche el acceso. Elegir empresa concede acceso a otro conjunto de datos, así que va resuelta en el servidor.
Filtrar los proveedores por empresa
Es conocimiento compartido a propósito — Pedro y Jesús se recomiendan fábricas. Partirlo destruiría el histórico de precios que acabamos de diseñar.
Traducir a mano y confiar en la disciplina
Inglés y chino tienen ~190 [TRANSLATE] cada uno. Ya se intentó dos veces. Sin una guarda que rompa la compilación, la tercera termina igual.
Traducir los documentos con el i18n de la app
La proforma ya se resolvió aparte, con etiquetas fijas EN / 中文 — el documento lo leen dos audiencias a la vez y no depende del idioma de quien lo genera.
| Riesgo | Arnés |
|---|---|
| Filtrar de más | Es el fallo más probable y el más silencioso: Andy ve una plataforma vacía y cree que está rota. Prueba de humo por rol: entrar como Andy y verificar que cada pantalla del menú devuelve algo — o dice explícitamente por qué no. La lista blanca de datos de referencia es parte de la migración, no un ajuste posterior. |
| Una ruta nueva que olvide el alcance | lint:company-scope en el mismo commit. El repo ya tiene el precedente y la lista de las tres fugas que causó no tenerlo. |
| El relleno de companyId | Todo lo existente es de Mogos Venezuela. Se rellena, se verifica que no quedó ninguna fila sin empresa, y solo entonces se pone NOT NULL. En ese orden. |
| Las cotizaciones existentes cambian de dueño | Ninguna debería, pero es lo que hay que demostrar: mismo conteo por lista antes y después para un usuario de Venezuela. |
| El corredor y la empresa se contradicen | Andy pertenece a China y su corredor por defecto es CN→VE. Si limpia el corredor debe seguir viendo solo China — son dos filtros, y el de empresa no depende del otro. Prueba explícita. |
| La traducción llega tarde | Andy puede usar la plataforma en inglés mientras tanto — pero inglés tiene 191 [TRANSLATE], así que tampoco está listo. Conviene medir cuánto de lo que Andy realmente toca está traducido, en vez de traducir 5 000 claves que no va a ver. |
Mismo filtro que la ruta. Distinta fuente de verdad.
Andy no necesita una plataforma nueva ni un modelo de inquilinos: necesita que el filtro que ya existe deje de venir del navegador y venga de quién es él. Eso, una columna para su idioma, y una guarda que impida que la regla se escriba dos veces. Lo demás es traducción — medible, aburrida y necesaria.
Cambia
La fuente del filtro: del navegador al servidor.
Se agrega
User.locale y companyId NOT NULL.
Se protege
lint:company-scope, en el mismo commit.
Se mide
Cuánto de lo que Andy toca está en chino. Hoy: no todo.