Ir al contenido

Roles, permisos y multi-tenancy

El modelo de acceso de EMA Lab responde a dos preguntas que aparecen constantemente en un laboratorio multi-cliente. La primera es de autorización: dado un usuario autenticado, qué puede hacer dentro de su laboratorio. La segunda es de aislamiento: cómo garantizar que los datos de un laboratorio jamás se filtren a otro y, dentro de un mismo laboratorio, cómo respetar las restricciones de visibilidad hacia clientes externos cuando corresponden. La autorización se resuelve con un modelo RBAC de seis roles, y el aislamiento se apoya en cuatro capas independientes que se refuerzan entre sí.

EMA Lab reconoce los roles típicos de la operación clínica de un laboratorio latinoamericano. Cada uno agrupa permisos coherentes con la responsabilidad real del cargo y se separa explícitamente de los demás para hacer auditable la cadena de decisiones:

RolResponsabilidad principal
lab_adminAdministración total del tenant: configuración, usuarios, billing, conectores, pricing.
lab_directorDirector técnico o BQF a cargo. Firma clínica, define políticas (TAT, SLA, ruteo, exclusiones), firma reportes de stewardship.
lab_techTecnólogo médico o BQF de banca. Ingresa resultados, valida técnicamente, opera QC, microbiología y molecular.
lab_receptionRecepciona órdenes, gestiona check-in, factura, atiende a clientes externos.
lab_phlebotomistPersonal de toma de muestra (TENS, técnicos de toma). Etiqueta tubos y hace check-in en site.
lab_viewerSolo lectura sobre informes — útil para clínicos consultantes que necesitan ver resultados sin operar el LIS.

A estos seis se suma un guard especial de plataforma. El platform_admin no es un rol JWT tradicional sino un mecanismo de acceso vía header X-Platform-Key que se usa exclusivamente para operaciones de provisioning desde EMA Vault. No puede invocarse desde el frontend ni desde un usuario humano: es un canal worker-a-worker para la creación inicial del tenant.

La tabla siguiente resume el acceso de cada rol a cada módulo del producto. La notación R+W indica acceso completo, R indica solo lectura, y un guion indica que el módulo no aparece para ese rol:

RolCatálogoÓrdenesMuestrasWorklistsResultadosQCValidaciónReportesMicroMolecStewInventarioBillingConectoresConfig
adminR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+W
directorR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR
techRR+WR+WR+WR+WR+WR+WR+WR+WR+WR+WR+W
receptionR+WR+WR+WR+WR+W
phlebotomistR+W
viewerR

platform_admin tiene R+W en todo, pero su uso queda restringido al canal X-Platform-Key.

Un mismo usuario puede tener varios roles en varios tenants y varios productos. Es la situación habitual de personal técnico que trabaja parte de la semana en un laboratorio y parte en una clínica relacionada, o de un director que supervisa varios laboratorios bajo una misma red:

SELECT user_id, tenant_id, product, role, is_tenant_admin
FROM emashared.user_roles
WHERE user_id = 'cristian-uuid';
-- Resultado:
-- | cristian-uuid | tenant-aurora-clinic | clinic | medico | false |
-- | cristian-uuid | tenant-aurora-lab | lab | lab_admin | true |
-- | cristian-uuid | tenant-andes-lab | lab | lab_director | false |

El SPA mantiene en localStorage cuál es el tenant activo en la sesión actual y un selector visible en la barra superior permite cambiar entre tenants si el usuario tiene más de uno asignado. El JWT que el frontend envía no incluye tenant_id: el tenantMiddleware lo resuelve consultando user_roles con el JWT del caller (no con service role, decisiones L099 y L102) lo que garantiza que el tenant nunca puede falsearse desde el cliente.

ORGBAC (Organization-Based Access Control) es el mecanismo que Aidbox aplica del lado del servidor desde el sprint S9. Cada tenant tiene su propia Organization en Aidbox, sus credenciales OAuth son independientes (encriptadas en tenant_aidbox_credentials con AES-GCM), y todas las queries FHIR pasan obligatoriamente por la URL /Organization/{tenantId}/fhir/*. Si una query intenta acceder a recursos de otro tenant, Aidbox la rechaza por policy server-side sin importar el filtro que el código cliente haya aplicado.

consulta

tenant=Aurora

bloquead

bloquead

Usuario lab Aurora

Worker

Aidbox

Organization Aurora

Aidbox

Organization Andes

Aunque alguien escapara los filtros de TypeScript por bug o por mal manejo, Aidbox seguiría rechazando la query porque la URL ya está scoped a la Organization correcta. A esta primera capa se suman otras tres. La segunda es RLS de Supabase: cada tabla operacional tiene la política tenant_id = (select tenant_id from user_roles where user_id = auth.uid() and product = 'lab'). La tercera es el client isolation middleware, que veremos a continuación. La cuarta es el tagging explícito con meta.tag = urn:ema:tenant|<id> en cada recurso FHIR.

Algunos laboratorios reciben órdenes de varias clínicas externas, y es habitual que el director quiera restringir a ciertos TM solo a las órdenes de clientes concretos —por ejemplo, dejar a un equipo turno noche únicamente las órdenes de tres clínicas amigas, o separar la operación de un convenio confidencial del resto del laboratorio. EMA Lab modela esto con user_client_access:

-- Ejemplo: TM en lab-aurora pero solo procesa muestras de
-- la clínica San José y la clínica Bellavista
INSERT INTO emashared.user_client_access (user_id, tenant_id, client_org_fhir_id)
VALUES
('tm-uuid', 'aurora-uuid', 'Organization/clinic-san-jose'),
('tm-uuid', 'aurora-uuid', 'Organization/clinic-bellavista');

El clientIsolationMiddleware aplica el filtro automáticamente en worklists, órdenes, reportes y billing. Cuando la tabla está vacía para un usuario, el filtro se deshabilita y el usuario ve todos los clientes del laboratorio. Cuando tiene filas, ve solo los listados.

Cada tenant configura qué proveedores admite. Por defecto un laboratorio nuevo recibe email y magic link habilitados; los laboratorios que ya operan con Google Workspace o Microsoft 365 suelen activar el proveedor correspondiente para reducir fricción de acceso:

ProveedorCuándo usarlo
emailEmail y contraseña, disponible siempre como fallback.
magic-linkLink mágico por correo, sin contraseña, ideal para usuarios infrecuentes.
googleGoogle OAuth, recomendado para administradores.
microsoftMicrosoft 365 OAuth, equivalente para clínicas que viven en el ecosistema Microsoft.

El frontend consulta GET /auth/resolve-tenant?slug=... antes de pintar la pantalla de login para saber qué botones mostrar; eso permite que dos tenants distintos tengan experiencias de login diferentes en la misma instalación.

Los pacientes acceden a sus resultados por una vía completamente distinta de la del staff del laboratorio. El flujo combina el identificador nacional del paciente (en Chile, el RUT), fecha de nacimiento y correo electrónico para enviar un OTP que, una vez validado, emite un token de portal con audiencia distinta a la del staff (paciente), validez más corta (treinta minutos) y permisos exclusivamente de lectura sobre los DR finales del paciente:

TokenAudienciaValidezPermisos
Staff JWTlab_admin/director/tech/reception/phlebotomist/viewer~1 hRBAC según rol
Portal tokenPaciente30 minRead-only sobre sus propios DR finales
Hub tokenHub on-premrotableEndpoints /api/hub/*

El patientAuthMiddleware valida el portal token solo en las rutas bajo /api/portal/*. La separación de audiencias es deliberada: el código de staff y el de portal nunca comparten el mismo token, y el modelo de permisos de uno no se filtra al otro por accidente.

Cada DR firmado lleva un QR con un código de verificación único. Un médico que recibe el reporte —en papel, en email o vía HL7v2— puede confirmar autenticidad apuntando a la URL pública correspondiente:

https://docs.emahealth.io/portal/verify/:code?tenant={slug}

El endpoint GET /api/portal/verify/:code es público, está rate-limitado a treinta consultas por minuto, y no expone PII: retorna fecha de emisión, laboratorio, hash de la firma y validez. La idea es que la verificación sea pública y trivial sin filtrar información del paciente.

La secuencia que sigue un request desde el navegador del staff hasta los datos ilustra cómo encajan todas las capas:

1. Frontend: localStorage JWT → apiFetch() inyecta `Authorization: Bearer`
2. authMiddleware: valida firma JWT + extrae user.id
3. tenantMiddleware: SELECT user_roles WHERE user_id, tenant_id, product='lab' → rol
4. clientIsolationMiddleware: filtra por user_client_access si aplica
5. fhirFetch / buildEmaFhirClient: per-tenant FHIR client decryptado
6. requireRole('admin', 'director', ...): RBAC gate, 403 si rol no en lista
7. Handler: usa c.get('supabase'), c.get('tenantId'), c.get('role')
8. Aidbox: ORGBAC valida path /Organization/{id}/fhir/*
9. Supabase: RLS valida tenant_id + product='lab'

Las primeras siete líneas ocurren en el worker y las dos últimas en los almacenes. Una request mal autorizada tiene nueve oportunidades distintas de ser rechazada antes de devolver datos.

EMA Lab no expone UI propia para crear tenants. El provisioning se hace desde EMA Vault vía un endpoint protegido por X-Platform-Key:

POST /api/platform/tenants
X-Platform-Key: <secret>
{
"slug": "aurora",
"country": "CL",
"products": ["lab"],
"admin": {
"email": "ana@laboratorio.cl",
"name": "Ana Pérez"
}
}

Esa única llamada produce una cascada de seis efectos: se crea el registro en tenants y tenant_products, se crea la Organization raíz en Aidbox, se crea un OAuth client en Aidbox y su secret se encripta con AES-GCM antes de guardarlo en tenant_aidbox_credentials, se crea el usuario admin en vault_users con el rol lab_admin y, por último, Loops envía el email de invitación al admin del cliente. El laboratorio queda operativo en cuestión de minutos.