PRODPiattaforma BaaS europea sovranaApri Dashboard →

Ingegneria · 10 lettura minima

Progettazione di un gateway API a doppio piano in Rust

Affane Daylami · Fondateur · 6 agosto 2026

Torniamo al blog

Un gateway che serve sia il traffico SDK degli utenti che il traffico amministrativo del back office protegge scarsamente entrambi allo stesso tempo. Il gateway Aurabase (aura-gateway, Rust/axum) risolve a monte questa tensione: due Router distinti, due porte, due modelli di autenticazione, un unico stato condiviso. Questa guida descrive questo modello piano dati/piano di gestione così come esiste effettivamente nel codice (percorsi, ordine del middleware, limitazione della velocità, interruttore automatico e proxy) e non una versione idealizzata di un diagramma architetturale.

Questo testo inglese è stato generato automaticamente dall'originale francese e non è stato ancora rivisto.
Questa pagina è stata tradotta automaticamente. Fa fede la versione inglese.

L'essenziale

Due assi Router su due porte (piano dati8080, piano di gestione 8090), costruiti dallo stesso AppStatecondiviso. Il piano dati richiede una chiave API su qualsiasi percorso; La gestione dell'aereo richiede una console per il pubblico JWT dedicata: i due meccanismi non si sovrappongono mai. La limitazione della velocità si applica due volte: tramite IP prima dell'autenticazione, quindi tramite attore autenticato dopo. Il circuit breaker non è un middleware globale: è un oggetto per servizio (e per target dedicato per PostgREST), invocato direttamente nel codice proxy. E il proxy finale cambia il trasporto a seconda del percorso, a volte secondo il metodo HTTP o una ricerca nel database: richiesta/risposta NATS per la maggior parte del traffico, flusso HTTP diretto per l'archiviazione, le tre varianti in tempo reale e il CRUD Postgres dedicato.

#
Il problema

Un unico gateway, due pubblici molto diversi

Il traffico del piano dati proviene dall'SDK o da un'app client: un volume di richieste anonime o richieste autenticate tramite chiave API, con un profilo di abuso vicino a qualsiasi API pubblica. Il piano di gestione del traffico proviene da Studio, l'interfaccia di amministrazione di un progetto, e svolge operazioni sensibili: creazione del progetto, rotazione delle chiavi, lettura dei log di un inquilino. I due condividono un obiettivo comune (proxying agli stessi servizi interni: aura-auth, aura-db, aura-storage, ecc.) ma non la stessa superficie di rischio.

Passare entrambi attraverso lo stesso router richiede la scelta tra due opzioni sbagliate: o Studio CORS eredita il carattere jolly necessario per l'SDK pubblico (Access-Control-Allow-Origin: *), oppure l'SDK eredita un elenco limitato di origini progettato per una dashboard interna. Il codice aura-gateway risolve questa tensione da main.rs: due Routerseparati, ciascuno con il proprio CorsLayer — carattere jolly autorizzato sul lato del piano dati, rifiutato e registrato come errore sul lato del piano di gestione.

#
Passaggio 1

Due router axum, un AppState condiviso

La separazione non è una distribuzione separata: i due piani vengono eseguiti nello stesso processo, sullo stesso AppState (pool Postgres, client NATS, cache Moka, interruttori automatici). Differisce solo la costruzione di Router, tramite due funzioni dedicate ciascuna chiamata una volta all'avvio e servita da due TcpListenerseparati.

gateway/server.rsrust
// Due porte, due router, un 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?;

// Stesso stato, due grafici di percorso distinti
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),
);

I due grafici dei percorsi partono dalla stessa base, service_routes(): gli stessi gestori proxy (db_proxy, storage_proxy, functions_proxy…) sono montati su entrambi i piani, con percorsi aggiuntivi specifici per ciascuno. Il riutilizzo degli stessi gestori evita una doppia implementazione del proxy; divergere solo sul middleware evita di duplicare la logica aziendale per ottenere un limite di sicurezza. Se il tuo backend è esso stesso strutturato come un'area di lavoro Cargo multiservizio, consulta la nostra Guida all'architettura dell'area di lavoro Cargo — il gateway è solo una cassa tra le altre in questa divisione.

#
Passaggio 2

L'autenticazione diverge all'ingresso

Sul piano dati, la chiave API è obbligatoria su qualsiasi percorso, ad eccezione di una manciata di percorsi veramente pubblici (/health, JWKS, endpoint di registrazione). Viaggia come intestazione apikey o X-API-Key o, solo per percorsi di streaming WebSocket e SSE, come parametro ?apikey=. Il codice proibisce esplicitamente quest'ultima modalità per una chiave service_role: una chiave URL trapela nei log di accesso, nelle tracce OTel e nell'intestazione Referer. Un JWT rimane facoltativo sul lato del piano dati: senza di esso, il chiamante rimane anon; con esso diventa authenticated.

Sul piano gestionale la chiave API non esiste: viene accettata solo una console JWT, la cui audience deve essere esattamente aurabase-control. Il ruolo non è trasferito dal token stesso: viene ricalcolato su ogni richiesta dall'appartenenza dell'utente all'organizzazione proprietaria del progetto, ereditata tramite la relazione progetto → organizzazione.

Gettone richiestoChiave API (apikey/X-API-Key), sempreConsole JWT (Autorizzazione: Portatore), sempre
Elevazione di ruoloJWT opzionale: anon → autenticatoRBAC ereditato dall'organizzazione (proprietario/amministratore/sviluppatore/visualizzatore)
Digitare la stringa di queryTollerato solo su WS/SSE, mai per service_roleNon applicabile
Pubblico attesoIl progetto mirato (UUID del percorso)risolto il problema con il "controllo aurabase"
CORSOCarattere jolly * consentitoJolly rifiutato, solo origini Studio
#
Passaggio 3

Il vero ordine del middleware (e perché è importante)

axum impila il middleware con successive chiamate .layer() — e la regola che governa l'ordine di esecuzione è sorprendente nella pratica: l'ULTIMO .layer() posizionato diventa lo strato OUTTERmost, quindi il primo attraversato da una richiesta in entrata e l'ultimo a vedere uscire la risposta. Una lettura lineare del file restituisce quindi l'ordine inverso rispetto all'effettivo ordine di esecuzione.

gateway/router.rsrust
// Scritto come segue (estratto effettivo, ordine dei file):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) classificato 1° → il più 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) piazzato ultimo → più esterno

// Una richiesta in arrivo passa quindi per (11) → (1), mai (1) → (11).
Un effetto concreto di quest'ordinanza

Il middleware request_id imposta l'intestazione X-Request-Id solo sulla RISPOSTA, mai sulla richiesta in entrata. Poiché AccessLogLayer viene posizionato dopo nel file - quindi più esterno, quindi attraversato prima - la sua acquisizione del campo request_id legge l'intestazione così come l'ha inviata il client, non l'identificatore generato ulteriormente nella catena. Se il chiamante non ha fornito alcun X-Request-Id, la riga del registro di accesso lascia un campo vuoto, mentre la risposta restituita porta un UUID appena generato. Non è un difetto nascosto: ricorda che l'ordine in cui è scritta una stringa .layer() non garantisce nulla sull'ordine logico che le attribuiamo.

#
Passaggio 4

Limitazione della velocità: prima l'IP, poi l'attore

La limitazione della velocità viene applicata in due passaggi distinti, in due momenti diversi della catena. Il primo viene eseguito PRIMA dell’autenticazione e dei limiti tramite indirizzo IP, un filtro anti-flood generico, attivo anche sulle strade pubbliche: senza di esso, un flusso non autenticato può colpire un endpoint costoso, come un’aggregazione di log, senza mai attivare un controllo JWT. Il secondo viene eseguito DOPO l'autenticazione e i limiti per attore – chiave API o utente – utilizzando le attestazioni che l'autenticazione ha appena iniettato: questa è la vera quota di prodotto, quella che conta per la fatturazione e i piani.

L'implementazione si basa sul governor crate (token bucket) per il calcolo locale, con uno script Lua Redis a finestra scorrevole per la distribuzione tra le istanze del gateway e un fallback locale (cache Moka) se Redis non è disponibile. Impostazioni predefinite del repository: 100 richieste/secondo, burst di 1000.

#
Passaggio 5

L'interruttore non è uno strato, è un oggetto per bersaglio

A differenza del resto della catena, l'interruttore non appare in ANY .layer(). AppState trasporta un'istanza CircuitBreaker per servizio (autenticazione, db, realtime, archiviazione, funzioni, notifiche, ai, provisioner, controllo) ed è il codice proxy stesso, non il router, che chiama try_acquire_probe() prima di tentare la richiesta, quindi record_success() o record_failure() a seconda del risultato.

Il caso PostgREST è distinto: i progetti in topologia dedicata (Postgres e PostgREST specifici del progetto) non hanno un dominio di errore condiviso: ogni processo PostgREST ha la propria destinazione. Il gateway mantiene quindi una tabella degli interruttori indicizzati per target risolto, popolata al volo ed eliminata ogni 60 secondi da uno sweep che rimuove le voci inattive: senza questa eliminazione, ogni nuovo progetto dedicato aggiungerebbe una voce che non scomparirebbe mai.

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

// …Tentativi di query NATS, con tentativi limitati…

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 non consumata → restituita da Drop
}

Il token sonda viene restituito come Drop se non viene mai consumato esplicitamente: utile quando tutti i tentativi di richiesta scadono senza raggiungere un ramo che lo avrebbe liberato. E la riproduzione automatica viene attivata solo su una rigorosa prova di mancata consegna sul lato NATS (NoResponders): un semplice timeout del gateway non prova nulla sulla reale consegna della richiesta, e riprodurla potrebbe eseguirla due volte.

#
Passaggio 6

L'ultimo collegamento: NATS o HTTP diretto, mai in modo casuale

Il proxy finale non parla un singolo protocollo all'indietro e la scelta non è fissata dal percorso: può dipendere dal metodo HTTP o anche da una ricerca nel database. Per la maggior parte del traffico (autenticazione, funzioni, notifiche, controllo e la maggior parte del db), il gateway serializza la richiesta HTTP in una busta NATS e la invia come richiesta/risposta a un soggetto dedicato al servizio — un viaggio di andata e ritorno senza handshake TCP, documentato nel codice come significativamente più veloce di un classico proxy HTTP per questo traffico di tipo RPC.

L'archiviazione, le tre varianti di tempo reale (WebSocket, SSE e REST per trasmissione/canali/presenza) e, in modo condizionale, le richieste CRUD di Postgres escono da questo percorso e passano attraverso un client HTTP live e in pool. Lo storage ha fatto questa scelta in modo esplicito: codificare un corpo binario in una busta NATS richiede la serializzazione, il caricamento completo in memoria su entrambe le estremità e il rispetto del limite di dimensione del messaggio NATS: un costo reale per oggetti di grandi dimensioni. WebSocket e SSE semplicemente non tollerano la semantica di richiesta/risposta: un aggiornamento del protocollo e un flusso che rimane aperto non hanno equivalenti NATS.

Il caso più interessante è /v1/db/*, il cui gestore decide da solo su ogni richiesta: i percorsi di gestione (schema, policy, SQL grezzo) vanno sempre in NATS su aura-db, un PUT va sempre in NATS (PostgREST restituisce 405 in caso di sostituzione completa), un progetto MongoDB va sempre in NATS — e solo un CRUD su un progetto Postgres con un'istanza PostgREST dedicata risolta va in HTTP diretto. Se questa istanza dedicata non viene risolta, il gateway risponde 503 anziché ricorrere a un PostgREST condiviso: presupposto fail-closed, non un fallback silenzioso degradato. Le intestazioni di sicurezza (CSP rigoroso, nessuna credenziale CORS) si applicano uniformemente a tutti questi percorsi, posizionati all'estremità della catena, prima che la risposta lasci il gateway.

#
Passaggio 7

Un budget di timeout per percorso, non un timeout globale

Il gateway applica un TimeoutLayer PER GRUPPO di percorsi anziché un timeout globale, una scelta legata agli stessi meccanismi di impilamento dell'ordine del middleware. Il percorso delle funzioni Edge richiede un budget molto più lungo rispetto agli altri (una funzione può legittimamente essere eseguita per diversi minuti): l'impostazione predefinita del repository è 30 secondi per la maggior parte dei percorsi, rispetto ai 380 secondi per /v1/functions/*.

Impilare un singolo TimeoutLayer globale sopra tutto avrebbe tagliato entrambi i gruppi allo stesso limite: è sempre il timeout più breve posizionato nella posizione più esterna a vincere, indipendentemente da un timeout più lungo posizionato più all'interno. L'unico modo per garantire alle funzioni un budget separato è quindi che non entrino MAI in un involucro comune: ogni ramo di percorsi porta il proprio TimeoutLayer, posto prima della fusione dei due router - e successivamente non viene applicato alcun timeout globale.

#
Da ricordare

Riproduci questo schema altrove: la lista di controllo

  1. Separato per PLAN (superficie di esposizione), non per servizio: un SDK pubblico compromesso non dovrebbe mai raggiungere l'elenco di origine CORS della dashboard di amministrazione.
  2. Mantieni un unico stato condiviso anziché due distribuzioni separate: la duplicazione della logica aziendale costa di più rispetto alla duplicazione di un router.
  3. Controllare l'ordine EFFETTIVO del middleware tracciandolo dall'ultimo .layer(), mai dalla lettura lineare del file.
  4. Separare la limitazione della velocità per IP (prima dell'autenticazione) dalla quota per attore (dopo), altrimenti un flusso non autenticato impone una verifica costosa senza limiti.
  5. Posiziona l'interruttore il più vicino possibile alla chiamata di rete effettiva, nel proxy, e dimensionalo in base al target quando il dominio di errore non è condiviso.
  6. Riproduci una richiesta solo dopo la prova di mancata consegna, mai per un semplice timeout.
  7. Assegna a ciascun gruppo di percorsi il proprio budget di timeout impostato prima di unire i router, mai un TimeoutLayer globale che sovrascriverebbe il budget più lungo.

PRONTO PER L'IMPLEMENTAZIONE?

Il tuo backend in cinque minuti.

Nessuna carta di credito richiesta · 500 MB gratuiti · 50.000 MAU