PRODSouveräne europäische BaaS-PlattformÖffnen Sie das Dashboard →

Ingenieurwesen · 10 Min. Lesezeit

Entwerfen eines Dual-Plane-API-Gateways in Rust

Affane Daylami · Fondateur · 6. August 2026

Zurück zum Blog

Ein Gateway, das sowohl den SDK-Datenverkehr Ihrer Benutzer als auch den Datenverkehr Ihres Backoffice-Administrators bedient, schützt beide gleichzeitig nur unzureichend. Das Aurabase-Gateway (aura-gateway, Rust/axum) löst diese Spannung im Upstream: zwei unterschiedliche Router, zwei Ports, zwei Authentifizierungsmodelle, ein einziger gemeinsamer Status. In diesem Leitfaden wird dieses Datenebenen-/Verwaltungsebenenmuster so beschrieben, wie es tatsächlich im Code vorhanden ist – Routen, Middleware-Reihenfolge, Ratenbegrenzung, Leistungsschalter und Proxy – und keine idealisierte Version eines Architekturdiagramms.

Dieser englische Text wurde automatisch aus dem französischen Original generiert und wurde noch nicht überprüft.
Diese Seite wurde automatisch übersetzt. Maßgeblich ist die englische Version.

Das Wesentliche

Zwei Router-Achsen an zwei Ports (8080 Datenebene, 8090 Verwaltungsebene), aufgebaut aus demselben gemeinsamen AppState. Die Datenebene erfordert auf jeder Route einen API-Schlüssel; Für die Flugzeugverwaltung ist eine dedizierte JWT-Publikumskonsole erforderlich – die beiden Mechanismen überschneiden sich nie. Die Ratenbegrenzung gilt zweimal: nach IP vor der Authentifizierung und dann nach dem authentifizierten Akteur. Der Leistungsschalter ist keine globale Middleware: Er ist ein Objekt pro Dienst (und pro dediziertem Ziel für PostgREST), das direkt im Proxy-Code aufgerufen wird. Und der endgültige Proxy ändert den Transport je nach Route – manchmal entsprechend der HTTP-Methode oder einer Datenbanksuche: NATS-Anfrage/Antwort für den Großteil des Datenverkehrs, direkter HTTP-Fluss für die Speicherung, die drei Echtzeitvarianten und das dedizierte Postgres CRUD.

#
Das Problem

Ein einziges Tor, zwei sehr unterschiedliche Zielgruppen

Der Datenverkehr auf Datenebene kommt vom SDK oder einer Client-App: eine Menge anonymer oder durch einen API-Schlüssel authentifizierter Anfragen mit einem Missbrauchsprofil, das jeder öffentlichen API ähnelt. Die Verkehrsmanagementebene stammt aus dem Studio – der Verwaltungsoberfläche eines Projekts – und führt sensible Vorgänge aus: Projekterstellung, Schlüsselrotation, Lesen der Protokolle eines Mandanten. Die beiden haben ein gemeinsames Ziel (Proxiing zu denselben internen Diensten: Aura-Auth, Aura-DB, Aura-Storage usw.), aber nicht die gleiche Risikooberfläche.

Um beide über denselben Router zu leiten, muss zwischen zwei schlechten Optionen gewählt werden: Entweder erbt Studio CORS den für das öffentliche SDK erforderlichen Platzhalter (Access-Control-Allow-Origin: *) oder das SDK erbt eine eingeschränkte Liste von Ursprüngen, die für ein internes Dashboard entwickelt wurde. Der Aura-Gateway-Code löst diese Spannung von main.rs: zwei separate Router, jeder mit seinem eigenen CorsLayer – Platzhalter, der auf der Seite der Datenebene autorisiert, abgelehnt und auf der Seite der Verwaltungsebene als Fehler protokolliert wird.

#
Schritt 1

Zwei Axum-Router, ein gemeinsamer AppState

Bei der Trennung handelt es sich nicht um eine separate Bereitstellung: Die beiden Pläne werden im selben Prozess auf demselben AppState ausgeführt (Postgres-Pools, NATS-Client, Moka-Caches, Leistungsschalter). Lediglich der Aufbau von Router unterscheidet sich durch zwei dedizierte Funktionen, die jeweils einmal beim Start aufgerufen und von zwei separaten TcpListenerbedient werden.

gateway/server.rsrust
// Zwei Ports, zwei Router, ein 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?;

// Gleicher Staat, zwei unterschiedliche Routendiagramme
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),
);

Die beiden Routendiagramme beginnen mit derselben Basis, service_routes(): Die gleichen Proxy-Handler (db_proxy, storage_proxy, functions_proxy…) werden auf beiden Plänen bereitgestellt, wobei für jeden Plan zusätzliche Routen spezifisch sind. Durch die Wiederverwendung derselben Handler wird eine doppelte Implementierung des Proxys vermieden. Wenn man nur bei der Middleware abweicht, vermeidet man die Duplizierung der Geschäftslogik, um eine Sicherheitsgrenze zu erreichen. Wenn Ihr Backend selbst als Cargo-Arbeitsbereich mit mehreren Diensten strukturiert ist, lesen Sie unseren Leitfaden zur Architektur des Cargo-Arbeitsbereichs – das Gateway ist nur eine Kiste unter anderen in dieser Abteilung.

#
Schritt 2

Die Authentifizierung weicht bei der Eingabe ab

Auf der Datenebene ist der API-Schlüssel auf jeder Route obligatorisch, mit Ausnahme einiger wirklich öffentlicher Pfade (/health, JWKS, Registrierungsendpunkte). Es wird als apikey- oder X-API-Key-Header übertragen – oder, nur für WebSocket- und SSE-Streaming-Routen, als ?apikey=-Parameter. Der Code verbietet diesen letzten Modus ausdrücklich für einen service_role-Schlüssel: Ein URL-Schlüssel verliert Zugriffsprotokolle, OTel-Traces und den Referer-Header. Ein JWT bleibt auf der Datenebenenseite optional: Ohne es bleibt der Aufrufer anon; damit wird es zu authenticated.

Auf der Verwaltungsebene ist der API-Schlüssel nicht vorhanden: Es wird nur eine JWT-Konsole akzeptiert, deren Zielgruppe genau aurabase-controlsein muss. Die Rolle wird nicht vom Token selbst getragen – sie wird bei jeder Anfrage aus der Mitgliedschaft des Benutzers in der Organisation, die das Projekt besitzt, neu berechnet, geerbt über die Projekt → Organisationsbeziehung.

Token erforderlichAPI-Schlüssel (apikey / X-API-Key), immerJWT-Konsole (Autorisierung: Inhaber), immer
RollenerhöhungOptionales JWT: anon → authentifiziertRBAC, geerbt von der Organisation (Eigentümer/Administrator/Entwickler/Betrachter)
Geben Sie die Abfragezeichenfolge einWird nur auf WS/SSE toleriert, niemals für service_roleNicht zutreffend
Erwartetes PublikumDas Zielprojekt (UUID des Pfades)„Aurabase-Kontrolle“ behoben
CORSPlatzhalter * erlaubtPlatzhalter abgelehnt, nur Studio-Ursprünge
#
Schritt 3

Die wahre Ordnung der Middleware (und warum sie wichtig ist)

axum stapelt Middleware mit aufeinanderfolgenden .layer()-Aufrufen – und die Regel, die die Reihenfolge der Ausführung regelt, ist in der Praxis überraschend: Der LETZTE platzierte .layer() wird zur ÄUSSERSTEN Ebene, also die erste, die von einer eingehenden Anfrage durchlaufen wird, und die letzte, die die Antwort verlässt. Ein lineares Lesen der Datei ergibt daher die umgekehrte Reihenfolge der tatsächlichen Ausführungsreihenfolge.

gateway/router.rsrust
// Wie folgt geschrieben (aktueller Auszug, Aktenordnung):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) an erster Stelle → am innersten platziert
    .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) an letzter Stelle → ganz außen platziert

// Eine eingehende Anfrage durchläuft daher (11) → (1), niemals (1) → (11).
Eine konkrete Wirkung dieser Anordnung

Die request_id-Middleware setzt den X-Request-Id-Header nur auf die RESPONSE, niemals auf die eingehende Anfrage. Da AccessLogLayer danach in der Datei platziert wird – also eher extern und daher vorher durchlaufen wird – liest die Erfassung des Felds request_id den Header, wie ihn der Client gesendet hat, und nicht den weiter unten in der Kette generierten Bezeichner. Wenn der Aufrufer kein X-Request-Idangegeben hat, verlässt die Zugriffsprotokollzeile ein leeres Feld, während die zurückgegebene Antwort eine frisch generierte UUID enthält. Kein versteckter Fehler – eine Erinnerung daran, dass die Reihenfolge, in der ein .layer()-String geschrieben wird, nichts über die logische Reihenfolge garantiert, die wir ihm zuordnen.

#
Schritt 4

Ratenbegrenzung: Zuerst IP, dann Akteur

Die Ratenbegrenzung wird in zwei unterschiedlichen Durchgängen zu zwei unterschiedlichen Zeitpunkten in der Kette angewendet. Der erste läuft VOR der Authentifizierung und den Beschränkungen nach IP-Adresse – ein generischer Anti-Flood-Filter, der sogar auf öffentlichen Straßen aktiv ist: Ohne ihn kann ein nicht authentifizierter Datenfluss einen teuren Endpunkt, wie etwa eine Protokollaggregation, zerstören, ohne jemals eine JWT-Prüfung auszulösen. Der zweite wird NACH der Authentifizierung und den Beschränkungen durch den Akteur – API-Schlüssel oder Benutzer – unter Verwendung der Ansprüche ausgeführt, die die Authentifizierung gerade eingeführt hat: Dies ist die tatsächliche Produktquote, die für die Abrechnung und Pläne zählt.

Die Implementierung basiert auf der governor-Kiste (Token-Bucket) für die lokale Berechnung, mit einem Lua-Redis-Skript mit Schiebefenster für die Verteilung zwischen Gateway-Instanzen und einem lokalen Fallback (Moka-Cache), wenn Redis nicht verfügbar ist. Repository-Standardwerte: 100 Anfragen/Sekunde, Burst von 1000.

#
Schritt 5

Der Leistungsschalter ist keine Schicht, sondern ein Objekt pro Ziel

Im Gegensatz zum Rest der Kette erscheint der Leistungsschalter nicht in ANY .layer(). AppState trägt eine CircuitBreaker-Instanz pro Dienst (Authentifizierung, Datenbank, Echtzeit, Speicher, Funktionen, Benachrichtigungen, KI, Provisioner, Kontrolle), und es ist der Proxy-Code selbst – nicht der Router –, der try_acquire_probe() aufruft, bevor er die Anfrage versucht, und dann je nach Ergebnis record_success() oder record_failure().

Der PostgREST-Fall ist anders: Projekte in einer dedizierten Topologie (projektspezifisches Postgres und PostgREST) haben keine gemeinsame Fehlerdomäne – jeder PostgREST-Prozess ist sein eigenes Ziel. Das Gateway führt daher eine Tabelle mit Leistungsschaltern, die nach aufgelöstem Ziel indiziert sind, im laufenden Betrieb gefüllt und alle 60 Sekunden durch einen Sweep gelöscht werden, der inaktive Einträge entfernt: Ohne diese Löschung würde jedes neue dedizierte Projekt einen Eintrag hinzufügen, der nie verschwindet.

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

// …NATS-Abfrageversuch(e), mit begrenzter Wiederholung…

match resultat {
    Ok(Ok(_))     => match probe.take() { Some(p) => p.record_success(), _ => {} },
    Ok(Err(_))    => match probe.take() { Some(p) => p.record_failure(), _ => {} },
    Err(_timeout) => {} // Sonde nicht verbraucht → von Drop zurückgegeben
}

Das Probe-Token wird als Drop zurückgegeben, wenn es nie explizit verbraucht wird – nützlich, wenn alle Versuche, eine Anfrage zu stellen, ablaufen, ohne einen Zweig zu erreichen, der sie freigegeben hätte. Und die automatische Wiedergabe wird nur bei einem strikten Nachweis der Nichtzustellung auf der NATS-Seite (NoResponders) ausgelöst: Ein einfacher Gateway-Timeout beweist nichts über die tatsächliche Zustellung der Anfrage, und eine Wiederholung könnte dazu führen, dass sie zweimal ausgeführt wird.

Der endgültige Proxy spricht kein einziges Protokoll rückwärts, und die Auswahl ist nicht durch die Route festgelegt: Sie kann von der HTTP-Methode oder sogar von einer Datenbanksuche abhängen. Für den Großteil des Datenverkehrs (Authentifizierung, Funktionen, Benachrichtigungen, Steuerung und den Großteil der Datenbank) serialisiert das Gateway die HTTP-Anfrage in einem NATS-Umschlag und sendet sie als Anfrage/Antwort an einen für den Dienst bestimmten Betreff – ein Roundtrip ohne TCP-Handshake, der im Code als deutlich schneller dokumentiert ist als ein klassischer HTTP-Proxy für diesen RPC-Typ-Datenverkehr.

Speicher, die drei Varianten von Echtzeit (WebSocket, SSE und REST für Broadcast/Kanäle/Präsenz) und – bedingt – Postgres CRUD-Anfragen verlassen diesen Pfad und durchlaufen einen Live-, gepoolten HTTP-Client. Storage hat diese Wahl explizit getroffen: Das Codieren eines Binärkörpers in einem NATS-Umschlag erfordert dessen Serialisierung, das vollständige Laden in den Speicher an beiden Enden und das Einhalten der NATS-Nachrichtengrößenbeschränkung – ein echter Kostenfaktor für große Objekte. WebSocket und SSE tolerieren einfach keine Anfrage-/Antwort-Semantik: Ein Protokoll-Upgrade und ein offen bleibender Fluss haben kein NATS-Äquivalent.

Der interessanteste Fall ist /v1/db/*, dessen Handler bei jeder Anfrage selbst entscheidet: Die Verwaltungsrouten (Schema, Richtlinien, Roh-SQL) gehen immer in NATS zu aura-db, ein PUT geht immer in NATS (PostgREST gibt 405 bei einem vollständigen Austausch zurück), ein MongoDB-Projekt geht immer in NATS – und nur ein CRUD auf einem Postgres-Projekt mit einer aufgelösten dedizierten PostgREST-Instanz geht in Direct HTTP. Wenn diese dedizierte Instanz nicht aufgelöst wird, antwortet das Gateway mit 503, anstatt auf einen gemeinsam genutzten PostgREST zurückzugreifen: Fail-Closed angenommen, kein heruntergestufter stiller Fallback. Die Sicherheitsheader (strikter CSP, keine CORS-Anmeldeinformationen) gelten einheitlich für alle diese Pfade und werden ganz am Ende der Kette platziert, bevor die Antwort das Gateway verlässt.

#
Schritt 7

Ein Timeout-Budget pro Route, kein globales Timeout

Das Gateway wendet ein TimeoutLayer PRO GRUPPE von Routen anstelle eines globalen Timeouts an – eine Auswahl, die mit der gleichen Stapelmechanik wie die Middleware-Reihenfolge verknüpft ist. Die Edge-Funktionsroute benötigt ein viel längeres Budget als die anderen (eine Funktion kann durchaus mehrere Minuten lang ausgeführt werden): Der Repository-Standardwert beträgt 30 Sekunden für die meisten Routen, verglichen mit 380 Sekunden für /v1/functions/*.

Das Stapeln eines einzelnen globalen TimeoutLayer über alles hätte beide Gruppen auf die gleiche Grenze gebracht: Es gewinnt immer das kürzeste Timeout an der äußersten Position, unabhängig von einem längeren Timeout weiter innen. Die einzige Möglichkeit, Funktionen ein separates Budget zu gewähren, besteht daher darin, dass sie NIEMALS in einen gemeinsamen Wrap eintreten: Jeder Routenzweig trägt seinen eigenen TimeoutLayer, der vor der Zusammenführung der beiden Router platziert wird – und danach wird kein globales Timeout angewendet.

#
Zum Erinnern

Reproduzieren Sie dieses Muster an anderer Stelle: der Checkliste

  1. Getrennt nach PLAN (Belichtungsoberfläche), nicht nach Dienst: Ein kompromittiertes öffentliches SDK sollte niemals die CORS-Ursprungsliste Ihres Admin-Dashboards erreichen.
  2. Behalten Sie einen einzigen gemeinsamen Status bei, anstatt zwei separate Bereitstellungen – die Duplizierung der Geschäftslogik kostet mehr als die Duplizierung eines Routers.
  3. Überprüfen Sie die TATSÄCHLICHE Middleware-Reihenfolge, indem Sie sie vom letzten .layer()aus verfolgen, niemals vom linearen Lesen der Datei.
  4. Trennen Sie die Ratenbegrenzung pro IP (vor der Authentifizierung) von der Quote pro Akteur (nach) – andernfalls erzwingt ein nicht authentifizierter Datenfluss eine kostspielige Verifizierung ohne Begrenzung.
  5. Platzieren Sie den Leistungsschalter so nah wie möglich am tatsächlichen Netzwerkaufruf im Proxy – und dimensionieren Sie ihn nach Ziel, wenn die Fehlerdomäne nicht gemeinsam genutzt wird.
  6. Wiederholen Sie eine Anfrage nur bei Nachweis der Nichtzustellung, niemals bei einem einfachen Timeout.
  7. Geben Sie jeder Routengruppe ihr eigenes Timeout-Budget, bevor Sie Router zusammenführen – niemals einen globalen TimeoutLayer, der das längste Budget überschreiben würde.

BEREIT ZUM EINSATZ?

Ihr Backend in fünf Minuten.

Keine Kreditkarte erforderlich · 500 MB kostenlos · 50.000 MAU