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>
    }

    $ runstack deploy --plan starter

    Não quer configurar manualmente?

    Não quer configurar manualmente? Implante o VPS para APIs em menos de 3 minutos com a Runstack. Infraestrutura da OPEN DATACENTER, com servidores no Brasil.

    Perguntas frequentes