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
92 changes: 78 additions & 14 deletions crates/aisix-core/src/filesource/desugar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
//! - `rate_limit_policies[].scope_ref` — for `scope: api_key` /
//! `scope: model`, a *name* resolved to the referenced entry's derived
//! id; other scopes pass through verbatim.
//! - `rate_limit_policies[].conditions[]` — leaves on the `api_key` /
//! `model` dimensions resolve their value name(s) the same way,
//! recursively through group nodes; other dimensions pass through.
//!
//! Everything this module emits must be exactly a canonical resource
//! document: the caller then runs the same JSON-Schema validators and
Expand Down Expand Up @@ -208,6 +211,12 @@ pub(crate) fn desugar_api_key(doc: &mut Value, env: EnvLookup<'_>) -> Result<(),
/// it with the derived id (the proxy matches policies by entry id).
/// Other scopes (`team`, `member`, `team_member`, or anything the schema
/// will reject later) pass through verbatim.
///
/// Conditional-form rows get the same sugar one level down:
/// `conditions[]` leaves on the `api_key` / `model` dimensions resolve
/// their `value` name(s) to derived ids, recursively through group
/// nodes. `team` / `member` values and the string dimensions
/// (`model_name`, `provider`) pass through verbatim.
pub(crate) fn desugar_rate_limit_policy(
doc: &mut Value,
maps: &IdentityMaps,
Expand All @@ -216,31 +225,86 @@ pub(crate) fn desugar_rate_limit_policy(
Some(o) => o,
None => return Ok(()),
};
let scope_kind = match obj.get("scope").and_then(Value::as_str) {
Some("api_key") => "api_keys",
Some("model") => "models",
if let Some(conditions) = obj.get_mut("conditions") {
desugar_condition_nodes(conditions, maps, 0)?;
}
let (scope_kind, label) = match obj.get("scope").and_then(Value::as_str) {
Some("api_key") => ("api_keys", "api key"),
Some("model") => ("models", "model"),
_ => return Ok(()),
};
let Some(name) = obj.get("scope_ref").and_then(Value::as_str) else {
// Missing / non-string scope_ref: canonical validation reports it.
return Ok(());
};
let resolved = maps
.get(scope_kind)
let resolved = resolve_entity_name(maps, scope_kind, label, name, "`scope_ref`")?;
obj.insert("scope_ref".into(), Value::String(resolved));
Ok(())
}

fn resolve_entity_name(
maps: &IdentityMaps,
kind: &str,
label: &str,
name: &str,
field: &str,
) -> Result<String, String> {
maps.get(kind)
.and_then(|m| m.get(name))
.cloned()
.ok_or_else(|| {
format!(
"`scope_ref` references unknown {} {name:?} ({})",
if scope_kind == "api_keys" {
"api key"
} else {
"model"
},
known_names(maps, scope_kind)
"{field} references unknown {label} {name:?} ({})",
known_names(maps, kind)
)
})?;
obj.insert("scope_ref".into(), Value::String(resolved));
})
}

/// Walk a `conditions` node list and resolve leaf values on the
/// `api_key`/`model` dimensions from names to derived ids. Shapes the
/// schema will reject later (non-array nodes, non-string values) pass
/// through untouched; the depth guard only stops runaway recursion on
/// not-yet-validated input — the real cap is enforced by
/// `validate_semantics`.
fn desugar_condition_nodes(
nodes: &mut Value,
maps: &IdentityMaps,
depth: usize,
) -> Result<(), String> {
if depth > 8 {
return Ok(());
}
let Some(items) = nodes.as_array_mut() else {
return Ok(());
};
for node in items {
let Some(obj) = node.as_object_mut() else {
continue;
};
if let Some(children) = obj.get_mut("children") {
desugar_condition_nodes(children, maps, depth + 1)?;
continue;
}
let (kind, label) = match obj.get("dimension").and_then(Value::as_str) {
Some("api_key") => ("api_keys", "api key"),
Some("model") => ("models", "model"),
_ => continue,
};
let field = format!("`conditions` {label} value");
match obj.get_mut("value") {
Some(Value::String(name)) => {
*name = resolve_entity_name(maps, kind, label, name, &field)?;
}
Some(Value::Array(values)) => {
for value in values {
if let Value::String(name) = value {
*name = resolve_entity_name(maps, kind, label, name, &field)?;
}
}
}
_ => {}
}
}
Ok(())
}

Expand Down
18 changes: 15 additions & 3 deletions crates/aisix-core/src/filesource/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,21 @@ pub fn load_from_str(
}
}
"rate_limit_policies" => {
if let Some(t) = finish(&scope, &entry.doc, validate_rate_limit_policy, &mut errors)
{
rate_limit_policies.push((id, scope, t));
if let Some(t) = finish::<RateLimitPolicy>(
&scope,
&entry.doc,
validate_rate_limit_policy,
&mut errors,
) {
// Semantic caps the schema can't express (condition-tree
// depth/leaf counts, operator×dimension admission, regex
// compilability) — a failing entry is a load error like
// any schema failure.
if let Err(message) = t.validate_semantics() {
errors.push(LoadError { scope, message });
} else {
rate_limit_policies.push((id, scope, t));
}
}
}
"oidc_providers" => {
Expand Down
72 changes: 68 additions & 4 deletions crates/aisix-core/src/filesource/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ rate_limit_policies:
scope_ref: team-uuid-1
window: hour
max_requests: 1000
- name: premium-family
conditions:
- dimension: team
operator: in
value: ["team-uuid-1"]
- logic: or
children:
- dimension: model
operator: in
value: ["gpt-4o"]
- dimension: provider
operator: "=="
value: anthropic
group_by: [member]
limits:
rpm: 20

oidc_providers:
- name: corp-keycloak
Expand Down Expand Up @@ -130,7 +146,7 @@ fn full_valid_file_loads_every_kind() {
assert_eq!(snap.a2a_agents.len(), 1);
assert_eq!(snap.cache_policies.len(), 1);
assert_eq!(snap.observability_exporters.len(), 1);
assert_eq!(snap.rate_limit_policies.len(), 3);
assert_eq!(snap.rate_limit_policies.len(), 4);
assert_eq!(snap.oidc_providers.len(), 1);

// The OIDC provider loads with serde defaults filled.
Expand Down Expand Up @@ -181,13 +197,61 @@ fn full_valid_file_loads_every_kind() {
};
assert_eq!(
by_policy_name("cap-gpt4o").value.scope_ref,
derive_id("models", "gpt-4o"),
Some(derive_id("models", "gpt-4o")),
);
assert_eq!(
by_policy_name("cap-ci-bot").value.scope_ref,
derive_id("api_keys", "ci-bot"),
Some(derive_id("api_keys", "ci-bot")),
);
assert_eq!(by_policy_name("cap-team").value.scope_ref, "team-uuid-1");
assert_eq!(
by_policy_name("cap-team").value.scope_ref.as_deref(),
Some("team-uuid-1")
);

// The conditional form loads with the same name sugar one level
// down: a `model` leaf's values resolve to derived ids; `team` and
// string-dimension values pass through verbatim (AISIX-Cloud#892).
let premium = by_policy_name("premium-family");
assert!(premium.value.is_conditional());
let conditions = serde_json::to_value(premium.value.conditions.as_ref().unwrap()).unwrap();
assert_eq!(conditions[0]["value"][0], "team-uuid-1");
assert_eq!(
conditions[1]["children"][0]["value"][0],
derive_id("models", "gpt-4o")
);
assert_eq!(conditions[1]["children"][1]["value"], "anthropic");
}

#[test]
fn conditional_policy_with_unknown_model_name_is_a_load_error() {
// Same contract as scope_ref: a typo in a conditions model
// reference must fail the load, never become a silently-dead leaf.
let file = r#"
_format_version: "1"

provider_keys:
- display_name: pk
provider: openai
api_key: sk-x
models:
- display_name: gpt-4o
provider: openai
model_name: gpt-4o
provider_key: pk
rate_limit_policies:
- name: bad-ref
conditions:
- dimension: model
operator: in
value: ["no-such-model"]
limits:
rpm: 5
"#;
let errors = errors_of(load(file, &env_of(&[])));
// Exactly one error: the dangling reference itself, so the test
// proves the unknown-model leaf alone fails the load.
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("no-such-model"), "{errors:?}");
}

#[test]
Expand Down
6 changes: 6 additions & 0 deletions crates/aisix-core/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod mcp_server;
pub mod model;
pub mod observability_exporter;
pub mod oidc_provider;
pub mod policy_conditions;
pub mod provider_key;
pub mod rate_limit;
pub mod rate_limit_policy;
Expand Down Expand Up @@ -57,6 +58,11 @@ pub use observability_exporter::{
ObjectStoreProvider, ObservabilityExporter, OtlpHttpConfig, SlsContentMode,
};
pub use oidc_provider::{BoundClaimExpect, OidcProvider};
pub use policy_conditions::{
eval_condition_nodes, validate_condition_nodes, ConditionGroup, ConditionInput, ConditionLogic,
ConditionNode, ConditionOperator, ConditionValue, GroupByDimension, PolicyAction,
PolicyCondition, PolicyDimension,
};
pub use provider_key::{
ParamConstraints, ProviderKey, RequestOverrides, ResponseOverrides, StreamDoneMarker,
TelemetryKind, TelemetryTags,
Expand Down
Loading
Loading