Yago Marinho
Voltar para os artigos
  • Auth
  • Arquitetura
  • Python
  • Segurança

Implementando autorização num sistema legado: os desafios de quem executa

Enfrentei várias bases, uma API que eu não podia tocar e os riscos de um motor genérico — e aprendi a entregar bem dentro de limites que não escolhi.

Foto de Yago MarinhoPor: Yago MarinhoÚltima atualização realizada em:
💡
Este é o terceiro e último artigo da série sobre um motor de autorização que criei num contrato privado. O primeiro conta o problema e a ideia, e o segundo, o que o motor entregou; este conta os desafios. O código original e os detalhes do cliente ficam de fora, e os exemplos foram escritos do zero, num domínio inventado.
Na lista OWASP Top 10, que reúne as falhas mais críticas em aplicações web, a quebra de controle de acesso (Broken Access Control) ocupa o primeiro lugar desde a edição de 2021 — e continua lá na de 2025. Ou seja: de todas as formas de uma aplicação dar errado, a mais comum é alguém conseguir fazer o que não deveria.
Esse dado diz muito sobre autorização, mas não diz tudo. Além de ser difícil por natureza, a autorização raramente é construída do zero, num projeto limpo. Na maior parte das vezes, ela nasce dentro de um sistema que já existe, com decisões tomadas antes de chegarmos e com limites que não fomos nós que escolhemos.
Foi exatamente o meu caso. Nos dois artigos anteriores, contei como criei um motor de autorização para uma aplicação sem poder tocar na API central de permissões, e o que ele entregou. Agora quero falar da parte que costuma ficar de fora dos artigos: os desafios — os que enfrentei durante a entrega e os que aprendi a enxergar quando olhei para o motor com mais calma.

O cenário: entregar dentro de limites

Antes dos desafios, vale lembrar o cenário. Eu trabalhava como desenvolvedor numa das aplicações de um ecossistema com vários sistemas, dados espalhados por várias bases e uma API central de permissões que todas as aplicações consultavam. A arquitetura desse ecossistema não era uma decisão minha, e a tarefa que recebi veio com uma condição clara: resolver a autorização da aplicação sem mexer na API central.
Isso não é uma crítica a quem decidiu. Cada decisão daquele ecossistema foi tomada num momento, com as restrições daquele momento — e é assim em praticamente todo sistema com alguns anos de vida. Para mim, o ponto é outro: como entregar algo bom quando boa parte do terreno já está construída?
Vamos aos desafios.

Desafios e soluções encontradas

1. Duas fontes da verdade

Desafio: ao levar as concessões para o banco da aplicação, ela passou a ter os próprios usuários e concessões, enquanto as outras aplicações continuavam usando a API central. Na prática, passaram a existir duas fontes da verdade sobre quem pode o quê.
Solução: assumir isso de forma explícita, em vez de esconder. O modelo de permissões valia só para o escopo daquela aplicação: ela era a dona das concessões dela, e a API central continuava dona das concessões das outras. Não havia sincronização entre as duas, e isso foi uma escolha consciente.
Óbvio que essa escolha tem um preço. Quem administra os acessos passa a gerir pessoas em dois lugares, e uma revogação feita na API central não chega sozinha à aplicação. Se o escopo crescesse, o caminho que eu proporia é o que comentei no primeiro artigo: a API central como o lugar onde as concessões são cadastradas, e cada serviço com uma cópia local das concessões que lhe interessam, atualizada por eventos. A gestão fica num lugar só; a decisão, perto de quem precisa dela.

2. Onde a regra mora

Desafio: uma regra de autorização precisa morar em algum lugar, e cada opção tem o seu custo:
  • No código: a regra é revisada, versionada e testada junto com a aplicação, mas qualquer mudança exige um deploy.
  • Num arquivo de configuração, como um JSON: muda sem mexer no código, mas continua precisando de deploy e perde a checagem do compilador.
  • No banco, por rota: muda sem deploy e vira uma fonte única, mas passa a exigir uma validação forte, porque uma regra errada no banco é uma falha de segurança em produção.
Solução: na entrega, a exigência ficou declarada no código de cada rota, e as permissões possíveis ficaram cadastradas no banco. Isso trouxe um cuidado novo: uma rota pode exigir uma permissão que ninguém cadastrou. E, se esse erro só aparece quando alguém acessa a rota, ele aparece tarde demais. Por isso, deixei registrado como próximo passo validar as regras e o esquema do motor na subida da aplicação: se algo estiver errado, a aplicação nem sobe.

3. O custo de cada pedido e o cache

Desafio: trazer os dados para perto tirou a ida à API central, mas não tirou a ida ao banco. A cada pedido, a aplicação ainda precisava ler as concessões da pessoa. E concessões são justamente o tipo de dado que quase nunca muda: alguém ganha um papel, perde outro, e só.
Então surge a questão: “Por que buscar no banco, a cada pedido, algo que quase nunca muda?” A resposta é simples: não precisa — desde que a revogação continue valendo na hora.
Solução: o cache das concessões ficou registrado como o próximo passo da entrega, com uma regra que considero inegociável: o prazo do cache é só uma rede de segurança; quem invalida o cache é a revogação. Veja abaixo como isso fica:
PRAZO_DO_CACHE = 300 # segundos: só uma rede de segurança def concessoes_do_usuario(usuario_id, cache, banco): concessoes = cache.get(usuario_id) if concessoes is None: concessoes = banco.concessoes_de(usuario_id) cache.set(usuario_id, concessoes, PRAZO_DO_CACHE) return concessoes def revogar(concessao_id, cache, banco): usuario_id = banco.revogar(concessao_id) # a revogação apaga o cache da pessoa na hora, sem esperar o prazo cache.delete(usuario_id)
Principais pontos positivos desse desenho:
  • Leitura barata → a segunda consulta da mesma pessoa já sai do cache.
  • Revogação imediata → quem perde um acesso perde na hora, e não daqui a cinco minutos.
  • Prazo como rede de segurança → se uma invalidação falhar, o estrago dura no máximo o prazo do cache.

Os riscos de um motor genérico

Um motor genérico resolve muita coisa, mas traz riscos que uma regra escrita à mão, num if, não tem. Como o motor compara qualquer campo com qualquer comparador, um detalhe de comparação vira uma brecha em todas as rotas ao mesmo tempo.
Esses são os riscos que aprendi a procurar em qualquer motor desse tipo. Para quem vai construir um, acredito que eles valem mais do que qualquer funcionalidade nova.

#Case 1: o campo ausente que autoriza

No primeiro artigo, deixei um comentário no motor: um campo ausente nunca autoriza. Prometi voltar a ele, e aqui está o porquê. Observe a diferença entre duas versões de um comparador de igualdade:
def igual_ingenuo(exigido, concedido): return exigido == concedido def igual(exigido, concedido): return exigido is not None and exigido == concedido # o pedido chegou sem organização, e a concessão também não tem uma igual_ingenuo(None, None) # True: autorizou sem saber sobre o quê igual(None, None) # False
O que está acontecendo aqui? Basta um parâmetro que não chegou no pedido e uma concessão que não tem aquele campo para a versão ingênua concluir que nada é igual a nada — e autorizar. Num campo de isolamento, como a organização, essa é exatamente a falha que o OWASP coloca em primeiro lugar. A regra que adoto é simples: ausência nunca autoriza.

#Case 2: os nomes que viram iguais

É tentador normalizar os nomes na hora de decidir: tirar espaços, ignorar maiúsculas, trocar hífen por sublinhado. O problema é que normalizar também é juntar nomes diferentes:
def normalizar(nome): return nome.strip().lower().replace("-", "_").replace(" ", "_") normalizar("relatorio-mensal") == normalizar("relatorio_mensal") # True
Em autorização, isso significa que uma concessão para um recurso pode valer para outro. A saída que considero correta é inverter o momento: o nome é conferido quando é gravado e comparado exatamente na hora de decidir. Veja o trecho de código abaixo:
import re NOME_VALIDO = re.compile(r"[a-z][a-z0-9_]*") def validar_nome(nome): # o nome é conferido quando é gravado, e não ajeitado na hora de decidir if not NOME_VALIDO.fullmatch(nome): raise ValueError(f"nome fora do padrão: {nome!r}") return nome
O mesmo vale para os apelidos “amigáveis”, como tratar all como curinga. O curinga é o *, e só ele. Um apelido escondido na normalização é um curinga que ninguém vê.

#Case 3: “alguma” no lugar de “todas”

Quando um pedido envolve vários alvos — uma ação em lote sobre documentos de duas organizações, por exemplo —, a pergunta certa é se as concessões cobrem todos eles. Um comparador de “contém” costuma responder outra coisa:
def alguma(exigido, concedido): return any(alvo in concedido for alvo in exigido) def todas(exigido, concedido): return bool(exigido) and all(alvo in concedido for alvo in exigido) # o pedido mexe em duas organizações; a concessão só cobre uma pedido = ["org-a", "org-b"] concedido = ["org-a"] alguma(pedido, concedido) # True: o pedido passaria pela metade todas(pedido, concedido) # False
Observe também o bool(exigido): um pedido sem nenhum alvo não pode ser considerado coberto. É o mesmo princípio do campo ausente, aparecendo de outra forma.

#Case 4: o erro de configuração que só aparece no pedido

Num motor guiado por esquema, o nome de um comparador é só um texto. Se ele estiver escrito errado, o erro só aparece quando um pedido passa por aquele campo — e aparece como um erro interno, e não como uma recusa. A prevenção é a mesma do segundo desafio: validar o esquema inteiro na subida, recusando qualquer comparador desconhecido antes do primeiro pedido.

#Case 5: o teste que não distingue “negou” de “quebrou”

Por último, um cuidado nos testes. Se um teste de recusa confere apenas que a resposta foi um erro 500, ele passa tanto quando o motor nega quanto quando o código quebra no meio do caminho. Uma recusa precisa ter um código próprio, normalmente o 403 (Forbidden), e o teste precisa conferir exatamente esse código. Do contrário, a suíte fica verde justamente no dia em que algo dá errado.

Influenciar sem decidir

Existe um desafio que não aparece no código, mas que atravessou toda essa entrega: como propor melhorias quando a decisão não é sua?
Eu era um dos desenvolvedores da aplicação, e não a pessoa que define a arquitetura do ecossistema. A restrição de não mexer na API central veio pronta, e ela tinha as suas razões: aquela API atendia todas as outras aplicações. O que eu podia decidir era como resolver o problema dentro do meu escopo. E foi aí que o motor nasceu.
Alguns aprendizados desse lugar:
  • Leia a restrição como requisito: em vez de brigar com o limite, use-o para desenhar a solução. Foi a restrição que me levou a separar o dado da decisão.
  • Entregue bem o que é seu: uma solução boa dentro do escopo convence mais do que uma proposta grande fora dele.
  • Deixe o próximo passo onde a próxima pessoa vai encontrá-lo: o que ficou para depois — o cache e a validação na subida — ficou registrado no próprio código, ao lado de onde precisaria entrar.
  • Mostre o custo antes de propor a mudança: uma arquitetura dificilmente muda por causa de uma opinião, mas costuma mudar por causa de um risco bem explicado.

Roadmap e próximos passos

Para fechar a série, deixo o que eu levaria para a próxima versão de um motor como esse:
  • Ausência nunca autoriza, com os campos de isolamento sempre obrigatórios.
  • Nomes conferidos na gravação e comparados exatamente na decisão, sem apelidos de curinga.
  • Esquema e regras validados na subida, antes do primeiro pedido.
  • Um comparador de cobertura para os pedidos com vários alvos.
  • Um registro de cada decisão, com o campo que negou, para investigar uma recusa sem precisar adivinhar.
  • Testes de propriedade, que geram milhares de casos automaticamente para conferir regras que nunca podem quebrar, como “uma concessão de uma organização nunca alcança outra”.

Conclusão

Comecei este artigo com o OWASP dizendo que a quebra de controle de acesso é a falha mais comum na web. Depois de construir esse motor, acredito que entendo melhor o porquê: autorização é feita de detalhes, e cada detalhe — um campo que não chegou, um nome normalizado, um “alguma” no lugar de “todas” — pode abrir uma porta em todas as rotas de uma vez.
Mas a lição mais importante dessa série, para mim, não é técnica. Nenhum dos três artigos fala de um projeto ideal, construído do zero, com liberdade total. Eles falam de uma tarefa com uma restrição clara, num sistema que já existia, e de como é possível entregar algo bom — e honesto sobre os próprios limites — sem decidir a arquitetura ao redor.
Como escrevi no primeiro artigo deste blog, a habilidade de transmitir conhecimento é tão ou até mais importante do que simplesmente possuí-lo. E contar os desafios, e não só as conquistas, também é transmitir conhecimento.
Se você também já precisou entregar dentro de limites que não escolheu, convido você a compartilhar a sua experiência. Afinal, é assim que uma comunidade aprende junto.
Foto de Yago Marinho
Yago MarinhoEngenheiro de Software Sênior. Construo produtos com IA, do design ao deploy.
Ver portfólio ↗