Aliquot

RBAC — roles & permissions

El catálogo de permissions, la tabla dinámica de Role, el middleware requirePermission, y el escape hatch OrSelf.

Última actualización 27 oct 2026

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.

packages/shared/src/permissions/index.ts:

export const RESOURCES = [
  'users',
  'roles',
  'settings',
  'reports',
  'organizations',
  'billing',
  'invitations',
  'webhooks',
  'api-keys',
  'queues',
  'crypto-invoices',
  'crypto-payouts',
  'crypto-ledger',
  'crypto-links',
  'crypto-accounts',
] as const;

export const ACTIONS = ['create', 'read', 'update', 'delete', 'manage'] as const;

// One-offs outside the resource × action grid.
export const EXTRA_PERMISSIONS = ['api-keys:manage', 'audit:read'] as const;

export type Permission = `${Resource}:${Action}` | ExtraPermission | '*:*';

export const SUPER_ADMIN: Permission = '*:*';

El producto cruzado da users:create, users:read, …, crypto-accounts:manage — 75 permissions, más el extra audit:read y el wildcard de super-admin *:*. Un resource nuevo automáticamente gana las cinco acciones.

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:delete y organizations: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.

Otorgar roles: sin escalada de privilegios

Tres reglas se suman a requirePermission en las rutas de tenancy:

  • La org de la URL debe coincidir con la org activa. Los permissions se resuelven desde el header x-organization-id, así que toda ruta /organizations/:organizationId/... monta además requireOrgMatchesPath() (infrastructure/http/require-org-matches-path.ts). Un mismatch es un 403; sin esto un miembro de la org A podría actuar sobre la org B cambiando el path.
  • Un rol solo lo puede otorgar quien ya lo tiene. Agregar un miembro, invitar y cambiar el rol de un miembro exigen que los permissions del rol destino sean un subconjunto de los grants del caller (un caller *:* puede otorgar cualquiera, por lo que solo los owners crean owners). También se rechaza modificar a un miembro cuyo rol actual excede tus grants, así que un admin no puede degradar a un owner.
  • El último owner no puede ser degradado ni removido. El conteo y la escritura corren en una única transacción serializable (IMembershipRoleChanger / IMembershipRemover), por lo que dos degradaciones concurrentes no pueden tener éxito ambas.
  • Los roles tienen el mismo límite. Crear o editar un rol exige que sus permissions sean un subconjunto de tus grants (salvo que tengas *:*); no podés editar un rol cuyos permissions actuales exceden los tuyos, ni el rol que tenés asignado (sin auto-ampliación). Remover a un miembro cuyo rol excede tus grants (un admin removiendo a un owner) se rechaza; removerte a vos mismo siempre está permitido.

Dos comportamientos más: POST /organizations/:id/members requiere users:create y DELETE .../members/:userId requiere users:delete (removerte a vos mismo siempre está permitido), y borrar un rol que alguna invitación todavía referencia devuelve 409 en lugar de fallar por la foreign key.

audit:read

GET /api/v1/audit?organizationId=... requiere el permission audit:read en esa org (expone IPs y user agents de los actores), no solo ser miembro. Es un permission extra (como api-keys:manage), otorgado a los owners *:* y al rol admin por defecto; una migración lo agrega a los roles de sistema admin existentes. Los roles custom deben recibirlo explícitamente. Leer tus propias entradas (?actorUserId=<vos>) no requiere permission.

Pagos cripto (crypto-*)

El módulo crypto-payments agrega cinco resources. La API de merchant (/api/v1/crypto/*) gatea cada route con uno de ellos, tanto para sesiones con cookie como para API keys (una API key tiene solo los permissions con los que se creó):

PermissionRoutes
crypto-invoices:readGET /networks, POST /quote, GET /invoices, GET /invoices/:id, GET /refunds, GET /overview, GET /requests
crypto-invoices:createPOST /invoices
crypto-invoices:updatePOST /invoices/:id/void
crypto-accounts:read / :createGET /accounts, GET /accounts/:id / POST /accounts
crypto-links:read / :create / :updateGET /payment-links[/:id] / POST /payment-links / PATCH /payment-links/:id
crypto-payouts:createPOST /payouts (más un step-up TOTP: por payout para sesiones con cookie, al crearse para API keys; se rechaza mientras se impersona)
crypto-payouts:readGET /payouts, GET /payouts/:id
crypto-ledger:readGET /balances, GET /ledger/transactions, GET /ledger/accounts

La API del checkout hosteado (/api/v1/public/*) no requiere sesión ni permission: el id de la invoice o el slug del payment link es la capability (un claim token de refund para los endpoints de refund). Ver Payouts para las reglas de step-up. La migración 20261027053000_grant_crypto_permissions_to_admin_roles otorga el subconjunto operativo (todo create/read/update de crypto-* salvo escrituras del ledger; nada de delete/manage) a los roles de sistema admin existentes. Los owners ya tienen *:*; los roles custom deben recibirlos explícitamente.

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.

/users está acotado a la organización

GET /api/v1/users y GET|PATCH|DELETE /api/v1/users/:id solo ven a los miembros de la organización activa (x-organization-id); un user fuera de ella responde 404. El self-update (PATCH sobre tu propio id con sesión cookie) sigue sin depender de la org. DELETE no borra la cuenta: los users pueden pertenecer a varias organizaciones, así que elimina la membresía en la organización activa (no se puede quitar al último owner). El borrado de cuenta es el flujo self-service /me/deletion.

Las sesiones de API key llevan el id de su creador solo para auditoría: reciben 403 en /me/* y en rutas de platform admin, aunque el creador sea platform admin. requirePermissionOrSelf tampoco les aplica el atajo self: una API key debe tener el permission (p. ej. users:update) incluso al apuntar al id de su creador, y lo mismo vale para quitar un miembro. La transferencia de ownership intercambia el rol owner y el rol degradado en una sola transacción serializable, así una org nunca queda con 0 o 2 owners (un cambio concurrente responde 409).

Agregar un permission

  1. Extendé RESOURCES (o, muy raramente, ACTIONS) en packages/shared/src/permissions/index.ts. El catálogo se regenera solo.
  2. Decidí qué roles obtienen el permission nuevo y actualizá el seed (apps/server/scripts/seed.ts).
  3. Corré bun run generate:api — la spec OpenAPI embebe la security scheme, así que el client tipado levanta el nuevo string de permission.
  4. Usá requirePermission('newresource:read') en la route.
  5. 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:own vs projects: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 audit vía eventos role.permissions_changed.

En esta página