Aplicando autorização corretamente em camadas: controller, serviço e banco de dados

Capítulo 14

Tempo estimado de leitura: 8 minutos

+ Exercício

Autorização em camadas: por que “defesa em profundidade” evita bypass

Em APIs reais, a autorização não deve existir em um único ponto. Um check apenas no controller (ou apenas no banco) costuma virar um “ponto único de falha”: basta um endpoint novo, um refactor, uma chamada interna ou um job assíncrono que ignore aquele ponto para surgir um bypass. A abordagem correta é distribuir a autorização em camadas, com responsabilidades claras:

  • Controller (middleware/guard): valida pré-condições gerais (ex.: usuário autenticado, escopos/roles mínimos, formato de parâmetros) e bloqueia cedo.
  • Serviço (domínio): aplica regras de negócio e validações de ownership/tenant; é o lugar mais confiável para garantir que a operação é permitida.
  • Banco/consulta (row-level filtering): garante que as queries já retornem apenas linhas autorizadas; reduz risco de IDOR e vazamento acidental.

O objetivo é que, mesmo que uma camada falhe ou seja esquecida, outra ainda impeça o acesso indevido.

Checagens prévias vs pós-condições

Checagens prévias (antes da ação)

São validações feitas antes de executar a operação. Exemplos:

  • O usuário tem permissão para update no recurso?
  • O recurso pertence ao mesmo tenantId do usuário?
  • O recurso está em um estado que permite a ação (ex.: pedido não pode ser cancelado após enviado)?

Vantagens: falha rápida, menor custo, evita efeitos colaterais (ex.: escrever no banco e depois negar).

Pós-condições (verificar resultado)

São validações após executar uma operação ou após obter um resultado. São úteis quando a autorização depende do que foi efetivamente afetado/retornado. Exemplos:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

  • Após um UPDATE, verificar se rowsAffected foi 1 (e não 0) para detectar tentativa de acessar recurso de outro tenant/owner.
  • Após buscar uma entidade, validar que ela pertence ao usuário/tenant antes de serializar a resposta.

Em geral, combine: pré-condições para bloquear cedo e pós-condições para garantir que a operação afetou apenas o que deveria.

IDOR (Insecure Direct Object Reference): o risco mais comum em endpoints com IDs

IDOR acontece quando o cliente consegue acessar ou modificar um objeto apenas trocando um identificador (ex.: /orders/123 para /orders/124) e o back-end não valida se aquele objeto é permitido para o usuário.

O padrão de prevenção é simples e deve existir em múltiplas camadas:

  • Validar ownership: o recurso tem ownerId e deve ser igual ao user.id.
  • Validar tenant: o recurso tem tenantId e deve ser igual ao user.tenantId.
  • Filtrar na query: buscar/alterar usando WHERE id = ? AND tenant_id = ? (e, quando aplicável, AND owner_id = ?).

Padrão prático de implementação por camadas

A seguir, um exemplo genérico (pseudocódigo) de como organizar os checks. Adapte para seu framework (Express/Nest/Fastify/Spring/.NET) e ORM (Prisma/TypeORM/Sequelize/Hibernate/EF).

1) Controller: guard/middleware para requisitos mínimos

No controller, foque em requisitos “globais” e baratos:

  • Usuário autenticado (já resolvido por auth middleware).
  • Permissão/escopo mínimo para a ação (ex.: orders:read).
  • Validação de input (ex.: orderId é UUID).
// Controller (exemplo genérico) GET /orders/:id
function getOrderController(req, res) {
  const user = req.user; // já autenticado
  assertHasScope(user, 'orders:read');
  const orderId = validateUuid(req.params.id);

  const order = orderService.getOrderById({ user, orderId });
  res.json(order);
}

Importante: o controller não deve ser o único lugar com ownership/tenant checks, porque chamadas internas podem pular o controller.

2) Serviço: autorização contextual (ownership/tenant + regras de negócio)

No serviço, trate a autorização como parte da regra de negócio. Aqui você tem contexto suficiente para decidir “pode ou não pode” de forma consistente.

// Service
function getOrderById({ user, orderId }) {
  // Pré-condição: usuário precisa estar em um tenant válido
  if (!user.tenantId) throw new Forbidden('Missing tenant');

  // Buscar já filtrando por tenant (e opcionalmente ownership)
  const order = orderRepo.findByIdForTenant({ orderId, tenantId: user.tenantId });

  // Pós-condição: se não encontrou, não revele se existe em outro tenant
  if (!order) throw new NotFound('Order not found');

  // Regra adicional: se a política exigir ownership
  if (order.ownerId !== user.id && !user.isAdmin) {
    throw new Forbidden('Not allowed');
  }

  return order;
}

Note o detalhe: quando o filtro por tenant já está na query, o NotFound evita “enumeration” (descobrir se o ID existe em outro tenant). O check de ownership pode ser necessário mesmo dentro do tenant (ex.: usuários do mesmo tenant não podem ver pedidos uns dos outros).

3) Repositório/consulta: row-level filtering (filtrar linhas autorizadas)

O repositório deve expor métodos que já exigem os parâmetros de escopo (tenant/owner) e nunca um findById(id) “solto” para recursos multi-tenant.

// Repository
function findByIdForTenant({ orderId, tenantId }) {
  return db.orders.findFirst({
    where: { id: orderId, tenantId: tenantId }
  });
}

Para operações de escrita, prefira que o UPDATE/DELETE também inclua o filtro de tenant/owner, e use pós-condição com rowsAffected:

// Repository
function cancelOrderForTenant({ orderId, tenantId }) {
  const result = db.orders.updateMany({
    where: { id: orderId, tenantId: tenantId, status: { in: ['CREATED','PAID'] } },
    data: { status: 'CANCELED' }
  });
  return result.count; // rows affected
}

// Service
function cancelOrder({ user, orderId }) {
  const affected = orderRepo.cancelOrderForTenant({ orderId, tenantId: user.tenantId });
  if (affected === 0) throw new NotFound('Order not found');
}

Aqui, a regra de estado (só cancela se estiver em CREATED/PAID) também está no filtro. Isso reduz condições de corrida e impede que o serviço “ache” que cancelou algo quando não cancelou.

Onde exatamente colocar cada tipo de check (mapa mental)

Tipo de checkMelhor camadaExemploObservação
AutenticadoMiddleware/guardToken/sessão válidosBloqueia cedo
Permissão/escopo mínimoMiddleware/guard + serviçoorders:writeGuard evita tráfego; serviço garante consistência
Tenant/ownershipServiço + queryWHERE id=? AND tenant_id=?Evita IDOR e vazamento
Regras de estadoServiço + queryNão cancelar pedido enviadoColocar no WHERE quando possível
Não revelar existênciaServiçoRetornar 404 em vez de 403 em recursos scopedReduz enumeração de IDs
Garantir efeitoPós-condiçãorowsAffected === 1Detecta bypass e concorrência

Anti-padrões comuns que geram bypass

  • Buscar por ID e checar depois, mas esquecer o check em algum endpoint: um novo endpoint “rápido” vira vulnerável.
  • Reutilizar método de repositório genérico: findById(id) usado em contexto multi-tenant sem filtro.
  • Confiar em dados do cliente: aceitar tenantId no body e usar na query.
  • Checar apenas role e ignorar ownership: “usuário” com role válida acessa objetos de outros usuários.
  • Retornar 403/404 inconsistentes: mensagens diferentes para “existe mas não pode” vs “não existe” podem permitir enumeração.

Passo a passo: corrigindo um endpoint vulnerável (exercício)

Cenário

Você tem um endpoint para atualizar o endereço de entrega de um pedido:

// VULNERÁVEL: PUT /orders/:id/shipping-address
function updateShippingAddressController(req, res) {
  const user = req.user;
  assertHasScope(user, 'orders:write');

  const orderId = req.params.id;
  const newAddress = req.body.address;

  // Problema: busca por ID sem tenant/ownership
  const order = db.orders.findUnique({ where: { id: orderId } });
  if (!order) return res.status(404).end();

  // Problema: não valida tenant/owner
  db.orders.update({ where: { id: orderId }, data: { shippingAddress: newAddress } });

  res.status(204).end();
}

Um atacante autenticado pode trocar :id por um pedido de outro usuário/tenant e atualizar o endereço (IDOR).

Tarefa 1: identificar os pontos de falha

  • O controller só checa escopo, mas não checa ownership/tenant.
  • A query de leitura e a de update não têm filtro por tenant/owner.
  • Não há pós-condição para garantir que o update afetou exatamente 1 linha autorizada.

Tarefa 2: aplicar correção em camadas

Passo 1 — Controller: manter apenas validações mínimas e input.

function updateShippingAddressController(req, res) {
  const user = req.user;
  assertHasScope(user, 'orders:write');

  const orderId = validateUuid(req.params.id);
  const newAddress = validateAddress(req.body.address);

  orderService.updateShippingAddress({ user, orderId, newAddress });
  res.status(204).end();
}

Passo 2 — Serviço: garantir tenant/ownership e regras de estado.

function updateShippingAddress({ user, orderId, newAddress }) {
  if (!user.tenantId) throw new Forbidden('Missing tenant');

  // Opção A: fazer UPDATE com filtro e checar rowsAffected (recomendado)
  const affected = orderRepo.updateShippingAddressScoped({
    orderId,
    tenantId: user.tenantId,
    ownerId: user.id,
    newAddress
  });

  // Pós-condição: se não afetou, trate como não encontrado (evita enumeração)
  if (affected === 0) throw new NotFound('Order not found');
}

Passo 3 — Repositório/consulta: row-level filtering no update.

function updateShippingAddressScoped({ orderId, tenantId, ownerId, newAddress }) {
  const result = db.orders.updateMany({
    where: {
      id: orderId,
      tenantId: tenantId,
      ownerId: ownerId,
      status: { in: ['CREATED','PAID'] }
    },
    data: { shippingAddress: newAddress }
  });
  return result.count;
}

Tarefa 3: variações para cenários com admin/suporte

Se existir um papel que pode editar pedidos de qualquer usuário dentro do tenant, ajuste o escopo no serviço:

function updateShippingAddress({ user, orderId, newAddress }) {
  const base = { orderId, tenantId: user.tenantId, newAddress };

  const affected = user.isAdmin
    ? orderRepo.updateShippingAddressForTenant(base)
    : orderRepo.updateShippingAddressForOwner({ ...base, ownerId: user.id });

  if (affected === 0) throw new NotFound('Order not found');
}

O ponto principal: mesmo para admin, mantenha o filtro por tenantId para impedir acesso cross-tenant.

Checklist rápido para revisar endpoints com IDs (anti-IDOR)

  • O repositório tem métodos “scoped” (...ForTenant, ...ForOwner) e você evita findById genérico?
  • Toda leitura de recurso inclui tenantId (e ownership quando aplicável) no WHERE?
  • Toda escrita (UPDATE/DELETE) inclui tenantId (e ownership quando aplicável) no WHERE?
  • Você valida pós-condição (rowsAffected) e responde sem revelar existência fora do escopo?
  • Há consistência entre endpoints HTTP e chamadas internas (jobs/consumers) usando o mesmo serviço?

Agora responda o exercício sobre o conteúdo:

Ao corrigir um endpoint vulnerável a IDOR em um sistema multi-tenant, qual abordagem melhor aplica “defesa em profundidade” para evitar bypass de autorização?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A defesa em profundidade distribui a autorização: o controller bloqueia cedo (escopo/input), o serviço aplica regras e valida ownership/tenant, e o repositório filtra linhas no WHERE. Checar rowsAffected e responder como NotFound quando 0 evita bypass e enumeração.

Próximo capitúlo

Boas práticas operacionais: logs, auditoria, monitoramento e respostas seguras

Arrow Right Icon
Capa do Ebook gratuito Autenticação e Autorização no Back-end: Sessões, JWT e Boas Práticas
78%

Autenticação e Autorização no Back-end: Sessões, JWT e Boas Práticas

Novo curso

18 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.