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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All @@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand Down Expand Up @@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down Expand Up @@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand Down Expand Up @@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand Down Expand Up @@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand Down Expand Up @@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand Down Expand Up @@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand Down Expand Up @@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down Expand Up @@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand Down Expand Up @@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All @@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand Down Expand Up @@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All @@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand Down Expand Up @@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All @@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All @@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand Down Expand Up @@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand Down Expand Up @@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand Down Expand Up @@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down Expand Up @@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading