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
5 changes: 5 additions & 0 deletions crates/aisix-core/src/bin/dump-schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ fn main() {
"oidc_provider",
schema::oidc_provider_root_schema(),
);
dump_value(
&out_dir,
"mcp_auth_settings",
schema::mcp_auth_settings_root_schema(),
);

dump::<EnsembleConfig>(&out_dir, "ensemble");
dump::<RateLimit>(&out_dir, "rate_limit");
Expand Down
7 changes: 7 additions & 0 deletions crates/aisix-core/src/filesource/desugar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ pub(crate) enum IdentityField {
/// `name`, with `display_name` accepted as the alternative spelling
/// (mcp_servers, a2a_agents — mirroring their schemas).
NameOrDisplayName,
/// A fixed constant — the kind is a per-file singleton
/// (mcp_auth_settings) whose entries carry no identity field of
/// their own. Every entry shares the constant, so a second entry
/// trips the pass-1 duplicate check.
Fixed(&'static str),
}

impl IdentityField {
Expand All @@ -70,6 +75,7 @@ impl IdentityField {
Self::DisplayName => "display_name",
Self::Name => "name",
Self::NameOrDisplayName => "name (or display_name)",
Self::Fixed(_) => "the singleton identity",
}
}

Expand All @@ -86,6 +92,7 @@ impl IdentityField {
Self::DisplayName => field("display_name"),
Self::Name => field("name"),
Self::NameOrDisplayName => field("name").or_else(|| field("display_name")),
Self::Fixed(v) => Some(v.to_string()),
}
}
}
Expand Down
42 changes: 38 additions & 4 deletions crates/aisix-core/src/filesource/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ use yaml_rust2::{Yaml, YamlLoader};

use crate::models::{
validate_a2a_agent, validate_apikey, validate_cache_policy, validate_guardrail,
validate_mcp_server, validate_model, validate_observability_exporter, validate_oidc_provider,
validate_provider_key, validate_rate_limit_policy, A2aAgent, ApiKey, CachePolicy, Guardrail,
validate_mcp_auth_settings, validate_mcp_server, validate_model,
validate_observability_exporter, validate_oidc_provider, validate_provider_key,
validate_rate_limit_policy, A2aAgent, ApiKey, CachePolicy, Guardrail, McpAuthSettings,
McpServer, Model, ObservabilityExporter, OidcProvider, ProviderKey, RateLimitPolicy,
SchemaError,
};
Expand Down Expand Up @@ -130,8 +131,8 @@ pub(crate) fn url_has_credentials(url: &str) -> bool {
false
}

/// Fixed processing order for the ten resource collections.
const KINDS: [(&str, IdentityField); 10] = [
/// Fixed processing order for the eleven resource collections.
const KINDS: [(&str, IdentityField); 11] = [
("provider_keys", IdentityField::DisplayName),
("models", IdentityField::DisplayName),
("api_keys", IdentityField::DisplayName),
Expand All @@ -142,6 +143,12 @@ const KINDS: [(&str, IdentityField); 10] = [
("observability_exporters", IdentityField::Name),
("rate_limit_policies", IdentityField::Name),
("oidc_providers", IdentityField::Name),
// Singleton: the fixed identity makes a second entry a duplicate
// at pass 1, enforcing at-most-one row per file.
(
"mcp_auth_settings",
IdentityField::Fixed("mcp_auth_settings"),
),
];

/// Load `path` into a fresh [`AisixSnapshot`], resolving `${VAR}`
Expand Down Expand Up @@ -346,6 +353,7 @@ pub fn load_from_str(
let mut observability_exporters: Vec<(String, String, ObservabilityExporter)> = Vec::new();
let mut rate_limit_policies: Vec<(String, String, RateLimitPolicy)> = Vec::new();
let mut oidc_providers: Vec<(String, String, OidcProvider)> = Vec::new();
let mut mcp_auth_settings: Vec<(String, String, McpAuthSettings)> = Vec::new();

for mut entry in prepared {
let id = derive_id(entry.kind, &entry.identity);
Expand Down Expand Up @@ -436,6 +444,12 @@ pub fn load_from_str(
oidc_providers.push((id, scope, t));
}
}
"mcp_auth_settings" => {
if let Some(t) = finish(&scope, &entry.doc, validate_mcp_auth_settings, &mut errors)
{
mcp_auth_settings.push((id, scope, t));
}
}
other => unreachable!("kind {other} is not in KINDS"),
}
}
Expand Down Expand Up @@ -595,6 +609,21 @@ pub fn load_from_str(
}
}

// Same rule for the MCP OAuth resource URL: it is published verbatim
// on the unauthenticated protected-resource-metadata endpoint, so an
// embedded credential would be world-readable.
for (_, scope, settings) in &mcp_auth_settings {
if url_has_credentials(&settings.resource_url) {
errors.push(LoadError {
scope: scope.clone(),
message: "mcp_auth_settings resource_url must not embed credentials (user \
info or a token query parameter) — protected resource metadata \
is public"
.into(),
});
}
}

// An explicit `provider_key_id` must also resolve: in file mode every
// provider-key id is derived from its name, so any other value is
// guaranteed dangling and would only surface per-request.
Expand Down Expand Up @@ -671,6 +700,11 @@ pub fn load_from_str(
.oidc_providers
.insert(ResourceEntry::new(id, v, revision));
}
for (id, _, v) in mcp_auth_settings {
snapshot
.mcp_auth_settings
.insert(ResourceEntry::new(id, v, revision));
}
Ok(snapshot)
}

Expand Down
49 changes: 49 additions & 0 deletions crates/aisix-core/src/filesource/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,55 @@ oidc_providers:
);
}

#[test]
fn mcp_auth_settings_singleton_loads() {
let contents = r#"
_format_version: "1"
mcp_auth_settings:
- resource_url: https://gw.example.com/mcp
"#;
let snapshot = load(contents, &env_of(&[])).expect("loads");
assert_eq!(snapshot.mcp_auth_settings.len(), 1);
let entry = snapshot.mcp_auth_settings.entries().pop().unwrap();
assert_eq!(entry.value.resource_url, "https://gw.example.com/mcp");
}

#[test]
fn mcp_auth_settings_resource_url_with_credentials_is_a_load_error() {
// The resource URL is served verbatim on the unauthenticated PRM
// endpoint, so embedded credentials must fail the load — same rule
// as OIDC issuer/jwks_uri.
let contents = r#"
_format_version: "1"
mcp_auth_settings:
- resource_url: https://user:s3cret@gw.example.com/mcp
"#;
let errs = errors_of(load(contents, &env_of(&[])));
assert!(
errs.iter()
.any(|e| e.contains("must not embed credentials")),
"{errs:?}"
);
}

#[test]
fn duplicate_mcp_auth_settings_is_a_load_error() {
// The kind is a per-environment singleton: its fixed identity makes
// any second entry a pass-1 duplicate (AISIX-Cloud#1143).
let contents = r#"
_format_version: "1"
mcp_auth_settings:
- resource_url: https://gw.example.com/mcp
- resource_url: https://other.example.com/mcp
"#;
let errs = errors_of(load(contents, &env_of(&[])));
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(
errs[0].contains("duplicate mcp_auth_settings entry"),
"{errs:?}"
);
}

#[test]
fn oidc_url_with_embedded_credentials_is_a_load_error() {
let contents = r#"
Expand Down
87 changes: 87 additions & 0 deletions crates/aisix-core/src/models/mcp_auth_settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! `McpAuthSettings` entity — the environment's inbound MCP OAuth
//! discovery identity, stored in etcd under `mcp_auth_settings/<uuid>`
//! (the control plane keys the singleton row by the environment id).
//!
//! Carrying a valid row activates the `/mcp` OAuth 2.1 resource-server
//! discovery surface (AISIX-Cloud#1143) when at least one enabled
//! [`OidcProvider`](super::oidc_provider::OidcProvider) also exists:
//! the RFC 9728 Protected Resource Metadata document is served under
//! `/.well-known/oauth-protected-resource`, and `/mcp` auth failures
//! carry a `WWW-Authenticate` challenge pointing at it. Without the
//! row the surface stays dormant and behavior is unchanged.
//!
//! At most one row exists per environment; the declarative resources
//! file rejects a document carrying more than one entry at load.

use serde::{Deserialize, Serialize};

use crate::resource::Resource;

#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct McpAuthSettings {
/// Canonical URI of this environment's `/mcp` endpoint, e.g.
/// `https://gw.example.com/mcp`. Published verbatim as the PRM
/// document's `resource` (never derived from the request Host
/// header) and the value the trust providers' `audiences` must
/// include for OAuth-for-MCP tokens to validate. The URL path must
/// be exactly `/mcp` — the gateway's fixed MCP route.
#[schemars(length(min = 1))]
pub resource_url: String,

/// etcd-key uuid. Filled by the loader and never included in the
/// JSON payload.
#[serde(skip)]
pub(crate) runtime_id: String,
}

impl Resource for McpAuthSettings {
fn id(&self) -> &str {
&self.runtime_id
}

/// Fixed identity: the row is a per-environment singleton, so the
/// by-name index key is a constant rather than a user-chosen label.
fn name(&self) -> &str {
"mcp_auth_settings"
}

fn kind() -> &'static str {
"mcp_auth_settings"
}
}

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

#[test]
fn deserialises_minimal_settings() {
let s: McpAuthSettings =
serde_json::from_str(r#"{"resource_url": "https://gw.example.com/mcp"}"#).unwrap();
assert_eq!(s.resource_url, "https://gw.example.com/mcp");
}

#[test]
fn rejects_unknown_fields() {
let r: Result<McpAuthSettings, _> =
serde_json::from_str(r#"{"resource_url": "https://gw.example.com/mcp", "extra": 1}"#);
assert!(r.is_err());
}

#[test]
fn rejects_missing_resource_url() {
let r: Result<McpAuthSettings, _> = serde_json::from_str(r#"{}"#);
assert!(r.is_err());
}

#[test]
fn resource_trait_uses_fixed_identity() {
assert_eq!(McpAuthSettings::kind(), "mcp_auth_settings");
let mut s: McpAuthSettings =
serde_json::from_str(r#"{"resource_url": "https://gw.example.com/mcp"}"#).unwrap();
s.runtime_id = "env-uuid-1".into();
assert_eq!(s.id(), "env-uuid-1");
assert_eq!(s.name(), "mcp_auth_settings");
}
}
8 changes: 5 additions & 3 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub mod cache_policy;
pub mod embedding;
pub mod ensemble;
pub mod guardrail;
pub mod mcp_auth_settings;
pub mod mcp_policy;
pub mod mcp_server;
pub mod model;
Expand All @@ -47,6 +48,7 @@ pub use guardrail::{
GuardrailScopeType, KeywordConfig, KeywordPattern, LakeraConfig, OpenaiModerationConfig,
PiiConfig, PiiCustomPattern, PiiDetectorConfig, PresidioConfig, PresidioEntityConfig,
};
pub use mcp_auth_settings::McpAuthSettings;
pub use mcp_policy::{McpAccess, McpAccessMode, McpPolicy, McpPolicyMode, McpPolicyScope};
pub use mcp_server::{McpAuthType, McpServer, McpServerType, McpTransport};
pub use model::{
Expand All @@ -66,9 +68,9 @@ pub use rate_limit_policy::{PolicyScope, PolicyWindow, RateLimitPolicy};
pub use routing::{Routing, RoutingStrategy, RoutingTarget, WhenAllUnavailablePolicy};
pub use schema::{
validate_a2a_agent, validate_apikey, validate_cache_policy, validate_guardrail,
validate_guardrail_attachment, validate_mcp_policy, validate_mcp_server, validate_model,
validate_observability_exporter, validate_oidc_provider, validate_provider_key,
validate_rate_limit_policy, SchemaError,
validate_guardrail_attachment, validate_mcp_auth_settings, validate_mcp_policy,
validate_mcp_server, validate_model, validate_observability_exporter, validate_oidc_provider,
validate_provider_key, validate_rate_limit_policy, SchemaError,
};
pub use semantic::{
Aggregation, DistanceMetric, EmbeddingFailureMode, OnEmbeddingFailure, Semantic, SemanticMatch,
Expand Down
18 changes: 18 additions & 0 deletions crates/aisix-core/src/models/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub struct Schemas {
pub mcp_policy: Validator,
pub a2a_agent: Validator,
pub oidc_provider: Validator,
pub mcp_auth_settings: Validator,
}

pub static SCHEMAS: Lazy<Arc<Schemas>> = Lazy::new(|| Arc::new(Schemas::compile()));
Expand Down Expand Up @@ -77,6 +78,9 @@ impl Schemas {
oidc_provider: jsonschema::options()
.build(&oidc_provider_root_schema())
.expect("oidc_provider schema is well-formed"),
mcp_auth_settings: jsonschema::options()
.build(&mcp_auth_settings_root_schema())
.expect("mcp_auth_settings schema is well-formed"),
}
}
}
Expand Down Expand Up @@ -155,6 +159,10 @@ pub fn validate_oidc_provider(value: &Value) -> Result<(), SchemaError> {
validate(&SCHEMAS.oidc_provider, value)
}

pub fn validate_mcp_auth_settings(value: &Value) -> Result<(), SchemaError> {
validate(&SCHEMAS.mcp_auth_settings, value)
}

/// Build a resource's canonical JSON Schema from its struct via `schemars`,
/// the single source of field shapes and per-field constraints.
///
Expand Down Expand Up @@ -415,6 +423,16 @@ pub fn oidc_provider_root_schema() -> Value {
schema
}

/// Canonical JSON Schema for the `mcp_auth_settings` resource, derived
/// from the [`McpAuthSettings`](crate::models::McpAuthSettings) struct.
/// One required field (`resource_url`); the singleton-per-environment
/// invariant is enforced by the writers (cp-api keys the row by the
/// environment id; the resources file rejects duplicates at load), not
/// by the document schema.
pub fn mcp_auth_settings_root_schema() -> Value {
struct_root_schema::<crate::models::McpAuthSettings>(false)
}

/// Canonical JSON Schema for the `mcp_policy` resource, derived from the
/// [`McpPolicy`](crate::models::McpPolicy) struct. Uses the nullable `Option`
/// representation (`true`) so `scope_ref` accepts an explicit `null` as
Expand Down
8 changes: 8 additions & 0 deletions crates/aisix-core/src/models/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use super::a2a_agent::A2aAgent;
use super::apikey::ApiKey;
use super::cache_policy::CachePolicy;
use super::guardrail::{Guardrail, GuardrailAttachment};
use super::mcp_auth_settings::McpAuthSettings;
use super::mcp_policy::McpPolicy;
use super::mcp_server::McpServer;
use super::model::Model;
Expand Down Expand Up @@ -56,6 +57,12 @@ pub struct AisixSnapshot {
/// request to the API key whose `jwt_subject` equals the token's
/// identity claim.
pub oidc_providers: ResourceTable<OidcProvider>,
/// The environment's inbound MCP OAuth discovery identity:
/// `/aisix/<env>/mcp_auth_settings/<env-uuid>` — a singleton row
/// (cp-api keys it by the environment id). Together with at least
/// one enabled `oidc_providers` row it activates the `/mcp`
/// RFC 9728 discovery surface (AISIX-Cloud#1143).
pub mcp_auth_settings: ResourceTable<McpAuthSettings>,
}

impl AisixSnapshot {
Expand All @@ -78,6 +85,7 @@ impl AisixSnapshot {
+ self.mcp_policies.len()
+ self.a2a_agents.len()
+ self.oidc_providers.len()
+ self.mcp_auth_settings.len()
}
}

Expand Down
Loading
Loading