Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions crates/aisix-core/src/models/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"] {
Expand Down
60 changes: 60 additions & 0 deletions crates/aisix-obs/src/otlp_http_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/attempt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions crates/aisix-proxy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions crates/aisix-proxy/src/passthrough_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"",
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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 => {
Expand Down
33 changes: 33 additions & 0 deletions crates/aisix-proxy/src/usage_attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions schemas/resources/passthrough_route.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/src/cases/passthrough-route-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down