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
updateno recurso? - O recurso pertence ao mesmo
tenantIddo 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:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
- Após um
UPDATE, verificar serowsAffectedfoi 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
ownerIde deve ser igual aouser.id. - Validar tenant: o recurso tem
tenantIde deve ser igual aouser.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 check | Melhor camada | Exemplo | Observação |
|---|---|---|---|
| Autenticado | Middleware/guard | Token/sessão válidos | Bloqueia cedo |
| Permissão/escopo mínimo | Middleware/guard + serviço | orders:write | Guard evita tráfego; serviço garante consistência |
| Tenant/ownership | Serviço + query | WHERE id=? AND tenant_id=? | Evita IDOR e vazamento |
| Regras de estado | Serviço + query | Não cancelar pedido enviado | Colocar no WHERE quando possível |
| Não revelar existência | Serviço | Retornar 404 em vez de 403 em recursos scoped | Reduz enumeração de IDs |
| Garantir efeito | Pós-condição | rowsAffected === 1 | Detecta 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
tenantIdno 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ê evitafindByIdgenérico? - Toda leitura de recurso inclui
tenantId(e ownership quando aplicável) noWHERE? - Toda escrita (
UPDATE/DELETE) incluitenantId(e ownership quando aplicável) noWHERE? - 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?