Skip to content
Closed
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
1 change: 1 addition & 0 deletions crates/aisix-core/src/bin/dump-schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fn main() {
"mcp_policy",
"a2a_agent",
"oidc_provider",
"claim_mapping",
] {
dump_value(
&out_dir,
Expand Down
34 changes: 34 additions & 0 deletions crates/aisix-core/src/filesource/desugar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,40 @@ pub(crate) fn desugar_model(doc: &mut Value, maps: &IdentityMaps) -> Result<(),
Ok(())
}

/// `claim_mappings[].resolve.api_key` (name) → `resolve.api_key_id`
/// (derived id).
pub(crate) fn desugar_claim_mapping(doc: &mut Value, maps: &IdentityMaps) -> Result<(), String> {
let Some(resolve) = doc.get_mut("resolve").and_then(Value::as_object_mut) else {
return Ok(()); // missing / mistyped resolve: canonical validation reports it
};
let Some(name_value) = resolve.get("api_key") else {
return Ok(());
};
let Some(name) = name_value.as_str() else {
return Err("`resolve.api_key` must be a string (an api key display_name)".into());
};
if resolve.contains_key("api_key_id") {
return Err(
"`resolve.api_key` (a name reference) and `resolve.api_key_id` are mutually \
exclusive — set exactly one"
.into(),
);
}
let resolved = maps
.get("api_keys")
.and_then(|m| m.get(name))
.cloned()
.ok_or_else(|| {
format!(
"`resolve.api_key` references unknown api key {name:?} ({})",
known_names(maps, "api_keys")
)
})?;
resolve.remove("api_key");
resolve.insert("api_key_id".into(), Value::String(resolved));
Ok(())
}

/// `api_keys[]`: strip the identity-only `display_name`, resolve
/// `key_env` XOR `key_hash`. The plaintext read from the environment is
/// hashed and dropped — it must never surface in errors, logs, or the
Expand Down
71 changes: 64 additions & 7 deletions crates/aisix-core/src/filesource/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ use std::path::Path;
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,
McpServer, Model, ObservabilityExporter, OidcProvider, ProviderKey, RateLimitPolicy,
SchemaError,
validate_a2a_agent, validate_apikey, validate_cache_policy, validate_claim_mapping,
validate_guardrail, validate_mcp_server, validate_model, validate_observability_exporter,
validate_oidc_provider, validate_provider_key, validate_rate_limit_policy, A2aAgent, ApiKey,
CachePolicy, ClaimMapping, Guardrail, McpServer, Model, ObservabilityExporter, OidcProvider,
ProviderKey, RateLimitPolicy, SchemaError,
};
use crate::resource::ResourceEntry;
use crate::AisixSnapshot;
Expand Down Expand Up @@ -130,8 +130,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 +142,7 @@ const KINDS: [(&str, IdentityField); 10] = [
("observability_exporters", IdentityField::Name),
("rate_limit_policies", IdentityField::Name),
("oidc_providers", IdentityField::Name),
("claim_mappings", IdentityField::Name),
];

/// Load `path` into a fresh [`AisixSnapshot`], resolving `${VAR}`
Expand Down Expand Up @@ -346,6 +347,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 claim_mappings: Vec<(String, String, ClaimMapping)> = Vec::new();

for mut entry in prepared {
let id = derive_id(entry.kind, &entry.identity);
Expand All @@ -372,6 +374,7 @@ pub fn load_from_str(
"rate_limit_policies" => {
desugar::desugar_rate_limit_policy(&mut entry.doc, &identity_maps)
}
"claim_mappings" => desugar::desugar_claim_mapping(&mut entry.doc, &identity_maps),
_ => Ok(()),
};
if let Err(message) = sugar_result {
Expand Down Expand Up @@ -448,6 +451,11 @@ pub fn load_from_str(
oidc_providers.push((id, scope, t));
}
}
"claim_mappings" => {
if let Some(t) = finish(&scope, &entry.doc, validate_claim_mapping, &mut errors) {
claim_mappings.push((id, scope, t));
}
}
other => unreachable!("kind {other} is not in KINDS"),
}
}
Expand Down Expand Up @@ -585,6 +593,50 @@ pub fn load_from_str(
}
}

// A claim mapping only ever evaluates against tokens verified by the
// provider it names, so a typo'd `jwt_provider` would make the rule
// silently dead. Resolve the reference at load like any other
// cross-reference. (API keys deliberately allow a dangling
// `jwt_provider`: the binding goes inert but the key still
// authenticates by plaintext. A mapping has no such fallback role.)
let provider_names = identity_maps.get("oidc_providers").unwrap_or(&empty);
let api_key_ids = identity_maps.get("api_keys").unwrap_or(&empty);
for (_, scope, mapping) in &claim_mappings {
if !provider_names.contains_key(&mapping.jwt_provider) {
let mut known: Vec<&str> = provider_names.keys().map(String::as_str).collect();
known.sort_unstable();
errors.push(LoadError {
scope: scope.clone(),
message: format!(
"jwt_provider references unknown OIDC provider {:?} (defined providers: {})",
mapping.jwt_provider,
if known.is_empty() {
"none".to_string()
} else {
known.join(", ")
}
),
});
}
// The `resolve.api_key` name sugar resolves (or errors) in
// desugar; a canonical `resolve.api_key_id` written directly must
// equally land on a key defined in this file, or the mapping
// would silently resolve nothing at runtime.
if !api_key_ids
.values()
.any(|derived| derived == &mapping.resolve.api_key_id)
{
errors.push(LoadError {
scope: scope.clone(),
message: format!(
"resolve.api_key_id {:?} does not match any api key defined in this file — \
reference the key by name via `resolve.api_key` instead",
mapping.resolve.api_key_id
),
});
}
}

// A JWKS/discovery URL must never carry embedded credentials
// (`user:pass@host` or a credential query): JWKS material is public,
// credentials there would only leak (e.g. through a snapshot export).
Expand Down Expand Up @@ -683,6 +735,11 @@ pub fn load_from_str(
.oidc_providers
.insert(ResourceEntry::new(id, v, revision));
}
for (id, _, v) in claim_mappings {
snapshot
.claim_mappings
.insert(ResourceEntry::new(id, v, revision));
}
Ok(snapshot)
}

Expand Down
155 changes: 155 additions & 0 deletions crates/aisix-core/src/filesource/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ oidc_providers:
issuer: https://sso.example.com/realms/agents
audiences: ["aisix-gateway"]
required_scopes: ["ai.access"]

claim_mappings:
- name: finance-dept
jwt_provider: corp-keycloak
priority: 100
match:
- claim: department
op: exact
values: ["finance"]
- claim: groups
op: contains
values: ["ai-users"]
resolve:
api_key: ops
"#;

fn full_env() -> HashMap<String, String> {
Expand All @@ -148,6 +162,7 @@ fn full_valid_file_loads_every_kind() {
assert_eq!(snap.observability_exporters.len(), 1);
assert_eq!(snap.rate_limit_policies.len(), 4);
assert_eq!(snap.oidc_providers.len(), 1);
assert_eq!(snap.claim_mappings.len(), 1);

// The OIDC provider loads with serde defaults filled.
let idp = snap.oidc_providers.get_by_name("corp-keycloak").unwrap();
Expand All @@ -161,6 +176,14 @@ fn full_valid_file_loads_every_kind() {
assert_eq!(ci_bot.value.jwt_subject.as_deref(), Some("agent-ci-bot"));
assert_eq!(ci_bot.value.jwt_provider.as_deref(), Some("corp-keycloak"));

// The claim mapping loads and its `resolve.api_key` name sugar
// resolved to the ops key's derived id.
let cm = snap.claim_mappings.get_by_name("finance-dept").unwrap();
assert_eq!(cm.value.jwt_provider, "corp-keycloak");
assert_eq!(cm.value.priority, 100);
assert_eq!(cm.value.match_.len(), 2);
assert_eq!(cm.value.resolve.api_key_id, derive_id("api_keys", "ops"));

// Interpolation landed in the provider key (full + partial).
let pk = snap.provider_keys.get_by_name("openai-prod").unwrap();
assert_eq!(pk.value.api_key, "sk-upstream");
Expand Down Expand Up @@ -757,3 +780,135 @@ fn report_formats_file_and_all_errors() {
"{text}"
);
}

/// Minimal valid prelude for claim-mapping error tests: one provider
/// key, one model, one api key, one OIDC provider.
const CLAIM_MAPPING_PRELUDE: &str = 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

api_keys:
- display_name: policy-key
key_hash: 91ed2dbc407561556f3e7be98ba0bd2a57986d6a868c482d867d19c6d40d201c
allowed_models: ["gpt-4o"]

oidc_providers:
- name: corp
issuer: https://sso.example.com/realms/agents
audiences: ["aisix"]
"#;

#[test]
fn claim_mapping_with_unknown_provider_is_a_load_error() {
let file = format!(
"{CLAIM_MAPPING_PRELUDE}
claim_mappings:
- name: bad-provider
jwt_provider: no-such-idp
match:
- claim: department
op: exact
values: [\"finance\"]
resolve:
api_key: policy-key
"
);
let errors = errors_of(load(&file, &env_of(&[])));
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("no-such-idp"), "{errors:?}");
assert!(errors[0].contains("corp"), "{errors:?}");
}

#[test]
fn claim_mapping_with_unknown_api_key_name_is_a_load_error() {
let file = format!(
"{CLAIM_MAPPING_PRELUDE}
claim_mappings:
- name: bad-target
jwt_provider: corp
match:
- claim: department
op: exact
values: [\"finance\"]
resolve:
api_key: no-such-key
"
);
let errors = errors_of(load(&file, &env_of(&[])));
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("no-such-key"), "{errors:?}");
}

#[test]
fn claim_mapping_with_raw_unmatched_api_key_id_is_a_load_error() {
// A canonical api_key_id written directly (e.g. copied from a
// managed environment) must still land on a key defined in this
// file — otherwise the mapping would silently resolve nothing.
let file = format!(
"{CLAIM_MAPPING_PRELUDE}
claim_mappings:
- name: raw-id
jwt_provider: corp
match:
- claim: department
op: exact
values: [\"finance\"]
resolve:
api_key_id: 99999999-9999-9999-9999-999999999999
"
);
let errors = errors_of(load(&file, &env_of(&[])));
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("resolve.api_key_id"), "{errors:?}");
}

#[test]
fn claim_mapping_name_and_id_reference_are_mutually_exclusive() {
let file = format!(
"{CLAIM_MAPPING_PRELUDE}
claim_mappings:
- name: both-refs
jwt_provider: corp
match:
- claim: department
op: exact
values: [\"finance\"]
resolve:
api_key: policy-key
api_key_id: 99999999-9999-9999-9999-999999999999
"
);
let errors = errors_of(load(&file, &env_of(&[])));
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("mutually"), "{errors:?}");
}

#[test]
fn claim_mapping_without_conditions_is_a_load_error() {
// An empty `match` list would make the rule match every verified
// token — the schema requires at least one condition so a mapping
// is always an explicit selection.
let file = format!(
"{CLAIM_MAPPING_PRELUDE}
claim_mappings:
- name: match-all
jwt_provider: corp
match: []
resolve:
api_key: policy-key
"
);
let errors = errors_of(load(&file, &env_of(&[])));
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("match"), "{errors:?}");
}
Loading