Roles, permisos y multi-tenancy
El modelo de acceso de EMA Clinic responde a dos preguntas distintas que conviven todo el tiempo. La primera es de autorización: dado un usuario que ya está autenticado, qué puede hacer. La segunda es de aislamiento: cómo garantizar que dos clínicas que comparten la misma instancia jamás vean datos la una de la otra. La autorización se resuelve con un modelo RBAC tradicional —seis roles clínicos más uno de plataforma— y el aislamiento se apoya en tres capas independientes que se refuerzan mutuamente, de forma que un fallo en cualquiera de ellas no compromete al sistema.
Los seis roles clínicos
Sección titulada «Los seis roles clínicos»EMA Clinic reconoce los roles típicos de una clínica ambulatoria latinoamericana. Cada uno agrupa permisos coherentes con la responsabilidad real del cargo, no con una jerarquía técnica abstracta:
| Rol | Responsabilidad principal |
|---|---|
admin | Configura la organización, gestiona usuarios, define prestaciones, administra convenios. Tiene visibilidad completa salvo sobre la práctica clínica individual. |
medico | Atiende pacientes, diagnostica, prescribe y declara las patologías priorizadas y notificaciones obligatorias del país (en Chile, GES y ENO). Es el rol que firma decisiones clínicas. |
enfermera | Cubre signos vitales, triaje en urgencia y asistencia clínica. Comparte parte de la ficha con el médico pero no firma diagnósticos. |
secretaria | Recepciona, agenda, registra pacientes nuevos y administra admisiones. |
cajero | Cobra, lleva caja diaria, reconcilia y emite documentos fiscales. |
profesional | Profesional aliado —kinesiólogo, nutricionista, fonoaudiólogo, psicólogo, otros— con acceso clínico acotado a su práctica. |
A estos seis se suma un rol especial, platform_admin, marcado en la
columna is_platform_admin de emashared.users. Está reservado para
staff interno de EMA Health y se usa exclusivamente en operaciones de
soporte y provisioning. Bypassea las verificaciones RBAC estándar pero,
como cualquier otra acción, queda registrado en el audit log
cross-product con el detalle del usuario y el cambio realizado.
La matriz de permisos por módulo
Sección titulada «La matriz de permisos por módulo»La tabla siguiente sintetiza qué puede leer y escribir cada rol en cada módulo. 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:
| Rol | Pacientes | Agenda | Admisión | Urgencia | Ficha | Caja | Reportes | Config | Diagnóstico | Prescripciones | Patolog. priorizadas |
|---|---|---|---|---|---|---|---|---|---|---|---|
| admin | R+W | R+W | R+W | R+W | R | R+W | R+W | R+W | R | R | R |
| medico | R+W | R+W | R | R+W | R+W | — | — | — | R+W | R+W | R+W |
| enfermera | R+W | R | R+W | R+W | R+W | — | — | — | R+W | — | R |
| secretaria | R+W | R+W | R+W | — | — | R+W | R | — | — | — | — |
| cajero | R | — | — | — | — | R+W | — | — | — | — | — |
| profesional | R+W | — | — | — | R+W | — | — | — | R+W | R+W | R |
Conviene notar la separación deliberada entre admisión clínica y firma clínica. El admin puede ver la ficha pero no escribir en ella; el médico puede escribir en la ficha pero no en la configuración. Esta separación permite auditar responsabilidades sin necesidad de mecanismos compensatorios como segundos pares de credenciales o registros paralelos.
Multi-tenancy desde el lado del usuario
Sección titulada «Multi-tenancy desde el lado del usuario»Un mismo usuario puede tener varios roles en varios tenants. El caso
típico es un médico que pasa consulta dos días a la semana en una
clínica y tres en otra; en EMA Clinic eso se modela como dos filas en
emashared.user_roles, una por cada combinación de tenant y rol:
SELECT user_id, tenant_id, role, is_tenant_adminFROM emashared.user_rolesWHERE user_id = 'cristian-uuid';
-- Resultado:-- | cristian-uuid | tenant-clinic-aurora | medico | false |-- | cristian-uuid | tenant-beta | platform_admin | true |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. Cada
request al backend incluye solo el JWT del usuario; no incluye
explícitamente el tenant_id. El tenantMiddleware lo resuelve
consultando user_roles con el rol activo, lo que garantiza que el
tenant nunca puede ser inferido o falseado desde el cliente.
ORGBAC: el aislamiento server-side
Sección titulada «ORGBAC: el aislamiento server-side»ORGBAC (Organization-Based Access Control) es el mecanismo de
aislamiento que Aidbox aplica del lado del servidor desde el sprint
S9. La idea es simple en su enunciado y poderosa en sus consecuencias:
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.
La consecuencia práctica es que aunque un bug en TypeScript dejara escapar los filtros del lado del cliente, Aidbox seguiría rechazando la query porque la URL ya está scoped a la Organization correcta desde antes. El aislamiento no depende de la disciplina de cada desarrollador.
A esa primera capa se suman dos más, que operan sobre datos no FHIR.
La segunda es RLS de Supabase: cada tabla operacional —caja,
audit, scheduling, configuración— tiene la política
tenant_id = auth.tenant_id, lo que produce un segundo filtro
servidor-side independiente de Aidbox. La tercera es el tagging
explícito de cada recurso FHIR con meta.tag = urn:ema:tenant|<id>,
que permite búsquedas y auditorías cross-resource sin recurrir a la
URL. Las tres capas son redundantes por diseño: si una falla, las
otras dos contienen el daño.
Proveedores de autenticación por tenant
Sección titulada «Proveedores de autenticación por tenant»Cada tenant configura qué proveedores de autenticación habilita en su
clínica. La decisión queda en tenant_config.auth.providers. Las
opciones disponibles son las siguientes:
| Proveedor | Cuándo usarlo |
|---|---|
email | Email y contraseña. Disponible siempre como fallback. |
google | Google OAuth, útil cuando el equipo ya usa Google Workspace. |
microsoft | Microsoft 365 OAuth, equivalente para clínicas que viven en el ecosistema Microsoft. |
magic-link | Link mágico por correo, sin contraseña; reduce fricción para usuarios infrecuentes. |
El front-end consulta GET /api/auth-config/:slug antes de pintar la
pantalla de login para saber qué botones mostrar; eso permite que dos
tenants distintos hospedados en la misma instalación tengan
experiencias de login diferentes.
El portal del paciente
Sección titulada «El portal del paciente»Los pacientes acceden a sus datos por una vía completamente distinta
de la del staff clínico. El flujo combina identificador nacional
(en Chile, RUT), fecha de nacimiento y
correo electrónico para enviar un OTP que, una vez validado, emite un
JWT específico de portal. Ese token tiene una audiencia distinta a la
del staff (paciente), una validez más corta (treinta minutos) y
permisos exclusivamente de lectura sobre los datos del paciente
mismo:
| Token | Audiencia | Validez | Permisos |
|---|---|---|---|
| Staff JWT | medico/enfermera/etc. | ~1 h | RBAC según rol |
| Portal JWT (OTP) | paciente | 30 min | Read-only sobre sus propios datos |
El patientAuthMiddleware valida el token del portal solo en las
rutas bajo /api/portal/*. La separación arquitectónica es
deliberada: el código de staff y el código de portal nunca
comparten el mismo token, y el modelo de permisos de uno no se
filtra al otro por accidente.
Recorrido completo de una request
Sección titulada «Recorrido completo de una request»La secuencia que sigue un request desde el navegador hasta los datos ilustra cómo encajan todas las capas:
1. Frontend: localStorage JWT → apiFetch() inyecta `Authorization: Bearer`2. authMiddleware: valida firma del JWT + extrae user.id3. tenantMiddleware: SELECT user_roles WHERE user_id, tenant_id → rol4. fhirMiddleware: buildFhirClientForTenant → EmaFhirClient per-tenant5. requireRole('medico', 'admin'): RBAC gate, 403 si rol no en lista6. Handler: usa c.get('fhir'), c.get('tenantId'), c.get('role')7. Aidbox: ORGBAC valida path /Organization/{id}/fhir/*8. Supabase: RLS valida tenant_id = auth.tenant_idLas primeras seis líneas ocurren en el worker; las dos últimas, en los almacenes de datos. Una request mal formada o mal autorizada tiene ocho oportunidades distintas de ser rechazada antes de devolver datos.