diff --git a/backend/crates/gateway-api/src/admin/auth.rs b/backend/crates/gateway-api/src/admin/auth.rs index b90b784ab..72df75344 100644 --- a/backend/crates/gateway-api/src/admin/auth.rs +++ b/backend/crates/gateway-api/src/admin/auth.rs @@ -14,6 +14,7 @@ use gateway_admin::{ model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError}, }; use serde::{Deserialize, Serialize}; +use tower_http::request_id::RequestId; use super::{AdminEnvelope, AdminError, AdminResponse}; @@ -51,22 +52,32 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let principal = require_admin_auth(state, &parts.headers).await?; - let request_id = parts - .headers - .get(REQUEST_ID_HEADER) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let request_id = admin_request_id(parts) .ok_or_else(|| AdminError::internal("Missing admin request context"))?; Ok(Self { context: AdminRequestContext { principal, - request_id: request_id.to_owned(), + request_id, }, }) } } +/// request-id 层按配置的 header 名注入,同时写入与名字无关的扩展; +/// 优先读扩展,使自定义 header 名不会让管理请求失去请求上下文。 +/// header 回退覆盖未装配该层的调用方。 +fn admin_request_id(parts: &Parts) -> Option { + parts + .extensions + .get::() + .map(RequestId::header_value) + .or_else(|| parts.headers.get(REQUEST_ID_HEADER)) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + pub async fn require_admin_session(state: &S, headers: &HeaderMap) -> Result where S: AdminSessionState + Send + Sync, diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index d10d4af3a..c790312f5 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -213,3 +213,26 @@ async fn settings_should_accept_admin_api_key_header() { assert_eq!(response.status(), StatusCode::OK); } + +#[tokio::test] +async fn admin_auth_should_accept_a_configured_request_id_header_name() { + use axum::http::HeaderName; + use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer}; + + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + // 部署把 api.request_id_header 改名后,注入的 header 不再叫 x-request-id; + // 管理请求仍须拿到请求上下文,而不是退化为 500。 + let custom = HeaderName::from_static("x-trace-id"); + let app = app(fixture.state()).layer(SetRequestIdLayer::new(custom, MakeRequestUuid)); + let unlabelled = Request::builder() + .method(Method::GET) + .uri("/api/admin/settings") + .header(header::COOKIE, "cpr_admin_session=valid-session") + .body(Body::empty()) + .expect("build settings request"); + + let response = app.oneshot(unlabelled).await.expect("settings response"); + + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/docs/api.md b/docs/api.md index 7da1a279d..d3da2a8b7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,8 +23,8 @@ Client Key 固定绑定一个 Provider:`openai` 或 `xai`。同一次请求不 - `x-api-key: `。 请求无需自带 `x-request-id`;缺失时服务端自动生成 UUID 并在响应头回传同一 request ID。 -`api.request_id_header` 只改变注入与回传的 header 名,而管理端鉴权固定读取 `x-request-id`, -因此启用管理端时该配置必须保持默认值。管理端响应统一带 `Cache-Control: no-store`。 +`api.request_id_header` 可改变注入与回传的 header 名,管理端鉴权不依赖该名字。 +管理端响应统一带 `Cache-Control: no-store`。 配置了 CORS 白名单 origin 时,跨域请求以凭据模式放行,仅允许 `GET`/`POST` 方法和 `authorization`、`content-type`、`x-api-key` 与 request ID 四个请求头,不使用通配符。