Skip to content
Open
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
15 changes: 11 additions & 4 deletions crates/aisix-admin/src/a2a_agents_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
//! reject duplicate names (409), generate a uuid v4 on POST, bump revision on
//! PUT. The name is the path segment under which the agent is exposed
//! (`/a2a/<name>`), so it must be a single URL path segment (no `/`). The
//! per-auth_type credential coupling is enforced here too, since the flat
//! schema stays permissive on it.
//! per-auth_type credential coupling is enforced by the canonical schema, so
//! every configuration path rejects an incomplete credential set; the checks
//! below are defense in depth.

use aisix_core::models::validate_a2a_agent;
use aisix_core::resource::ResourceEntry;
Expand Down Expand Up @@ -137,14 +138,20 @@ fn assert_unique_name(

#[cfg(test)]
mod tests {
// These rules moved into the canonical schema (see
// `a2a_agent_credential_coupling` / `mcp_server_credential_coupling` and the
// `name` patterns), so the schema gate now rejects these payloads before
// `decode`'s own checks run. The variant is `Schema` rather than
// `BadRequest`; both map to 400, so the wire contract is unchanged. The
// assertions below check the status-bearing outcome, not the variant.
use super::*;
use serde_json::json;

#[test]
fn decode_rejects_slash_in_name() {
let err = decode(&json!({"display_name": "a/b", "url": "https://x/a2a"}))
.expect_err("`/` in the agent name must be rejected");
assert!(matches!(err, AdminError::BadRequest(_)));
assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST);
}

#[test]
Expand All @@ -155,7 +162,7 @@ mod tests {
"auth_type": "bearer"
}))
.expect_err("bearer auth without a secret must be rejected");
assert!(matches!(err, AdminError::BadRequest(_)));
assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST);
}

#[test]
Expand Down
37 changes: 27 additions & 10 deletions crates/aisix-admin/src/mcp_servers_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,20 @@ fn assert_unique_name(

#[cfg(test)]
mod tests {
// These rules moved into the canonical schema (see
// `a2a_agent_credential_coupling` / `mcp_server_credential_coupling` and the
// `name` patterns), so the schema gate now rejects these payloads before
// `decode`'s own checks run. The variant is `Schema` rather than
// `BadRequest`; both map to 400, so the wire contract is unchanged. The
// assertions below check the status-bearing outcome, not the variant.
use super::*;
use serde_json::json;

#[test]
fn decode_rejects_separator_in_name() {
let err = decode(&json!({"display_name": "a__b", "url": "https://x/mcp"}))
.expect_err("`__` in the server name must be rejected");
assert!(matches!(err, AdminError::BadRequest(_)));
assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST);
}

#[test]
Expand All @@ -215,7 +221,7 @@ mod tests {
"auth_type": "bearer"
}))
.expect_err("bearer auth without a secret must be rejected");
assert!(matches!(err, AdminError::BadRequest(_)));
assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST);
}

#[test]
Expand All @@ -226,7 +232,7 @@ mod tests {
"auth_type": "api_key"
}))
.expect_err("api_key auth without a secret must be rejected");
assert!(matches!(err, AdminError::BadRequest(_)));
assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST);
}

#[test]
Expand All @@ -244,7 +250,7 @@ mod tests {
v.as_object_mut().unwrap().remove(missing);
let err = decode(&v).unwrap_err();
assert!(
matches!(err, AdminError::BadRequest(_)),
err.status() == axum::http::StatusCode::BAD_REQUEST,
"oauth2 without `{missing}` must be a BadRequest"
);
}
Expand Down Expand Up @@ -278,7 +284,7 @@ mod tests {
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("spec")),
err.status() == axum::http::StatusCode::BAD_REQUEST && err.to_string().contains("spec"),
"{err:?}"
);

Expand All @@ -290,8 +296,13 @@ mod tests {
"spec": { "swagger": "2.0", "paths": { "/a": { "get": {} } } }
}))
.unwrap_err();
// The schema gate rejects the `swagger` key before the handler's own
// check runs, so the targeted "convert to OpenAPI 3.x" hint is no longer
// what the caller sees — the pointer names `/spec` instead. The rule is
// intact; only the message is less actionable. See the note on
// `mcp_server_credential_coupling`.
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("OpenAPI 3")),
err.status() == axum::http::StatusCode::BAD_REQUEST && err.to_string().contains("spec"),
"{err:?}"
);

Expand All @@ -307,7 +318,8 @@ mod tests {
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("duplicate tool names")),
err.status() == axum::http::StatusCode::BAD_REQUEST
&& err.to_string().contains("duplicate tool names"),
"{err:?}"
);

Expand All @@ -321,7 +333,8 @@ mod tests {
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("auth_type")),
err.status() == axum::http::StatusCode::BAD_REQUEST
&& err.to_string().contains("auth_type"),
"{err:?}"
);
let err = decode(&json!({
Expand All @@ -335,7 +348,8 @@ mod tests {
}))
.unwrap_err();
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("header name")),
err.status() == axum::http::StatusCode::BAD_REQUEST
&& err.to_string().contains("api_key_header"),
"{err:?}"
);

Expand All @@ -350,8 +364,11 @@ mod tests {
field: value
}))
.unwrap_err();
// The schema names the offending field via its pointer rather
// than explaining that it is openapi-only.
assert!(
matches!(&err, AdminError::BadRequest(m) if m.contains("openapi")),
err.status() == axum::http::StatusCode::BAD_REQUEST
&& err.to_string().contains(field),
"{field}: {err:?}"
);
}
Expand Down
112 changes: 104 additions & 8 deletions crates/aisix-core/src/models/a2a_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,28 @@

use serde::{Deserialize, Serialize};

use serde_json::{json, Value};

use crate::resource::Resource;

#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct A2aAgent {
/// Operator-facing label, unique within the gateway. It is the path segment
/// under which the agent is exposed to callers as `/a2a/<name>`, so it must
/// be a single non-empty URL path segment.
/// be a single non-empty URL path segment. The name is interpolated into the
/// advertised agent-card URL without percent-encoding, so `/`, `?`, `#`, `%`
/// and whitespace are rejected: `a?b` would advertise a URL whose path is
/// just `/a2a/a`, and the lookup is an exact match on the stored name.
// `display_name` is the field's former name; stored documents and
// callers that still use it keep deserializing (schema-side acceptance
// lives in `schema::a2a_agent_root_schema`). Re-serialization always
// emits `name`.
#[serde(alias = "display_name")]
#[schemars(length(min = 1))]
#[schemars(
regex(pattern = "^[^/?#%\\s\\x00-\\x1f\\x7f\u{0080}-\u{009f}]+$"),
length(min = 1)
)]
pub name: String,

/// The upstream agent's base URL, such as `https://agents.example.com/a2a`.
Expand Down Expand Up @@ -58,12 +66,12 @@ pub struct A2aAgent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,

// Cross-field coupling (`bearer`/`api_key` require `secret`) is
// deliberately NOT expressed in this flat schema — that would force
// restructuring the resource into a oneOf. The control plane enforces the
// coupling strictly at write time, this gateway's own Admin API re-checks
// it on write, and the runtime degrades gracefully when a snapshot-loaded
// agent is misconfigured.
// Cross-field coupling (`bearer`/`api_key` require a non-empty `secret`) is
// expressed as an injected `allOf` of `if`/`then` subschemas rather than in
// this flat struct — see `a2a_agent_credential_coupling`. That keeps the
// resource flat (no oneOf restructuring) while giving the published schema
// and every runtime validator one shared definition, so a declarative
// `resources.yaml` and the control plane reject the same documents.
/// Maximum time, in milliseconds, to wait for a single upstream operation,
/// including fetching the agent card or invoking the agent. When omitted,
/// AISIX applies a built-in default.
Expand Down Expand Up @@ -130,10 +138,98 @@ impl Resource for A2aAgent {
}
}

/// The `auth_type` → credential coupling, as a JSON Schema `allOf` that
/// [`crate::models::schema::a2a_agent_root_schema`] injects into the generated
/// schema. `schemars` cannot express a cross-field conditional, so this is the
/// single definition the published schema and every runtime validator share:
/// declaring `bearer` or `api_key` without a non-empty `secret` leaves the
/// gateway sending an empty credential upstream, so it is rejected at load.
pub fn a2a_agent_credential_coupling() -> Value {
json!([
{
"if": { "properties": { "auth_type": { "const": "bearer" } }, "required": ["auth_type"] },
"then": {
"required": ["secret"],
"properties": { "secret": { "type": "string", "minLength": 1 } }
}
},
{
"if": { "properties": { "auth_type": { "const": "api_key" } }, "required": ["auth_type"] },
"then": {
"required": ["secret"],
"properties": { "secret": { "type": "string", "minLength": 1 } }
}
}
])
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn schema_pins_each_a2a_obligation_individually() {
// Every excluded character is pinned, under both accepted spellings, so
// narrowing the pattern back to `^[^/]+$` fails here.
for bad in [
"a/b", "a?b", "a#b", "a%2Fb", "a b", "a\tb", "a\nb", "a\x7fb", "a\u{80}b", "a\u{9f}b",
] {
for key in ["name", "display_name"] {
let doc = json!({key: bad, "url": "https://x/a2a"});
crate::models::schema::validate_a2a_agent(&doc)
.expect_err(&format!("{key}={bad:?} must be rejected"));
}
}
// A plain name is still fine under both spellings.
for key in ["name", "display_name"] {
let doc = json!({key: "invoice-processor", "url": "https://x/a2a"});
crate::models::schema::validate_a2a_agent(&doc).expect("plain name is valid");
}
// Credential obligations: missing, empty and null all fail.
for bad in [None, Some(json!("")), Some(json!(null))] {
let mut doc = json!({"name": "a", "url": "https://x/a2a", "auth_type": "bearer"});
if let Some(v) = bad {
doc["secret"] = v;
}
crate::models::schema::validate_a2a_agent(&doc)
.expect_err("bearer needs a non-empty string secret");
}
}

#[test]
fn schema_rejects_slash_in_name() {
// The name is the `/a2a/<name>` path segment, so a slash would split
// into two segments and route somewhere else entirely.
let doc = json!({"name": "a/b", "url": "https://x/a2a"});
let err = crate::models::schema::validate_a2a_agent(&doc)
.expect_err("a name containing `/` must be rejected");
assert!(err.path.contains("name"), "unexpected path: {}", err.path);
}

#[test]
fn schema_requires_secret_for_bearer_and_api_key() {
for auth in ["bearer", "api_key"] {
let doc = json!({"name": "agent", "url": "https://x/a2a", "auth_type": auth});
crate::models::schema::validate_a2a_agent(&doc)
.expect_err(&format!("{auth} without a secret must be rejected"));

let empty =
json!({"name": "agent", "url": "https://x/a2a", "auth_type": auth, "secret": ""});
crate::models::schema::validate_a2a_agent(&empty)
.expect_err(&format!("{auth} with an empty secret must be rejected"));

let ok = json!({"name": "agent", "url": "https://x/a2a", "auth_type": auth, "secret": "tok"});
crate::models::schema::validate_a2a_agent(&ok)
.expect("a complete credential set must be accepted");
}
}

#[test]
fn schema_accepts_none_auth_without_secret() {
let doc = json!({"name": "agent", "url": "https://x/a2a"});
crate::models::schema::validate_a2a_agent(&doc).expect("auth_type none needs no secret");
}

#[test]
fn deserialises_minimal_a2a_agent() {
let a: A2aAgent = serde_json::from_str(
Expand Down
Loading
Loading