Autorização (RBAC e ABAC)
Modelos para decidir o que cada usuário pode fazer, por papel ou por atributos.
As cinco versões desta página foram executadas e receberam os mesmos dez pedidos. A política em OPA e a segurança por linha no Postgres também foram testadas.
A política, em cada linguagem
Cada versão tem as mesmas peças:
- Uma tabela de alcances: para cada papel e ação, "os próprios" ou "todos". O que não está na tabela é negado.
- Uma função
pode(usuario, acao, pedido), o único lugar onde a regra mora. - Nas rotas, a conferência depois de carregar o pedido: 404 se o usuário não pode vê-lo, 403 se pode ver, mas não pode fazer a ação.
import http from "node:http";
type Usuario = { id: number; papel: string };
type Pedido = { id: number; cliente_id: number; total: string };
type Acao = "pedido:ler" | "pedido:reembolsar";
// Cada papel diz o alcance de cada ação; o que não está aqui é negado
const alcances: Record<string, Partial<Record<Acao, "proprios" | "todos">>> = {
cliente: { "pedido:ler": "proprios" },
suporte: { "pedido:ler": "todos" },
financeiro: { "pedido:ler": "todos", "pedido:reembolsar": "todos" },
};
function pode(usuario: Usuario, acao: Acao, pedido: Pedido): boolean {
const alcance = alcances[usuario.papel]?.[acao];
if (alcance === "todos") return true;
if (alcance === "proprios") return pedido.cliente_id === usuario.id;
return false;
}
// No exemplo, o usuário vem de um cabeçalho; num sistema real, da sessão ou do token
const usuarios = new Map<string, Usuario>([
["ana", { id: 1, papel: "cliente" }],
["bruno", { id: 2, papel: "cliente" }],
["carla", { id: 3, papel: "suporte" }],
["davi", { id: 4, papel: "financeiro" }],
["eva", { id: 5, papel: "estagiario" }],
]);
const pedidos = new Map<number, Pedido>([
[1, { id: 1, cliente_id: 1, total: "120.00" }],
[2, { id: 2, cliente_id: 2, total: "80.00" }],
]);
function responder(res: http.ServerResponse, status: number, corpo?: object) {
res.writeHead(status, { "content-type": "application/json" });
res.end(corpo ? JSON.stringify(corpo) : undefined);
}
http.createServer((req, res) => {
const usuario = usuarios.get(String(req.headers["x-usuario"]));
if (!usuario) return responder(res, 401);
const rota = req.url?.match(/^\/pedidos\/(\d+)(\/reembolso)?$/);
if (!rota) return responder(res, 404);
const pedido = pedidos.get(Number(rota[1]));
// Quem não pode ver o pedido recebe 404, igual a um pedido que não existe
if (!pedido || !pode(usuario, "pedido:ler", pedido)) return responder(res, 404);
if (req.method === "GET" && !rota[2]) return responder(res, 200, pedido);
if (req.method === "POST" && rota[2]) {
if (!pode(usuario, "pedido:reembolsar", pedido)) return responder(res, 403);
return responder(res, 200, { reembolsado: pedido.id });
}
responder(res, 405);
}).listen(3000);from dataclasses import dataclass
from fastapi import FastAPI, Header, HTTPException
@dataclass
class Usuario:
id: int
papel: str
# Cada papel diz o alcance de cada ação; o que não está aqui é negado
ALCANCES = {
"cliente": {"pedido:ler": "proprios"},
"suporte": {"pedido:ler": "todos"},
"financeiro": {"pedido:ler": "todos", "pedido:reembolsar": "todos"},
}
def pode(usuario: Usuario, acao: str, pedido: dict) -> bool:
alcance = ALCANCES.get(usuario.papel, {}).get(acao)
if alcance == "todos":
return True
if alcance == "proprios":
return pedido["cliente_id"] == usuario.id
return False
# No exemplo, o usuário vem de um cabeçalho; num sistema real, da sessão ou do token
USUARIOS = {
"ana": Usuario(1, "cliente"),
"bruno": Usuario(2, "cliente"),
"carla": Usuario(3, "suporte"),
"davi": Usuario(4, "financeiro"),
"eva": Usuario(5, "estagiario"),
}
PEDIDOS = {
1: {"id": 1, "cliente_id": 1, "total": "120.00"},
2: {"id": 2, "cliente_id": 2, "total": "80.00"},
}
app = FastAPI()
def usuario_atual(x_usuario: str | None) -> Usuario:
usuario = USUARIOS.get(x_usuario or "")
if usuario is None:
raise HTTPException(401)
return usuario
def pedido_visivel(usuario: Usuario, id: int) -> dict:
pedido = PEDIDOS.get(id)
# Quem não pode ver o pedido recebe 404, igual a um pedido que não existe
if pedido is None or not pode(usuario, "pedido:ler", pedido):
raise HTTPException(404)
return pedido
@app.get("/pedidos/{id}")
def buscar(id: int, x_usuario: str | None = Header(None)):
return pedido_visivel(usuario_atual(x_usuario), id)
@app.post("/pedidos/{id}/reembolso")
def reembolsar(id: int, x_usuario: str | None = Header(None)):
usuario = usuario_atual(x_usuario)
pedido = pedido_visivel(usuario, id)
if not pode(usuario, "pedido:reembolsar", pedido):
raise HTTPException(403)
return {"reembolsado": pedido["id"]}package main
import (
"encoding/json"
"net/http"
"strconv"
)
type Usuario struct {
ID int
Papel string
}
type Pedido struct {
ID int `json:"id"`
ClienteID int `json:"cliente_id"`
Total string `json:"total"`
}
// Cada papel diz o alcance de cada ação; o que não está aqui é negado
var alcances = map[string]map[string]string{
"cliente": {"pedido:ler": "proprios"},
"suporte": {"pedido:ler": "todos"},
"financeiro": {"pedido:ler": "todos", "pedido:reembolsar": "todos"},
}
func pode(u Usuario, acao string, p Pedido) bool {
switch alcances[u.Papel][acao] { // mapa ausente devolve "", que nega
case "todos":
return true
case "proprios":
return p.ClienteID == u.ID
}
return false
}
// No exemplo, o usuário vem de um cabeçalho; num sistema real, da sessão ou do token
var usuarios = map[string]Usuario{
"ana": {1, "cliente"}, "bruno": {2, "cliente"}, "carla": {3, "suporte"},
"davi": {4, "financeiro"}, "eva": {5, "estagiario"},
}
var pedidos = map[int]Pedido{
1: {1, 1, "120.00"},
2: {2, 2, "80.00"},
}
// pedidoVisivel responde o erro e devolve ok=false quando o pedido não pode ser usado
func pedidoVisivel(w http.ResponseWriter, r *http.Request) (Usuario, Pedido, bool) {
u, ok := usuarios[r.Header.Get("X-Usuario")]
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return u, Pedido{}, false
}
id, _ := strconv.Atoi(r.PathValue("id"))
p, existe := pedidos[id]
// Quem não pode ver o pedido recebe 404, igual a um pedido que não existe
if !existe || !pode(u, "pedido:ler", p) {
w.WriteHeader(http.StatusNotFound)
return u, p, false
}
return u, p, true
}
func main() {
http.HandleFunc("GET /pedidos/{id}", func(w http.ResponseWriter, r *http.Request) {
if _, p, ok := pedidoVisivel(w, r); ok {
json.NewEncoder(w).Encode(p)
}
})
http.HandleFunc("POST /pedidos/{id}/reembolso", func(w http.ResponseWriter, r *http.Request) {
u, p, ok := pedidoVisivel(w, r)
if !ok {
return
}
if !pode(u, "pedido:reembolsar", p) {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]int{"reembolsado": p.ID})
})
http.ListenAndServe(":3000", nil)
}import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.regex.Pattern;
public class App {
record Usuario(int id, String papel) {}
record Pedido(int id, int clienteId, String total) {
String json() {
return "{\"id\":" + id + ",\"cliente_id\":" + clienteId
+ ",\"total\":\"" + total + "\"}";
}
}
// Cada papel diz o alcance de cada ação; o que não está aqui é negado
static final Map<String, Map<String, String>> ALCANCES = Map.of(
"cliente", Map.of("pedido:ler", "proprios"),
"suporte", Map.of("pedido:ler", "todos"),
"financeiro", Map.of("pedido:ler", "todos", "pedido:reembolsar", "todos"));
static boolean pode(Usuario u, String acao, Pedido p) {
var alcance = ALCANCES.getOrDefault(u.papel(), Map.of()).get(acao);
if ("todos".equals(alcance)) return true;
if ("proprios".equals(alcance)) return p.clienteId() == u.id();
return false;
}
// No exemplo, o usuário vem de um cabeçalho; num sistema real, da sessão ou do token
static final Map<String, Usuario> USUARIOS = Map.of(
"ana", new Usuario(1, "cliente"), "bruno", new Usuario(2, "cliente"),
"carla", new Usuario(3, "suporte"), "davi", new Usuario(4, "financeiro"),
"eva", new Usuario(5, "estagiario"));
static final Map<Integer, Pedido> PEDIDOS = Map.of(
1, new Pedido(1, 1, "120.00"),
2, new Pedido(2, 2, "80.00"));
static final Pattern ROTA = Pattern.compile("^/pedidos/(\\d+)(/reembolso)?$");
static void responder(HttpExchange ex, int status, String corpo) throws IOException {
var bytes = corpo == null ? new byte[0] : corpo.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
if (bytes.length > 0) ex.getResponseBody().write(bytes);
ex.close();
}
static void tratar(HttpExchange ex) throws IOException {
var nome = ex.getRequestHeaders().getFirst("X-Usuario");
var usuario = nome == null ? null : USUARIOS.get(nome);
if (usuario == null) { responder(ex, 401, null); return; }
var rota = ROTA.matcher(ex.getRequestURI().getPath());
if (!rota.matches()) { responder(ex, 404, null); return; }
var pedido = PEDIDOS.get(Integer.parseInt(rota.group(1)));
// Quem não pode ver o pedido recebe 404, igual a um pedido que não existe
if (pedido == null || !pode(usuario, "pedido:ler", pedido)) {
responder(ex, 404, null);
return;
}
var metodo = ex.getRequestMethod();
boolean reembolso = rota.group(2) != null;
if (metodo.equals("GET") && !reembolso) { responder(ex, 200, pedido.json()); return; }
if (metodo.equals("POST") && reembolso) {
if (!pode(usuario, "pedido:reembolsar", pedido)) { responder(ex, 403, null); return; }
responder(ex, 200, "{\"reembolsado\":" + pedido.id() + "}");
return;
}
responder(ex, 405, null);
}
public static void main(String[] args) throws IOException {
var servidor = HttpServer.create(new InetSocketAddress(3000), 0);
servidor.createContext("/", App::tratar);
servidor.start();
}
}// Cada papel diz o alcance de cada ação; o que não está aqui é negado
var alcances = new Dictionary<string, Dictionary<string, string>>
{
["cliente"] = new() { ["pedido:ler"] = "proprios" },
["suporte"] = new() { ["pedido:ler"] = "todos" },
["financeiro"] = new() { ["pedido:ler"] = "todos", ["pedido:reembolsar"] = "todos" },
};
bool Pode(Usuario u, string acao, Pedido p) =>
alcances.GetValueOrDefault(u.Papel)?.GetValueOrDefault(acao) switch
{
"todos" => true,
"proprios" => p.ClienteId == u.Id,
_ => false,
};
// No exemplo, o usuário vem de um cabeçalho; num sistema real, da sessão ou do token
var usuarios = new Dictionary<string, Usuario>
{
["ana"] = new(1, "cliente"), ["bruno"] = new(2, "cliente"), ["carla"] = new(3, "suporte"),
["davi"] = new(4, "financeiro"), ["eva"] = new(5, "estagiario"),
};
var pedidos = new Dictionary<int, Pedido> { [1] = new(1, 1, "120.00"), [2] = new(2, 2, "80.00") };
// Devolve o erro, ou null quando o usuário pode ver o pedido
IResult? Barrar(HttpRequest req, int id, out Usuario usuario, out Pedido pedido)
{
pedido = null!;
if (!usuarios.TryGetValue(req.Headers["X-Usuario"].ToString(), out usuario!))
return Results.Unauthorized();
// Quem não pode ver o pedido recebe 404, igual a um pedido que não existe
if (!pedidos.TryGetValue(id, out pedido!) || !Pode(usuario, "pedido:ler", pedido))
return Results.NotFound();
return null;
}
var app = WebApplication.Create(args);
app.MapGet("/pedidos/{id:int}", (int id, HttpRequest req) =>
Barrar(req, id, out _, out var pedido) ?? Results.Ok(pedido));
app.MapPost("/pedidos/{id:int}/reembolso", (int id, HttpRequest req) =>
{
if (Barrar(req, id, out var usuario, out var pedido) is { } erro) return erro;
if (!Pode(usuario, "pedido:reembolsar", pedido)) return Results.StatusCode(403);
return Results.Ok(new { reembolsado = pedido.Id });
});
app.Run("http://0.0.0.0:3000");
record Usuario(int Id, string Papel);
record Pedido(int Id, int ClienteId, string Total);O resultado, nas cinco linguagens
O pedido 1 é da Ana; o 2, do Bruno.
| Pedido | Esperado | Resultado nas cinco |
|---|---|---|
GET /pedidos/1 sem usuário | 401 | 401 |
| Ana lê o pedido 1, dela | 200 | 200 |
| Ana lê o pedido 2, do Bruno | 404 | 404 |
| Ana lê o pedido 999, que não existe | 404 | 404 |
| Carla, do suporte, lê o pedido 2 | 200 | 200 |
| Ana pede o reembolso do pedido 1, dela | 403 | 403 |
| Bruno pede o reembolso do pedido 1, da Ana | 404 | 404 |
| Carla, do suporte, pede o reembolso do pedido 2 | 403 | 403 |
| Davi, do financeiro, pede o reembolso do pedido 2 | 200 | 200 |
| Eva, com o papel "estagiario", que não está na tabela, lê o pedido 1 | 404 | 404 |
Repare em duas linhas. Ana lendo o pedido 2 e lendo o 999 recebem a mesma resposta: de fora, não dá para saber se o pedido do Bruno existe. E o Bruno, que não vê o pedido da Ana, recebe 404 também no reembolso, e não 403: a conferência de leitura vem antes da conferência da ação.
O cabeçalho X-Usuario só existe para o exemplo rodar sozinho. Num sistema real, o usuário vem da sessão ou do token validado, como mostra o tópico de autenticação, e nunca de um valor que o cliente escolhe.
O que muda de uma linguagem para outra
| A tabela de alcances | Papel ou ação ausente | |
|---|---|---|
| TypeScript | Record com Partial, e tipos para as ações | ?. devolve undefined, que nega |
| Python | Dicionários | .get(..., {}) devolve vazio, que nega |
| Go | map[string]map[string]string | Ler um mapa ausente devolve "", que nega |
| Java | Map.of, imutável | getOrDefault com mapa vazio |
| C# | Dictionary e um switch de expressão | GetValueOrDefault e o caso _ |
Em todas, negar é o caminho que não exige código: o valor ausente cai no return false. Com frameworks, a mesma função vira uma política registrada: IAuthorizationHandler no ASP.NET Core, @PreAuthorize com um bean no Spring, uma dependência no FastAPI. O importante é a política continuar num só lugar.
A mesma política num motor de políticas
A política em Rego, a linguagem do OPA (versão 1.22 no teste):
package pedidos
default permitir := false
alcances := {
"cliente": {"pedido:ler": "proprios"},
"suporte": {"pedido:ler": "todos"},
"financeiro": {"pedido:ler": "todos", "pedido:reembolsar": "todos"},
}
permitir if alcances[input.usuario.papel][input.acao] == "todos"
permitir if {
alcances[input.usuario.papel][input.acao] == "proprios"
input.pedido.cliente_id == input.usuario.id
}Avaliada com opa eval para os casos principais:
| Usuário | Ação | Dono do pedido | Resultado |
|---|---|---|---|
| Ana, cliente 1 | ler | cliente 1 | permitido |
| Ana, cliente 1 | ler | cliente 2 | negado |
| Carla, suporte | ler | cliente 2 | permitido |
| Ana, cliente 1 | reembolsar | cliente 1 | negado |
| Carla, suporte | reembolsar | cliente 2 | negado |
| Davi, financeiro | reembolsar | cliente 2 | permitido |
| Eva, estagiario | ler | cliente 1 | negado |
Os mesmos resultados das cinco linguagens. A diferença é onde a regra mora: num arquivo que várias aplicações consultam, com testes e revisão próprios. O preço é que cada pergunta precisa levar os dados do pedido, aqui o cliente_id, e alguém precisa buscá-los antes.
Isolar empresas com segurança por linha
A configuração testada no nível Conceito, com as correções que o teste exigiu:
-- A aplicação conecta com este usuário, que não é dono das tabelas
CREATE ROLE app LOGIN PASSWORD '...';
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
ALTER TABLE pedidos FORCE ROW LEVEL SECURITY; -- vale também para o dono
CREATE POLICY por_empresa ON pedidos
USING (empresa_id = nullif(current_setting('app.empresa_id', true), '')::int);
GRANT SELECT ON pedidos TO app;E, na aplicação, a empresa definida dentro da transação de cada pedido:
const conexao = await pool.connect();
try {
await conexao.query("BEGIN");
// true = só nesta transação; some no COMMIT, sem vazar para o próximo pedido
await conexao.query("SELECT set_config('app.empresa_id', $1, true)", [String(usuario.empresaId)]);
const { rows } = await conexao.query("SELECT * FROM pedidos"); // o banco filtra
await conexao.query("COMMIT");
return rows;
} catch (erro) {
await conexao.query("ROLLBACK");
throw erro;
} finally {
conexao.release();
}No teste, com um pool de uma conexão, essa forma devolveu as 100 linhas da empresa e, no pedido seguinte sem empresa definida, 0. Com SET no lugar de set_config(..., true), o pedido seguinte recebeu as 100 linhas da empresa anterior.
Testar a autorização
A tabela de resultados acima é o formato de teste que mais pega falhas de autorização: uma matriz de usuários, ações e recursos, com o status esperado em cada célula. Ela precisa de pelo menos:
- dois usuários com o mesmo papel, para pegar a falta de conferência do dono;
- um usuário de cada papel;
- um papel que não está na política, para conferir a negação por padrão;
- todas as rotas que recebem um identificador: detalhe, edição, exclusão e as ações, e não só a listagem.
Um teste extra que vale a pena: percorrer todas as rotas registradas na aplicação e falhar se alguma não passar pela função de autorização.
Lista de revisão
Modelo
- As permissões são conferidas pela ação (
pedido:reembolsar), e não pelo nome do papel? - O alcance de cada permissão (os próprios, todos) está explícito?
- O que não está na política é negado?
Conferência
- Toda rota que recebe um identificador confere se o usuário pode usar aquele recurso?
- A conferência acontece depois de carregar o recurso, ou dentro da consulta?
- As listagens filtram pelo que o usuário pode ver, no banco?
- O usuário vem da sessão ou do token, e nunca de um parâmetro enviado pelo cliente?
- Recursos invisíveis devolvem 404, e ações proibidas em recursos visíveis, 403?
Política
- A regra mora num só lugar: uma função, uma biblioteca ou um motor?
- Papéis e permissões que mudam são consultados, e não presos no token?
Empresas
- Há um isolamento que não depende de cada consulta lembrar o filtro?
- Com segurança por linha: a aplicação não é dona das tabelas, usa
SET LOCALe a política trata o valor ausente?
Testes
- Há uma matriz de usuários, ações e recursos, com dois usuários do mesmo papel?
- Um teste garante que toda rota passa pela autorização?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Implementar a autorização por objeto
Quero implementar a autorização na minha API, com papéis e conferência do dono de cada recurso. Pergunte qual linguagem e framework eu uso e como os usuários são autenticados. Vamos em etapas: 1. A tabela de alcances por papel e ação, negando o que não estiver nela. 2. A função central que decide, recebendo o usuário, a ação e o recurso. 3. As rotas que recebem um identificador, com 404 para o que o usuário não vê e 403 para a ação proibida. 4. As listagens, filtradas no banco pelo que o usuário pode ver. 5. A matriz de testes com dois usuários do mesmo papel, um de cada papel e um papel desconhecido. Em cada etapa, me passe os comandos para testar.
Configurar a segurança por linha no Postgres
Quero isolar os dados de cada empresa com a segurança por linha do Postgres. Pergunte qual linguagem, driver e pool de conexões eu uso, e com qual usuário a aplicação conecta hoje. Explique, com código: 1. O usuário da aplicação, que não é dono das tabelas. 2. ENABLE e FORCE ROW LEVEL SECURITY e a política, tratando o valor ausente com nullif. 3. A definição da empresa com set_config dentro da transação de cada pedido. 4. Um teste que confere que uma consulta sem empresa definida devolve zero linhas. 5. Um teste que confere que o valor não vaza para o pedido seguinte na mesma conexão.
Revisar a autorização de uma API
Vou colar abaixo o código das rotas de uma API e da parte que decide permissões. Revise a autorização. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. Toda rota que recebe um identificador confere se o usuário pode usar aquele recurso? 2. O usuário vem da sessão ou do token, e não de um parâmetro do cliente? 3. As permissões são conferidas por ação, num só lugar, negando por padrão? 4. As listagens filtram no banco? 5. Recursos invisíveis devolvem 404, e ações proibidas, 403? 6. Há isolamento entre empresas que não depende de cada consulta? 7. Há testes com dois usuários do mesmo papel? Para cada problema, descreva como explorá-lo e mostre a correção. Código: [cole aqui]