La autenticación no es autorización
Estas dos palabras se suelen utilizar indistintamente, pero no son lo mismo. La autenticación responde a “¿quién eres?” y termina con un principal confiable: un id de usuario, un tenant y una forma de verificar que la solicitud provino de ellos. La autorización responde a “¿qué tienes permitido hacer?” y se ejecuta después de la autenticación, en cada solicitud, contra un objetivo específico.
Confundirlas es el origen de algunos de los errores de seguridad más comunes en la web. Un login implementado a la perfección no dice nada sobre si el usuario conectado debería poder leer la factura de otro cliente. Una sesión válida demuestra la identidad; no otorga ningún permiso. Cada endpoint debe decidir, explícitamente, si este principal puede realizar esta acción sobre este recurso.
Esta guía trata sobre la segunda pregunta. Asume que ya tienes resuelta la primera —una sesión o token que devuelve un usuario— y se centra en el modelo y las comprobaciones que convierten a ese usuario en un permiso o una denegación. Si aún no has implementado la autenticación, lee primero la guía de session auth.
El modelo RBAC
El Role-Based Access Control (Control de Acceso Basado en Roles) es el modelo de autorización más utilizado porque se ajusta a la forma en que las organizaciones operan en la realidad. Consta de tres conceptos:
- Principals: son las entidades que actúan: usuarios, cuentas de servicio, API keys. Cada principal pertenece a un tenant.
- Roles: son conjuntos nombrados de capacidades:
viewer,editor,admin,billing. - Permissions: son los átomos: una única acción sobre un único tipo de recurso, escrita como
posts:updateobilling:read.
A un principal se le asignan uno o más roles, y cada rol está mapeado a un conjunto de permisos. El conjunto de permisos efectivos para una solicitud es la unión de todos los permisos de todos los roles del principal. Así, una comprobación de autorización se reduce a una simple prueba de pertenencia al conjunto, además de cualquier regla específica del recurso.
La elegancia de este sistema es que los permisos son estables mientras que los roles son fluidos. Puedes añadir un rol moderator, mover posts:delete dentro de él y ningún handler tendrá que cambiar. La regla de “quién puede eliminar una publicación” reside en los datos, no en una cadena de sentencias if dispersas por todo el código base.
Usuarios, roles y permisos
La estructura relacional consta de cuatro tablas y dos uniones de muchos a muchos. Vale la pena interiorizarla, ya que casi cualquier implementación de RBAC es una variación de esta.
CREATE TABLE permissions (
id bigserial PRIMARY KEY,
action text NOT NULL UNIQUE
);
CREATE TABLE roles (
id bigserial PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE role_permissions (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Es importante realizar el seeding de estos datos en una migración. Los permisos y los mapeos de roles forman parte del contrato de tu aplicación, no son algo que un administrador improvise en producción. Mantén el seed en el control de versiones para que todos los entornos coincidan en lo que significa editor, y trata cualquier cambio en él con el mismo cuidado que un cambio de esquema.
Cargar los permisos efectivos requiere una sola consulta:
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1;
Almacena el resultado en caché por solicitud. Cargarlo una vez y adjuntarlo a req.user evita repetir la consulta en cada comprobación, y una caché con un TTL corto indexada por el id del usuario mantiene la base de datos fuera de la ruta crítica.
Jerarquías de roles
Las organizaciones reales tienen niveles. Un rol senior suele hacer todo lo que puede hacer un rol junior, y más. Modelar esto copiando cada permiso en cada rol es una trampa de mantenimiento: si cambias posts:read, debes recordar los cinco roles que lo incluyen.
En su lugar, permite que los roles hereden. Añade una tabla de unión parent_role_id o role_inherits, y expande la jerarquía al construir el conjunto de permisos. Una estructura común es viewer → editor → admin, donde cada nivel añade capacidades.
CREATE TABLE role_inherits (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
parent_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, parent_id)
);
La expansión es una consulta recursiva o, más sencillamente, una closure table precomputada que almacena cada par de ancestros. La closure table sacrifica un poco de almacenamiento a cambio de una búsqueda trivial y optimizada para índices, lo cual suele ser la decisión correcta ya que las comprobaciones de permisos son mucho más frecuentes que las ediciones de roles.
Protégete contra los ciclos. Un rol que hereda de sí mismo, ya sea directamente o a través de una cadena, hará que la expansión entre en un bucle infinito. Valida al escribir: rechaza cualquier padre que pueda crear un ciclo. Mantén las jerarquías superficiales —tres o cuatro niveles son suficientes— porque los árboles profundos son difíciles de razonar para los humanos y fáciles de implementar incorrectamente.
Por qué los permisos son mejores que los strings de roles
El error más común al implementar RBAC es no construir un sistema de RBAC en absoluto. En su lugar, se suelen esparcir comprobaciones de roles por todo el código:
if (req.user.role !== "admin") return res.sendStatus(403);
Esto parece inofensivo y es una decisión de política integrada en un handler. Indica que solo admin puede hacer esto, lo cual pudo ser cierto en el momento en que se escribió. Cuando el producto añade un rol de support que también necesita acceso, alguien tiene que buscar cada una de estas comprobaciones y editarlas —y se saltarán alguna. La regla ahora está distribuida en docenas de archivos sin una única fuente de verdad.
Comprobar un permiso invierte la dependencia. El handler pregunta “¿puede este principal actualizar un post?” y la respuesta proviene de los datos:
if (!can(req.user, "update", post)) return res.sendStatus(403);
Ahora, otorgar a support la capacidad de actualizar posts es una fila en role_permissions, no un cambio de código. La política es revisable, testeable y consistente. El handler describe la intención en lugar de codificar un rol específico.
La regla de oro: los roles son para humanos, los permisos son para el código. Una UI puede decir “Los administradores pueden gestionar la facturación”, pero la comprobación subyacente debe solicitar billing:manage.
El pipeline de autorización
Cada solicitud sigue la misma secuencia, y cada etapa tiene exactamente una función.
- Autenticar. Resolver la sesión o el token en un principal: un id de usuario, un id de tenant y una lista de roles. Si esto falla, la solicitud es anónima y las rutas protegidas devuelven un 401.
- Cargar roles. Obtener las asignaciones de roles, generalmente desde la base de datos o un caché poblado durante el login.
- Expandir a permisos. Aplanar los roles, incluyendo los heredados, en un único conjunto de strings de permisos.
- Verificar la acción sobre el recurso. Llamar a
can(user, action, resource)para el objetivo concreto, después de haberlo cargado. - Permitir o denegar. Si tiene éxito, ejecutar el handler. Si falla, devolver un 403 sin efectos secundarios.
- Registrar la decisión. Grabar el principal, la acción, el recurso y el resultado.
El orden es fundamental por dos razones. La autenticación debe ir primero porque todo lo demás depende de un principal confiable. Las verificaciones de recursos deben ocurrir después de cargar el recurso, ya que no se puede evaluar la propiedad de un registro que aún no se ha recuperado.
El “failing closed” (fallar cerrando el acceso) es innegociable. Si la carga de roles lanza un error o el caché no está disponible, el valor predeterminado es denegar. Un sistema de autorización que devuelve “permitir” ante un error es peor que no tener ningún sistema, ya que genera una falsa sensación de seguridad.
Creando un helper can()
Centralizar la decisión en una sola función es lo que evita que los route guards y las comprobaciones de recursos se desincronicen. La firma es sencilla: un principal, una acción y un recurso opcional.
export type Action = "read" | "create" | "update" | "delete" | "manage";
export function can(
user: Principal,
action: Action,
resource?: Resource
): boolean {
const permission = `${resource?.type ?? "global"}:${action}`;
if (!user.permissions.has(permission)) return false;
if (resource && resource.tenantId !== user.tenantId) return false;
if (resource && action !== "read" && resource.ownerId !== user.id) {
return user.permissions.has(`${resource.type}:manage`);
}
return true;
}
Aquí se codifican tres reglas, en orden de importancia. El conjunto de permisos es la puerta principal: si ningún rol concede la acción, se detiene el proceso. A continuación viene el aislamiento del tenant, que es absoluto: un principal nunca debe actuar fuera de su tenant, independientemente de sus permisos. Por último, las escrituras en un recurso requieren propiedad o una concesión explícita de manage, que es lo que permite que un editor edite sus propios borradores mientras que un admin puede editar cualquier cosa.
El helper es puro. Recibe datos simples y devuelve un booleano, sin llamadas a la base de datos en su interior. Esto hace que sea trivial realizar unit tests con una matriz de principals, acciones y recursos, y significa que la misma función puede ejecutarse en un route guard, un servicio, un background job o un componente de UI que decida si debe renderizar un botón.
Para la UI, expón la misma función al cliente a través de un endpoint o un objeto de permisos renderizado en el servidor. El cliente debe ocultar los controles que el usuario no puede utilizar, pero el servidor debe seguir aplicando cada comprobación, ya que un botón oculto no es un control de seguridad.
Aplicando restricciones a nivel de ruta
Un route guard es la primera línea de defensa: decide si este tipo de acción está disponible para este principal en absoluto. Se ejecuta antes del handler y antes de cualquier operación de base de datos, lo que lo convierte en una forma económica de rechazar denegaciones evidentes.
export function requirePermission(
action: Action,
type: string
): RequestHandler {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "unauthorized" });
if (!can(req.user, action, { type, ownerId: req.user.id, tenantId: req.user.tenantId })) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
Montalo en el router para que la regla sea visible donde se definen las rutas:
router.get("/posts", requirePermission("read", "post"), listPosts);
router.post("/posts", requirePermission("create", "post"), createPost);
Es importante devolver un 401 cuando falta el principal y un 403 cuando este ha sido denegado. 401 significa “no sé quién eres”; 403 significa “sé quién eres y no tienes permiso para hacer esto”. Los clientes y las herramientas de monitoreo los tratan de manera diferente, y confundirlos dificulta la depuración.
Los route guards son necesarios pero no suficientes. Responden a “¿puede este principal actualizar posts en general?”, no a “¿puede actualizar el post 42?”. Esta segunda pregunta requiere el recurso.
Validación a nivel de recurso
La validación a nivel de recurso es donde se encuentran la mayoría de las vulnerabilidades reales, ya que es la que la gente suele olvidar. Un endpoint como PATCH /posts/:id recibe un id del cliente. Si confía en ese id sin verificar la propiedad, cualquier usuario autenticado puede modificar cualquier post adivinando o enumerando ids. Esto es una Referencia Directa Insegura a Objetos, o IDOR.
La solución siempre tiene la misma estructura: cargar el recurso y luego validarlo.
router.patch("/posts/:id", requireAuth(), async (req, res) => {
const post = await db.post.findById(req.params.id);
if (!post) return res.status(404).json({ error: "not_found" });
const allowed = can(req.user!, "update", {
type: "post",
ownerId: post.authorId,
tenantId: post.tenantId,
});
if (!allowed) return res.status(403).json({ error: "forbidden" });
const updated = await db.post.update(post.id, req.body);
res.json(updated);
});
Existe una sutil elección de orden para los recursos entre tenants. Si un usuario del tenant A solicita un post del tenant B, devolver un 403 confirma que el post existe, lo que filtra información entre tenants. Muchos sistemas devuelven un 404 en ese caso para que el recurso sea indistinguible de uno que no existe. Independientemente de lo que elijas, sé consistente y documéntalo.
El mismo patrón se aplica a los recursos anidados. Antes de actuar sobre /teams/:teamId/projects/:projectId, verifica que el principal pueda acceder al equipo y que el proyecto pertenezca a este. Cada id en la ruta está controlado por el atacante y debe ser validado.
La matriz de permisos
La matriz de permisos es una tabla donde las filas son los roles y las columnas son los permisos, completada con las concesiones (grants). Es el artefacto que permite que un sistema de autorización sea revisable.
posts:read posts:create posts:update posts:delete billing:read
viewer x
editor x x x
admin x x x x x
billing x x
Manténla en el control de versiones junto al código y genera las migraciones de seed a partir de ella, para que la documentación y los datos no diverjan. Cuando alguien propone un nuevo rol, la primera pregunta es qué columnas obtiene; y la respuesta es un diff de esta tabla, no una búsqueda exhaustiva entre handlers.
Dos hábitos hacen que la matriz sea útil. Primero, nombra los permisos de manera consistente como resource:action, para que la tabla se lea con claridad y los strings sean predecibles. Segundo, revisa la matriz cada vez que un rol cambie, ya que una sola columna adicional es fácil de pasar por alto en una migración y puede otorgar mucho más de lo previsto.
Roles multi-tenant
En una aplicación multi-tenant, una misma persona puede tener diferentes roles en distintas organizaciones. El dueño de una agencia es administrador de su propio tenant y un visor en el de un cliente. Una única columna global role no puede expresar eso.
La solución es limitar las asignaciones de roles por tenant. Añade tenant_id a user_roles y haz que forme parte de la clave primaria, para que un usuario pueda tener roles distintos por tenant. Cuando construyas el conjunto de permisos para una solicitud, hazlo para un solo tenant: aquel dentro del cual se está ejecutando la solicitud.
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2;
El tenant debe provenir de una fuente confiable: la sesión, un subdominio que controles o el token. Nunca lo aceptes desde el cuerpo de una solicitud o una cadena de consulta (query string) sin verificar que el principal pertenezca a él. Una vez establecido, el aislamiento del tenant es la primera regla en can() y se aplica antes de considerar cualquier permiso, de modo que ninguna concesión pueda cruzar el límite.
Cambiar de tenant es un cambio de privilegios. Si el tenant activo reside en la sesión, actualízalo en el servidor y regenera cualquier conjunto de permisos almacenado en caché para que las concesiones del tenant anterior no se filtren en el nuevo contexto.
ABAC y motores de políticas
RBAC responde a la mayoría de las preguntas, pero algunas reglas dependen de más factores que el rol y la propiedad: la hora del día, la sensibilidad de los datos, el departamento del usuario o la puntuación de riesgo de la solicitud. Estas son reglas basadas en atributos, e intentar codificarlas como roles produce una explosión combinatoria.
ABAC (Attribute-Based Access Control) evalúa políticas basándose en los atributos del sujeto, el recurso, la acción y el entorno. Una regla podría decir: “un usuario puede leer un documento si su departamento coincide con el del documento y la clasificación no es secreta”. Esto es expresable, testeable y auditable de una manera que una matriz de roles no lo es.
Los motores de políticas hacen que esto sea práctico. Open Policy Agent evalúa políticas escritas en Rego y puede consultarse como un sidecar o una librería, permitiendo que las mismas reglas se apliquen en diferentes servicios y lenguajes. Casbin ofrece un enfoque de modelo y adaptador más ligero con soporte para RBAC, ABAC y combinaciones, siendo muy popular dentro del código de la aplicación.
Adopta un motor de políticas solo cuando las reglas realmente superen las capacidades de RBAC, no antes. Esto añade un nuevo lenguaje, una superficie de despliegue y una curva de aprendizaje. Un helper de can() bien factorizado con reglas claras puede gestionar una cantidad sorprendente de casos, y siempre puedes envolverlo en un motor de políticas más adelante cuando una decisión específica requiera más contexto.
Pruebas de autorización
Los errores de autorización son vulnerabilidades de seguridad, por lo que las pruebas deben tratar los casos de denegación como ciudadanos de primera clase. Para cada acción protegida, escribe una matriz de pruebas: un llamador anónimo, un principal sin el permiso, un propietario, un no propietario con el permiso y un principal de otro tenant.
describe("PATCH /posts/:id", () => {
it("rejects anonymous users", async () => {
await request(app).patch("/posts/1").send({ title: "x" }).expect(401);
});
it("rejects users without posts:update", async () => {
await request(app).patch("/posts/1").set("Cookie", viewerCookie).expect(403);
});
it("allows the owner", async () => {
await request(app).patch("/posts/1").set("Cookie", ownerCookie).expect(200);
});
it("rejects a non-owner editor", async () => {
await request(app).patch("/posts/1").set("Cookie", editorCookie).expect(403);
});
it("rejects a user from another tenant", async () => {
await request(app).patch("/posts/1").set("Cookie", otherTenantCookie).expect(404);
});
});
Prueba can() directamente como una función pura, utilizando una tabla de principals, acciones y recursos. Esto cubre la lógica de manera exhaustiva y económica, mientras que las pruebas del endpoint demuestran que la comprobación está realmente implementada. Un fallo común es tener un helper correcto que el handler olvidó llamar, y solo una prueba de integración puede detectar eso.
Sembrado de permisos mediante migraciones
Los permisos y el mapeo de roles forman parte del contrato de tu aplicación, por lo que deben residir en las migraciones y no en un panel de administración que difiere entre entornos.
Escribe una migración de sembrado (seed) que realice un upsert de los permisos por nombre y luego concilie las concesiones de cada rol con la matriz. Los upserts mantienen la migración idempotente, lo cual es fundamental ya que podría ejecutarse contra bases de datos que ya contienen algunas filas.
INSERT INTO permissions (action) VALUES
('posts:read'), ('posts:create'), ('posts:update'), ('posts:delete'),
('billing:read'), ('billing:manage')
ON CONFLICT (action) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.action IN ('posts:read', 'posts:create', 'posts:update')
WHERE r.name = 'editor'
ON CONFLICT DO NOTHING;
Eliminar un permiso es más arriesgado que añadir uno. Verifica sus usos en el código y, si se referencia en algún lugar, renómbralo o retíralo gradualmente. Una migración que elimine posts:update mientras un handler todavía lo verifica convertirá cada solicitud en un denegado; esto es seguro, pero confuso hasta que alguien revise el diff del sembrado.
Almacenamiento en caché del conjunto de permisos
Una comprobación de permisos nunca debería realizar una consulta a la base de datos. Construye el conjunto efectivo una vez por solicitud, adjúntalo al principal y reutilízalo para cada comprobación dentro de esa misma solicitud.
Para sistemas de alto tráfico, almacena el conjunto en caché por usuario durante un periodo corto, utilizando como clave el usuario y el tenant. Un TTL de 30 a 60 segundos suele ser suficiente para eliminar la consulta de la ruta crítica (hot path) manteniendo los cambios de roles visibles rápidamente. Cuando un rol cambie, invalida la caché explícitamente en lugar de esperar al TTL, para que un permiso revocado deje de funcionar inmediatamente.
async function permissionsFor(userId: string, tenantId: string) {
const key = `perm:${tenantId}:${userId}`;
const cached = await redis.get(key);
if (cached) return new Set(JSON.parse(cached));
const rows = await db.query(permissionQuery, [userId, tenantId]);
const set = new Set(rows.map((r) => r.action));
await redis.set(key, JSON.stringify([...set]), "EX", 60);
return set;
}
Existe un compromiso de seguridad con el TTL. Cuanto más larga sea la caché, mayor será la ventana de tiempo en la que un rol revocado seguirá funcionando. Prioriza la invalidación explícita en cada cambio de asignación de roles y mantén el TTL corto como medida de respaldo para las invalidaciones omitidas.
Mejores prácticas
- Verifica los permisos, no los nombres de los roles, en el código de la aplicación; mantén los roles como paquetes organizados para los humanos.
- Centraliza la decisión en una única función
can(user, action, resource)pura. - Deniega por defecto y falla en estado cerrado si los roles o permisos no pueden cargarse.
- Aplica el aislamiento de tenants antes de cualquier verificación de permisos, y obtén el tenant de una fuente confiable.
- Carga el recurso antes de autorizar una acción sobre él para prevenir IDOR.
- Devuelve 401 para solicitudes no autenticadas y 403 para las denegadas.
- Almacena en caché el conjunto de permisos efectivos por solicitud e invalídalo cuando cambien los roles.
- Mantén la matriz de permisos en el control de versiones y genera los seeds a partir de ella.
- Prueba los casos negativos: anónimos, permiso incorrecto, no propietario, otro tenant.
- Registra las decisiones de permitir y denegar con suficiente contexto para poder explicarlas posteriormente.
Errores comunes
- Tratar una sesión o token válido como prueba de autorización.
- Crear ramificaciones basadas en
user.role === "admin"en todo el código base. - Verificar la ruta pero nunca el recurso, dejando un agujero de IDOR.
- Confiar en un tenant id proveniente del cuerpo de la solicitud o de la query string.
- Otorgar permisos de
managedemasiado amplios para evitar modelar una regla real. - Construir jerarquías de roles profundas que nadie puede comprender.
- Cachear los permisos indefinidamente y dejar permisos obsoletos tras un cambio de rol.
- Devolver un 403 cuando un 404 evitaría filtrar la existencia del recurso de otro tenant.
- Permitir que los botones ocultos de la UI sustituyan la validación en el servidor.
- Olvidar verificar cada id en una ruta anidada.
Próximos pasos
RBAC es el modelo de autorización que utilizarás con más frecuencia y se integra perfectamente con todo lo que hayas construido. Si tus principals llegan como tokens, la guía de JWT muestra dónde encajan los claims como los roles y por qué aún debes verificarlos en el servidor. El principal en sí proviene de la autenticación de sesión o, en el caso de máquinas, de API keys. Y dado que la autorización siempre se refiere a un objetivo, la guía de REST es el complemento ideal para modelar recursos y sus ids. Cuando tus reglas empiecen a depender del contexto en lugar de los roles, vuelve a la sección de ABAC y recurre a un motor de políticas.