RBAC en Express con TypeScript: permisos por organización
Armá RBAC en una API REST con Express y TypeScript: catálogo de permisos tipado, roles por organización y un middleware requirePermission.
Para agregar RBAC a una API de Express en TypeScript, definí un catálogo cerrado
de permisos resource:action, guardá una lista de ellos en cada rol de cada
organización, cargá el rol del usuario en req con un middleware y protegé cada
ruta con un middleware requirePermission('resource:action'). El paso que casi
todos se olvidan es un segundo chequeo de que la organización de la URL sea la
misma para la que se cargaron los permisos. Esta guía arma toda la cadena con la
forma del código de useDeploy, y los snippets corren en Express 4.22, la
versión que usa. La mecánica sigue la guía de middleware de
Express.
1. Un catálogo de permisos tipado
Un permiso es un string como roles:update. Hacé el conjunto cerrado para que un
typo sea un error de compilación. Este es el catálogo que trae useDeploy, en
packages/shared/src/permissions/index.ts:
export const RESOURCES = [
'users', 'roles', 'settings', 'reports', 'organizations',
'billing', 'invitations', 'webhooks', 'api-keys', 'queues',
] as const;
export const ACTIONS = ['create', 'read', 'update', 'delete', 'manage'] as const;
// Scopes gruesos que no entran en la grilla resource:action.
export const EXTRA_PERMISSIONS = ['api-keys:manage', 'audit:read'] as const;
export type Resource = (typeof RESOURCES)[number];
export type Action = (typeof ACTIONS)[number];
export type ExtraPermission = (typeof EXTRA_PERMISSIONS)[number];
export type Permission = `${Resource}:${Action}` | ExtraPermission | '*:*';
export const SUPER_ADMIN: Permission = '*:*';
export const hasPermission = (
granted: ReadonlyArray<Permission>,
required: Permission,
): boolean => granted.includes(SUPER_ADMIN) || granted.includes(required);requirePermission('roles:upadte') no compila. *:* es el comodín del owner.
2. Los roles son filas, acotadas a una organización
Dejar if (role === 'admin') hardcodeado deja de servir la primera vez que un
cliente pide "un admin que no pueda tocar la facturación". Guardá los roles como
datos. En useDeploy un rol pertenece a una organización y guarda sus permisos en
un array de strings:
model Role {
id String @id @default(cuid())
organizationId String
name String
isSystem Boolean @default(false)
permissions String[] @default([])
@@unique([organizationId, name])
}
model OrganizationMember {
organizationId String
userId String
roleId String
@@unique([organizationId, userId])
}Cada organización nueva recibe tres roles de sistema que no se pueden editar ni
borrar: owner (*:*), admin (users, lectura de roles, settings, reports,
audit e invitations) y member (users:read, reports:read). Los roles custom
son lo que la organización arme a partir del catálogo.
3. Cargá los grants del usuario
La autorización necesita tres datos: quién es el usuario, sobre qué organización es el request y qué le permite su rol ahí. Poné los middlewares en este orden:
import type { RequestHandler, Response } from 'express';
import type { Permission } from './permissions';
declare global {
namespace Express {
interface Request {
session?: { userId: string };
organization?: { id: string; permissions: ReadonlyArray<Permission> };
grants?: ReadonlyArray<Permission>;
}
}
}
const deny = (res: Response, status: number, message: string) =>
res.status(status).json({ error: message });
export const authMiddleware: RequestHandler = async (req, res, next) => {
const session = await getSession(req); // tu librería de auth: BetterAuth en useDeploy
if (!session) return void deny(res, 401, 'Authentication required');
req.session = { userId: session.userId };
next();
};
export const organizationContext: RequestHandler = async (req, res, next) => {
const orgId = req.header('x-organization-id');
if (!orgId) return void deny(res, 403, 'Missing x-organization-id header');
const membership = await findMembership(orgId, req.session!.userId);
if (!membership) return void deny(res, 403, 'You are not a member of this organization');
const role = await findRoleForOrg(membership.roleId, orgId); // filtrado por orgId
if (!role) return void deny(res, 403, 'Membership role missing');
req.organization = { id: orgId, permissions: role.permissions };
next();
};
export const hydratePermissions: RequestHandler = (req, _res, next) => {
if (req.organization) req.grants = req.organization.permissions;
next();
};La versión real de useDeploy es attachOrganizationContext, en
modules/tenancy/interfaces/http/organization-context.middleware.ts. Suma un
chequeo de suspensión que rechaza en cada request a los miembros de una
organización suspendida o borrada, y trata aparte las sesiones de API key, porque
una key trae sus propios permisos acotados y no tiene que heredar los de quien la
creó.
Dos detalles para copiar:
- El header es una afirmación, no un hecho. El cliente dice "estoy actuando en la org X"; el middleware lo comprueba buscando la membresía. Nunca pongas permisos en el header ni en un token que el usuario pueda editar.
- Consultá los roles con el id de la organización.
findRoleForOrg(roleId, orgId)recibe los dos. Buscar un rol solo por id es el tipo de query que filtra datos entre tenants; mirá Multi-tenancy in Prisma without RLS (en inglés).
useDeploy cachea la búsqueda de roles individuales por 300 segundos y descarta las entradas de la organización en cada guardado o borrado de un rol, así que editar un rol se aplica en el siguiente request.
4. El gate
export const requirePermission =
(permission: Permission): RequestHandler =>
(req, res, next) => {
if (!req.session) return void deny(res, 401, 'Authentication required');
if (!hasPermission(req.grants ?? [], permission)) {
return void deny(res, 403, `Missing permission: ${permission}`);
}
next();
};Devolvé 401 cuando no hay identidad y 403 cuando hay una identidad que no
tiene permiso. Con req.grants ?? [], una ruta que se olvidó del middleware de
carga deniega todo en lugar de permitir todo.
5. El chequeo que todos se olvidan
Los permisos salen de la organización del header. La ruta actúa sobre la de la
URL. Sin un chequeo de que coincidan, un miembro de la org A con roles:update
puede llamar a PATCH /organizations/B/roles/... y el chequeo de permisos pasa,
porque se evaluó contra A:
export const requireOrgMatchesPath: RequestHandler = (req, res, next) => {
if (!req.organization || req.organization.id !== req.params.organizationId) {
return void deny(res, 403, 'Organization context does not match URL');
}
next();
};En useDeploy es requireOrgMatchesPath() en
infrastructure/http/require-org-matches-path.ts, montado en los routers de tenancy y de API keys; el router de webhooks tiene un
chequeo equivalente inline. Existe porque ese agujero entre tenants es fácil de
escribir y fácil de pasar por alto en un review.
6. Conectalo
const router = Router();
router.use(authMiddleware, organizationContext, hydratePermissions);
router.use('/:organizationId', requireOrgMatchesPath);
router.get('/:organizationId/roles', requirePermission('roles:read'), listRoles);
router.patch('/:organizationId/roles/:roleId', requirePermission('roles:update'), updateRole);
app.use('/api/v1/organizations', router);Corrimos esta cadena con seis requests, con dos organizaciones y tres usuarios. La salida:
anonymous 401 Authentication required
owner reads roles in its own org 200
member without roles:read 403 Missing permission: roles:read
not a member of the org in the header 403 You are not a member of this organization
org B admin, header B, URL says org A 403 Organization context does not match URL
org B owner updating org B 2007. Editar tu propio perfil
Algunas rutas tienen que permitir siempre "yo" y proteger solo "otro", como
PATCH /users/:id. useDeploy tiene requirePermissionOrSelf(permission, getTargetUserId), que saltea el chequeo de permiso cuando una sesión de cookie
apunta a su propio id de usuario. Las sesiones de API key nunca reciben ese
atajo: una key trae el id de quien la creó solo para auditoría, así que tiene que
tener el permiso.
8. Que los roles no escalen
Un gate en POST /members no alcanza si un admin puede asignarse el rol owner.
Las reglas que useDeploy aplica en los use cases:
- Quien llama solo puede otorgar un rol cuyos permisos sean un subconjunto de los
suyos (
ensureRoleGrantable). Quien tiene*:*puede otorgar cualquiera, así que solo los owners crean owners. - Quien llama no puede modificar a un miembro cuyo rol actual excede sus grants, así que un admin no puede degradar a un owner.
- No se puede degradar ni sacar al último owner. El conteo y la escritura corren en una sola transacción serializable para que dos requests concurrentes no puedan ganar las dos.
Preguntas frecuentes
¿Los permisos van en el JWT o en la sesión? No si los roles pueden cambiar. Leerlos de la base (o de un cache corto) en cada request hace que la edición de un rol se aplique al instante y que un miembro removido pierda el acceso en la siguiente llamada.
¿RBAC o ABAC?
RBAC cubre "qué puede hacer este rol". Las reglas de pertenencia, como "solo
editás tu propia factura", van encima como un chequeo en el use case, o como la
variante OrSelf para el caso común.
¿Dónde pongo el chequeo, en el middleware o en el use case?
Los gates gruesos (roles:update) en el middleware, así cada ruta declara lo que
necesita en una línea visible. Las reglas que dependen de los datos (si quien
llama puede otorgar este rol) en el use case, donde los datos ya están cargados.
¿Esto funciona con una librería de auth distinta de Better Auth?
Sí. Lo único que necesita de la autenticación es un id de usuario en
req.session.
useDeploy trae toda esta cadena, los endpoints del editor de roles y el catálogo de permisos, más las invariantes de arriba. Mirá la feature de RBAC, la documentación de RBAC, y cómo encaja el modelo de tenancy en Organizaciones con Better Auth: multi-tenant paso a paso y Reusing one multi-tenant base across every client (en inglés).