O essencial
Dois eixos Router em duas portas (plano de dados8080, plano de gerenciamento 8090), construídos a partir do mesmo AppStatecompartilhado. O plano de dados requer uma chave de API em qualquer rota; O gerenciamento do plano requer um console de público JWT dedicado — os dois mecanismos nunca se sobrepõem. A limitação de taxa se aplica duas vezes: por IP antes da autenticação e depois por ator autenticado. O disjuntor não é um middleware global: é um objeto por serviço (e por destino dedicado para PostgREST), invocado diretamente no código proxy. E o proxy final muda o transporte dependendo da rota - às vezes de acordo com o método HTTP ou uma pesquisa no banco de dados: solicitação/resposta NATS para a maior parte do tráfego, fluxo HTTP direto para armazenamento, as três variantes em tempo real e o Postgres CRUD dedicado.
Um único gateway, dois públicos muito diferentes
O tráfego do plano de dados vem do SDK ou de um aplicativo cliente: um volume de solicitações anônimas ou autenticadas por chave de API, com um perfil de abuso próximo a qualquer API pública. O plano de gerenciamento de tráfego vem do Studio — a interface de administração de um projeto — e realiza operações confidenciais: criação de projeto, rotação de chaves, leitura de logs de um locatário. Os dois compartilham um alvo comum (proxy para os mesmos serviços internos: aura-auth, aura-db, aura-storage, etc.), mas não a mesma superfície de risco.
Passar ambos pelo mesmo roteador requer a escolha entre duas opções ruins: ou o Studio CORS herda o curinga necessário para o SDK público (Access-Control-Allow-Origin: *) ou o SDK herda uma lista restrita de origens projetada para um painel interno. O código do aura-gateway resolve essa tensão de main.rs: dois Routerseparados, cada um com seu próprio CorsLayer — curinga autorizado no lado do plano de dados, recusado e registrado como um erro no lado do plano de gerenciamento.
Dois roteadores axum, um AppState compartilhado
A separação não é uma implantação separada: os dois planos são executados no mesmo processo, no mesmo AppState (pools Postgres, cliente NATS, caches Moka, disjuntores). Apenas a construção de Router difere, por meio de duas funções dedicadas, cada uma chamada uma vez na inicialização e atendida por duas TcpListenerseparadas.
Os dois gráficos de rotas começam na mesma base, service_routes(): os mesmos manipuladores de proxy (db_proxy, storage_proxy, functions_proxy…) são montados em ambos os planos, com rotas adicionais específicas para cada um. A reutilização dos mesmos manipuladores evita uma implementação dupla do proxy; divergir apenas no middleware evita a duplicação da lógica de negócios para obter um limite de segurança. Se o seu back-end estiver estruturado como um espaço de trabalho Cargo multisserviço, consulte nosso Guia de arquitetura de espaço de trabalho Cargo — o gateway é apenas uma caixa entre outras nesta divisão.
A autenticação diverge na entrada
No plano de dados, a chave API é obrigatória em qualquer rota, exceto em alguns caminhos verdadeiramente públicos (/health, JWKS, endpoints de registro). Ele viaja como um cabeçalho apikey ou X-API-Key — ou, apenas para rotas de streaming WebSocket e SSE, como um parâmetro ?apikey=. O código proíbe explicitamente este último modo para uma chave service_role: uma chave de URL vaza nos logs de acesso, nos rastreamentos do OTel e no cabeçalho Referer. Um JWT permanece opcional no lado do plano de dados: sem ele, o chamador permanece anon; com isso, torna-se authenticated.
No plano de gerenciamento, a chave API não existe: apenas é aceito um console JWT, cujo público deve ser exatamente aurabase-control. A função não é desempenhada pelo token em si — ela é recalculada a cada solicitação a partir da associação do usuário à organização proprietária do projeto, herdada por meio do relacionamento projeto → organização.
| É necessário token | Chave API (apikey/X-API-Key), sempre | Console JWT (Autorização: Portador), sempre |
|---|---|---|
| Elevação de função | JWT opcional: anon → autenticado | RBAC herdado da organização (proprietário/administrador/desenvolvedor/visualizador) |
| Digite a string de consulta | Tolerado apenas em WS/SSE, nunca para service_role | Não aplicável |
| Público esperado | O projeto de destino (UUID do caminho) | "controle aurabase" corrigido |
| CORS | Curinga * permitido | Wildcard recusado, apenas origens do Studio |
A verdadeira ordem do middleware (e por que isso é importante)
axum empilha middleware com chamadas .layer() sucessivas - e a regra que rege a ordem de execução é surpreendente na prática: o ÚLTIMO .layer() colocado torna-se a camada mais EXTERNA, portanto a primeira atravessada por uma solicitação recebida e a última a ver a resposta sair. Uma leitura linear do arquivo fornece, portanto, a ordem inversa da ordem de execução real.
O middleware request_id define apenas o cabeçalho X-Request-Id no RESPONSE, nunca na solicitação recebida. Como AccessLogLayer é colocado depois dele no arquivo - portanto mais externo, portanto percorrido antes - sua captura do campo request_id lê o cabeçalho conforme o cliente o enviou, não o identificador gerado mais adiante na cadeia. Se o chamador não tiver fornecido nenhum X-Request-Id, a linha do log de acesso sairá com um campo vazio, enquanto a resposta retornada carrega um UUID recém-gerado. Não é um defeito oculto — um lembrete de que a ordem em que uma string .layer() é escrita não garante nada sobre a ordem lógica que atribuímos a ela.
Limitação de taxa: primeiro o IP, depois o ator
A limitação de taxa é aplicada em duas passagens distintas, em dois momentos diferentes da cadeia. O primeiro executa ANTES da autenticação e limita por endereço IP — um filtro anti-inundação genérico, ativo mesmo em vias públicas: sem ele, um fluxo não autenticado pode prejudicar um endpoint caro, como uma agregação de log, sem nunca acionar uma verificação JWT. A segunda é executada APÓS a autenticação e os limites por ator — chave de API ou usuário — usando as declarações que a autenticação acabou de injetar: essa é a cota real do produto, aquela que conta para faturamento e planos.
A implementação depende da caixa governor (token bucket) para cálculo local, com um script Lua Redis de janela deslizante para distribuição entre instâncias de gateway e um substituto local (cache Moka) se o Redis não estiver disponível. Padrões do repositório: 100 solicitações/segundo, burst de 1.000.
O disjuntor não é uma camada, é um objeto por alvo
Ao contrário do resto da cadeia, o disjuntor não aparece em ANY .layer(). AppState carrega uma instância CircuitBreaker por serviço (autenticação, banco de dados, tempo real, armazenamento, funções, notificações, IA, provisionador, controle) e é o próprio código proxy - não o roteador - que chama try_acquire_probe() antes de tentar a solicitação, então record_success() ou record_failure() dependendo do resultado.
O caso PostgREST é distinto: projetos em topologia dedicada (Postgres e PostgREST específicos do projeto) não possuem um domínio de falha compartilhado - cada processo PostgREST é seu próprio alvo. O gateway, portanto, mantém uma tabela de disjuntores indexados por alvo resolvido, preenchido dinamicamente e eliminado a cada 60 segundos por uma varredura que remove entradas inativas: sem essa eliminação, cada novo projeto dedicado adicionaria uma entrada que nunca desaparece.
O token de teste é retornado como Drop se nunca for explicitamente consumido — útil quando todas as tentativas de uma solicitação atingem o tempo limite sem atingir uma ramificação que o teria liberado. E a reprodução automática só é acionada em uma prova estrita de não entrega no lado NATS (NoResponders): um simples tempo limite do gateway não prova nada sobre a entrega real da solicitação, e reproduzi-la poderia executá-la duas vezes.
O último link: NATS ou HTTP direto, nunca aleatoriamente
O proxy final não fala um único protocolo ao contrário, e a escolha não é fixada por rota: pode depender do método HTTP, ou mesmo de uma consulta ao banco de dados. Para a maior parte do tráfego (autenticação, funções, notificações, controle e a maior parte do banco de dados), o gateway serializa a solicitação HTTP em um envelope NATS e a envia como solicitação/resposta para um assunto dedicado ao serviço - uma viagem de ida e volta sem handshake TCP, documentado no código como significativamente mais rápido do que um proxy HTTP clássico para esse tipo de tráfego RPC.
Armazenamento, as três variantes de tempo real (WebSocket, SSE e REST para transmissão/canais/presença) e – condicionalmente – solicitações Postgres CRUD saem desse caminho e passam por um cliente HTTP em pool ao vivo. O armazenamento fez essa escolha explicitamente: codificar um corpo binário em um envelope NATS requer serializá-lo, carregá-lo inteiramente na memória em ambas as extremidades e permanecer abaixo do limite de tamanho de mensagem NATS – um custo real para objetos grandes. WebSocket e SSE simplesmente não toleram a semântica de solicitação/resposta: uma atualização de protocolo e um fluxo que permanece aberto não têm equivalente NATS.
O caso mais interessante é /v1/db/*, cujo manipulador decide por si mesmo em cada solicitação: as rotas de gerenciamento (esquema, políticas, SQL bruto) sempre vão em NATS para aura-db, um PUT sempre vai em NATS (PostgREST retorna 405 em uma substituição completa), um projeto MongoDB sempre vai em NATS — e apenas um CRUD em um projeto Postgres com uma instância PostgREST dedicada resolvida vai em Direct HTTP. Se esta instância dedicada não for resolvida, o gateway responderá 503 em vez de recorrer a um PostgREST compartilhado: falha fechada assumida, não um fallback silencioso degradado. Os cabeçalhos de segurança (CSP estrito, sem credenciais CORS) aplicam-se uniformemente a todos esses caminhos, colocados bem no final da cadeia, antes que a resposta deixe o gateway.
Um orçamento de tempo limite por rota, não um tempo limite global
O gateway aplica um TimeoutLayer POR GRUPO de rotas em vez de um tempo limite global — uma escolha vinculada à mesma mecânica de empilhamento da ordem do middleware. A rota das funções Edge precisa de um orçamento muito maior do que o resto (uma função pode ser executada legitimamente por vários minutos): o padrão do repositório é 30 segundos para a maioria das rotas, em comparação com 380 segundos para /v1/functions/*.
Empilhar um único TimeoutLayer global em cima de tudo teria deixado ambos os grupos no mesmo limite: é sempre o timeout mais curto colocado na posição mais externa que vence, independentemente de um timeout mais longo colocado mais para dentro. A única maneira de conceder às funções um orçamento separado é, portanto, NUNCA entrar em um envoltório comum: cada ramo de rotas carrega seu próprio TimeoutLayer, colocado antes da fusão dos dois roteadores - e nenhum tempo limite global é aplicado depois.
Reproduza este padrão em outro lugar: a lista de verificação
- Separado por PLANO (superfície de exposição), não por serviço: um SDK público comprometido nunca deve chegar à lista de origem CORS do seu painel de administração.
- Mantenha um único estado compartilhado em vez de duas implantações separadas — duplicar a lógica de negócios custa mais do que duplicar um roteador.
- Verifique a ordem REAL do middleware rastreando-a desde o último
.layer(), nunca a partir da leitura linear do arquivo. - Limitação de taxa separada por IP (antes da autenticação) da cota por ator (depois) — caso contrário, um fluxo não autenticado forçará uma verificação dispendiosa sem limite.
- Coloque o disjuntor o mais próximo possível da chamada de rede real, no proxy — e dimensione-o por alvo quando o domínio de falha não for compartilhado.
- Repetir uma solicitação apenas mediante prova de não entrega, nunca em um simples tempo limite.
- Dê a cada grupo de rotas seu próprio orçamento de tempo limite definido antes de mesclar os roteadores – nunca um
TimeoutLayerglobal que substituiria o orçamento mais longo.