Autenticação não é autorização
As duas palavras são usadas como sinônimos, mas não são a mesma coisa. A Autenticação responde a “quem é você?” e resulta em um principal confiável: um ID de usuário, um tenant e uma forma de verificar que a requisição veio deles. A Autorização responde a “o que você tem permissão para fazer?” e é executada após a autenticação, em cada requisição, contra um alvo específico.
Confundi-las é a origem de alguns dos bugs de segurança mais comuns na web. Um login perfeitamente implementado não diz nada sobre se o usuário logado deve ser capaz de ler a fatura de outro cliente. Uma sessão válida prova a identidade; ela não concede permissões. Cada endpoint ainda precisa decidir, explicitamente, se este principal pode realizar esta ação neste recurso.
Este guia é sobre a segunda pergunta. Ele assume que você já resolveu a primeira — possuindo uma sessão ou token que identifica um usuário — e foca no modelo e nas verificações que transformam esse usuário em uma permissão ou negação. Se a autenticação ainda for um ponto em aberto, leia primeiro o guia de session auth.
O modelo RBAC
O Role-Based Access Control (Controle de Acesso Baseado em Papéis) é o modelo de autorização mais utilizado porque reflete a maneira como as organizações realmente funcionam. Existem três conceitos:
- Principals são as entidades que agem: usuários, contas de serviço, API keys. Cada principal pertence a um tenant.
- Roles são pacotes nomeados de capacidades:
viewer,editor,admin,billing. - Permissions são os átomos: uma única ação em um único tipo de recurso, escrita como
posts:updateoubilling:read.
Um principal recebe um ou mais roles, e cada role é mapeado para um conjunto de permissions. O conjunto de permissões efetivas para uma requisição é a união de todas as permissions de todos os roles do principal. Uma verificação de autorização, então, resume-se a um único teste de pertinência ao conjunto, além de quaisquer regras específicas do recurso.
A elegância disso é que as permissions são estáveis, enquanto os roles são fluidos. Você pode adicionar um role moderator, mover posts:delete para dentro dele e nenhum handler precisará ser alterado. A regra de “quem pode deletar um post” reside nos dados, e não em uma cadeia de instruções if espalhadas por toda a base de código.
Usuários, papéis e permissões
A estrutura relacional consiste em quatro tabelas e duas junções many-to-many. Vale a pena internalizar esse conceito, pois quase toda implementação de RBAC é uma variação disso.
CREATE TABLE permissions (
id bigserial PRIMARY KEY,
action text NOT NULL UNIQUE
);
CREATE TABLE roles (
id bigserial PRIMARY KEY,
name text NOT NULL UNIQUE
);
CREATE TABLE role_permissions (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
permission_id bigint NOT NULL REFERENCES permissions (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
Fazer o seeding desses dados em uma migration é importante. Permissões e mapeamentos de papéis fazem parte do contrato da sua aplicação, e não algo que um administrador improvisa em produção. Mantenha o seed no controle de versão para que todos os ambientes concordem com o que editor significa, e trate qualquer alteração nele com o mesmo cuidado que trataria uma mudança de schema.
Carregar as permissões efetivas requer apenas uma query:
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1;
Faça o cache do resultado por requisição. Carregá-lo uma única vez e anexá-lo ao req.user evita a repetição da query a cada verificação, e um cache com TTL curto indexado pelo ID do usuário mantém o banco de dados fora do caminho crítico (hot path).
Hierarquias de cargos
Organizações reais possuem níveis. Um cargo sênior geralmente faz tudo o que um cargo júnior faz, e mais. Modelar isso copiando cada permissão para cada cargo é uma armadilha de manutenção: altere posts:read e você precisará lembrar de todos os cinco cargos que a incluem.
Em vez disso, permita que os cargos herdem. Adicione uma tabela de junção parent_role_id ou role_inherits e expanda a hierarquia ao construir o conjunto de permissões. Um formato comum é viewer → editor → admin, onde cada nível adiciona novas capacidades.
CREATE TABLE role_inherits (
role_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
parent_id bigint NOT NULL REFERENCES roles (id) ON DELETE CASCADE,
PRIMARY KEY (role_id, parent_id)
);
A expansão é feita por meio de uma consulta recursiva ou, de forma mais simples, por uma closure table pré-computada que armazena cada par de ancestrais. A closure table troca um pouco de armazenamento por uma busca trivial e amigável a índices, o que geralmente é a escolha certa, pois as verificações de permissão são muito mais frequentes do que as edições de cargos.
Proteja-se contra ciclos. Um cargo que herda de si mesmo, direta ou indiretamente, fará com que a expansão entre em loop infinito. Valide na escrita: rejeite qualquer pai que possa criar um ciclo. Mantenha as hierarquias rasas — três ou quatro níveis são suficientes — porque árvores profundas são difíceis de serem compreendidas por humanos e fáceis de implementar incorretamente.
Por que permissões são melhores que strings de roles
O erro mais comum de RBAC é não construir um RBAC de fato. É espalhar verificações de roles por toda a base de código:
if (req.user.role !== "admin") return res.sendStatus(403);
Isso parece inofensivo e é uma decisão de política incorporada em um handler. Ela diz que apenas admin pode fazer isso, o que pode ter sido verdade quando foi escrito. Quando o produto adiciona uma role de support que também precisa de acesso, alguém terá que encontrar cada uma dessas verificações e editá-las — e eles vão esquecer alguma. A regra agora está espalhada por dezenas de arquivos sem uma única fonte de verdade.
Verificar uma permissão inverte a dependência. O handler pergunta “este principal pode atualizar um post?” e a resposta vem dos dados:
if (!can(req.user, "update", post)) return res.sendStatus(403);
Agora, conceder a support a capacidade de atualizar posts é apenas uma linha em role_permissions, e não uma alteração de código. A política torna-se revisável, testável e consistente. O handler descreve a intenção em vez de codificar uma role específica.
A regra de ouro: roles são para humanos, permissões são para o código. Uma UI pode dizer “Admins podem gerenciar o faturamento”, mas a verificação por baixo deve solicitar billing:manage.
O pipeline de autorização
Cada requisição segue a mesma sequência, e cada etapa tem exatamente uma função.
- Autenticar. Resolve a sessão ou o token em um principal: um user id, um tenant id e uma lista de roles. Se isso falhar, a requisição é anônima e as rotas protegidas retornam 401.
- Carregar roles. Busca as atribuições de roles, geralmente do banco de dados ou de um cache populado no login.
- Expandir para permissões. Achata as roles, incluindo as herdadas, em um conjunto único de strings de permissão.
- Verificar a ação no recurso. Chama
can(user, action, resource)para o alvo concreto, após carregá-lo. - Permitir ou negar. Em caso de sucesso, executa o handler. Em caso de falha, retorna 403 sem efeitos colaterais.
- Logar a decisão. Registra o principal, a ação, o recurso e o resultado.
A ordem é importante por dois motivos. A autenticação deve vir primeiro porque todo o restante depende de um principal confiável. As verificações de recurso devem vir após o carregamento do recurso, pois você não pode avaliar a propriedade de um registro que ainda não foi buscado.
O “fail closed” (falha fechada) é inegociável. Se o carregamento de roles lançar um erro ou o cache estiver inacessível, o padrão é negar. Um sistema de autorização que retorna “permitir” em caso de erro é pior do que não ter sistema nenhum, pois gera uma falsa confiança.
Criando um helper can()
Centralizar a decisão em uma única função é o que impede que os route guards e as verificações de recursos fiquem dessincronizados. A assinatura é simples: um principal, uma ação e um recurso opcional.
export type Action = "read" | "create" | "update" | "delete" | "manage";
export function can(
user: Principal,
action: Action,
resource?: Resource
): boolean {
const permission = `${resource?.type ?? "global"}:${action}`;
if (!user.permissions.has(permission)) return false;
if (resource && resource.tenantId !== user.tenantId) return false;
if (resource && action !== "read" && resource.ownerId !== user.id) {
return user.permissions.has(`${resource.type}:manage`);
}
return true;
}
Três regras estão codificadas aqui, em ordem de importância. O conjunto de permissões é o filtro inicial: se nenhum papel (role) concede a ação, pare. O isolamento de tenant vem em seguida e é absoluto — um principal nunca deve agir fora de seu tenant, independentemente das permissões. Por fim, gravações em um recurso exigem a propriedade do mesmo ou uma concessão explícita de manage, que é o que permite que um editor edite seus próprios rascunhos enquanto um admin edita qualquer coisa.
O helper é puro. Ele recebe dados simples e retorna um booleano, sem chamadas ao banco de dados internamente. Isso torna trivial a criação de testes unitários com uma matriz de principals, ações e recursos, e significa que a mesma função pode ser executada em um route guard, em um serviço, em um background job ou em um componente de UI que decide se deve renderizar um botão.
Para a UI, exponha a mesma função ao cliente através de um endpoint ou de um objeto de permissões renderizado no servidor. O cliente deve ocultar controles que o usuário não pode usar, mas o servidor ainda deve aplicar cada verificação, pois um botão oculto não é um controle de segurança.
Aplicando no nível de rota
Um route guard é a primeira linha de defesa: ele decide se esse tipo de ação está disponível para esse principal. Ele é executado antes do handler e de qualquer operação no banco de dados, o que o torna uma maneira barata de rejeitar negações óbvias.
export function requirePermission(
action: Action,
type: string
): RequestHandler {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "unauthorized" });
if (!can(req.user, action, { type, ownerId: req.user.id, tenantId: req.user.tenantId })) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
Monte-o no router para que a regra fique visível onde as rotas são definidas:
router.get("/posts", requirePermission("read", "post"), listPosts);
router.post("/posts", requirePermission("create", "post"), createPost);
Retornar 401 para um principal ausente e 403 para um principal negado é fundamental. 401 significa “Eu não sei quem você é”; 403 significa “Eu sei quem você é e você não pode fazer isso”. Clientes e sistemas de monitoramento os tratam de forma diferente, e confundi-los torna a depuração mais difícil.
Route guards são necessários, mas não suficientes. Eles respondem a “este principal pode atualizar posts em geral?”, e não “ele pode atualizar o post 42?”. Essa segunda pergunta requer o recurso.
Aplicando a validação no nível do recurso
A verificação no nível do recurso é onde a maioria das vulnerabilidades reais é encontrada, pois é a etapa que as pessoas costumam esquecer. Um endpoint como PATCH /posts/:id recebe um id do cliente. Se ele confiar nesse id sem verificar a propriedade, qualquer usuário autenticado poderá modificar qualquer post apenas adivinhando ou enumerando ids. Isso é chamado de Insecure Direct Object Reference, ou IDOR.
A solução segue sempre o mesmo padrão: carregue o recurso e, em seguida, verifique-o.
router.patch("/posts/:id", requireAuth(), async (req, res) => {
const post = await db.post.findById(req.params.id);
if (!post) return res.status(404).json({ error: "not_found" });
const allowed = can(req.user!, "update", {
type: "post",
ownerId: post.authorId,
tenantId: post.tenantId,
});
if (!allowed) return res.status(403).json({ error: "forbidden" });
const updated = await db.post.update(post.id, req.body);
res.json(updated);
});
Existe uma escolha sutil de ordenação para recursos entre diferentes tenants. Se um usuário do tenant A solicitar um post do tenant B, retornar 403 confirma que o post existe, o que vaza informações entre tenants. Muitos sistemas retornam 404 nesse caso, para que o recurso seja indistinguível de um que não existe. Independentemente da sua escolha, seja consistente e documente-a.
O mesmo padrão se aplica a recursos aninhados. Antes de agir sobre /teams/:teamId/projects/:projectId, verifique se o principal pode acessar a equipe e se o projeto pertence a ela. Cada id no caminho é controlado pelo atacante e deve ser verificado.
A matriz de permissões
A matriz de permissões é uma tabela com as roles nas linhas e as permissões nas colunas, preenchida com as concessões (grants). É o artefato que torna um sistema de autorização revisável.
posts:read posts:create posts:update posts:delete billing:read
viewer x
editor x x x
admin x x x x x
billing x x
Mantenha-a no controle de versão junto ao código e gere as migrations de seed a partir dela, para que a documentação e os dados não divirjam. Quando alguém propõe uma nova role, a primeira pergunta é quais colunas ela recebe — e a resposta é um diff nesta tabela, não uma busca exaustiva pelos handlers.
Dois hábitos tornam a matriz útil. Primeiro, nomeie as permissões de forma consistente como resource:action, para que a tabela seja lida com clareza e as strings sejam previsíveis. Segundo, revise a matriz sempre que uma role for alterada, pois uma única coluna extra é fácil de passar despercebida em uma migration e pode conceder muito mais do que o pretendido.
Papéis multi-tenant
Em uma aplicação multi-tenant, a mesma pessoa pode ter papéis diferentes em organizações distintas. O dono de uma agência é um admin do seu próprio tenant e um viewer no de um cliente. Uma única coluna global role não consegue expressar isso.
A solução é definir o escopo das atribuições de papéis por tenant. Adicione tenant_id a user_roles e torne-o parte da chave primária, para que um usuário possa ter papéis distintos por tenant. Quando você constrói o conjunto de permissões para uma requisição, você o constrói para um único tenant — aquele no qual a requisição está atuando.
SELECT DISTINCT p.action
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2;
O tenant deve vir de uma fonte confiável: a sessão, um subdomínio que você controla ou o token. Nunca o aceite de um corpo de requisição ou query string sem verificar se o principal pertence a ele. Uma vez estabelecido, o isolamento de tenant é a primeira regra em can() e se aplica antes que qualquer permissão seja considerada, portanto, nenhuma concessão pode cruzar essa fronteira.
Trocar de tenant é uma mudança de privilégio. Se o tenant ativo estiver na sessão, atualize-o no lado do servidor e regenere qualquer conjunto de permissões em cache para que as concessões do tenant antigo não vazem para o novo contexto.
ABAC e policy engines
O RBAC resolve a maioria das questões, mas algumas regras dependem de mais do que apenas a role e a propriedade: hora do dia, a sensibilidade dos dados, o departamento do usuário ou a pontuação de risco da requisição. Estas são regras baseadas em atributos, e tentar codificá-las como roles gera uma explosão combinatória.
O ABAC (Attribute-Based Access Control) avalia políticas com base em atributos do principal, do recurso, da ação e do ambiente. Uma regra poderia ser: “um usuário pode ler um documento se o seu departamento coincidir com o departamento do documento e a classificação não for secreta”. Isso é expressável, testável e auditável de uma forma que uma matriz de roles não é.
Os policy engines tornam isso prático. O Open Policy Agent avalia políticas escritas em Rego e pode ser consultado como um sidecar ou uma biblioteca, permitindo que as mesmas regras sejam aplicadas em diferentes serviços e linguagens. O Casbin oferece uma abordagem de modelo e adaptador mais leve, com suporte para RBAC, ABAC e combinações, sendo popular em código de aplicação.
Adote um policy engine apenas quando as regras realmente superarem a capacidade do RBAC, não antes. Isso adiciona uma nova linguagem, uma superfície de deploy e uma curva de aprendizado. Um helper can() bem fatorado com regras claras resolve a maioria dos casos, e você sempre poderá encapsulá-lo em um policy engine mais tarde, quando uma decisão específica exigir mais contexto.
Testando a autorização
Bugs de autorização são bugs de segurança, portanto, os testes devem tratar os casos de negação como prioridade. Para cada ação protegida, escreva uma matriz de testes: um chamador anônimo, um principal sem a permissão, um proprietário, um não proprietário com a permissão e um principal de outro tenant.
describe("PATCH /posts/:id", () => {
it("rejects anonymous users", async () => {
await request(app).patch("/posts/1").send({ title: "x" }).expect(401);
});
it("rejects users without posts:update", async () => {
await request(app).patch("/posts/1").set("Cookie", viewerCookie).expect(403);
});
it("allows the owner", async () => {
await request(app).patch("/posts/1").set("Cookie", ownerCookie).expect(200);
});
it("rejects a non-owner editor", async () => {
await request(app).patch("/posts/1").set("Cookie", editorCookie).expect(403);
});
it("rejects a user from another tenant", async () => {
await request(app).patch("/posts/1").set("Cookie", otherTenantCookie).expect(404);
});
});
Teste can() diretamente como uma função pura, com uma tabela de principals, ações e recursos. Isso cobre a lógica de forma exaustiva e barata, enquanto os testes de endpoint provam que a verificação está realmente implementada. Uma falha comum é ter um helper correto que o handler esqueceu de chamar, e apenas um teste de integração consegue detectar isso.
Populando permissões como migrations
Permissões e mapeamentos de roles fazem parte do contrato da sua aplicação, portanto, devem estar em migrations, e não em um painel administrativo que diverge entre os ambientes.
Escreva uma migration de seed que faça o upsert de permissões por nome e, em seguida, reconcilie as concessões de cada role com a matriz. O uso de upserts mantém a migration idempotente, o que é fundamental, pois ela pode ser executada em bancos de dados que já possuam algumas linhas.
INSERT INTO permissions (action) VALUES
('posts:read'), ('posts:create'), ('posts:update'), ('posts:delete'),
('billing:read'), ('billing:manage')
ON CONFLICT (action) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.action IN ('posts:read', 'posts:create', 'posts:update')
WHERE r.name = 'editor'
ON CONFLICT DO NOTHING;
Deletar uma permissão é mais arriscado do que adicionar uma. Verifique os usos no código e, se ela for referenciada em qualquer lugar, renomeie-a ou descontinue-a gradualmente. Uma migration que remove posts:update enquanto um handler ainda a verifica transformará cada requisição em um “deny”, o que é seguro, mas confuso até que alguém analise o diff do seed.
Cacheando o conjunto de permissões
Uma verificação de permissão nunca deve acessar o banco de dados. Monte o conjunto efetivo uma vez por requisição, anexe-o ao principal e reutilize-o para cada verificação naquela requisição.
Para sistemas de alto tráfego, faça o cache do conjunto por usuário por um curto período, utilizando o usuário e o tenant como chaves. Um TTL de 30 a 60 segundos geralmente é suficiente para remover a query do caminho crítico (hot path), mantendo as alterações de função visíveis rapidamente. Quando uma função for alterada, invalide o cache explicitamente em vez de esperar pelo TTL, para que uma permissão revogada pare de funcionar imediatamente.
async function permissionsFor(userId: string, tenantId: string) {
const key = `perm:${tenantId}:${userId}`;
const cached = await redis.get(key);
if (cached) return new Set(JSON.parse(cached));
const rows = await db.query(permissionQuery, [userId, tenantId]);
const set = new Set(rows.map((r) => r.action));
await redis.set(key, JSON.stringify([...set]), "EX", 60);
return set;
}
Existe um trade-off de segurança no TTL. Quanto maior o cache, maior a janela de tempo em que uma função revogada ainda funciona. Prefira a invalidação explícita a cada alteração de atribuição de função e mantenha o TTL curto como uma rede de segurança para invalidações perdidas.
Melhores práticas
- Verifique permissões, e não nomes de roles, no código da aplicação; mantenha as roles como pacotes para humanos.
- Centralize a decisão em uma única
can(user, action, resource)pura. - Negue por padrão e falhe no modo fechado (fail closed) caso as roles ou permissões não possam ser carregadas.
- Aplique o isolamento de tenant antes de qualquer verificação de permissão, e obtenha o tenant de uma fonte confiável.
- Carregue o recurso antes de autorizar uma ação sobre ele, para evitar IDOR.
- Retorne 401 para requisições não autenticadas e 403 para requisições negadas.
- Faça cache do conjunto de permissões efetivas por requisição e invalide-o quando as roles mudarem.
- Mantenha a matriz de permissões no controle de versão e gere seeds a partir dela.
- Teste os casos negativos: anônimo, permissão incorreta, não proprietário, outro tenant.
- Registre (log) as decisões de permissão e negação com contexto suficiente para explicá-las posteriormente.
Erros comuns
- Tratar uma sessão ou token válido como prova de autorização.
- Criar ramificações baseadas em
user.role === "admin"por todo o código. - Verificar a rota, mas nunca o recurso, deixando uma vulnerabilidade de IDOR.
- Confiar em um tenant id vindo do corpo da requisição ou da query string.
- Conceder permissões amplas de
managepara evitar a modelagem de uma regra real. - Construir hierarquias de roles profundas que ninguém consegue compreender.
- Fazer cache de permissões indefinidamente, mantendo concessões obsoletas após a alteração de uma role.
- Retornar 403 quando um 404 evitaria vazar a existência de um recurso de outro tenant.
- Deixar que botões ocultos na UI substituam a validação no servidor.
- Esquecer de verificar cada id em um caminho de rota aninhada.
Próximos passos
O RBAC é o modelo de autorização que você mais utilizará e ele se integra perfeitamente a tudo o que você já construiu. Se os seus principals chegam via tokens, o guia de JWT mostra onde claims como roles se encaixam e por que você ainda deve verificá-las no server-side. O principal em si vem da autenticação de sessão ou, no caso de máquinas, de API keys. E como a autorização sempre envolve um alvo, o guia de REST é o companheiro ideal para modelar recursos e seus ids. Quando suas regras começarem a depender do contexto em vez de roles, volte à seção de ABAC e utilize um policy engine.