Skip to main content

Permisos y roles

Proteus tiene RBAC granular por permiso, no por rol. Un rol es un paquete de permisos, pero lo que se chequea es el permiso.

proteus-backend/src/migrations/permissions.catalog.ts es el catálogo completo (~138 códigos). No tiene dependencias a entidades ni a services, justamente para que lo puedan consumir la migración, el seed y los tests.

Cada permiso tiene dos caras:

CaraFormatoEjemplo
name legiblePERMISSION_<MODULO>_[SUBMODULO_]<ACCION>PERMISSION_PATIENT_CREATE
code internoIniciales del módulo + inicial de cada palabra del namePAT_PPC
El code es la fuente de verdad

El backend chequea el código, no el nombre legible. Ante una colisión de códigos se agrega un sufijo numérico (por ejemplo CAP_PCAV vs. CAP_PCAV2), y esos casos están marcados en el catálogo.

Módulo, submódulo y acción no son campos: ya están codificados en el name.

Granularidad

Las decisiones de diseño:

  • Acciones Ver / Crear / Editar / Borrar, más acciones especiales donde hacen falta.
  • Permisos explícitos, sin jerarquía: tener "editar" no implica tener "ver".
  • Permisos individuales: nada de un "manage" compartido que englobe varias acciones.
  • El rol VIEWER se arma filtrando los permisos cuyo name termina en _VIEW.

Roles

src/migrations/roles.catalog.ts define los roles globales, cada uno con su level:

SUPERUSER · ADMIN · MODERATOR · USER · COORDINATOR · VIEWER · CHIEF_MEDICAL_OFFICER · ADMINITRATIVE_1..3 · APPOINTMENT_LIMIT_AUTHORIZER · APPOINTMENT_AUTHORIZATION_VIEWER

La key del rol en Ajolote es name.toLowerCase().

Los roles también gobiernan el bypass de empresa

Solo SUPERUSER y ADMIN pueden ver los datos de todas las empresas. Un MODERATOR no. Ver Multi-tenant.

Enforcement en el backend

Toda la seguridad real está acá. El patrón, dentro de un controller que extiende SecuredController:

@Get("/")
async list(@QueryParams() query: FindHealthCenterDTO, @Req() request: any): Promise<CustomResponse> {
try {
const permission = await this.hasPermissionV2(PermissionCode.HEALTHCENTER_ABM_VIEW, request);
if (!permission.success) return permission;

return await HealthCenterService.findAllPaginated(this.getUserId(request), query);
} catch (e) {
return this.unhandledException(request, e);
}
}
  • hasPermissionV2(code, req) valida el código de permiso contra configuration.ROLEPERMISSIONS. Devuelve un CustomResponse: si !success, se devuelve tal cual.
  • Los códigos salen de PermissionCode (el catálogo), nunca de strings literales.
  • Tablas: configuration.PERMISSIONS + configuration.ROLEPERMISSIONS. AccessService.hasRole() resuelve.
  • El seed es idempotente y corre al boot de la app.

Enforcement en el frontend

El front hace UI-gating: oculta lo que el usuario no puede usar. No es seguridad.

SuperficieMecanismo
Elementos del templateLa directiva estructural *hasPermission
Rutasguards/permission.guard.ts
MenúCada MenuItem lleva visible: hasPermission(...)

El servicio (pages/posadas/posadas.permission.service.ts) tiene el enum PermissionCodes (espejo del catálogo del backend, con los mismos códigos) y los métodos hasPermission / hasAnyPermission / hasAllPermissions. Los permisos se cargan de GET /role/allow y se exponen como signal.

La directiva *hasPermission

<button *hasPermission="PermissionCodes.PATIENT_CREATE">Nuevo paciente</button>

<div *hasPermission="[a, b]"></div> <!-- anyOf (default) -->
<div *hasPermission="[a, b]; mode:'all'"></div> <!-- requiere todos -->
// en el componente, para poder nombrar los códigos desde el template:
protected readonly PermissionCodes = PermissionCodes;

Es reactiva: si los permisos cambian (tras recargarlos), la vista se agrega o se quita sola. Y es standalone: hay que importarla en el imports del componente.

Gotchas

El enum del front y el catálogo del back se sincronizan a mano

Un desync no falla: el botón simplemente nunca aparece, o aparece cuando no debería. Los valores del enum del front son los códigos internos (PAT_PPC), no los nombres legibles (PERMISSION_PATIENT_CREATE), y tienen que coincidir exactamente con PERMISSIONS.code.

  • El gate del front no protege nada. Todo endpoint necesita su hasPermissionV2. Ocultar el botón no protege la ruta.
  • /role/allow se cachea en el navegador si no se le pasa cache-buster: al re-loguear con otro usuario servía los permisos del anterior. Se usa un query param (?_=<timestamp>), no un header Cache-Control, porque el header dispara preflight CORS y el backend no lo permite.
  • Algunos endpoints van sin permiso a propósito: los que todo usuario autenticado necesita, como /health-center/mine (hace falta para poder elegir el centro activo).
  • hasPermission(role, …) (por rol) es legacy. En código nuevo se usa hasPermissionV2 (por código).
  • CheckPermission / CheckLevel / CheckPermissionAndLevel exportados en SecuredController.ts son stubs vacíos: solo hacen console.log + next(). No gatean nada. No los uses creyendo que sí.
  • El seed corre al boot: si se agregan permisos al catálogo, hay que reiniciar la app para que existan en la tabla. Y hay que correr el DDL de la tabla de permisos antes. Ver DDL y ops.