RBAC — roles & permissions
El catálogo de permissions, la tabla dinámica de Role, el middleware requirePermission, y el escape hatch OrSelf.
La autorización se construye alrededor de un catálogo cerrado de strings resource:action, mapeados a un rol por organización, hidratados sobre la request, y luego chequeados por middleware en cada route.
El catálogo
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'] as const;
export type Permission = `${Resource}:${Action}` | '*:*';
export const SUPER_ADMIN: Permission = '*:*';El producto cruzado da users:create, users:read, …, queues:delete — 40 permissions más el wildcard de super-admin *:*. Un resource nuevo automáticamente gana las cuatro acciones CRUD.
export const hasPermission = (
granted: ReadonlyArray<Permission>,
required: Permission,
): boolean => granted.includes(SUPER_ADMIN) || granted.includes(required);Roles dinámicos
El módulo tenancy es dueño de un modelo Prisma Role — roles por organización con su propia lista de permissions otorgados. El seed crea tres defaults por org:
- Owner —
*:* - Admin — todo excepto
roles:deleteyorganizations:delete(configurable en el seed) - Member — read en la mayoría de los recursos, sin writes
Las organizaciones pueden editar grants de rol en runtime a través de los endpoints de roles (gateados por roles:update). El enum estático anterior se fue — los roles son data, no tipos.
Cómo se gatea una request
authMiddleware → rechaza con 401 si no hay session
organizationContext → hidrata req.organization (org activa + rol de este user + sus permissions)
hydratePermissions → copia req.organization.permissions sobre req.grants
requirePermission(perm) → da 403 si perm no está en req.grants (y no es '*:*')hydratePermissions siempre corre (nunca bloquea tráfico anónimo); requirePermission es el gate. Ambos viven en apps/server/src/infrastructure/http/require-permission.ts.
router.post(
'/organizations/:id/members',
authMiddleware,
organizationContext,
hydratePermissions,
writeLimiter,
requirePermission('invitations:create'),
validateRequest({ body: InviteSchema }),
handler,
);requirePermissionOrSelf
Para routes donde editar tu propio recurso debe ser libre pero editar el de otro requiere el grant:
import { requirePermissionOrSelf } from '@/infrastructure/http/require-permission.js';
router.patch(
'/users/:id',
authMiddleware,
organizationContext,
hydratePermissions,
writeLimiter,
requirePermissionOrSelf('users:update', (req) => req.params.id),
validateRequest({ body: UpdateUserSchema }),
handler,
);El segundo argumento extrae el id del user target desde la request. Si es igual a req.session.userId, el gate saltea el chequeo de permission. Si no, cae al chequeo estándar.
Así modela el boilerplate "podés editar tu propio perfil, pero necesitás users:update para editar el de cualquier otro" sin dos routes ni branching en el handler.
Agregar un permission
- Extendé
RESOURCES(o, muy raramente,ACTIONS) enpackages/shared/src/permissions/index.ts. El catálogo se regenera solo. - Decidí qué roles obtienen el permission nuevo y actualizá el seed (
apps/server/scripts/seed.ts). - Corré
bun run generate:api— la spec OpenAPI embebe la security scheme, así que el client tipado levanta el nuevo string de permission. - Usá
requirePermission('newresource:read')en la route. - Las organizaciones existentes necesitan un backfill: una migration (o un endpoint admin) que agregue el nuevo permission a los roles que decidiste.
No introduzcas strings de permission one-off fuera de la forma resource:action — el sistema de tipos los rechaza, y la convención existe para que requirePermission autocomplete todo el catálogo.
Visibilidad en el frontend
req.grants se expone en la session vía /auth/me (o el endpoint que cargue el bootstrap de session). El dashboard puede esconder UI para acciones que el user no puede ejecutar:
const { permissions } = useSession();
{hasPermission(permissions, 'invitations:create') && (
<Button onClick={openInviteModal}>Invite member</Button>
)}Esconder UI no es un security boundary — el server sigue gateando. Esconder UI es un nicety de UX, nada más.
Roadmap
- Scoping por recurso (
projects:update:ownvsprojects:update:any) — tracked. - UI para editar grants de permission por rol — entregado parcialmente bajo
(dashboard)/settings/roles. - Audit logging de mutaciones de rol — ya cableado a través del módulo
auditvía eventosrole.permissions_changed.