RBAC em Node.js: controle de acesso por papel

RBAC (Role-Based Access Control) organiza permissões em papéis, admin, editor, viewer, e atribui papéis a usuários. É o modelo mais adotado por APIs de SaaS: mais simples que permissões individuais por usuário, mais flexível que hard-coded checks de "é admin?". Implementar RBAC correto desde o início evita refatorações dolorosas no futuro.

Schema de banco para RBAC

Estrutura de tabelas para papéis e permissões:

sql
-- Schema RBAC no PostgreSQL:

-- Permissões granulares (recurso:ação):
CREATE TABLE permissoes (
  id     SERIAL PRIMARY KEY,
  nome   TEXT UNIQUE NOT NULL,  -- ex: "pedidos:read", "usuarios:write", "relatorios:export"
  descricao TEXT
);

-- Papéis (roles):
CREATE TABLE roles (
  id     SERIAL PRIMARY KEY,
  nome   TEXT UNIQUE NOT NULL,  -- "admin", "editor", "viewer", "billing"
  descricao TEXT
);

-- Permissões por papel (N:N):
CREATE TABLE role_permissoes (
  role_id       INT REFERENCES roles(id) ON DELETE CASCADE,
  permissao_id  INT REFERENCES permissoes(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, permissao_id)
);

-- Papéis por usuário (um usuário pode ter múltiplos papéis):
CREATE TABLE usuario_roles (
  usuario_id  UUID REFERENCES usuarios(id) ON DELETE CASCADE,
  role_id     INT REFERENCES roles(id) ON DELETE CASCADE,
  -- Opcional: escopo (para multi-tenant):
  org_id      UUID REFERENCES organizacoes(id),
  PRIMARY KEY (usuario_id, role_id, org_id)
);

-- Seed de permissões e roles:
INSERT INTO permissoes (nome) VALUES
  ('pedidos:read'), ('pedidos:write'), ('pedidos:delete'),
  ('usuarios:read'), ('usuarios:write'),
  ('relatorios:view'), ('relatorios:export'),
  ('admin:*');

INSERT INTO roles (nome) VALUES ('admin'), ('editor'), ('viewer'), ('billing');

-- Atribuir permissões ao admin (todas):
INSERT INTO role_permissoes (role_id, permissao_id)
SELECT r.id, p.id FROM roles r, permissoes p WHERE r.nome = 'admin';

Carregar permissões do usuário

Query eficiente para obter todas as permissões de um usuário:

typescript
// lib/rbac.ts
import { db } from './db'
import { redis } from './redis'

// Carregar permissões com cache no Redis (5 minutos):
export async function carregarPermissoes(userId: string, orgId?: string): Promise<Set<string>> {
  const cacheKey = `permissoes:${userId}:${orgId ?? 'global'}`

  const cached = await redis.get(cacheKey)
  if (cached) return new Set(JSON.parse(cached))

  const { rows } = await db.query(
    `SELECT DISTINCT p.nome
     FROM usuario_roles ur
     JOIN role_permissoes rp ON rp.role_id = ur.role_id
     JOIN permissoes p ON p.id = rp.permissao_id
     WHERE ur.usuario_id = $1
       AND (ur.org_id = $2 OR ur.org_id IS NULL)`,
    [userId, orgId ?? null]
  )

  const permissoes = rows.map(r => r.nome)
  await redis.setex(cacheKey, 300, JSON.stringify(permissoes))
  return new Set(permissoes)
}

// Verificar permissão (suporta wildcard "admin:*"):
export function temPermissao(permissoes: Set<string>, recurso: string): boolean {
  if (permissoes.has('admin:*')) return true  // admin tem tudo
  if (permissoes.has(recurso)) return true

  // Verificar wildcard por recurso: "pedidos:*" cobre "pedidos:read":
  const [namespace] = recurso.split(':')
  return permissoes.has(`${namespace}:*`)
}

// Limpar cache ao alterar roles:
export async function invalidarCachePermissoes(userId: string) {
  const keys = await redis.keys(`permissoes:${userId}:*`)
  if (keys.length) await redis.del(...keys)
}

Middleware de autorização RBAC

Verificar permissões em cada rota:

typescript
// middleware/authorize.ts
import { carregarPermissoes, temPermissao } from '../lib/rbac'

// Middleware factory — exige permissão específica:
export function pode(permissao: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const userId = req.usuario?.userId
    if (!userId) return res.status(401).json({ error: 'Não autenticado' })

    const orgId = req.headers['x-org-id'] as string | undefined
    const permissoes = await carregarPermissoes(userId, orgId)

    if (!temPermissao(permissoes, permissao)) {
      return res.status(403).json({
        error: 'Permissão negada',
        required: permissao,
      })
    }

    // Adicionar permissões ao request para uso nas rotas:
    req.permissoes = permissoes
    next()
  }
}

// Verificar múltiplas permissões (ANY ou ALL):
export function podeQualquer(...permissoesList: string[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const permissoes = await carregarPermissoes(req.usuario!.userId)
    const temAlguma = permissoesList.some(p => temPermissao(permissoes, p))
    if (!temAlguma) return res.status(403).json({ error: 'Permissão negada' })
    next()
  }
}

// Usar nas rotas:
app.get('/api/pedidos',
  autenticar,
  pode('pedidos:read'),
  listarPedidos
)

app.delete('/api/pedidos/:id',
  autenticar,
  pode('pedidos:delete'),
  deletarPedido
)

app.get('/api/admin',
  autenticar,
  pode('admin:*'),
  adminPanel
)

Gerenciar roles via API

Endpoints para atribuir e revogar papéis:

typescript
// Atribuir papel a usuário (apenas admin):
app.post('/api/admin/usuarios/:userId/roles',
  autenticar,
  pode('admin:*'),
  async (req, res) => {
    const { userId } = req.params
    const { roleNome, orgId } = req.body

    const { rows: [role] } = await db.query(
      'SELECT id FROM roles WHERE nome = $1', [roleNome]
    )
    if (!role) return res.status(404).json({ error: 'Role não encontrado' })

    await db.query(
      `INSERT INTO usuario_roles (usuario_id, role_id, org_id)
       VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
      [userId, role.id, orgId ?? null]
    )

    // Invalidar cache de permissões do usuário:
    await invalidarCachePermissoes(userId)

    res.json({ message: `Role ${roleNome} atribuído` })
  }
)

// Ver permissões efetivas de um usuário:
app.get('/api/usuarios/:userId/permissoes',
  autenticar,
  pode('usuarios:read'),
  async (req, res) => {
    const permissoes = await carregarPermissoes(req.params.userId)
    const roles = await db.query(
      `SELECT r.nome FROM usuario_roles ur
       JOIN roles r ON r.id = ur.role_id
       WHERE ur.usuario_id = $1`,
      [req.params.userId]
    )
    res.json({
      roles: roles.rows.map(r => r.nome),
      permissoes: [...permissoes].sort(),
    })
  }
)

RBAC no frontend: mostrar/ocultar elementos

Expor permissões no JWT e usar no React:

typescript
// Incluir roles no JWT (não as permissões — são muitas):
const accessToken = await gerarAccessToken({
  userId: usuario.id,
  email: usuario.email,
  roles: usuario.roles,  // ['editor', 'billing']
})

// Endpoint para o frontend verificar permissões sem round-trip:
app.get('/api/auth/me', autenticar, async (req, res) => {
  const permissoes = await carregarPermissoes(req.usuario!.userId)
  res.json({
    usuario: req.usuario,
    permissoes: [...permissoes],
  })
})

// React — hook de permissões:
// hooks/usePermissoes.ts
import { useQuery } from '@tanstack/react-query'

export function usePermissoes() {
  return useQuery({
    queryKey: ['me'],
    queryFn: () => fetch('/api/auth/me').then(r => r.json()),
    staleTime: 5 * 60 * 1000,  // 5 minutos
  })
}

export function usePode(permissao: string) {
  const { data } = usePermissoes()
  return data?.permissoes?.includes(permissao) ?? false
}

// Componente React:
function BotaoDeletar({ pedidoId }: { pedidoId: string }) {
  const podeDeletar = usePode('pedidos:delete')
  if (!podeDeletar) return null
  return <button onClick={() => deletar(pedidoId)}>Deletar</button>
}

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

RBAC (Role-Based): papéis definem grupos de permissões, simples, escalável, fácil de auditar. Funciona bem quando os requisitos de acesso são relativamente estáveis. ABAC (Attribute-Based): políticas baseadas em atributos do usuário, do recurso e do contexto (horário, IP, etc.), mais flexível, mas complexo. Para 90% das APIs SaaS: RBAC é suficiente. Use ABAC quando precisar de regras como "pode editar apenas seus próprios recursos criados nas últimas 24h".

Sim para roles (poucos dados), não para permissões completas (pode ser muito grande). O risco: o JWT é imutável até expirar, se você alterar o role de um usuário, o JWT antigo ainda tem o role anterior até expirar (15min no access token). Solução: ou aceitar esse lag de 15 minutos, ou sempre consultar as permissões do banco para operações críticas (não confiar apenas no JWT).

Adicionar org_id na tabela usuario_roles, o mesmo usuário pode ter role "admin" em uma organização e "viewer" em outra. No middleware: ler o org_id do header (X-Org-Id) ou do subdomínio (org.seuapp.com), e carregar permissões filtrando por usuario_id + org_id. O JWT deve incluir o orgId da sessão atual.

Ambos, mas com papéis diferentes. Backend (obrigatório): a verificação real de segurança, nunca confie no frontend para autorização, o cliente pode ser manipulado. Frontend (UX): ocultar botões e menus que o usuário não tem permissão para acessar, evitando frustrações e erros 403 desnecessários. O frontend NUNCA deve ser a única linha de defesa.

Crie tabela de auditoria: CREATE TABLE audit_log (id BIGSERIAL, usuario_id UUID, role TEXT, acao TEXT, recurso TEXT, recurso_id TEXT, ip INET, criado_em TIMESTAMPTZ DEFAULT NOW()). Popule via middleware: após cada ação write (POST, PUT, DELETE), registre. Ou use triggers no PostgreSQL para capturar todas as mudanças. Armazene por pelo menos 90 dias para compliance.