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.
Fuente de verdad: el catálogo
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:
| Cara | Formato | Ejemplo |
|---|---|---|
name legible | PERMISSION_<MODULO>_[SUBMODULO_]<ACCION> | PERMISSION_PATIENT_CREATE |
code interno | Iniciales del módulo + inicial de cada palabra del name | PAT_PPC |
code es la fuente de verdadEl 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
VIEWERse arma filtrando los permisos cuyonametermina 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().
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 contraconfiguration.ROLEPERMISSIONS. Devuelve unCustomResponse: 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.
| Superficie | Mecanismo |
|---|---|
| Elementos del template | La directiva estructural *hasPermission |
| Rutas | guards/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
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/allowse 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 headerCache-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 usahasPermissionV2(por código).CheckPermission/CheckLevel/CheckPermissionAndLevelexportados enSecuredController.tsson stubs vacíos: solo hacenconsole.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.