SaaS Starter

RBAC — roles & permissions

O catálogo de permissions, a tabela dinâmica Role, o middleware requirePermission, e o escape hatch OrSelf.

Última atualização 4 de mai. de 2026

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.

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

Conceder roles: sem escalada de privilégios

Três regras se somam ao requirePermission nas rotas de tenancy:

  • A org da URL deve coincidir com a org ativa. As permissions são resolvidas a partir do header x-organization-id, então toda rota /organizations/:organizationId/... também monta requireOrgMatchesPath() (infrastructure/http/require-org-matches-path.ts). Um mismatch é um 403; sem isso um membro da org A poderia agir sobre a org B trocando o path.
  • Um role só pode ser concedido por quem já o possui. Adicionar um membro, convidar e alterar o role de um membro exigem que as permissions do role de destino sejam um subconjunto dos grants do caller (um caller *:* pode conceder qualquer um, então só owners criam owners). Também é recusado modificar um membro cujo role atual excede seus grants, então um admin não consegue rebaixar um owner.
  • O último owner não pode ser rebaixado nem removido. A contagem e a escrita rodam em uma única transação serializable (IMembershipRoleChanger / IMembershipRemover), então duas rebaixas concorrentes não podem ter sucesso ambas.
  • Os roles têm o mesmo limite. Criar ou editar um role exige que suas permissions sejam um subconjunto dos seus grants (salvo se você tiver *:*); você não pode editar um role cujas permissions atuais excedem as suas, nem o role que você possui (sem auto-ampliação). Remover um membro cujo role excede seus grants (um admin removendo um owner) é recusado; remover a si mesmo é sempre permitido.

Dois comportamentos a mais: POST /organizations/:id/members exige users:create e DELETE .../members/:userId exige users:delete (remover a si mesmo é sempre permitido), e apagar um role que algum convite ainda referencia retorna 409 em vez de falhar na foreign key.

audit:read

GET /api/v1/audit?organizationId=... exige a permission audit:read nessa org (expõe IPs e user agents dos atores), não apenas ser membro. É uma permission extra (como api-keys:manage), concedida aos owners *:* e ao role admin padrão; uma migration a adiciona aos roles de sistema admin existentes. Roles custom precisam recebê-la explicitamente. Ler suas próprias entradas (?actorUserId=<você>) não exige permission.

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.

/users é restrito à organização

GET /api/v1/users e GET|PATCH|DELETE /api/v1/users/:id só enxergam os membros da organização ativa (x-organization-id); um user fora dela responde 404. O self-update (PATCH no seu próprio id com sessão cookie) continua independente da org. DELETE não apaga a conta: users podem pertencer a várias organizações, então remove a membership na organização ativa (o último owner não pode ser removido). A exclusão de conta é o fluxo self-service /me/deletion.

Sessões de API key carregam o id do criador apenas para auditoria: recebem 403 em /me/* e em rotas de platform admin, mesmo que o criador seja platform admin. requirePermissionOrSelf também não aplica o atalho self a elas: uma API key precisa ter a permission (p. ex. users:update) mesmo ao apontar para o id do seu criador, e o mesmo vale para remover um membro. A transferência de ownership troca o role owner e o role rebaixado em uma única transação serializable, então uma org nunca fica com 0 ou 2 owners (uma mudança concorrente responde 409).

Adicionar um permission

  1. Estenda RESOURCES (ou, raramente, ACTIONS) em packages/shared/src/permissions/index.ts. O catálogo se regenera sozinho.
  2. Decida quais roles recebem o novo permission e atualize o seed (apps/server/scripts/seed.ts).
  3. Rode bun run generate:api — a spec OpenAPI embute a security scheme, então o client tipado pega o novo string de permission.
  4. Use requirePermission('newresource:read') na route.
  5. 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:own vs projects: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 audit via eventos role.permissions_changed.

Nesta página