Multi-tenant por empresa
La decisión: Proteus sirve a varias empresas (clientes) desde la misma base de datos. El aislamiento
es por columna COMPANYID en las tablas del dominio, se resuelve por sesión y es fail-closed.
Cómo se resuelve el tenant
- El login pasa por Ajolote, que resuelve el tenant server-side y lo pinnea en cookie httpOnly. El front nunca conoce la lista de tenants. Ver Autenticación.
- El JIT-provisioning persiste el
appIdenuser.company. Del lado de Proteus alcanza con eluserId: no hay que threadear datos del request por toda la pila. CompanyContextServiceresuelve elUserScope({ role, companyId }) leyendo de Redis (USERSCOPE-<id>) con fallback a la base, y cachea el mapeoappId → Company.iden memoria.
Los dos sentinelas del fail-closed
export const NO_COMPANY_SCOPE = -1; // "ninguna empresa": WHERE COMPANYID = -1 → 0 filas
Valor de companyId | Significado | A quién le toca |
|---|---|---|
null | Ve TODO (bypass del filtro) | Solo SUPERUSER y ADMIN |
-1 | No ve nada | Un usuario no privilegiado sin empresa asignada |
| un id | Ve solo esa empresa | Todos los demás |
null y -1 a un solo "falsy"Distinguirlos es el corazón del diseño. null es "ve todo" y -1 es "no ve nada": son opuestos. Un
if (!companyId) que los trate igual convierte un usuario sin empresa en un superusuario.
La elección es deliberada: para un usuario no privilegiado sin empresa, preferimos que no vea nada antes que verlo todo.
Alcance
COMPANYID está —o debe estar— en las tablas del dominio:
Patient · AtentionProcess · Authorizations · USERS · Supplies · HospitalBedOccupancy ·
Company.APPID · y las tablas de los módulos nuevos.
Un paciente por empresa
Desde el cambio de unicidad, un mismo paciente-persona con dos empresas son dos filas de Patient, una
por empresa. La clave de unicidad es (tipo de documento + documento + COMPANYID). Ver
Pacientes y credenciales.
El síntoma clásico: "no veo nada en la bandeja"
Cuando una bandeja aparece vacía para un usuario no privilegiado, el primer sospechoso es COMPANYID sin
poblar en las filas viejas (Authorizations, USERS, Patient…). El filtro fail-closed hace exactamente
lo que tiene que hacer y devuelve 0 filas.
La solución es correr el backfill, no tocar la query. Ver DDL y ops.
Gotchas
- El bypass es solo
SUPERUSERyADMIN(canSeeAllCompanies). UnMODERATORno ve todo. - El scope está cacheado en Redis. Cambiar la empresa de un usuario directamente en la base no se
refleja hasta que expire la key
USERSCOPE-<id>. - Hay un segundo filtro ortogonal: el centro médico activo. Un usuario puede tener la empresa correcta y el centro incorrecto, y el síntoma es idéntico. Cuando diagnostiques un "no veo nada", chequeá los dos.
- El DDL de las columnas
COMPANYIDy sus backfills está enmigrations/postgres/ddl/, y tiene que correr antes de levantar el código que las usa.