Retour au blog
Ingénierie Rust · Guide Technique · 10 min de lecture

Passerelle à double plan : concevoir un API gateway data-plane/management-plane en Rust

Affane Daylami · Fondateur· 23 août 2026

Un gateway qui sert à la fois le trafic SDK de vos utilisateurs et le trafic admin de votre back-office protège mal les deux à la fois. Le gateway d'Aurabase (aura-gateway, Rust/axum) tranche cette tension en amont : deux Router distincts, deux ports, deux modèles d'authentification, un seul état partagé. Ce guide décrit ce pattern data-plane/management-plane tel qu'il existe réellement dans le code — routes, ordre des middlewares, rate limiting, circuit breaker et proxy — pas une version idéalisée d'un schéma d'architecture.

L'essentiel

Deux Router axum sur deux ports (8080 data plane, 8090 management plane), construits depuis le même AppState partagé. Le data plane exige une clé API sur toute route ; le management plane exige un JWT console d'audience dédiée — les deux mécanismes ne se chevauchent jamais. Le rate limiting s'applique deux fois : par IP avant l'authentification, puis par acteur authentifié après. Le circuit breaker n'est pas un middleware global : c'est un objet par service (et par cible dédiée pour PostgREST), invoqué directement dans le code du proxy. Et le proxy final change de transport selon la route — parfois selon la méthode HTTP ou un lookup en base : NATS request/reply pour l'essentiel du trafic, flux HTTP direct pour le storage, les trois variantes realtime et le CRUD Postgres dédié.

#
Le problème

Un seul gateway, deux publics très différents

Le trafic data plane vient du SDK ou d'une app cliente : un volume de requêtes anonymes ou authentifiées par clé API, avec un profil d'abus proche de n'importe quelle API publique. Le trafic management plane vient du Studio — l'interface d'administration d'un projet — et transporte des opérations sensibles : création de projet, rotation de clé, lecture des logs d'un tenant. Les deux partagent une cible commune (proxifier vers les mêmes services internes : aura-auth, aura-db, aura-storage…) mais pas la même surface de risque.

Faire transiter les deux par le même routeur oblige à choisir entre deux mauvaises options : soit le CORS du Studio hérite du wildcard nécessaire au SDK public (Access-Control-Allow-Origin: *), soit le SDK hérite d'une liste d'origines restreinte pensée pour un dashboard interne. Le code d'aura-gateway range cette tension dès main.rs : deux Router séparés, chacun avec son propre CorsLayer — wildcard autorisé côté data plane, refusé et journalisé en erreur côté management plane.

#
Étape 1

Deux Router axum, un AppState partagé

La séparation n'a rien d'un déploiement distinct : les deux plans tournent dans le même processus, sur le même AppState (pools Postgres, client NATS, caches Moka, disjoncteurs). Seule la construction du Router diffère, via deux fonctions dédiées appelées une fois chacune au démarrage et servies par deux TcpListener distincts.

services/aura-gateway/src/main.rs
RUST
// Deux ports, deux Router, un même 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?;
// Même state, deux graphes de routes distincts
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),
);

Les deux graphes de routes partent du même socle, service_routes() : les mêmes handlers de proxy (db_proxy, storage_proxy, functions_proxy…) sont montés sur les deux plans, avec des routes additionnelles propres à chacun. Réutiliser les mêmes handlers évite une double implémentation du proxy ; diverger uniquement sur les middlewares évite de dupliquer la logique métier pour gagner une frontière de sécurité. Si votre backend est lui-même structuré en workspace Cargo multi-services, voir notre guide d'architecture Cargo workspace — le gateway n'est qu'un crate parmi d'autres dans ce découpage.

#
Étape 2

L'authentification diverge dès l'entrée

Sur le data plane, la clé API est obligatoire sur toute route, sauf une poignée de chemins vraiment publics (/health, JWKS, endpoints d'inscription). Elle voyage en en-tête apikey ou X-API-Key — ou, pour les seules routes de streaming WebSocket et SSE, en paramètre ?apikey=. Le code interdit explicitement ce dernier mode pour une clé service_role : une clé en URL fuit dans les journaux d'accès, les traces OTel et l'en-tête Referer. Un JWT, lui, reste optionnel côté data plane : sans lui, l'appelant reste anon ; avec lui, il devient authenticated.

Sur le management plane, la clé API n'existe pas : seul un JWT console est accepté, dont l'audience doit valoir exactement aurabase-control. Le rôle n'est pas porté par le jeton lui-même — il est recalculé à chaque requête depuis l'appartenance de l'utilisateur à l'organisation propriétaire du projet, héritée via la relation projet → organisation.

Jeton requisClé API (apikey / X-API-Key), toujoursJWT console (Authorization: Bearer), toujours
Élévation de rôleJWT optionnel : anon → authenticatedRBAC hérité de l’organisation (owner/admin/developer/viewer)
Clé en query stringTolérée sur WS/SSE uniquement, jamais pour service_roleNon applicable
Audience attendueLe projet ciblé (UUID du path)"aurabase-control" fixe
CORSWildcard * autoriséWildcard refusé, origines Studio uniquement
#
Étape 3

L'ordre réel des middlewares (et pourquoi il compte)

axum empile les middlewares avec des appels .layer() successifs — et la règle qui gouverne l'ordre d'exécution surprend en pratique : le DERNIER .layer() posé devient la couche la plus EXTÉRIEURE, donc la première traversée par une requête entrante, et la dernière à voir repartir la réponse. Une lecture linéaire du fichier donne donc l'ordre inverse de l'ordre d'exécution réel.

services/aura-gateway/src/main.rs — build_data_plane_router
RUST
// Écrit ainsi (extrait réel, ordre du fichier) :
service_routes(...).merge(data_plane_extra)
.layer(metrics_auth_middleware) // (1) posé en 1er → le plus intérieur
.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) posé en dernier → le plus extérieur
// Une requête entrante traverse donc (11) → (1), jamais (1) → (11).
Un effet concret de cet ordre
Le middleware request_id ne pose l'en-tête X-Request-Id que sur la RÉPONSE, jamais sur la requête entrante. Comme AccessLogLayer est posé après lui dans le fichier — donc plus extérieur, donc traversé avant — sa capture du champ request_id lit l'en-tête tel que le client l'a envoyé, pas l'identifiant généré plus loin dans la chaîne. Si l'appelant n'a fourni aucun X-Request-Id, la ligne d'access-log part avec un champ vide, alors que la réponse renvoyée porte, elle, un UUID fraîchement généré. Pas un défaut caché — un rappel que l'ordre d'écriture d'une chaîne de .layer() ne garantit rien sur l'ordre logique qu'on lui prête.
#
Étape 4

Rate limiting : IP d'abord, acteur ensuite

Le rate limiting s'applique en deux passes distinctes, à deux moments différents de la chaîne. La première tourne AVANT l'authentification et limite par adresse IP — un filtre anti-flood générique, actif même sur les routes publiques : sans lui, un flux non authentifié peut marteler un endpoint coûteux, comme une agrégation de logs, sans jamais déclencher de vérification JWT. La seconde tourne APRÈS l'authentification et limite par acteur — clé API ou utilisateur — en utilisant les claims que l'authentification vient d'injecter : c'est le quota produit réel, celui qui compte pour la facturation et les plans.

L'implémentation repose sur le crate governor (seau à jetons) pour le calcul local, avec un script Lua Redis à fenêtre glissante pour la distribution entre instances du gateway, et un repli local (cache Moka) si Redis est indisponible. Valeurs par défaut du dépôt : 100 requêtes/seconde, rafale de 1000.

#
Étape 5

Le circuit breaker n'est pas une couche, c'est un objet par cible

Contrairement au reste de la chaîne, le disjoncteur n'apparaît dans AUCUN .layer(). AppState porte une instance CircuitBreaker par service (auth, db, realtime, storage, functions, notifications, ai, provisioner, control), et c'est le code du proxy lui-même — pas le routeur — qui appelle try_acquire_probe() avant de tenter la requête, puis record_success() ou record_failure() selon l'issue.

Le cas PostgREST est distinct : les projets en topologie dédiée (Postgres et PostgREST propres à un projet) n'ont pas un domaine de panne partagé — chaque process PostgREST est sa propre cible. Le gateway tient donc une table de disjoncteurs indexée par cible résolue, peuplée à la volée et purgée toutes les 60 secondes par un balayage qui retire les entrées inactives : sans cette purge, chaque nouveau projet dédié ajouterait une entrée qui ne disparaît jamais.

services/aura-gateway/src/proxy/mod.rs
RUST
let Some(probe) = circuit_breaker.try_acquire_probe() else {
return Err(ServiceUnavailable);
};
// … tentative(s) de requête NATS, avec retry borné …
match resultat {
Ok(Ok(_)) => match probe.take() { Some(p) => p.record_success(), _ => {} },
Ok(Err(_)) => match probe.take() { Some(p) => p.record_failure(), _ => {} },
Err(_timeout) => {} // probe non consommé → rendu par Drop
}

Le jeton de sonde est rendu par Drop s'il n'est jamais explicitement consommé — utile quand toutes les tentatives d'une requête finissent en timeout sans atteindre une branche qui l'aurait libéré. Et le rejeu automatique ne se déclenche que sur une preuve stricte de non-livraison côté NATS (NoResponders) : un simple timeout du gateway ne prouve rien sur la livraison réelle de la requête, et la rejouer pourrait l'exécuter deux fois.

#
Étape 6

Le dernier maillon : NATS ou HTTP direct, jamais au hasard

Le proxy final ne parle pas un seul protocole vers l'arrière, et le choix n'est pas figé par route : il peut dépendre de la méthode HTTP, voire d'un lookup en base. Pour l'essentiel du trafic (auth, functions, notifications, control, et la majorité de db), le gateway sérialise la requête HTTP en enveloppe NATS et l'envoie en request/reply sur un sujet dédié au service — un aller-retour sans handshake TCP, documenté dans le code comme nettement plus rapide qu'un proxy HTTP classique pour ce trafic de type RPC.

Le storage, les trois variantes de realtime (WebSocket, SSE, et REST pour le broadcast/channels/presence) et — sous condition — les requêtes CRUD Postgres sortent de ce chemin et passent par un client HTTP poolé, en direct. Le storage a fait ce choix explicitement : encoder un corps binaire dans une enveloppe NATS oblige à le sérialiser, à le charger intégralement en mémoire aux deux bouts, et à rester sous le plafond de taille de message NATS — un coût réel pour des objets volumineux. WebSocket et SSE, eux, ne tolèrent tout simplement pas la sémantique request/reply : un upgrade de protocole et un flux qui reste ouvert n'ont pas d'équivalent NATS.

Le cas le plus intéressant est /v1/db/*, dont le handler tranche lui-même à chaque requête : les routes de gestion (schéma, policies, SQL brut) partent toujours en NATS vers aura-db, un PUT part toujours en NATS (PostgREST renvoie 405 sur un remplacement complet), un projet MongoDB part toujours en NATS — et seul un CRUD sur un projet Postgres avec une instance PostgREST dédiée résolue part en HTTP direct. Si cette instance dédiée n'est pas résolue, le gateway répond 503 plutôt que de retomber sur un PostgREST partagé : fail-closed assumé, pas un fallback silencieux dégradé. Les en-têtes de sécurité (CSP stricte, pas de credentials CORS) s'appliquent uniformément à tous ces chemins, posés en toute fin de chaîne, avant que la réponse ne quitte le gateway.

#
Étape 7

Un budget de timeout par route, pas un timeout global

Le gateway applique un TimeoutLayer PAR GROUPE de routes plutôt qu'un timeout global — un choix lié à la même mécanique d'empilement que l'ordre des middlewares. La route des fonctions Edge a besoin d'un budget bien plus long que le reste (une fonction peut légitimement tourner plusieurs minutes) : le défaut du dépôt est de 30 secondes pour la majorité des routes, contre 380 secondes pour /v1/functions/*.

Empiler un unique TimeoutLayer global au-dessus de tout aurait coupé les deux groupes à la même limite : c'est toujours le timeout le plus court posé en position la plus extérieure qui l'emporte, quel que soit un timeout plus long posé plus à l'intérieur. La seule façon d'accorder aux fonctions un budget distinct est donc qu'elles n'entrent JAMAIS dans un wrap commun : chaque branche de routes porte son propre TimeoutLayer, posé avant la fusion des deux routeurs — et aucun timeout global n'est appliqué ensuite.

#
À retenir

Reproduire ce pattern ailleurs : la checklist

  1. Séparez par PLAN (surface d'exposition), pas par service : un SDK public compromis ne doit jamais atteindre la liste d'origines CORS de votre dashboard admin.
  2. Gardez un seul état partagé plutôt que deux déploiements séparés — dupliquer la logique métier coûte plus cher que dupliquer un routeur.
  3. Vérifiez l'ordre RÉEL des middlewares en le traçant depuis le dernier .layer(), jamais depuis la lecture linéaire du fichier.
  4. Séparez le rate limiting par IP (avant l'auth) du quota par acteur (après) — sinon un flux non authentifié force une vérification coûteuse sans limite.
  5. Placez le disjoncteur au plus près de l'appel réseau réel, dans le proxy — et dimensionnez-le par cible quand le domaine de panne n'est pas partagé.
  6. Ne rejouez une requête que sur une preuve de non-livraison, jamais sur un simple timeout.
  7. Donnez à chaque groupe de routes son propre budget de timeout posé avant la fusion des routeurs — jamais un TimeoutLayer global qui écraserait le budget le plus long.
ARCHITECTURE VÉRIFIÉE

Testez ce gateway directement.

Chaque route de ce guide tourne en production sur le plan gratuit. Créez un projet et observez le comportement réel du data plane et du management plane.

Créer un projet Explorer l'architecture
Aucune carte bancaire requise · 500 MB gratuits · 50 000 MAU