Skip to content
Merged
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main]
pull_request:
branches: [main]
# Manual escape hatch: GitHub intermittently fails to materialize
# pull_request-triggered runs for a branch (observed on #936 — the
# synchronize/opened events produced no check suite while every other
# branch's did). Dispatching on the PR ref attaches the same check
# runs to the head commit.
workflow_dispatch:

env:
CARGO_TERM_COLOR: always
Expand Down
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