PRODPlataforma BaaS soberana europeaAbrir panel →

Ingeniería · 10 lectura mínima

Diseño de una puerta de enlace API de doble plano en Rust

Affane Daylami · Fondateur · 6 de agosto de 2026

volver al blog

Una puerta de enlace que atiende tanto el tráfico del SDK de sus usuarios como el tráfico de administración de su back office protege mal a ambos al mismo tiempo. La puerta de enlace de Aurabase (aura-gateway, Rust/axum) resuelve esta tensión en sentido ascendente: dos enrutadores distintos, dos puertos, dos modelos de autenticación, un único estado compartido. Esta guía describe este patrón de plano de datos/plano de administración tal como existe realmente en el código (rutas, orden del middleware, limitación de velocidad, disyuntor y proxy), no una versión idealizada de un diagrama arquitectónico.

Este texto en inglés se generó automáticamente a partir del original en francés y aún no ha sido revisado.
Esta página fue traducida automáticamente. La versión en inglés es autorizada.

Lo esencial

Dos ejes Router en dos puertos (plano de datos8080, plano de gestión 8090), construidos a partir del mismo AppStatecompartido. El plano de datos requiere una clave API en cualquier ruta; La gestión del avión requiere una consola de audiencia JWT dedicada: los dos mecanismos nunca se superponen. La limitación de velocidad se aplica dos veces: por IP antes de la autenticación y luego por actor autenticado después. El disyuntor no es un middleware global: es un objeto por servicio (y por objetivo dedicado para PostgREST), invocado directamente en el código proxy. Y el proxy final cambia el transporte según la ruta, a veces según el método HTTP o una búsqueda en la base de datos: solicitud/respuesta NATS para la mayor parte del tráfico, flujo HTTP directo para almacenamiento, las tres variantes en tiempo real y el CRUD de Postgres dedicado.

#
el problema

Una única puerta de entrada, dos públicos muy diferentes

El tráfico del plano de datos proviene del SDK o de una aplicación cliente: un volumen de solicitudes anónimas o solicitudes autenticadas mediante clave API, con un perfil de abuso cercano al de cualquier API pública. El plano de gestión del tráfico proviene de Studio (la interfaz de administración de un proyecto) y lleva a cabo operaciones sensibles: creación de proyectos, rotación de claves, lectura de los registros de un inquilino. Los dos comparten un objetivo común (proxy a los mismos servicios internos: aura-auth, aura-db, aura-storage, etc.) pero no la misma superficie de riesgo.

Pasar ambos a través del mismo enrutador requiere elegir entre dos malas opciones: o Studio CORS hereda el comodín necesario para el SDK público (Access-Control-Allow-Origin: *), o el SDK hereda una lista restringida de orígenes diseñada para un panel interno. El código aura-gateway separa esta tensión de main.rs: dos Routerseparados, cada uno con su propio CorsLayer: comodín autorizado en el lado del plano de datos, rechazado y registrado como un error en el lado del plano de administración.

#
Paso 1

Dos enrutadores axum, un AppState compartido

La separación no es una implementación separada: los dos planes se ejecutan en el mismo proceso, en el mismo AppState (grupos de Postgres, cliente NATS, cachés Moka, disyuntores). Solo difiere la construcción de Router, a través de dos funciones dedicadas, cada una de las cuales se llama una vez al inicio y es atendida por dos TcpListenerseparados.

gateway/server.rsrust
// Dos puertos, dos enrutadores, 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?;

// Mismo estado, dos gráficos de ruta 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),
);

Los dos gráficos de rutas comienzan desde la misma base, service_routes(): los mismos controladores de proxy (db_proxy, storage_proxy, functions_proxy…) están montados en ambos planes, con rutas adicionales específicas para cada uno. Reutilizar los mismos controladores evita una doble implementación del proxy; divergir sólo en el middleware evita duplicar la lógica empresarial para obtener un límite de seguridad. Si su backend está estructurado como un espacio de trabajo de carga multiservicio, consulte nuestra guía de arquitectura del espacio de trabajo de carga : la puerta de enlace es solo una caja entre otras en esta división.

#
Paso 2

La autenticación diverge al ingresar

En el plano de datos, la clave API es obligatoria en cualquier ruta, excepto en algunas rutas verdaderamente públicas (/health, JWKS, puntos finales de registro). Viaja como un encabezado apikey o X-API-Key o, solo para rutas de transmisión WebSocket y SSE, como un parámetro ?apikey=. El código prohíbe explícitamente este último modo para una clave service_role: una clave URL se filtra en los registros de acceso, los seguimientos de OTel y el encabezado Referer. Un JWT sigue siendo opcional en el lado del plano de datos: sin él, la persona que llama sigue siendo anon; con él, se convierte en authenticated.

En el plano de gestión, la clave API no existe: solo se acepta una consola JWT, cuya audiencia debe ser exactamente aurabase-control. La función no la desempeña el token en sí: se recalcula en cada solicitud de la membresía del usuario en la organización propietaria del proyecto, heredada a través de la relación proyecto → organización.

Token requeridoClave API (apikey / X-API-Key), siempreConsola JWT (Autorización: Portador), siempre
Elevación de rolesJWT opcional: anónimo → autenticadoRBAC heredado de la organización (propietario/administrador/desarrollador/espectador)
Introduzca la cadena de consultaTolerado solo en WS/SSE, nunca para service_roleNo aplicable
Audiencia esperadaEl proyecto de destino (UUID de la ruta)Se corrigió el "control de aurabase".
CORSComodín * permitidoComodín rechazado, solo orígenes de Studio
#
Paso 3

El verdadero orden del middleware (y por qué es importante)

axum acumula middleware con sucesivas llamadas .layer(), y la regla que gobierna el orden de ejecución es sorprendente en la práctica: el ÚLTIMO .layer() colocado se convierte en la capa EXTERIOR, por lo tanto, la primera que atraviesa una solicitud entrante y la última en ver la respuesta sale. Por tanto, una lectura lineal del fichero da el orden inverso al orden de ejecución real.

gateway/router.rsrust
// Escrito de la siguiente manera (extracto real, orden de los archivos):

service_routes(...).merge(data_plane_extra)
    .layer(metrics_auth_middleware)      // (1) colocado en primer lugar → el más 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 en último lugar → más externo

// Por lo tanto, una solicitud entrante pasa por (11) → (1), nunca por (1) → (11).
Un efecto concreto de este orden

El middleware request_id solo establece el encabezado X-Request-Id en la RESPUESTA, nunca en la solicitud entrante. Como AccessLogLayer se coloca después de él en el archivo (por lo tanto, es más externo y, por lo tanto, se recorre antes), su captura del campo request_id lee el encabezado tal como lo envió el cliente, no el identificador generado más adelante en la cadena. Si la persona que llama no ha proporcionado ningún X-Request-Id, la línea del registro de acceso sale con un campo vacío, mientras que la respuesta devuelta lleva un UUID recién generado. No es un defecto oculto: un recordatorio de que el orden en el que se escribe una cadena .layer() no garantiza nada sobre el orden lógico que le atribuimos.

#
Paso 4

Limitación de velocidad: primero IP, luego actor

La limitación de velocidad se aplica en dos pasadas distintas, en dos momentos diferentes de la cadena. El primero se ejecuta ANTES de la autenticación y limita por dirección IP: un filtro anti-inundación genérico, activo incluso en la vía pública: sin él, un flujo no autenticado puede dañar un punto final costoso, como una agregación de registros, sin siquiera activar una verificación JWT. El segundo se ejecuta DESPUÉS de la autenticación y limita por actor (clave API o usuario) utilizando los reclamos que la autenticación acaba de inyectar: ​​esta es la cuota real del producto, la que cuenta para la facturación y los planes.

La implementación se basa en la caja governor (depósito de tokens) para el cálculo local, con un script Lua Redis de ventana deslizante para la distribución entre instancias de puerta de enlace y un respaldo local (caché Moka) si Redis no está disponible. Valores predeterminados del repositorio: 100 solicitudes/segundo, ráfaga de 1000.

#
Paso 5

El disyuntor no es una capa, es un objeto por objetivo.

A diferencia del resto de la cadena, el disyuntor no aparece en ANY .layer(). AppState lleva una instancia de CircuitBreaker por servicio (autenticación, base de datos, tiempo real, almacenamiento, funciones, notificaciones, ai, aprovisionador, control), y es el código de proxy en sí (no el enrutador) el que llama a try_acquire_probe() antes de intentar la solicitud, luego a record_success() o record_failure() dependiendo del resultado.

El caso de PostgREST es distinto: los proyectos en topología dedicada (Postgres y PostgREST específicos del proyecto) no tienen un dominio de falla compartido: cada proceso de PostgREST es su propio objetivo. Por lo tanto, la puerta de enlace mantiene una tabla de disyuntores indexados por objetivo resuelto, completada sobre la marcha y purgada cada 60 segundos mediante un barrido que elimina las entradas inactivas: sin esta purga, cada nuevo proyecto dedicado agregaría una entrada que nunca desaparece.

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

// …intento(s) de consulta NATS, con reintento limitado…

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 no consumida → devuelta por Drop
}

El token de sonda se devuelve como Drop si nunca se consume explícitamente, lo que resulta útil cuando todos los intentos de una solicitud expiran sin llegar a una rama que la habría liberado. Y la reproducción automática solo se activa ante una prueba estricta de no entrega en el lado NATS (NoResponders): un simple tiempo de espera de la puerta de enlace no prueba nada sobre la entrega real de la solicitud, y reproducirla podría ejecutarla dos veces.

#
Paso 6

El último enlace: NATS o HTTP directo, nunca al azar

El proxy final no habla un solo protocolo al revés y la elección no está determinada por la ruta: puede depender del método HTTP o incluso de una búsqueda en la base de datos. Para la mayor parte del tráfico (autenticación, funciones, notificaciones, control y la mayor parte de la base de datos), la puerta de enlace serializa la solicitud HTTP en un sobre NATS y la envía como solicitud/respuesta a un sujeto dedicado al servicio: un viaje de ida y vuelta sin protocolo de enlace TCP, documentado en el código como significativamente más rápido que un proxy HTTP clásico para este tráfico de tipo RPC.

El almacenamiento, las tres variantes de tiempo real (WebSocket, SSE y REST para transmisión/canales/presencia) y, condicionalmente, las solicitudes CRUD de Postgres salen de esta ruta y pasan por un cliente HTTP agrupado en vivo. El almacenamiento tomó esta decisión explícitamente: codificar un cuerpo binario en un sobre NATS requiere serializarlo, cargarlo completamente en la memoria en ambos extremos y permanecer por debajo del límite de tamaño del mensaje NATS, un costo real para objetos grandes. WebSocket y SSE simplemente no toleran la semántica de solicitud/respuesta: una actualización de protocolo y un flujo que permanece abierto no tienen equivalente NATS.

El caso más interesante es /v1/db/*, cuyo controlador decide por sí mismo en cada solicitud: las rutas de administración (esquema, políticas, SQL sin formato) siempre van en NATS a aura-db, un PUT siempre va en NATS (PostgREST devuelve 405 en un reemplazo completo), un proyecto MongoDB siempre va en NATS, y solo un CRUD en un proyecto de Postgres con una instancia de PostgREST dedicada resuelta va en Direct HTTP. Si esta instancia dedicada no se resuelve, la puerta de enlace responde 503 en lugar de recurrir a un PostgREST compartido: se supone un cierre fallido, no un respaldo silencioso degradado. Los encabezados de seguridad (CSP estricto, sin credenciales CORS) se aplican de manera uniforme a todas estas rutas, ubicadas al final de la cadena, antes de que la respuesta abandone la puerta de enlace.

#
Paso 7

Un presupuesto de tiempo de espera por ruta, no un tiempo de espera global

La puerta de enlace aplica un TimeoutLayer POR GRUPO de rutas en lugar de un tiempo de espera global, una elección vinculada a la misma mecánica de apilamiento que el orden del middleware. La ruta de funciones de Edge necesita un presupuesto mucho mayor que el resto (una función puede ejecutarse legítimamente durante varios minutos): el valor predeterminado del repositorio es 30 segundos para la mayoría de las rutas, en comparación con 380 segundos para /v1/functions/*.

Apilar un único TimeoutLayer global encima de todo habría cortado ambos grupos al mismo límite: siempre gana el tiempo de espera más corto colocado en la posición más externa, independientemente de un tiempo de espera más largo colocado más adentro. Por lo tanto, la única forma de otorgar a las funciones un presupuesto separado es que NUNCA entren en un paquete común: cada rama de rutas lleva su propio TimeoutLayer, colocado antes de la fusión de los dos enrutadores, y no se aplica ningún tiempo de espera global después.

#
para recordar

Reproduzca este patrón en otra parte: la lista de verificación

  1. Separe por PLAN (superficie de exposición), no por servicio: un SDK público comprometido nunca debería llegar a la lista de orígenes CORS de su panel de administración.
  2. Mantenga un único estado compartido en lugar de dos implementaciones separadas: duplicar la lógica empresarial cuesta más que duplicar un enrutador.
  3. Verifique el orden REAL del middleware rastreándolo desde el último .layer(), nunca desde la lectura lineal del archivo.
  4. Separe la limitación de la tasa por IP (antes de la autenticación) de la cuota por actor (después); de lo contrario, un flujo no autenticado obliga a una costosa verificación sin límite.
  5. Coloque el disyuntor lo más cerca posible de la llamada de red real, en el proxy, y dimensione según el objetivo cuando el dominio de falla no esté compartido.
  6. Reproduzca una solicitud únicamente cuando se compruebe que no se ha entregado, nunca en un simple tiempo de espera.
  7. Asigne a cada grupo de rutas su propio presupuesto de tiempo de espera establecido antes de fusionar enrutadores; nunca un TimeoutLayer global que sobrescriba el presupuesto más largo.

¿LISTO PARA IMPLEMENTAR?

Tu backend en cinco minutos.

No se requiere tarjeta de crédito · 500 MB gratis · 50,000 MAU