diff --git a/crates/aisix-core/src/models/passthrough_route.rs b/crates/aisix-core/src/models/passthrough_route.rs index 8465dcb4..350bee4a 100644 --- a/crates/aisix-core/src/models/passthrough_route.rs +++ b/crates/aisix-core/src/models/passthrough_route.rs @@ -379,6 +379,30 @@ pub fn passthrough_route_coupling() -> Value { } } }, + // Cross-mode leftovers are configuration errors, not ignored + // fields: a companion outside its mode is never consulted, so a + // row carrying one is rejected rather than half-honored. The CP + // enforces the identical rule on create and patch. + { + "title": "auth_header_name only in header_key mode", + "if": { + "anyOf": [ + { "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } }, + { "title": "auth_mode: gateway_key or anonymous", "properties": { "auth_mode": { "enum": ["gateway_key", "anonymous"] } }, "required": ["auth_mode"] } + ] + }, + "then": { "not": { "required": ["auth_header_name"] } } + }, + { + "title": "anonymous_key_id only in anonymous mode", + "if": { + "anyOf": [ + { "title": "auth_mode omitted (defaults to gateway_key)", "not": { "required": ["auth_mode"] } }, + { "title": "auth_mode: gateway_key or header_key", "properties": { "auth_mode": { "enum": ["gateway_key", "header_key"] } }, "required": ["auth_mode"] } + ] + }, + "then": { "not": { "required": ["anonymous_key_id"] } } + }, // credential_mode couplings: inject needs a real ProviderKey id; a // forward_client route carrying one is a configuration error, not // an ignored field. @@ -516,6 +540,39 @@ mod coupling_tests { assert!(validate_passthrough_route(&doc).is_err()); } + #[test] + fn cross_mode_leftover_companions_are_rejected() { + // A companion outside its mode is never consulted at runtime, so + // both validators refuse the row instead of half-honoring it. + // auth_header_name without header_key (auth_mode absent = gateway_key). + let mut doc = base(); + doc["auth_header_name"] = json!("x-aisix-api-key"); + assert!(validate_passthrough_route(&doc).is_err()); + assert!(validate_passthrough_route_lenient(&doc).is_err()); + // anonymous_key_id on an explicit header_key route. + let doc = json!({ + "name": "r", "path_prefix": "/p", + "target_url": "https://u.example", "provider_key_id": "pk", + "auth_mode": "header_key", "auth_header_name": "x-aisix-api-key", + "anonymous_key_id": "ak-1" + }); + assert!(validate_passthrough_route(&doc).is_err()); + // auth_header_name on an anonymous route. + let doc = json!({ + "name": "r", "path_prefix": "/p", + "target_url": "https://u.example", "provider_key_id": "pk", + "auth_mode": "anonymous", "anonymous_key_id": "ak-1", + "source_cidrs": ["10.0.0.0/8"], + "auth_header_name": "x-aisix-api-key" + }); + assert!(validate_passthrough_route(&doc).is_err()); + // source_cidrs is deliberately NOT mode-coupled: an extra IP + // allowlist is honored on every mode. + let mut doc = base(); + doc["source_cidrs"] = json!(["10.0.0.0/8"]); + assert!(validate_passthrough_route(&doc).is_ok()); + } + #[test] fn credential_bearing_header_slots_are_rejected() { for field in ["auth_header_name", "identity_header"] { diff --git a/crates/aisix-obs/src/otlp_http_sink.rs b/crates/aisix-obs/src/otlp_http_sink.rs index ad3d53c7..d0059f3b 100644 --- a/crates/aisix-obs/src/otlp_http_sink.rs +++ b/crates/aisix-obs/src/otlp_http_sink.rs @@ -743,6 +743,16 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value { &event.client_user_agent, )); } + // End-user identity a passthrough route's `identity_header` extracted — + // the per-employee attribution of the forward-proxy scenario. Not gated + // on the protocol so a future handler that learns to populate it exports + // it without touching this sink. + if !event.client_identity.is_empty() { + attributes.push(attr_string_capped( + "aisix.client_identity", + &event.client_identity, + )); + } // JWT identity attribution (AISIX-Cloud#564): who the request ran as // when it authenticated with a JWT — the identity behind the (possibly // shared) api_key_id. @@ -802,6 +812,14 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value { attributes.push(attr_string("aisix.mcp.server_name", &event.mcp_server_name)); } } + "passthrough" => { + if !event.passthrough_route_name.is_empty() { + attributes.push(attr_string( + "aisix.passthrough.route_name", + &event.passthrough_route_name, + )); + } + } _ => {} } // Opt-in captured content (#519 B.2) — present ONLY on a record built by @@ -841,6 +859,7 @@ fn operation_name(event: &UsageEvent) -> &'static str { match event.inbound_protocol.as_str() { "a2a" => "invoke_agent", "mcp" => "execute_tool", + "passthrough" => "passthrough", _ => "chat", } } @@ -867,6 +886,9 @@ fn span_name(event: &UsageEvent) -> String { let target = match event.inbound_protocol.as_str() { "a2a" => &event.a2a_agent_name, "mcp" => &event.mcp_tool_name, + // A route name is a registered resource like an agent name, so it is + // operator-bounded — but it still passes the length cap below. + "passthrough" => &event.passthrough_route_name, _ => return "chat.completions".to_string(), }; if target.is_empty() || target.len() > MAX_SPAN_NAME_TARGET { @@ -1535,6 +1557,44 @@ mod tests { assert_eq!(string_at("aisix.a2a.task_state"), "working"); } + #[test] + fn a_passthrough_relay_exports_route_and_identity_not_a_chat_span() { + // The passthrough handler emits usage events with + // `inbound_protocol = "passthrough"`; without the explicit branch a + // Copilot relay exported as a bare `chat.completions` span with the + // route and the device-injected end-user identity recorded nowhere. + let mut ev = sample_event(); + ev.inbound_protocol = "passthrough".into(); + ev.passthrough_route_name = "copilot-chat".into(); + ev.client_identity = "alice@example.com".into(); + + let body = build_otlp_traces_payload(&ev, "test-exp"); + let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0]; + assert_eq!(span["name"], "passthrough copilot-chat"); + let attrs = span["attributes"].as_array().unwrap(); + let find = |k: &str| attrs.iter().find(|a| a["key"] == k); + let string_at = |k: &str| find(k).unwrap()["value"]["stringValue"].clone(); + assert_eq!(string_at("gen_ai.operation.name"), "passthrough"); + assert_eq!(string_at("aisix.passthrough.route_name"), "copilot-chat"); + assert_eq!(string_at("aisix.client_identity"), "alice@example.com"); + } + + #[test] + fn client_identity_exports_without_a_protocol_gate() { + // The identity attribute must not depend on the passthrough branch: + // any handler that populates the field gets it exported. + let mut ev = sample_event(); + ev.client_identity = "bob@example.com".into(); + let body = build_otlp_traces_payload(&ev, "test-exp"); + let span = &body["resourceSpans"][0]["scopeSpans"][0]["spans"][0]; + let attrs = span["attributes"].as_array().unwrap(); + let id = attrs + .iter() + .find(|a| a["key"] == "aisix.client_identity") + .unwrap(); + assert_eq!(id["value"]["stringValue"], "bob@example.com"); + } + #[test] fn a_streamed_a2a_call_carries_its_event_count() { // A unary call has no count, and exporting a zero would read as "the diff --git a/crates/aisix-proxy/src/attempt.rs b/crates/aisix-proxy/src/attempt.rs index b434c3e2..42b52a1c 100644 --- a/crates/aisix-proxy/src/attempt.rs +++ b/crates/aisix-proxy/src/attempt.rs @@ -291,6 +291,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool { ProxyError::Bridge(be) => be.reached_upstream(), ProxyError::ContentFiltered(_) => true, ProxyError::MissingAuth + | ProxyError::MissingRouteAuthHeader(_) | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired | ProxyError::ApiKeyDisabled diff --git a/crates/aisix-proxy/src/error.rs b/crates/aisix-proxy/src/error.rs index fbb21333..5e36fef4 100644 --- a/crates/aisix-proxy/src/error.rs +++ b/crates/aisix-proxy/src/error.rs @@ -166,6 +166,13 @@ impl ErrorEnvelope { pub enum ProxyError { #[error("missing or malformed Authorization header")] MissingAuth, + /// A `header_key` passthrough route saw no gateway key in its configured + /// header. Named separately from [`ProxyError::MissingAuth`] because on + /// these routes `Authorization` deliberately carries the caller's own + /// upstream credential — telling the integrator to fix `Authorization` + /// points at exactly the wrong header. + #[error("missing or malformed {0} header (this route's gateway API key header)")] + MissingRouteAuthHeader(String), #[error("invalid API key")] InvalidApiKey, /// The presented key exists but its `expires_at` deadline has @@ -336,7 +343,8 @@ pub(crate) fn guardrail_block_message(side: &str, guardrail_name: Option<&str>) impl ProxyError { pub fn status(&self) -> StatusCode { match self { - ProxyError::MissingAuth + ProxyError::MissingRouteAuthHeader(_) + | ProxyError::MissingAuth | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired | ProxyError::ApiKeyDisabled @@ -369,7 +377,8 @@ impl ProxyError { pub fn kind(&self) -> &'static str { match self { - ProxyError::MissingAuth + ProxyError::MissingRouteAuthHeader(_) + | ProxyError::MissingAuth | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired | ProxyError::ApiKeyDisabled diff --git a/crates/aisix-proxy/src/passthrough_route.rs b/crates/aisix-proxy/src/passthrough_route.rs index a9512038..809024ac 100644 --- a/crates/aisix-proxy/src/passthrough_route.rs +++ b/crates/aisix-proxy/src/passthrough_route.rs @@ -352,10 +352,7 @@ pub async fn entry( status, elapsed, ); - crate::usage_attr::emit_error_usage_event( - &state, - &snapshot, - "passthrough_route", + let mut event = crate::usage_attr::build_error_usage_event( "passthrough", &request_id, "", @@ -364,6 +361,16 @@ pub async fn entry( error.kind(), &client, ); + // The route matched before the pipeline failed, so a rejected + // request still attributes to it — an operator triaging 401s + // per route needs the name on the event, not just in the log. + event.passthrough_route_name = route_name.clone(); + crate::usage_attr::emit_prepared_usage_event( + &state, + &snapshot, + "passthrough_route", + event, + ); error.into_response() } } @@ -917,7 +924,7 @@ async fn authenticate( .and_then(|v| v.to_str().ok()) .map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string()) .filter(|v| !v.is_empty()) - .ok_or(ProxyError::MissingAuth)?; + .ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?; crate::auth::authenticate_token(state, &token, ctx).await } PassthroughAuthMode::Anonymous => { diff --git a/crates/aisix-proxy/src/usage_attr.rs b/crates/aisix-proxy/src/usage_attr.rs index 95afed50..69481c6a 100644 --- a/crates/aisix-proxy/src/usage_attr.rs +++ b/crates/aisix-proxy/src/usage_attr.rs @@ -354,6 +354,30 @@ pub(crate) fn emit_error_usage_event( error_class: &str, client: &ClientContext, ) { + let event = build_error_usage_event( + inbound_protocol, + request_id, + requested_model, + api_key_id, + status_code, + error_class, + client, + ); + emit_prepared_usage_event(state, snap, label, event); +} + +/// The [`emit_error_usage_event`] event without the emission, for a caller +/// that attributes handler-specific fields (e.g. the passthrough route name) +/// before handing it to [`emit_prepared_usage_event`]. +pub(crate) fn build_error_usage_event( + inbound_protocol: &'static str, + request_id: &str, + requested_model: &str, + api_key_id: &str, + status_code: u16, + error_class: &str, + client: &ClientContext, +) -> UsageEvent { let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), @@ -367,6 +391,15 @@ pub(crate) fn emit_error_usage_event( ..Default::default() }; apply_jwt_identity(&mut event, client.jwt.as_ref()); + event +} + +pub(crate) fn emit_prepared_usage_event( + state: &ProxyState, + snap: &AisixSnapshot, + label: &'static str, + event: UsageEvent, +) { state.usage_sink.try_emit(label, event.clone()); let exporters = live_exporters(state, snap); state diff --git a/schemas/resources/passthrough_route.schema.json b/schemas/resources/passthrough_route.schema.json index 2fcc6f53..9a7c7caa 100644 --- a/schemas/resources/passthrough_route.schema.json +++ b/schemas/resources/passthrough_route.schema.json @@ -240,6 +240,78 @@ ] } }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "auth_mode" + ] + }, + "title": "auth_mode omitted (defaults to gateway_key)" + }, + { + "properties": { + "auth_mode": { + "enum": [ + "gateway_key", + "anonymous" + ] + } + }, + "required": [ + "auth_mode" + ], + "title": "auth_mode: gateway_key or anonymous" + } + ] + }, + "then": { + "not": { + "required": [ + "auth_header_name" + ] + } + }, + "title": "auth_header_name only in header_key mode" + }, + { + "if": { + "anyOf": [ + { + "not": { + "required": [ + "auth_mode" + ] + }, + "title": "auth_mode omitted (defaults to gateway_key)" + }, + { + "properties": { + "auth_mode": { + "enum": [ + "gateway_key", + "header_key" + ] + } + }, + "required": [ + "auth_mode" + ], + "title": "auth_mode: gateway_key or header_key" + } + ] + }, + "then": { + "not": { + "required": [ + "anonymous_key_id" + ] + } + }, + "title": "anonymous_key_id only in anonymous mode" + }, { "if": { "anyOf": [ diff --git a/tests/e2e/src/cases/passthrough-route-e2e.test.ts b/tests/e2e/src/cases/passthrough-route-e2e.test.ts index d7f6c65b..e9676fe1 100644 --- a/tests/e2e/src/cases/passthrough-route-e2e.test.ts +++ b/tests/e2e/src/cases/passthrough-route-e2e.test.ts @@ -281,6 +281,25 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, 410 tombstone // …and the gateway's side-channel headers did not. expect(hit.headers["x-aisix-api-key"]).toBeUndefined(); expect(hit.headers["x-aisix-user"]).toBeUndefined(); + + // Missing gateway key: the 401 must point at the route's configured + // header, not `Authorization` — on this route Authorization carries the + // caller's own upstream credential and is exactly the wrong thing to fix. + const noKey = await harnessRequest(`${app!.proxyUrl}/v1/chat/completions`, { + method: "POST", + headers: { + host: "ai-upstream.example.com", + authorization: "Bearer employee-official-token", + "content-type": "application/json", + }, + body: JSON.stringify({ model: "gpt-4o", messages: [] }), + }); + expect(noKey.statusCode).toBe(401); + const noKeyBody = (await noKey.body.json()) as { + error?: { message?: string }; + }; + expect(noKeyBody.error?.message).toContain("x-aisix-api-key"); + expect(noKeyBody.error?.message).not.toContain("Authorization"); }); test("anonymous route binds the configured principal behind source_cidrs", async (ctx) => {