PRODSoeverein Europees BaaS-platformOpen Dashboard →

Techniek · 10 min gelezen

Een dual-plane API-gateway ontwerpen in Rust

Affane Daylami · Fondateur · 6 augustus 2026

Terug naar blog

Een gateway die zowel het SDK-verkeer van uw gebruikers als het administratieve verkeer van uw backoffice bedient, beschermt beide slecht tegelijkertijd. De Aurabase-gateway (aura-gateway, Rust/axum) lost deze spanning stroomopwaarts op: twee verschillende routers, twee poorten, twee authenticatiemodellen, één gedeelde status. Deze handleiding beschrijft dit datavlak/beheervlakpatroon zoals het feitelijk in de code voorkomt (routes, middleware-volgorde, snelheidsbeperking, stroomonderbreker en proxy) en niet een geïdealiseerde versie van een architectonisch diagram.

Deze Engelse tekst is automatisch gegenereerd op basis van het Franse origineel en is nog niet beoordeeld.
Deze pagina is automatisch vertaald. De Engelse versie is gezaghebbend.

De essentie

Twee Router-assen op twee poorten (gegevensvlak8080, beheervlak 8090), opgebouwd uit dezelfde gedeelde AppState. Het datavlak vereist op elke route een API-sleutel; Vliegtuigbeheer vereist een speciale JWT-publieksconsole; de ​​twee mechanismen overlappen elkaar nooit. Snelheidsbeperking is twee keer van toepassing: via IP vóór authenticatie, en daarna via geauthenticeerde actor. De stroomonderbreker is geen globale middleware: het is een object per service (en per speciaal doel voor PostgREST), dat rechtstreeks in de proxycode wordt aangeroepen. En de uiteindelijke proxy verandert het transport afhankelijk van de route – soms volgens de HTTP-methode of een database-lookup: NATS-verzoek/antwoord voor het grootste deel van het verkeer, directe HTTP-stroom voor opslag, de drie realtime varianten en de speciale Postgres CRUD.

#
Het probleem

Eén enkele toegangspoort, twee heel verschillende doelgroepen

Het datavlakverkeer is afkomstig van de SDK of een client-app: een volume aan anonieme verzoeken of verzoeken die zijn geverifieerd door middel van een API-sleutel, met een misbruikprofiel dat dicht bij een openbare API ligt. Het verkeersbeheervlak komt uit Studio – de beheerinterface van een project – en voert gevoelige bewerkingen uit: het maken van projecten, sleutelrotatie, het lezen van de logboeken van een huurder. De twee delen een gemeenschappelijk doel (het benaderen van dezelfde interne diensten: aura-auth, aura-db, aura-storage, enz.), maar niet hetzelfde risicooppervlak.

Om beide door dezelfde router te laten gaan, moet je kiezen tussen twee slechte opties: óf de Studio CORS erft het jokerteken dat nodig is voor de openbare SDK (Access-Control-Allow-Origin: *), óf de SDK erft een beperkte lijst met oorsprongen die is ontworpen voor een intern dashboard. De aura-gatewaycode sorteert deze spanning uit main.rs: twee afzonderlijke Router, elk met zijn eigen CorsLayer - wildcard geautoriseerd aan de kant van het datavlak, geweigerd en geregistreerd als een fout aan de kant van het beheervlak.

#
Stap 1

Twee axum-routers, één gedeelde AppState

De scheiding is geen afzonderlijke implementatie: de twee plannen draaien in hetzelfde proces, op dezelfde AppState (Postgres-pools, NATS-client, Moka-caches, stroomonderbrekers). Alleen de constructie van Router verschilt, via twee speciale functies die elk één keer worden aangeroepen bij het opstarten en worden bediend door twee afzonderlijke TcpListener.

gateway/server.rsrust
// Twee poorten, twee routers, één 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?;

// Dezelfde staat, twee verschillende routegrafieken
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),
);

De twee routegrafieken starten vanaf dezelfde basis, service_routes(): dezelfde proxyhandlers (db_proxy, storage_proxy, functions_proxy…) zijn op beide plannen gemonteerd, met extra routes die specifiek zijn voor elk plan. Door dezelfde handlers opnieuw te gebruiken, wordt een dubbele implementatie van de proxy vermeden; Door alleen op middleware af te wijken, wordt het dupliceren van bedrijfslogica vermeden om een ​​veiligheidsgrens te verkrijgen. Als uw backend zelf is gestructureerd als een multi-service Cargo-werkruimte, raadpleeg dan onze Cargo-werkruimtearchitectuurgids — de gateway is slechts één krat onder andere in deze divisie.

#
Stap 2

Authenticatie wijkt af bij binnenkomst

Op datavlak is de API-sleutel verplicht op elke route, behalve op een handvol echt openbare paden (/health, JWKS, registratie-eindpunten). Het reist als een apikey- of X-API-Key-header - of, alleen voor WebSocket- en SSE-streamingroutes, als een ?apikey=-parameter. De code verbiedt deze laatste modus expliciet voor een service_role sleutel: een URL-sleutel lekt in toegangslogboeken, OTel-traceringen en de Referer-header. Een JWT blijft optioneel op het datavlak: zonder deze blijft de beller anon; hiermee wordt het authenticated.

Op beheervlak bestaat de API-sleutel niet: alleen een JWT-console wordt geaccepteerd, waarvan de doelgroep precies aurabase-controlmoet zijn. De rol wordt niet door het token zelf uitgevoerd; deze wordt bij elk verzoek opnieuw berekend op basis van het lidmaatschap van de gebruiker in de organisatie die eigenaar is van het project, geërfd via de relatie project → organisatie.

Token vereistAPI-sleutel (apikey / X-API-Key), altijdJWT-console (autorisatie: Bearer), altijd
RolverheffingOptioneel JWT: anon → geverifieerdRBAC overgenomen van de organisatie (eigenaar/beheerder/ontwikkelaar/viewer)
Voer de querytekenreeks inAlleen toegestaan op WS/SSE, nooit voor service_roleNiet van toepassing
Verwacht publiekHet beoogde project (UUID van het pad)vaste "aurabase-controle"
CORSWildcard * toegestaanWildcard geweigerd, alleen Studio-oorsprong
#
Stap 3

De echte orde van middleware (en waarom het ertoe doet)

axum stapelt middleware op met opeenvolgende .layer()-aanroepen - en de regel die de volgorde van uitvoering bepaalt is in de praktijk verrassend: de LAATSTE geplaatste .layer() wordt de buitenste laag, dus de eerste die wordt doorkruist door een binnenkomend verzoek, en de laatste die het antwoord ziet vertrekken. Een lineaire lezing van het bestand geeft dus de omgekeerde volgorde van de daadwerkelijke uitvoeringsvolgorde.

gateway/router.rsrust
// Als volgt geschreven (werkelijk uittreksel, bestandsvolgorde):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) 1e geplaatst → de binnenste
    .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) als laatste geplaatst → buitenste

// Een binnenkomend verzoek loopt dus via (11) → (1), nooit (1) → (11).
Een concreet effect van deze bestelling

De middleware request_id stelt alleen de header X-Request-Id in op de RESPONSE, nooit op het binnenkomende verzoek. Omdat AccessLogLayer erna in het bestand wordt geplaatst (dus meer extern en dus eerder wordt doorlopen), leest de opname van het veld request_id de header zoals de client deze heeft verzonden, en niet de identificatie die verderop in de keten wordt gegenereerd. Als de beller geen X-Request-Idheeft opgegeven, vertrekt de toegangslogregel met een leeg veld, terwijl het geretourneerde antwoord een nieuw gegenereerde UUID bevat. Geen verborgen gebrek – een herinnering dat de volgorde waarin een .layer()-reeks is geschreven niets garandeert over de logische volgorde die we eraan toeschrijven.

#
Stap 4

Snelheidsbeperking: eerst IP, daarna acteur

Snelheidsbeperking wordt toegepast in twee afzonderlijke stappen, op twee verschillende tijdstippen in de keten. De eerste loopt VOOR authenticatie en limieten op basis van IP-adres – een generiek anti-flood-filter, zelfs actief op de openbare weg: zonder dit filter kan een niet-geverifieerde stroom een ​​duur eindpunt, zoals een log-aggregatie, ondermijnen zonder ooit een JWT-controle te activeren. De tweede loopt NA authenticatie en limieten per actor (API-sleutel of gebruiker) met behulp van de claims die de authenticatie zojuist heeft geïnjecteerd: dit is het echte productquotum, het quotum dat telt voor facturering en abonnementen.

De implementatie is afhankelijk van de governor krat (tokenbucket) voor lokale berekeningen, met een Lua Redis-script met schuifvenster voor distributie tussen gateway-instanties, en een lokale fallback (Moka-cache) als Redis niet beschikbaar is. Standaardwaarden voor de opslagplaats: 100 verzoeken/seconde, burst van 1000.

#
Stap 5

De stroomonderbreker is geen laag, maar een object per doel

In tegenstelling tot de rest van de keten komt de stroomonderbreker in GEEN ENKELE .layer()voor. AppState heeft één CircuitBreaker-instantie per service (auth, db, realtime, opslag, functies, meldingen, ai, provisioner, controle), en het is de proxycode zelf (niet de router) die try_acquire_probe() aanroept voordat het verzoek wordt geprobeerd, en vervolgens record_success() of record_failure(), afhankelijk van de uitkomst.

Het geval van PostgREST is verschillend: projecten in een specifieke topologie (projectspecifieke Postgres en PostgREST) hebben geen gedeeld foutdomein; elk PostgREST-proces is zijn eigen doel. De gateway houdt daarom een ​​tabel met stroomonderbrekers bij, geïndexeerd op opgelost doel, direct gevuld en elke 60 seconden opgeschoond door een sweep die inactieve items verwijdert: zonder deze opschoning zou elk nieuw specifiek project een item toevoegen dat nooit verdwijnt.

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

// …NATS-querypoging(en), met begrensde nieuwe pogingen…

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 niet verbruikt → geretourneerd door Drop
}

Het probe-token wordt geretourneerd als Drop als het nooit expliciet wordt gebruikt. Dit is handig als bij alle pogingen tot een verzoek een time-out optreedt zonder een vertakking te bereiken die het zou hebben vrijgegeven. En de automatische herhaling wordt alleen geactiveerd bij een strikt bewijs van niet-aflevering aan de NATS-kant (NoResponders): een eenvoudige gateway-time-out bewijst niets over de werkelijke levering van het verzoek, en het opnieuw afspelen ervan zou het twee keer kunnen uitvoeren.

De uiteindelijke proxy spreekt geen enkel protocol achterstevoren uit, en de keuze ligt niet per route vast: deze kan afhankelijk zijn van de HTTP-methode, of zelfs van het opzoeken van een database. Voor het grootste deel van het verkeer (authenticatie, functies, meldingen, controle en het grootste deel van de database) serialiseert de gateway het HTTP-verzoek in een NATS-envelop en verzendt het als verzoek/antwoord naar een onderwerp dat aan de service is gewijd - een retour zonder TCP-handshake, gedocumenteerd in de code als aanzienlijk sneller dan een klassieke HTTP-proxy voor dit RPC-type verkeer.

Opslag, de drie varianten van realtime (WebSocket, SSE en REST voor uitzending/kanalen/aanwezigheid) en – voorwaardelijk – Postgres CRUD-verzoeken verlaten dit pad en gaan via een live, gepoolde HTTP-client. Storage heeft deze keuze expliciet gemaakt: het coderen van een binaire body in een NATS-envelop vereist het serialiseren ervan, het aan beide kanten volledig in het geheugen laden en onder de NATS-berichtgroottelimiet blijven – een reële kostenpost voor grote objecten. WebSocket en SSE tolereren eenvoudigweg geen verzoek/antwoord-semantiek: een protocolupgrade en een stroom die open blijft, hebben geen NATS-equivalent.

Het meest interessante geval is /v1/db/*, waarvan de handler zelf beslist over elk verzoek: de beheerroutes (schema, beleid, onbewerkte SQL) gaan altijd van NATS naar aura-db, een PUT gaat altijd naar NATS (PostgREST retourneert 405 bij een volledige vervanging), een MongoDB-project gaat altijd naar NATS - en alleen een CRUD op een Postgres-project met een speciale opgeloste PostgREST-instantie gaat naar Direct HTTP. Als dit speciale exemplaar niet wordt opgelost, reageert de gateway met 503 in plaats van terug te vallen op een gedeelde PostgREST: fail-closed aangenomen, geen gedegradeerde stille fallback. De beveiligingsheaders (strikte CSP, geen CORS-referenties) zijn uniform van toepassing op al deze paden, die helemaal aan het einde van de keten worden geplaatst, voordat het antwoord de gateway verlaat.

#
Stap 7

Een time-outbudget per route, geen globale time-out

De gateway past een TimeoutLayer PER GROEP routes toe in plaats van een globale time-out – een keuze die verband houdt met hetzelfde stapelmechanisme als de middleware-volgorde. De Edge-functiesroute heeft een veel langer budget nodig dan de rest (een functie kan legitiem meerdere minuten draaien): de standaardwaarde van de repository is 30 seconden voor de meeste routes, vergeleken met 380 seconden voor /v1/functions/*.

Het stapelen van een enkele globale TimeoutLayer bovenop alles zou beide groepen op dezelfde limiet hebben gebracht: het is altijd de kortste time-out die op de buitenste positie wordt geplaatst die wint, ongeacht een langere time-out die verder naar binnen wordt geplaatst. De enige manier om functies een afzonderlijk budget te geven is daarom dat ze NOOIT een gemeenschappelijke wrap aangaan: elke tak van routes heeft zijn eigen TimeoutLayer, geplaatst vóór de fusie van de twee routers – en er wordt daarna geen globale time-out toegepast.

#
Om te onthouden

Neem dit patroon elders over: de checklist

  1. Scheid per PLAN (blootstellingsoppervlak), niet per service: een gecompromitteerde openbare SDK mag nooit de CORS-oorsprongslijst van uw beheerdersdashboard bereiken.
  2. Behoud één gedeelde status in plaats van twee afzonderlijke implementaties; het dupliceren van bedrijfslogica kost meer dan het dupliceren van een router.
  3. Controleer de WERKELIJKE middleware-volgorde door deze te traceren vanaf de laatste .layer(), nooit vanaf de lineaire lezing van het bestand.
  4. Aparte snelheidslimieten per IP (vóór auth) en quota per actor (na) – anders dwingt een niet-geverifieerde stroom tot kostbare verificatie zonder limiet.
  5. Plaats de onderbreker zo dicht mogelijk bij de daadwerkelijke netwerkoproep, in de proxy, en rangschik deze op doel als het foutdomein niet wordt gedeeld.
  6. Speel een verzoek alleen opnieuw af als er een bewijs is dat de bestelling niet is afgeleverd, en nooit als er sprake is van een simpele time-out.
  7. Geef elke routegroep een eigen time-outbudget voordat u routers samenvoegt; nooit een globale TimeoutLayer die het langste budget zou overschrijven.

KLAAR VOOR IMPLEMENTATIE?

Uw backend in vijf minuten.

Geen creditcard vereist · 500 MB gratis · 50.000 MAU