PRODPlataforma BaaS europeia soberanaAbra o painel →

Engenharia · 10 minutos de leitura

Projetando um gateway API de plano duplo em Rust

Affane Daylami · Fondateur · 6 de agosto de 2026

Voltar ao blog

Um gateway que atende tanto o tráfego do SDK dos seus usuários quanto o tráfego do administrador de back office protege mal os dois ao mesmo tempo. O gateway Aurabase (aura-gateway, Rust/axum) resolve esta tensão upstream: dois roteadores distintos, duas portas, dois modelos de autenticação, um único estado compartilhado. Este guia descreve esse padrão de plano de dados/plano de gerenciamento como ele realmente existe no código — rotas, ordem de middleware, limitação de taxa, disjuntor e proxy — e não uma versão idealizada de um diagrama de arquitetura.

Este texto em inglês foi gerado automaticamente a partir do original em francês e ainda não foi revisado.
Esta página foi traduzida automaticamente. A versão em inglês é oficial.

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.

#
O problema

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.

#
Passo 1

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.

gateway/server.rsrust
// Duas portas, dois roteadores, um AppState

let data_addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let mgmt_addr: SocketAddr = format!("{}:{}", config.host, config.management_port).parse()?;

let data_listener = TcpListener::bind(data_addr).await?;
let mgmt_listener = TcpListener::bind(mgmt_addr).await?;

// Mesmo estado, dois gráficos de rotas distintos
let data_app = build_data_plane_router(state.clone(), data_cors);
let mgmt_app = build_management_plane_router(state);

tokio::join!(
    axum::serve(data_listener, data_app),
    axum::serve(mgmt_listener, mgmt_app),
);

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.

#
Etapa 2

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 tokenChave API (apikey/X-API-Key), sempreConsole JWT (Autorização: Portador), sempre
Elevação de funçãoJWT opcional: anon → autenticadoRBAC herdado da organização (proprietário/administrador/desenvolvedor/visualizador)
Digite a string de consultaTolerado apenas em WS/SSE, nunca para service_roleNão aplicável
Público esperadoO projeto de destino (UUID do caminho)"controle aurabase" corrigido
CORSCuringa * permitidoWildcard recusado, apenas origens do Studio
#
Etapa 3

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.

gateway/router.rsrust
// Escrito da seguinte forma (extrato real, ordem do arquivo):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) colocado em 1º → o mais interno
    .layer(rate_limit_actor_middleware)  // (2)
    .layer(data_plane_auth_middleware)   // (3)
    .layer(rate_limit_middleware)        // (4)
    .layer(request_id_middleware)        // (5)
    .layer(AccessLogLayer)               // (6)
    .layer(prometheus_layer)             // (7)
    .layer(TraceLayer)                   // (8)
    .layer(RequestBodyLimitLayer)        // (9)
    .layer(security_headers_middleware)  // (10)
    .layer(cors)                         // (11) colocado por último → mais externo

// Uma solicitação recebida, portanto, passa por (11) → (1), nunca (1) → (11).
Um efeito concreto desta ordem

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.

#
Etapa 4

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.

#
Etapa 5

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.

gateway/circuit_breaker.rsrust
let Some(probe) = circuit_breaker.try_acquire_probe() else {
    return Err(ServiceUnavailable);
};

// …tentativa(s) de consulta NATS, com nova tentativa limitada…

match resultat {
    Ok(Ok(_))     => match probe.take() { Some(p) => p.record_success(), _ => {} },
    Ok(Err(_))    => match probe.take() { Some(p) => p.record_failure(), _ => {} },
    Err(_timeout) => {} // sonda não consumida → retornada por Drop
}

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 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.

#
Etapa 7

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.

#
Para lembrar

Reproduza este padrão em outro lugar: a lista de verificação

  1. 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.
  2. 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.
  3. Verifique a ordem REAL do middleware rastreando-a desde o último .layer(), nunca a partir da leitura linear do arquivo.
  4. 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.
  5. 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.
  6. Repetir uma solicitação apenas mediante prova de não entrega, nunca em um simples tempo limite.
  7. Dê a cada grupo de rotas seu próprio orçamento de tempo limite definido antes de mesclar os roteadores – nunca um TimeoutLayer global que substituiria o orçamento mais longo.

PRONTO PARA IMPLEMENTAR?

Seu back-end em cinco minutos.

Não é necessário cartão de crédito · 500 MB grátis · 50.000 MAU