Yago Marinho
Voltar para os artigos
  • Auth
  • Arquitetura
  • Python
  • Microsserviços

Proibido mexer na API de permissões: como tirei a autorização da rede

Quando toda aplicação pergunta à mesma API se o usuário pode agir, a lentidão dela vira a de todas. Separei o dado da decisão num motor de regras em Python.

Foto de Yago MarinhoPor: Yago MarinhoÚltima atualização realizada em:
💡
Este é o primeiro de três artigos sobre um motor de autorização que criei num contrato privado, como desenvolvedor de uma das aplicações de um ecossistema maior. Conto o problema, a ideia e as decisões; o código original e os detalhes do cliente ficam de fora, e os exemplos foram escritos do zero, num domínio inventado.
Toda aplicação com usuários precisa responder, a cada pedido, a uma pergunta simples: essa pessoa pode fazer isso? A forma mais comum de responder é perguntar a outro serviço. E é aí que uma pergunta simples pode virar o ponto fraco de um ecossistema inteiro.
Este artigo conta como resolvi a autorização de uma aplicação sem poder mexer no serviço que respondia essa pergunta para todas as outras, e por que a solução acabou sendo uma pequena linguagem de regras em Python.

O problema que me passaram

Eu trabalhava como desenvolvedor numa das aplicações de um ecossistema com vários sistemas e com os dados espalhados por vários bancos. As permissões ficavam numa API central: antes de responder, cada aplicação perguntava a ela se aquele usuário podia fazer aquilo.
A aplicação em que eu trabalhava precisava de uma autorização própria, com usuários que têm papéis diferentes em organizações diferentes e rotas que exigem coisas diferentes. A tarefa veio com uma condição clara: resolver sem tocar na API de permissões. Ela atendia as outras aplicações, e mudar o seu comportamento não estava em discussão.
Uma restrição assim parece só um limite. Neste caso, ela acabou guiando a solução.

Por que uma API central de permissões vira um ponto único de falha

Uma API central de permissões é a forma mais natural de começar: um lugar só para cadastrar quem pode o quê, e um lugar só para consultar. Não há nada de errado nessa escolha no início. O custo aparece quando crescem o número de aplicações e o volume de pedidos, e ele vem de três formas.

A disponibilidade se multiplica

Se toda resposta depende de dois serviços estarem de pé, a disponibilidade do conjunto é o produto das disponibilidades de cada um. Com números ilustrativos: uma aplicação com 99,9% de disponibilidade, consultando uma API de permissões com 99,5%, fica com 99,9% × 99,5% ≈ 99,4%. Sozinha, ela ficaria cerca de 9 horas por ano fora do ar. Dependendo da outra, passa de 52.
E a conta piora a cada dependência que entra na mesma cadeia.

A latência se soma

Cada pedido ganha uma ida e volta pela rede antes de começar a fazer o que importa. Uma tela que dispara dez pedidos faz dez consultas à mesma API, e essa API recebe a soma do tráfego de todas as aplicações.

A falha se espalha

Quando a API de permissões fica lenta, as aplicações não caem de imediato: elas esperam. As conexões ficam presas, os tempos limite estouram e as novas tentativas aumentam a carga justamente de quem já está sobrecarregado. A lentidão de um serviço vira a lentidão de todos.

Separar o dado da decisão

O que me destravou foi perceber que "autorizar" junta duas coisas diferentes:
  • o dado: quem tem qual papel, em qual escopo;
  • a decisão: esta concessão cobre este pedido?
O dado muda pouco: alguém ganha um papel, perde outro, troca de organização. A decisão acontece em todo pedido. Uma API central consultada a cada pedido coloca do outro lado da rede justamente a parte que acontece o tempo todo.
Há um vocabulário antigo para essas peças, vindo do XACML, um padrão de controle de acesso:
  • o PEP (policy enforcement point) é quem barra o pedido;
  • o PDP (policy decision point) é quem decide;
  • o PIP (policy information point) é de onde vêm os fatos para a decisão.
No desenho que eu encontrei, o PEP estava em cada aplicação, e o PDP e o PIP estavam juntos, do outro lado da rede. A minha saída foi trazer os dois para dentro da aplicação, em dois movimentos.

1. Os dados perto

O modelo de permissões passou a morar no banco da própria aplicação: os usuários, os papéis e as concessões. Uma concessão é um papel num escopo. A mesma pessoa pode ser administradora numa organização e usuária comum em outra: são duas concessões, e uma não empresta nada à outra.

2. A decisão dentro do processo

O PDP virou uma biblioteca, chamada pela própria rota. Decidir deixou de ser uma ida à rede: é uma função comparando dados que já estão no serviço.
A API central continuou exatamente como estava, atendendo as outras aplicações. A restrição foi respeitada, e a aplicação em que eu trabalhava deixou de depender dela para responder.

Por que uma linguagem de regras, e não ifs

A forma mais direta de autorizar dentro da aplicação seria espalhar condições pelas rotas:
if usuario.papel == "admin" or ( usuario.papel == "editor" and documento.organizacao in usuario.organizacoes ): ...
Funciona na primeira rota. Na vigésima, cada rota tem a sua interpretação do que é ser admin, saber quem pode fazer o quê exige ler o código inteiro, e mudar uma regra significa caçar todos os lugares onde ela foi escrita.
Eu quis o contrário: que cada rota declarasse o que exige, numa linha, e que um motor só fizesse a comparação. Algo assim:
@requer("usuario", "documento", "ler") async def ver_documento(pedido): ...
Lê-se: alguém com privilégio de usuário, ou maior, com permissão de ler documentos. O decorator monta a exigência com a organização do pedido, busca as concessões do usuário no banco da aplicação e pergunta ao motor. A rota não sabe como a decisão é tomada; ela só diz do que precisa.
É isso que chamo de DSL aqui: uma linguagem pequena, do domínio da autorização, em que a regra é um dado. E um dado se lista, se revisa, se testa, e pode até sair do código e ir para o banco.

Como o motor decide

O motor não sabe o que é documento, organização ou admin. Ele compara dois dados, a exigência da rota e cada concessão do usuário, campo a campo, segundo um esquema que diz qual comparador usar em cada campo:
NIVEIS = {"usuario": 1, "admin": 2, "root": 3} def igual(exigido, concedido): return exigido is not None and exigido == concedido def igual_ou_curinga(exigido, concedido): if exigido is None or concedido is None: return False # um campo ausente nunca autoriza return concedido == "*" or exigido == concedido def nivel_minimo(exigido, concedido): if exigido not in NIVEIS or concedido not in NIVEIS: return False return NIVEIS[concedido] >= NIVEIS[exigido] COMPARADORES = { "igual": igual, "igual_ou_curinga": igual_ou_curinga, "nivel_minimo": nivel_minimo, } ESQUEMA = { "privilegio": "nivel_minimo", "recurso": "igual_ou_curinga", "acao": "igual_ou_curinga", "organizacao": "igual", } def autorizar( exigencia, concessoes, esquema=ESQUEMA, comparadores=COMPARADORES ): return [ concessao for concessao in concessoes if all( comparadores[nome](exigencia.get(campo), concessao.get(campo)) for campo, nome in esquema.items() ) ]
Três ideias estão nesse trecho:
  • O privilégio é um nível, não um nome. Ele vale "a partir de": um admin cumpre uma exigência de usuário, e o contrário não. Um nível desconhecido não cumpre nada.
  • O curinga é explícito e só vale do lado da concessão. Um * na concessão cobre qualquer recurso; um * na exigência não abre nada.
  • Um campo ausente nunca autoriza. Parece um detalhe, e não é. Volto a ele no terceiro artigo.
Com uma exigência e duas concessões, o motor devolve só a que cobre o pedido:
exigencia = { "privilegio": "usuario", "recurso": "documento", "acao": "ler", "organizacao": "org-a", } concessoes = [ {"privilegio": "usuario", "recurso": "documento", "acao": "ler", "organizacao": "org-a"}, {"privilegio": "admin", "recurso": "*", "acao": "*", "organizacao": "org-b"}, ] autorizar(exigencia, concessoes) # [{"privilegio": "usuario", "recurso": "documento", "acao": "ler", "organizacao": "org-a"}]
A administradora da org-b, mesmo com curinga em tudo, não alcança um documento da org-a. O isolamento entre organizações é só mais um campo comparado, e não tem exceção.

Um eixo novo sem mexer no motor

A parte de que mais gosto nesse desenho é o que acontece quando surge uma regra nova. Digamos que algumas concessões passem a valer só para certas regiões. Não é preciso tocar no autorizar: basta um comparador novo e um campo a mais no esquema.
def pertence(exigido, concedido): if exigido is None or concedido is None: return False return exigido in concedido COMPARADORES["pertence"] = pertence ESQUEMA_COM_REGIAO = {**ESQUEMA, "regiao": "pertence"}
Uma concessão com "regiao": ["norte", "nordeste"] cobre um pedido do norte e recusa um do sul, e as rotas que usam o esquema antigo continuam iguais. É o princípio aberto/fechado no sentido mais literal: o motor fica fechado para modificação e aberto para extensão.

A resposta é uma lista, não um sim

Repare que o autorizar não devolve True ou False. Ele devolve as concessões que autorizaram. A rota fica sabendo por qual concessão o pedido passou, e em qual escopo.
Isso parece um detalhe de implementação, mas muda o papel da autorização: ela deixa de responder só "pode?" e passa a responder "pode, sobre o quê?". É o assunto do próximo artigo.

Testar sem banco e sem rede

Como o motor é uma função pura, testá-lo é escrever dados e conferir o resultado. Não há banco, nem rede, nem mock, e cada regra do motor ganha um teste que a descreve:
def test_admin_de_uma_organizacao_nao_alcanca_outra(): concessoes = [ {"privilegio": "admin", "recurso": "*", "acao": "*", "organizacao": "org-b"}, ] exigencia = { "privilegio": "usuario", "recurso": "documento", "acao": "ler", "organizacao": "org-a", } assert autorizar(exigencia, concessoes) == [] def test_privilegio_maior_cobre_o_menor_e_nao_o_contrario(): pede_usuario = { "privilegio": "usuario", "recurso": "documento", "acao": "ler", "organizacao": "org-a", } pede_admin = {**pede_usuario, "privilegio": "admin"} admin = [{**pede_usuario, "privilegio": "admin"}] usuario = [pede_usuario] assert autorizar(pede_usuario, admin) == admin assert autorizar(pede_admin, usuario) == []
O decorator, que faz a parte com efeitos (ler o usuário do pedido e buscar as concessões no banco), recebe essas fontes como parâmetros. Nos testes, elas viram funções falsas que devolvem dados fixos, e o caminho inteiro da rota é testado sem subir nada.

O que ficou fora do meu escopo

A solução valeu para a aplicação em que eu trabalhava. A API central continuou atendendo as outras, como a tarefa pedia, e a arquitetura do ecossistema não era uma decisão minha.
Se fosse, eu proporia o mesmo princípio para o resto: a API central como o lugar onde as concessões são cadastradas e geridas, e o motor como uma biblioteca em cada serviço, decidindo com uma cópia local das concessões que lhe interessam. A gestão fica num lugar só; a decisão, perto de quem precisa dela.
Essa escolha tem um preço, e ele é real: a aplicação passou a guardar os próprios usuários e concessões. Duas fontes da verdade pedem cuidado, e é disso que trata o terceiro artigo.

O que eu levo desse projeto

  • Separe o dado da decisão. O dado pode morar longe e mudar pouco; a decisão acontece em todo pedido e precisa estar perto.
  • Leia a restrição como requisito. "Não mexa na API" parecia um obstáculo e acabou guiando o desenho.
  • Regra como dado envelhece melhor que regra espalhada. Uma linha por rota se lê, se testa e se muda num lugar só.
  • O motor não deve conhecer o domínio. Quanto menos ele sabe, mais regras cabem nele sem mudar.
No próximo artigo, conto o que esse motor entregou, principalmente o que muda quando a autorização passa a dizer sobre quais dados uma pessoa pode agir.
Foto de Yago Marinho
Yago MarinhoEngenheiro de Software Sênior. Construo produtos com IA, do design ao deploy.
Ver portfólio ↗