RBAC — roles & permissions
O catálogo de permissions, a tabela dinâmica Role, o middleware requirePermission, e o escape hatch OrSelf.
A autorização é construída ao redor de um catálogo fechado de strings resource:action, mapeadas para um role por organização, hidratadas na request, e então checadas por middleware em cada route.
O 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 = '*:*';O produto cartesiano dá users:create, users:read, …, queues:delete — 40 permissions mais o wildcard super-admin *:*. Um resource novo automaticamente ganha as quatro ações CRUD.
export const hasPermission = (
granted: ReadonlyArray<Permission>,
required: Permission,
): boolean => granted.includes(SUPER_ADMIN) || granted.includes(required);Roles dinâmicos
O módulo tenancy é dono de um model Prisma Role — roles por organização com sua própria lista de permissions concedidos. O seed cria três defaults por org:
- Owner —
*:* - Admin — tudo exceto
roles:deleteeorganizations:delete(configurável no seed) - Member — read na maioria dos recursos, sem writes
Organizações podem editar grants de role em runtime através dos endpoints de roles (gateados por roles:update). O enum estático anterior se foi — roles são dados, não tipos.
Como uma request é gateada
authMiddleware → rejeita 401 se não há session
organizationContext → hidrata req.organization (org ativa + role deste user + suas permissions)
hydratePermissions → copia req.organization.permissions para req.grants
requirePermission(perm) → retorna 403 se perm não está em req.grants (e não é '*:*')hydratePermissions sempre roda (nunca bloqueia tráfego anônimo); requirePermission é o gate. Ambos vivem em 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 onde editar seu próprio recurso deve ser livre mas editar o de outra pessoa precisa do 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,
);O segundo argumento extrai o id do user alvo da request. Se for igual a req.session.userId, o gate pula o check de permission. Caso contrário, cai para o check padrão.
É assim que o boilerplate modela "você pode editar seu próprio perfil, mas precisa de users:update para editar o de qualquer outro" sem duas routes ou branching no handler.
Adicionar um permission
- Estenda
RESOURCES(ou, raramente,ACTIONS) empackages/shared/src/permissions/index.ts. O catálogo se regenera sozinho. - Decida quais roles recebem o novo permission e atualize o seed (
apps/server/scripts/seed.ts). - Rode
bun run generate:api— a spec OpenAPI embute a security scheme, então o client tipado pega o novo string de permission. - Use
requirePermission('newresource:read')na route. - Organizações existentes precisam de um backfill: uma migration (ou um endpoint admin) que adicione o novo permission aos roles que você decidiu.
Não introduza strings de permission one-off fora da forma resource:action — o sistema de tipos rejeita, e a convenção existe para que requirePermission autocomplete o catálogo inteiro.
Visibilidade no frontend
req.grants é exposto na session via /auth/me (ou qualquer endpoint que carregue o bootstrap da session). O dashboard pode esconder UI para ações que o usuário não pode executar:
const { permissions } = useSession();
{hasPermission(permissions, 'invitations:create') && (
<Button onClick={openInviteModal}>Invite member</Button>
)}Esconder UI não é um security boundary — o server ainda gateia. Esconder UI é um nicety de UX, nada mais.
Roadmap
- Scoping por recurso (
projects:update:ownvsprojects:update:any) — tracked. - UI para editar grants de permission por role — parcialmente entregue em
(dashboard)/settings/roles. - Audit logging de mutações de role — já cabeado através do módulo
auditvia eventosrole.permissions_changed.