Ir al contenido

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.

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:

RolResponsabilidad principal
adminConfigura la organización, gestiona usuarios, define prestaciones, administra convenios. Tiene visibilidad completa salvo sobre la práctica clínica individual.
medicoAtiende 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.
enfermeraCubre signos vitales, triaje en urgencia y asistencia clínica. Comparte parte de la ficha con el médico pero no firma diagnósticos.
secretariaRecepciona, agenda, registra pacientes nuevos y administra admisiones.
cajeroCobra, lleva caja diaria, reconcilia y emite documentos fiscales.
profesionalProfesional 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 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:

RolPacientesAgendaAdmisiónUrgenciaFichaCajaReportesConfigDiagnósticoPrescripcionesPatolog. priorizadas
adminR+WR+WR+WR+WRR+WR+WR+WRRR
medicoR+WR+WRR+WR+WR+WR+WR+W
enfermeraR+WRR+WR+WR+WR+WR
secretariaR+WR+WR+WR+WR
cajeroRR+W
profesionalR+WR+WR+WR+WR

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.

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_admin
FROM emashared.user_roles
WHERE 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 (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.

consulta

tenant=A

bloquead

bloquead

Usuario clínica A

Worker

Aidbox

Organization A

Aidbox

Organization B

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.

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:

ProveedorCuándo usarlo
emailEmail y contraseña. Disponible siempre como fallback.
googleGoogle OAuth, útil cuando el equipo ya usa Google Workspace.
microsoftMicrosoft 365 OAuth, equivalente para clínicas que viven en el ecosistema Microsoft.
magic-linkLink 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.

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:

TokenAudienciaValidezPermisos
Staff JWTmedico/enfermera/etc.~1 hRBAC según rol
Portal JWT (OTP)paciente30 minRead-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.

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.id
3. tenantMiddleware: SELECT user_roles WHERE user_id, tenant_id → rol
4. fhirMiddleware: buildFhirClientForTenant → EmaFhirClient per-tenant
5. requireRole('medico', 'admin'): RBAC gate, 403 si rol no en lista
6. 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_id

Las 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.