Autenticação responde “quem é você”. Autorização responde “você pode fazer isso”.
Todo mundo sabe a definição. Mesmo assim, a falha de segurança mais comum em API é ter autenticação impecável e autorização nenhuma.
O motivo é estrutural: autenticação é um problema resolvido, centralizado num middleware, testado uma vez. Autorização é uma decisão por endpoint, por recurso, por campo. Ela se espalha, e o que se espalha se esquece.
O formato do bug
@UseGuards(JwtAuthGuard) // autenticação: perfeita
@Controller("invoices")
export class InvoiceController {
@Get(":id")
findOne(@Param("id") id: string) {
return this.invoiceService.findById(id); // autorização: nenhuma
}
}
Esse endpoint exige token válido. Qualquer usuário logado consegue ler a fatura de qualquer outro trocando o ID na URL.
O guard fez o trabalho dele e a maioria dos times para por aí, porque o cadeado na porta dá a sensação de que a casa está protegida. Só que a porta separa a rua da recepção, não a recepção do cofre.
Isso tem nome, IDOR, e continua sendo o achado número um em teste de invasão de aplicação. Não porque é difícil de evitar, mas porque é fácil de esquecer.
Por que “adicionar um if” não resolve
A correção óbvia:
const invoice = await this.invoiceService.findById(id);
if (invoice.userId !== user.id) throw new ForbiddenException();
return invoice;
Funciona. E é frágil por três motivos.
Depende de alguém lembrar em cada endpoint novo. Vai falhar no décimo quinto.
Busca primeiro e valida depois, o que já vazou a informação de que aquele recurso existe, e em alguns fluxos vaza mais do que isso quando o objeto passa por log ou por cache antes da verificação.
E replica a regra em todo lugar. No dia em que “usuário vê a própria fatura” virar “usuário vê a própria fatura, e admin da organização vê todas da organização”, você tem quinze lugares pra mudar e vai achar treze.
O escopo pertence à consulta
O padrão que sobrevive é fazer a autorização parte da forma como o dado é buscado.
async findOneForUser(id: string, user: User): Promise<Invoice> {
const invoice = await this.repo.findOne({
where: { id, ...this.scopeFor(user) },
});
if (!invoice) throw new NotFoundException();
return invoice;
}
private scopeFor(user: User) {
if (user.hasPermission(Permission.ORG_ADMIN)) {
return { organization: { id: user.organizationId } };
}
return { userId: user.id };
}
Agora a regra mora num lugar só. Um endpoint novo que use findOneForUser já nasce protegido, e um que use findById direto salta aos olhos no code review, porque a assinatura sem usuário fica visivelmente diferente do resto.
Repare também no NotFoundException em vez de ForbiddenException. Responder 403 confirma que o recurso existe, o que entrega informação a quem está sondando IDs. 404 nos dois casos não conta nada.
Papel ou permissão
Duas formas de modelar, e a escolha errada trava o produto depois.
Papel é grosso: admin, consultor, usuário. Fácil de entender, fácil de explicar, e engessa. No dia em que um cliente pedir “esse cargo aqui pode ver relatório mas não pode convidar gente”, você cria um papel novo. Depois outro. Em dois anos são catorze papéis e ninguém sabe a diferença entre três deles.
Permissão é fina: invoice.read, user.invite, report.export. O código pergunta por permissão, nunca por papel. Papel vira só um pacote de permissões, uma conveniência de administração.
// frágil: amarra a regra ao organograma
if (user.role === "admin") { ... }
// resistente: amarra a regra ao que precisa ser feito
if (user.can(Permission.INVOICE_EXPORT)) { ... }
A migração de papel pra permissão depois que o sistema cresceu é dolorosa. Começar já perguntando por permissão custa quase nada.
O contexto que quase todo mundo esquece
Em sistema multi-tenant, a permissão sozinha não basta. invoice.read significa ler qualquer fatura ou as faturas da organização daquela pessoa?
A pergunta certa tem três partes: quem, pode fazer o quê, sobre qual recurso. Deixar a terceira implícita é como o vazamento entre clientes acontece, e é o tipo de incidente que encerra contrato.
O frontend não autoriza nada
Esconder o botão é experiência do usuário. Não é controle de acesso.
Todo controle precisa existir no servidor, do jeito que existiria se o frontend não existisse. Porque, na prática, ele não existe: a pessoa que está te atacando chama a API direto.
Vale o inverso também: se o backend autoriza direito, uma tela que mostra um botão a mais é um bug de usabilidade, não uma falha de segurança. Priorize nessa ordem.
O teste que trava a regressão
Para cada regra de autorização que importa, um teste:
it("não deixa membro comum exportar relatório da organização", async () => {
const { token } = await loginAs(memberWithoutPermission);
const res = await request(app)
.get(`/organizations/${org.id}/reports/export`)
.set("Authorization", `Bearer ${token}`);
expect(res.status).toBe(403);
});
É o único mecanismo que impede a regra de voltar a quebrar em silêncio daqui a oito meses, quando alguém refatorar o serviço achando que aquele parâmetro extra não fazia nada.
Escrevi sobre o resto do básico em como construir aplicações seguras e sobre como achar esses buracos em modelagem de ameaças em uma hora.