From 66cfa58363d0011905fb29778314f4d5402a177b Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 11 Aug 2026 18:06:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(auth):=20claim=5Fmappings=20=E2=80=94?= =?UTF-8?q?=20resolve=20verified=20JWT=20claims=20to=20an=20existing=20API?= =?UTF-8?q?=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new `claim_mappings` resource generalises JWT identity resolution beyond the 1:1 (jwt_provider, jwt_subject) key binding: after full OIDC verification, when no key binds the token's subject directly, the enabled mappings for the matched trust provider are evaluated in priority order (ties broken by name) and the first mapping whose claim conditions all hold selects the API key the request runs as — inheriting its model/tool/agent ACLs, rate limits, and budget unchanged. Conditions support `exact` (string claim) and `contains` (array claim) against dotted claim paths; every no-match, dangling reference, or mistyped claim denies. Because many identities can now share one policy key, usage events grow jwt_subject / jwt_provider / jwt_claim_mapping attribution (wire + OTLP span attributes; Datadog/SLS inherit via serde), stamped through one shared helper across every handler family emit path. The resources file loads the new kind with a `resolve.api_key` name sugar (desugared to the derived id like models[].provider_key) and load-time cross-checks for the provider and key references; the snapshot export resugars the id back to the key name. Ref api7/AISIX-Cloud#564 --- crates/aisix-core/src/bin/dump-schema.rs | 1 + crates/aisix-core/src/filesource/desugar.rs | 34 ++ crates/aisix-core/src/filesource/mod.rs | 71 ++- crates/aisix-core/src/filesource/tests.rs | 155 +++++++ crates/aisix-core/src/models/claim_mapping.rs | 232 ++++++++++ crates/aisix-core/src/models/mod.rs | 18 +- crates/aisix-core/src/models/schema.rs | 19 + crates/aisix-core/src/models/snapshot.rs | 7 + crates/aisix-etcd/src/loader.rs | 19 +- crates/aisix-etcd/src/supervisor.rs | 10 + crates/aisix-obs/src/otlp_http_sink.rs | 15 + crates/aisix-obs/src/usage.rs | 23 + crates/aisix-proxy/src/a2a.rs | 3 +- crates/aisix-proxy/src/audio.rs | 1 + crates/aisix-proxy/src/auth.rs | 25 +- crates/aisix-proxy/src/chat.rs | 3 +- crates/aisix-proxy/src/client_ip.rs | 9 + crates/aisix-proxy/src/completions.rs | 1 + crates/aisix-proxy/src/embeddings.rs | 1 + crates/aisix-proxy/src/images.rs | 1 + crates/aisix-proxy/src/jobs.rs | 7 + crates/aisix-proxy/src/jwt.rs | 287 +++++++++++- crates/aisix-proxy/src/lib.rs | 1 + crates/aisix-proxy/src/mcp.rs | 3 +- crates/aisix-proxy/src/messages.rs | 3 +- crates/aisix-proxy/src/passthrough.rs | 1 + crates/aisix-proxy/src/quota.rs | 1 + crates/aisix-proxy/src/realtime.rs | 1 + crates/aisix-proxy/src/rerank.rs | 1 + crates/aisix-proxy/src/responses.rs | 6 +- crates/aisix-proxy/src/usage_attr.rs | 23 +- crates/aisix-proxy/src/videos.rs | 1 + crates/aisix-server/src/export/document.rs | 48 ++ schemas/resources/claim_mapping.schema.json | 120 +++++ tests/e2e/src/cases/claim-mapping-e2e.test.ts | 417 ++++++++++++++++++ tests/e2e/src/harness/seed.ts | 6 + 36 files changed, 1531 insertions(+), 43 deletions(-) create mode 100644 crates/aisix-core/src/models/claim_mapping.rs create mode 100644 schemas/resources/claim_mapping.schema.json create mode 100644 tests/e2e/src/cases/claim-mapping-e2e.test.ts diff --git a/crates/aisix-core/src/bin/dump-schema.rs b/crates/aisix-core/src/bin/dump-schema.rs index 5d9cfe4d..fcf57896 100644 --- a/crates/aisix-core/src/bin/dump-schema.rs +++ b/crates/aisix-core/src/bin/dump-schema.rs @@ -62,6 +62,7 @@ fn main() { "mcp_policy", "a2a_agent", "oidc_provider", + "claim_mapping", ] { dump_value( &out_dir, diff --git a/crates/aisix-core/src/filesource/desugar.rs b/crates/aisix-core/src/filesource/desugar.rs index 3369a12c..40a5a8b2 100644 --- a/crates/aisix-core/src/filesource/desugar.rs +++ b/crates/aisix-core/src/filesource/desugar.rs @@ -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 diff --git a/crates/aisix-core/src/filesource/mod.rs b/crates/aisix-core/src/filesource/mod.rs index df55b175..c80da632 100644 --- a/crates/aisix-core/src/filesource/mod.rs +++ b/crates/aisix-core/src/filesource/mod.rs @@ -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; @@ -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), @@ -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}` @@ -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); @@ -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 { @@ -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"), } } @@ -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). @@ -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) } diff --git a/crates/aisix-core/src/filesource/tests.rs b/crates/aisix-core/src/filesource/tests.rs index 8373e714..acb7f2b6 100644 --- a/crates/aisix-core/src/filesource/tests.rs +++ b/crates/aisix-core/src/filesource/tests.rs @@ -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 { @@ -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(); @@ -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"); @@ -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:?}"); +} diff --git a/crates/aisix-core/src/models/claim_mapping.rs b/crates/aisix-core/src/models/claim_mapping.rs new file mode 100644 index 00000000..a31d36c3 --- /dev/null +++ b/crates/aisix-core/src/models/claim_mapping.rs @@ -0,0 +1,232 @@ +//! `ClaimMapping` entity — a rule mapping verified JWT claims to an +//! existing API key, stored in etcd under `claim_mappings/` +//! (AISIX-Cloud#564). +//! +//! The direct `(jwt_provider, jwt_subject)` binding on an API key admits +//! exactly one pre-registered identity per key. Claim mappings admit a +//! *class* of identities instead: after a token passes the full +//! [`OidcProvider`](super::oidc_provider::OidcProvider) verification and +//! no key binds the token's subject directly, the enabled mappings whose +//! `jwt_provider` names the matched trust provider are evaluated in +//! `priority` order (ties broken by `name`), and the first mapping whose +//! `match` conditions all hold selects the API key named by +//! `resolve.api_key_id`. The request then runs as that key — its model +//! and tool access, rate limits, and budget apply unchanged, and the +//! token's identity claim is recorded for usage attribution. +//! +//! Claims only ever *select* a key an operator already created; no claim +//! value becomes configuration. A token matching no mapping is rejected, +//! never admitted with defaults. + +use serde::{Deserialize, Serialize}; + +use crate::resource::Resource; + +/// How one [`ClaimMatch`] compares the claim's value against `values`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ClaimMatchOp { + /// The claim must be a string equal to one of `values`. An array + /// claim never matches `exact`. + Exact, + /// The claim must be an array of strings containing one of `values`. + /// A string claim never matches `contains`. + Contains, +} + +/// One claim condition. A mapping matches only when every condition +/// holds (logical AND); within one condition, `values` are alternatives +/// (logical OR). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ClaimMatch { + /// Claim to inspect. Dots traverse nested objects (for example + /// `realm_access.roles`). A missing claim never matches. + #[schemars(length(min = 1))] + pub claim: String, + + /// Comparison operator. A claim whose JSON type does not fit the + /// operator (an array for `exact`, a string for `contains`) never + /// matches — mistyped claims deny rather than surprise. + pub op: ClaimMatchOp, + + /// Accepted values; the condition holds when any one matches. + #[schemars(length(min = 1))] + pub values: Vec, +} + +/// What a matched mapping resolves to. Targets always reference +/// existing resources — a dangling reference rejects the request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ClaimResolve { + /// Id of the API key the request runs as. The key's model and tool + /// access, rate limits, and budget apply exactly as if the caller + /// had presented the key itself. + #[schemars(length(min = 1))] + pub api_key_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ClaimMapping { + /// Human-readable mapping name, unique within the environment. + #[schemars(length(min = 1))] + pub name: String, + + /// Name of the OIDC provider whose tokens this mapping applies to. + /// A mapping never matches a token verified by a different + /// provider, so two providers cannot select each other's keys. + #[schemars(length(min = 1))] + pub jwt_provider: String, + + /// Evaluation order among the provider's mappings: lower values are + /// evaluated first, ties are broken by `name`. Defaults to 0. + #[serde(default, skip_serializing_if = "is_zero")] + pub priority: u32, + + /// Claim conditions, all of which must hold for the mapping to + /// match. + #[serde(rename = "match")] + #[schemars(length(min = 1))] + pub match_: Vec, + + /// The API key a matching token resolves to. + pub resolve: ClaimResolve, + + /// Whether the mapping participates in evaluation. A disabled + /// mapping is kept but skipped. Treated as `true` when omitted. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// etcd-key uuid. Filled by the loader and never included in the + /// JSON payload. + #[serde(skip)] + pub(crate) runtime_id: String, +} + +fn default_enabled() -> bool { + true +} + +fn is_zero(v: &u32) -> bool { + *v == 0 +} + +impl Resource for ClaimMapping { + fn id(&self) -> &str { + &self.runtime_id + } + + fn name(&self) -> &str { + &self.name + } + + fn kind() -> &'static str { + "claim_mappings" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialises_minimal_mapping_with_defaults() { + let m: ClaimMapping = serde_json::from_str( + r#"{ + "name": "finance-dept", + "jwt_provider": "corp-keycloak", + "match": [ + {"claim": "department", "op": "exact", "values": ["finance"]} + ], + "resolve": {"api_key_id": "11111111-1111-1111-1111-111111111111"} + }"#, + ) + .unwrap(); + assert_eq!(m.name, "finance-dept"); + assert_eq!(m.jwt_provider, "corp-keycloak"); + assert_eq!(m.priority, 0); + assert_eq!(m.match_.len(), 1); + assert_eq!(m.match_[0].claim, "department"); + assert_eq!(m.match_[0].op, ClaimMatchOp::Exact); + assert_eq!(m.match_[0].values, vec!["finance"]); + assert_eq!(m.resolve.api_key_id, "11111111-1111-1111-1111-111111111111"); + assert!(m.enabled); + } + + #[test] + fn deserialises_full_mapping() { + let m: ClaimMapping = serde_json::from_str( + r#"{ + "name": "mcp-admins", + "jwt_provider": "corp-keycloak", + "priority": 200, + "match": [ + {"claim": "groups", "op": "contains", "values": ["mcp-admin", "platform"]}, + {"claim": "realm_access.department", "op": "exact", "values": ["ai-lab"]} + ], + "resolve": {"api_key_id": "k-admin"}, + "enabled": false + }"#, + ) + .unwrap(); + assert_eq!(m.priority, 200); + assert_eq!(m.match_.len(), 2); + assert_eq!(m.match_[0].op, ClaimMatchOp::Contains); + assert_eq!(m.match_[1].claim, "realm_access.department"); + assert!(!m.enabled); + } + + #[test] + fn tolerates_unknown_fields_for_forward_compat() { + // A newer control plane may ship fields ahead of this DP; serde + // must accept them. The write path still rejects them via the + // strict schema validator (validate_claim_mapping in models/schema.rs). + let m: ClaimMapping = serde_json::from_str( + r#"{ + "name": "x", + "jwt_provider": "p", + "match": [{"claim": "c", "op": "exact", "values": ["v"]}], + "resolve": {"api_key_id": "k"}, + "extra": 1 + }"#, + ) + .unwrap(); + assert_eq!(m.name, "x"); + } + + #[test] + fn defaults_stay_off_the_wire() { + let m: ClaimMapping = serde_json::from_str( + r#"{ + "name": "x", + "jwt_provider": "p", + "match": [{"claim": "c", "op": "exact", "values": ["v"]}], + "resolve": {"api_key_id": "k"} + }"#, + ) + .unwrap(); + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("priority").is_none()); + // enabled serialises with its default value — meaningful to echo + // back through exports, matching OidcProvider. + assert_eq!(v["enabled"], true); + // The matcher list round-trips under the wire name `match`. + assert_eq!(v["match"][0]["op"], "exact"); + } + + #[test] + fn resource_trait_points_at_name_and_kind() { + assert_eq!(ClaimMapping::kind(), "claim_mappings"); + let mut m: ClaimMapping = serde_json::from_str( + r#"{ + "name": "finance-dept", + "jwt_provider": "p", + "match": [{"claim": "c", "op": "exact", "values": ["v"]}], + "resolve": {"api_key_id": "k"} + }"#, + ) + .unwrap(); + m.runtime_id = "cm-1".into(); + assert_eq!(m.id(), "cm-1"); + assert_eq!(m.name(), "finance-dept"); + } +} diff --git a/crates/aisix-core/src/models/mod.rs b/crates/aisix-core/src/models/mod.rs index 4f2eeae6..972f48af 100644 --- a/crates/aisix-core/src/models/mod.rs +++ b/crates/aisix-core/src/models/mod.rs @@ -19,6 +19,7 @@ pub mod a2a_agent; pub mod apikey; pub mod cache_policy; +pub mod claim_mapping; pub mod embedding; pub mod ensemble; pub mod guardrail; @@ -39,6 +40,7 @@ pub mod snapshot; pub use a2a_agent::{A2aAgent, A2aAuthType, A2aProtocolVersion}; pub use apikey::ApiKey; pub use cache_policy::{AppliesTo, CacheBackend, CachePolicy, CacheScope, SemanticCacheConfig}; +pub use claim_mapping::{ClaimMapping, ClaimMatch, ClaimMatchOp, ClaimResolve}; pub use embedding::EmbeddingConfig; pub use ensemble::{EnsembleConfig, Judge, PanelMember}; pub use guardrail::{ @@ -73,14 +75,14 @@ pub use rate_limit_policy::{PolicyScope, PolicyWindow, RateLimitPolicy}; pub use routing::{Routing, RoutingStrategy, RoutingTarget, WhenAllUnavailablePolicy}; pub use schema::{ validate_a2a_agent, validate_a2a_agent_lenient, validate_apikey, validate_apikey_lenient, - validate_cache_policy, validate_cache_policy_lenient, validate_guardrail, - validate_guardrail_attachment, validate_guardrail_attachment_lenient, - validate_guardrail_lenient, validate_mcp_policy, validate_mcp_policy_lenient, - validate_mcp_server, validate_mcp_server_lenient, validate_model, validate_model_lenient, - validate_observability_exporter, validate_observability_exporter_lenient, - validate_oidc_provider, validate_oidc_provider_lenient, validate_provider_key, - validate_provider_key_lenient, validate_rate_limit_policy, validate_rate_limit_policy_lenient, - SchemaError, + validate_cache_policy, validate_cache_policy_lenient, validate_claim_mapping, + validate_claim_mapping_lenient, validate_guardrail, validate_guardrail_attachment, + validate_guardrail_attachment_lenient, validate_guardrail_lenient, validate_mcp_policy, + validate_mcp_policy_lenient, validate_mcp_server, validate_mcp_server_lenient, validate_model, + validate_model_lenient, validate_observability_exporter, + validate_observability_exporter_lenient, validate_oidc_provider, + validate_oidc_provider_lenient, validate_provider_key, validate_provider_key_lenient, + validate_rate_limit_policy, validate_rate_limit_policy_lenient, SchemaError, }; pub use semantic::{ Aggregation, DistanceMetric, EmbeddingFailureMode, OnEmbeddingFailure, Semantic, SemanticMatch, diff --git a/crates/aisix-core/src/models/schema.rs b/crates/aisix-core/src/models/schema.rs index e1169652..93991eff 100644 --- a/crates/aisix-core/src/models/schema.rs +++ b/crates/aisix-core/src/models/schema.rs @@ -58,6 +58,7 @@ pub struct Schemas { pub mcp_policy: Validator, pub a2a_agent: Validator, pub oidc_provider: Validator, + pub claim_mapping: Validator, } pub static SCHEMAS: Lazy> = Lazy::new(|| Arc::new(Schemas::compile(true))); @@ -99,6 +100,7 @@ pub fn resource_root_schema(resource: &str, strict: bool) -> Value { "mcp_policy" => mcp_policy_root_schema(), "a2a_agent" => a2a_agent_root_schema(), "oidc_provider" => oidc_provider_root_schema(), + "claim_mapping" => claim_mapping_root_schema(), other => panic!("unknown resource {other:?}"), }; if strict && closes_on_write(resource) { @@ -127,6 +129,7 @@ impl Schemas { mcp_policy: build("mcp_policy"), a2a_agent: build("a2a_agent"), oidc_provider: build("oidc_provider"), + claim_mapping: build("claim_mapping"), } } } @@ -237,6 +240,10 @@ pub fn validate_oidc_provider(value: &Value) -> Result<(), SchemaError> { validate(&SCHEMAS.oidc_provider, value) } +pub fn validate_claim_mapping(value: &Value) -> Result<(), SchemaError> { + validate(&SCHEMAS.claim_mapping, value) +} + // ---- lenient variants (etcd snapshot loader only, issue #871) ---- // // Unknown fields pass; every other constraint still applies. The loader @@ -291,6 +298,10 @@ pub fn validate_oidc_provider_lenient(value: &Value) -> Result<(), SchemaError> validate(&LENIENT_SCHEMAS.oidc_provider, value) } +pub fn validate_claim_mapping_lenient(value: &Value) -> Result<(), SchemaError> { + validate(&LENIENT_SCHEMAS.claim_mapping, value) +} + /// Build a resource's canonical JSON Schema from its struct via `schemars`, /// the single source of field shapes and per-field constraints. /// @@ -588,6 +599,14 @@ pub fn oidc_provider_root_schema() -> Value { schema } +/// Canonical JSON Schema for the `claim_mapping` resource, derived from +/// the [`ClaimMapping`](crate::models::ClaimMapping) struct. Uses the +/// plain-but-absent `Option` representation (`false`): the resource has +/// no nullable fields, only defaults omitted when unset. +pub fn claim_mapping_root_schema() -> Value { + struct_root_schema::(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 diff --git a/crates/aisix-core/src/models/snapshot.rs b/crates/aisix-core/src/models/snapshot.rs index 71c93001..8db2bce1 100644 --- a/crates/aisix-core/src/models/snapshot.rs +++ b/crates/aisix-core/src/models/snapshot.rs @@ -7,6 +7,7 @@ use super::a2a_agent::A2aAgent; use super::apikey::ApiKey; use super::cache_policy::CachePolicy; +use super::claim_mapping::ClaimMapping; use super::guardrail::{Guardrail, GuardrailAttachment}; use super::mcp_policy::McpPolicy; use super::mcp_server::McpServer; @@ -56,6 +57,11 @@ pub struct AisixSnapshot { /// request to the API key whose `jwt_subject` equals the token's /// identity claim. pub oidc_providers: ResourceTable, + /// Claim-mapping rules: `/aisix//claim_mappings/`. When a + /// verified JWT's subject binds to no API key directly, the enabled + /// rules for the matched trust provider are evaluated in priority + /// order and the first match selects the key the request runs as. + pub claim_mappings: ResourceTable, } impl AisixSnapshot { @@ -78,6 +84,7 @@ impl AisixSnapshot { + self.mcp_policies.len() + self.a2a_agents.len() + self.oidc_providers.len() + + self.claim_mappings.len() } } diff --git a/crates/aisix-etcd/src/loader.rs b/crates/aisix-etcd/src/loader.rs index 11b51442..5cac13b5 100644 --- a/crates/aisix-etcd/src/loader.rs +++ b/crates/aisix-etcd/src/loader.rs @@ -25,10 +25,11 @@ use aisix_core::models::{ validate_a2a_agent_lenient, validate_apikey_lenient, validate_cache_policy_lenient, - validate_guardrail_attachment_lenient, validate_guardrail_lenient, validate_mcp_policy_lenient, - validate_mcp_server_lenient, validate_model_lenient, validate_observability_exporter_lenient, + validate_claim_mapping_lenient, validate_guardrail_attachment_lenient, + validate_guardrail_lenient, validate_mcp_policy_lenient, validate_mcp_server_lenient, + validate_model_lenient, validate_observability_exporter_lenient, validate_oidc_provider_lenient, validate_provider_key_lenient, - validate_rate_limit_policy_lenient, A2aAgent, ApiKey, CachePolicy, Guardrail, + validate_rate_limit_policy_lenient, A2aAgent, ApiKey, CachePolicy, ClaimMapping, Guardrail, GuardrailAttachment, McpPolicy, McpServer, Model, ObservabilityExporter, OidcProvider, ProviderKey, RateLimitPolicy, SchemaError, }; @@ -389,6 +390,18 @@ pub fn build_snapshot(prefix: &str, entries: &[RawEntry]) -> (AisixSnapshot, Bui snapshot.oidc_providers.insert(entry); } } + "claim_mappings" => { + if let Some(entry) = validate_and_parse::( + &raw.key, + raw.revision, + parsed, + &value, + validate_claim_mapping_lenient, + &mut stats, + ) { + snapshot.claim_mappings.insert(entry); + } + } other => { tracing::debug!(key = %raw.key, kind = %other, "unknown etcd kind; skipping"); stats.unknown_kind += 1; diff --git a/crates/aisix-etcd/src/supervisor.rs b/crates/aisix-etcd/src/supervisor.rs index 7f00ff9c..6ca8552e 100644 --- a/crates/aisix-etcd/src/supervisor.rs +++ b/crates/aisix-etcd/src/supervisor.rs @@ -831,6 +831,9 @@ impl Supervisor

{ "oidc_providers" => { new.oidc_providers.remove(parsed.id); } + "claim_mappings" => { + new.claim_mappings.remove(parsed.id); + } _ => {} } new @@ -1165,6 +1168,7 @@ fn merge_snapshot(dst: &AisixSnapshot, src: &AisixSnapshot) { mcp_policies, a2a_agents, oidc_providers, + claim_mappings, } = src; for e in models.entries() { dst.models.insert(clone_entry(&e)); @@ -1202,6 +1206,9 @@ fn merge_snapshot(dst: &AisixSnapshot, src: &AisixSnapshot) { for e in oidc_providers.entries() { dst.oidc_providers.insert(clone_entry(&e)); } + for e in claim_mappings.entries() { + dst.claim_mappings.insert(clone_entry(&e)); + } } /// Whether the snapshot holds an entry for `(kind, id)`. An unknown @@ -1222,6 +1229,7 @@ fn snapshot_has(snap: &AisixSnapshot, kind: &str, id: &str) -> bool { mcp_policies, a2a_agents, oidc_providers, + claim_mappings, } = snap; match kind { "models" => models.get_by_id(id).is_some(), @@ -1236,6 +1244,7 @@ fn snapshot_has(snap: &AisixSnapshot, kind: &str, id: &str) -> bool { "mcp_policies" => mcp_policies.get_by_id(id).is_some(), "a2a_agents" => a2a_agents.get_by_id(id).is_some(), "oidc_providers" => oidc_providers.get_by_id(id).is_some(), + "claim_mappings" => claim_mappings.get_by_id(id).is_some(), _ => false, } } @@ -1269,6 +1278,7 @@ fn resource_counts(snap: &AisixSnapshot) -> BTreeMap { ("mcp_policies", snap.mcp_policies.len()), ("a2a_agents", snap.a2a_agents.len()), ("oidc_providers", snap.oidc_providers.len()), + ("claim_mappings", snap.claim_mappings.len()), ] { if n > 0 { counts.insert(kind.to_string(), n); diff --git a/crates/aisix-obs/src/otlp_http_sink.rs b/crates/aisix-obs/src/otlp_http_sink.rs index 43862725..296ba512 100644 --- a/crates/aisix-obs/src/otlp_http_sink.rs +++ b/crates/aisix-obs/src/otlp_http_sink.rs @@ -716,6 +716,21 @@ fn build_otlp_span(record: &SinkRecord, exporter_name: &str) -> Value { &event.client_user_agent, )); } + // JWT identity attribution (AISIX-Cloud#564): who the request ran as + // when it authenticated with a JWT — the identity behind the (possibly + // shared) api_key_id. + if !event.jwt_subject.is_empty() { + attributes.push(attr_string("aisix.jwt_subject", &event.jwt_subject)); + } + if !event.jwt_provider.is_empty() { + attributes.push(attr_string("aisix.jwt_provider", &event.jwt_provider)); + } + if !event.jwt_claim_mapping.is_empty() { + attributes.push(attr_string( + "aisix.jwt_claim_mapping", + &event.jwt_claim_mapping, + )); + } // Opt-in captured content (#519 B.2) — present ONLY on a record built by // [`content_record`] for a `content_mode = full` exporter. Keys match the // Datadog sink's flattened content fields, so one query vocabulary works diff --git a/crates/aisix-obs/src/usage.rs b/crates/aisix-obs/src/usage.rs index 5b0eb014..e58012ae 100644 --- a/crates/aisix-obs/src/usage.rs +++ b/crates/aisix-obs/src/usage.rs @@ -459,6 +459,29 @@ pub struct UsageEvent { /// Empty for non-A2A events; cp-api stores empty as NULL. #[serde(default, skip_serializing_if = "String::is_empty")] pub a2a_method: String, + + // ─── JWT identity attribution (AISIX-Cloud#564) ─── + /// Value of the OIDC trust provider's identity claim (`sub` by + /// default) when the request authenticated with a JWT. Claim + /// mappings let many external identities share one API key, so + /// `api_key_id` alone can no longer name the caller — this field + /// restores per-identity attribution. Empty for requests + /// authenticated with the key's plaintext; cp-api stores empty as + /// NULL. Older cp-api images ignore it (DP-first rollout). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub jwt_subject: String, + + /// Name of the OIDC trust provider that verified the token. + /// Subjects are only unique per provider, so attribution carries + /// both. Empty for non-JWT events; cp-api stores empty as NULL. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub jwt_provider: String, + + /// Name of the claim mapping that selected the API key. Empty for + /// non-JWT events and for identities bound to their key directly + /// via `jwt_subject`; cp-api stores empty as NULL. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub jwt_claim_mapping: String, } #[inline] diff --git a/crates/aisix-proxy/src/a2a.rs b/crates/aisix-proxy/src/a2a.rs index deabf754..ec8fc36f 100644 --- a/crates/aisix-proxy/src/a2a.rs +++ b/crates/aisix-proxy/src/a2a.rs @@ -532,7 +532,7 @@ fn emit_a2a_usage( status_code: u16, latency: Duration, ) { - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), api_key_id: auth.entry.id.clone(), @@ -546,6 +546,7 @@ fn emit_a2a_usage( a2a_method: method.to_string(), ..Default::default() }; + crate::usage_attr::apply_jwt_identity(&mut event, auth.jwt.as_ref()); state.usage_sink.try_emit("a2a", event.clone()); let snap = state.snapshot.load(); let exporters = snap.observability_exporters.entries(); diff --git a/crates/aisix-proxy/src/audio.rs b/crates/aisix-proxy/src/audio.rs index 34b791ec..5e4639a3 100644 --- a/crates/aisix-proxy/src/audio.rs +++ b/crates/aisix-proxy/src/audio.rs @@ -1462,6 +1462,7 @@ fn emit_usage_event( // responses (AISIX-Cloud#867 parity). crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); // Handler label "audio" — bucketed prometheus counter (#408). + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("audio", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/auth.rs b/crates/aisix-proxy/src/auth.rs index 6a7c1996..90e1a9e4 100644 --- a/crates/aisix-proxy/src/auth.rs +++ b/crates/aisix-proxy/src/auth.rs @@ -19,6 +19,24 @@ use crate::state::ProxyState; #[derive(Debug, Clone)] pub struct AuthenticatedKey { pub entry: Arc>, + /// The external identity behind this request when it authenticated + /// with a JWT instead of the key's plaintext; `None` on the API-key + /// path. Carried for usage attribution — the resolved key alone + /// cannot name the subject once claim mappings let many identities + /// share one key (AISIX-Cloud#564). + pub jwt: Option>, +} + +/// The verified JWT identity a request authenticated as. +#[derive(Debug)] +pub struct JwtIdentity { + /// Value of the trust provider's identity claim (`sub` by default). + pub subject: String, + /// Name of the OIDC provider that verified the token. + pub provider: String, + /// Name of the claim mapping that selected the API key, or `None` + /// when the subject was bound to the key directly via `jwt_subject`. + pub claim_mapping: Option, } /// Per-request context carried onto an authentication denial. @@ -95,6 +113,11 @@ where // (AISIX-Cloud#1112) — which is why every handler declares // `auth: AuthenticatedKey` before `client: ClientContext`. parts.extensions.insert(authed.entry.clone()); + // Same for the JWT identity: `ClientContext` carries it to each + // handler's usage-event emitter for attribution. + if let Some(jwt) = &authed.jwt { + parts.extensions.insert(jwt.clone()); + } Ok(authed) } } @@ -159,7 +182,7 @@ pub(crate) async fn authenticate_token( )); } state.metrics.record_auth_decision("api_key", true, ""); - Ok(AuthenticatedKey { entry }) + Ok(AuthenticatedKey { entry, jwt: None }) } /// Record an API-key denial on the decision metric + log diff --git a/crates/aisix-proxy/src/chat.rs b/crates/aisix-proxy/src/chat.rs index 1fd0ad03..dbd76ffa 100644 --- a/crates/aisix-proxy/src/chat.rs +++ b/crates/aisix-proxy/src/chat.rs @@ -4113,7 +4113,7 @@ fn emit_usage_event( } else { Default::default() }; - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), // RFC 3339 UTC. cp-api parses with time.Parse(time.RFC3339, ...); // chrono's `to_rfc3339_opts(Secs, true)` emits the trailing Z. @@ -4176,6 +4176,7 @@ fn emit_usage_event( // MCP attribution does not apply to the chat path. ..Default::default() }; + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); // Handler label "chat" matches the documented enumeration for // `aisix_usage_events_emitted_total` (#408). Keep `&'static str` // so prometheus cardinality stays bounded. diff --git a/crates/aisix-proxy/src/client_ip.rs b/crates/aisix-proxy/src/client_ip.rs index a15f98df..8832a1d6 100644 --- a/crates/aisix-proxy/src/client_ip.rs +++ b/crates/aisix-proxy/src/client_ip.rs @@ -157,6 +157,11 @@ pub struct ClientContext { /// arranges by declaring `auth` before `client`. Default (empty) on /// the unauthenticated paths; those resolve no `api_key` variable. pub caller: aisix_gateway::CallerIdentity, + /// The verified JWT identity behind the request, for usage + /// attribution (AISIX-Cloud#564). Published by the same auth + /// extractor; `None` when the request authenticated with the key's + /// plaintext. + pub jwt: Option>, } /// Resolve the caller's address from the peer plus the trusted-proxy @@ -232,6 +237,10 @@ where .get::>>() .map(|e| aisix_gateway::CallerIdentity::from_entry(e)) .unwrap_or_default(), + jwt: parts + .extensions + .get::>() + .cloned(), }) } } diff --git a/crates/aisix-proxy/src/completions.rs b/crates/aisix-proxy/src/completions.rs index 2e7da937..b6b855ba 100644 --- a/crates/aisix-proxy/src/completions.rs +++ b/crates/aisix-proxy/src/completions.rs @@ -701,6 +701,7 @@ fn emit_usage_event( ..Default::default() }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("completions", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/embeddings.rs b/crates/aisix-proxy/src/embeddings.rs index 8c537dc7..a2e53c5f 100644 --- a/crates/aisix-proxy/src/embeddings.rs +++ b/crates/aisix-proxy/src/embeddings.rs @@ -668,6 +668,7 @@ fn emit_usage_event( }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); // Handler label "embeddings" — bucketed prometheus counter (#408). + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("embeddings", event.clone()); // Per-env OTLP/HTTP fan-out — same shape as chat.rs:1334. The // snapshot's exporter table is empty for envs that haven't diff --git a/crates/aisix-proxy/src/images.rs b/crates/aisix-proxy/src/images.rs index 861fa435..6455c615 100644 --- a/crates/aisix-proxy/src/images.rs +++ b/crates/aisix-proxy/src/images.rs @@ -506,6 +506,7 @@ fn emit_usage_event( }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); // Handler label "images" — bucketed prometheus counter (#408). + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("images", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index 10f79cc9..bfc0ccd3 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -596,6 +596,7 @@ fn emit_job_usage_event( ..Default::default() }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, &target.pk_entry.id); + crate::usage_attr::apply_jwt_identity(&mut event, auth.jwt.as_ref()); state.usage_sink.try_emit(label, event.clone()); let exporters = snap.observability_exporters.entries(); state @@ -1645,6 +1646,7 @@ fn maybe_attribute_batch( let state = state.clone(); let api_key_id = auth.entry.id.clone(); + let jwt = auth.jwt.clone(); let model_id = target.model_entry.id.clone(); let display_name = target.display_name().to_string(); let cost = target.model_entry.value.cost.clone(); @@ -1659,6 +1661,7 @@ fn maybe_attribute_batch( if let Err(e) = attribute_batch_usage( &state, &api_key_id, + jwt.as_ref(), &model_id, &display_name, cost.as_ref(), @@ -1690,6 +1693,7 @@ fn maybe_attribute_batch( async fn attribute_batch_usage( state: &ProxyState, api_key_id: &str, + jwt: Option<&std::sync::Arc>, model_id: &str, display_name: &str, cost: Option<&aisix_core::models::model::ModelCost>, @@ -1797,6 +1801,9 @@ async fn attribute_batch_usage( ..Default::default() }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, pk_id); + // Attribution names the identity that observed completion — the + // same caller the event's api_key_id already reflects. + crate::usage_attr::apply_jwt_identity(&mut event, jwt); state.usage_sink.try_emit("batch", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/jwt.rs b/crates/aisix-proxy/src/jwt.rs index 08166c6b..68d7dccb 100644 --- a/crates/aisix-proxy/src/jwt.rs +++ b/crates/aisix-proxy/src/jwt.rs @@ -33,14 +33,14 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock, RwLock}; use std::time::{Duration, Instant}; -use aisix_core::models::{BoundClaimExpect, OidcProvider}; +use aisix_core::models::{BoundClaimExpect, ClaimMapping, ClaimMatch, ClaimMatchOp, OidcProvider}; use aisix_core::resource::ResourceEntry; use aisix_core::{AisixSnapshot, ApiKey}; use base64::Engine; use jsonwebtoken::jwk::JwkSet; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; -use crate::auth::AuthenticatedKey; +use crate::auth::{AuthenticatedKey, JwtIdentity}; use crate::error::ProxyError; use crate::state::ProxyState; @@ -180,6 +180,51 @@ fn key_for_subject( found } +/// The highest-priority enabled claim mapping for `provider_name` whose +/// conditions all hold against the verified claims. Candidates are +/// ordered by `priority` (ascending) with `name` as the tie-break, so +/// evaluation is deterministic across replicas and across snapshot +/// updates — the same token always resolves the same mapping. +fn matching_claim_mapping( + snapshot: &AisixSnapshot, + provider_name: &str, + claims: &serde_json::Value, +) -> Option>> { + let mut candidates: Vec<_> = snapshot + .claim_mappings + .entries() + .into_iter() + .filter(|e| e.value.enabled && e.value.jwt_provider == provider_name) + .collect(); + candidates.sort_by(|a, b| { + (a.value.priority, a.value.name.as_str()).cmp(&(b.value.priority, b.value.name.as_str())) + }); + candidates + .into_iter() + .find(|e| e.value.match_.iter().all(|m| claim_match_holds(claims, m))) +} + +/// Whether one claim condition holds. A missing claim, or a claim whose +/// JSON type does not fit the operator, never matches (default deny): +/// `exact` requires a string claim equal to one of the accepted values, +/// `contains` an array of strings containing one of them. +fn claim_match_holds(claims: &serde_json::Value, m: &ClaimMatch) -> bool { + let Some(actual) = nested_claim(claims, &m.claim) else { + return false; + }; + match m.op { + ClaimMatchOp::Exact => actual + .as_str() + .is_some_and(|s| m.values.iter().any(|v| v == s)), + ClaimMatchOp::Contains => actual.as_array().is_some_and(|items| { + items + .iter() + .filter_map(|v| v.as_str()) + .any(|s| m.values.iter().any(|v| v == s)) + }), + } +} + /// Authenticate a JWT-shaped bearer. Called from the auth choke point /// once [`looks_like_jwt`] and [`any_enabled_provider`] both hold, with /// the snapshot the gate already loaded (avoids a second atomic load; @@ -314,20 +359,56 @@ pub(crate) async fn authenticate_jwt( )); }; - let Some(entry) = key_for_subject(snapshot, &prov.name, subject) else { - tracing::warn!( - target: "aisix::auth", - method = "jwt", - reason = "jwt_identity_unmapped", - provider = %clip(&prov.name), - issuer = %clip(&iss), - subject = ?clip(subject), - "rejected inbound JWT: no API key binds this identity to this provider", - ); - state - .metrics - .record_auth_decision("jwt", false, "jwt_identity_unmapped"); - return Err(ProxyError::JwtIdentityUnmapped); + // The direct `(jwt_provider, jwt_subject)` key binding is + // authoritative for its subject — including its disabled/expired + // lifecycle. Claim mappings only admit identities no key binds + // explicitly, so adding a mapping can never reroute (or re-enable) + // an identity an operator pinned to a specific key. + let (entry, claim_mapping) = match key_for_subject(snapshot, &prov.name, subject) { + Some(entry) => (entry, None), + None => match matching_claim_mapping(snapshot, &prov.name, &claims) { + Some(mapping) => { + let Some(entry) = snapshot + .apikeys + .get_by_id(&mapping.value.resolve.api_key_id) + else { + tracing::warn!( + target: "aisix::auth", + method = "jwt", + reason = "claim_mapping_target_missing", + provider = %clip(&prov.name), + issuer = %clip(&iss), + subject = ?clip(subject), + claim_mapping = %clip(&mapping.value.name), + "rejected inbound JWT: the matched claim mapping resolves to \ + an api key that does not exist", + ); + state.metrics.record_auth_decision( + "jwt", + false, + "claim_mapping_target_missing", + ); + return Err(ProxyError::JwtIdentityUnmapped); + }; + (entry, Some(mapping.value.name.clone())) + } + None => { + tracing::warn!( + target: "aisix::auth", + method = "jwt", + reason = "jwt_identity_unmapped", + provider = %clip(&prov.name), + issuer = %clip(&iss), + subject = ?clip(subject), + "rejected inbound JWT: no API key binds this identity to this \ + provider and no claim mapping matched", + ); + state + .metrics + .record_auth_decision("jwt", false, "jwt_identity_unmapped"); + return Err(ProxyError::JwtIdentityUnmapped); + } + }, }; // Same lifecycle enforcement as the API-key path (#933). @@ -358,9 +439,17 @@ pub(crate) async fn authenticate_jwt( issuer = %iss, subject = %subject, api_key_id = %entry.id, + claim_mapping = ?claim_mapping, "jwt authentication succeeded", ); - Ok(AuthenticatedKey { entry }) + Ok(AuthenticatedKey { + entry, + jwt: Some(Arc::new(JwtIdentity { + subject: subject.to_string(), + provider: prov.name.clone(), + claim_mapping, + })), + }) } /// Cap on attacker-controlled token metadata reproduced in the decision @@ -1347,4 +1436,168 @@ jyxumGxNpoIV8LlzsMsaWQ== ); assert!(unverified_issuer("sk-abc").is_none()); } + + fn mapping(json: serde_json::Value) -> ClaimMapping { + serde_json::from_value(json).unwrap() + } + + #[test] + fn claim_match_ops_are_strictly_typed() { + let claims = serde_json::json!({ + "department": "finance", + "groups": ["dev", "mcp-admin", 42], + "realm_access": {"roles": ["agent"]}, + "count": 7, + }); + let m = |claim: &str, op: &str, values: serde_json::Value| -> ClaimMatch { + serde_json::from_value(serde_json::json!({ + "claim": claim, "op": op, "values": values + })) + .unwrap() + }; + + // exact: string equality against any accepted value. + assert!(claim_match_holds( + &claims, + &m("department", "exact", serde_json::json!(["hr", "finance"])) + )); + assert!(!claim_match_holds( + &claims, + &m("department", "exact", serde_json::json!(["hr"])) + )); + // exact never matches an array claim, even one containing the value. + assert!(!claim_match_holds( + &claims, + &m("groups", "exact", serde_json::json!(["mcp-admin"])) + )); + + // contains: array membership; non-string items are ignored. + assert!(claim_match_holds( + &claims, + &m("groups", "contains", serde_json::json!(["mcp-admin"])) + )); + assert!(!claim_match_holds( + &claims, + &m("groups", "contains", serde_json::json!(["ops"])) + )); + // contains never matches a string claim. + assert!(!claim_match_holds( + &claims, + &m("department", "contains", serde_json::json!(["finance"])) + )); + + // Dots traverse nested objects, as everywhere else in JWT config. + assert!(claim_match_holds( + &claims, + &m( + "realm_access.roles", + "contains", + serde_json::json!(["agent"]) + ) + )); + + // Missing claims and non-string/array shapes never match. + assert!(!claim_match_holds( + &claims, + &m("missing", "exact", serde_json::json!(["x"])) + )); + assert!(!claim_match_holds( + &claims, + &m("count", "exact", serde_json::json!(["7"])) + )); + } + + #[test] + fn mapping_selection_is_priority_ordered_and_provider_scoped() { + let snapshot = AisixSnapshot::new(); + let mk = |id: &str, m: serde_json::Value| { + snapshot + .claim_mappings + .insert(ResourceEntry::new(id, mapping(m), 1)); + }; + // Both match `department=finance`; the lower priority value wins. + mk( + "cm-broad", + serde_json::json!({ + "name": "broad", "jwt_provider": "corp", "priority": 200, + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "k-broad"}, + }), + ); + mk( + "cm-narrow", + serde_json::json!({ + "name": "narrow", "jwt_provider": "corp", "priority": 100, + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "k-narrow"}, + }), + ); + // Same priority as `narrow` but later in name order — the tie + // break is deterministic, never insertion order. + mk( + "cm-tie", + serde_json::json!({ + "name": "zz-tie", "jwt_provider": "corp", "priority": 100, + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "k-tie"}, + }), + ); + // Would win on priority, but is disabled. + mk( + "cm-off", + serde_json::json!({ + "name": "off", "jwt_provider": "corp", "priority": 1, "enabled": false, + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "k-off"}, + }), + ); + // Would win on priority, but belongs to another provider. + mk( + "cm-partner", + serde_json::json!({ + "name": "partner-rule", "jwt_provider": "partner", "priority": 1, + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "k-partner"}, + }), + ); + + let claims = serde_json::json!({"department": "finance"}); + assert_eq!( + matching_claim_mapping(&snapshot, "corp", &claims) + .unwrap() + .id, + "cm-narrow" + ); + assert_eq!( + matching_claim_mapping(&snapshot, "partner", &claims) + .unwrap() + .id, + "cm-partner" + ); + // Every condition must hold: a rule with one unmet condition is + // skipped even at the best priority. + let missing = serde_json::json!({"department": "hr"}); + assert!(matching_claim_mapping(&snapshot, "corp", &missing).is_none()); + } + + #[test] + fn mapping_conditions_are_conjunctive() { + let snapshot = AisixSnapshot::new(); + snapshot.claim_mappings.insert(ResourceEntry::new( + "cm-and", + mapping(serde_json::json!({ + "name": "and-rule", "jwt_provider": "corp", + "match": [ + {"claim": "department", "op": "exact", "values": ["finance"]}, + {"claim": "groups", "op": "contains", "values": ["mcp-admin"]}, + ], + "resolve": {"api_key_id": "k-and"}, + })), + 1, + )); + let both = serde_json::json!({"department": "finance", "groups": ["mcp-admin"]}); + let one = serde_json::json!({"department": "finance", "groups": ["dev"]}); + assert!(matching_claim_mapping(&snapshot, "corp", &both).is_some()); + assert!(matching_claim_mapping(&snapshot, "corp", &one).is_none()); + } } diff --git a/crates/aisix-proxy/src/lib.rs b/crates/aisix-proxy/src/lib.rs index 8f342e84..0c5716f3 100644 --- a/crates/aisix-proxy/src/lib.rs +++ b/crates/aisix-proxy/src/lib.rs @@ -3762,6 +3762,7 @@ data: [DONE]\n\n" .unwrap(), 1, )), + jwt: None, }; // Suspended: max_requests=1 would deny the second reservation diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index eeebd4e6..98dc7fc0 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -488,7 +488,7 @@ fn emit_tool_call_usage( guardrail_blocked: bool, guardrail_monitor_hits: Vec, ) { - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), api_key_id: auth.entry.id.clone(), @@ -504,6 +504,7 @@ fn emit_tool_call_usage( guardrail_monitor_hits, ..Default::default() }; + crate::usage_attr::apply_jwt_identity(&mut event, auth.jwt.as_ref()); state.usage_sink.try_emit("mcp", event.clone()); // #698: fan the event out to the per-env OTLP/SLS/Datadog exporters like // every other emitter — pre-fix MCP usage reached only the CP sink, so diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index 67964c55..fc45eb4a 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -2729,7 +2729,7 @@ fn emit_anthropic_usage_event( // #890 req-3: readable provider-key name for the metric label (shared // resolver so chat + messages can't drift). let provider_key_name = crate::usage_attr::provider_key_metric_name(&snap, provider_key_id); - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), model_id: model_id.to_string(), @@ -2769,6 +2769,7 @@ fn emit_anthropic_usage_event( }; // Handler label "messages" — Anthropic /v1/messages inbound // path. Bucketed prometheus counter (#408). + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("messages", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/passthrough.rs b/crates/aisix-proxy/src/passthrough.rs index 0cce25d5..3794157d 100644 --- a/crates/aisix-proxy/src/passthrough.rs +++ b/crates/aisix-proxy/src/passthrough.rs @@ -758,6 +758,7 @@ fn emit_usage_event( ..Default::default() }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("passthrough", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/quota.rs b/crates/aisix-proxy/src/quota.rs index 09efca0b..1bf1544a 100644 --- a/crates/aisix-proxy/src/quota.rs +++ b/crates/aisix-proxy/src/quota.rs @@ -596,6 +596,7 @@ mod tests { key, 1, )), + jwt: None, } } diff --git a/crates/aisix-proxy/src/realtime.rs b/crates/aisix-proxy/src/realtime.rs index c707a9b5..29d83ed2 100644 --- a/crates/aisix-proxy/src/realtime.rs +++ b/crates/aisix-proxy/src/realtime.rs @@ -731,6 +731,7 @@ async fn run_session( ..Default::default() }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, &pk_id); + crate::usage_attr::apply_jwt_identity(&mut event, auth.jwt.as_ref()); state.usage_sink.try_emit("realtime", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/rerank.rs b/crates/aisix-proxy/src/rerank.rs index 592f576e..33fa36b3 100644 --- a/crates/aisix-proxy/src/rerank.rs +++ b/crates/aisix-proxy/src/rerank.rs @@ -661,6 +661,7 @@ fn emit_usage_event( // branded_provider / pk_label / byo_label) ARE populated — same lookup as // chat / messages / responses / embeddings (AISIX-Cloud#867 parity). crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("rerank", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/responses.rs b/crates/aisix-proxy/src/responses.rs index e82f9ed6..77d4f926 100644 --- a/crates/aisix-proxy/src/responses.rs +++ b/crates/aisix-proxy/src/responses.rs @@ -2845,7 +2845,7 @@ fn emit_usage_event( ) { let snap = state.snapshot.load(); let tags = provider_telemetry_tags(&snap, provider_key_id); - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), model_id: model_id.to_string(), @@ -2882,6 +2882,7 @@ fn emit_usage_event( guardrail_monitor_hits, ..Default::default() }; + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("responses", event.clone()); let exporters = snap.observability_exporters.entries(); state @@ -2951,7 +2952,7 @@ fn emit_zero_token_event( ) { let snap = state.snapshot.load(); let tags = provider_telemetry_tags(&snap, provider_key_id); - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), model_id: model_id.to_string(), @@ -2976,6 +2977,7 @@ fn emit_zero_token_event( client_user_agent: client.user_agent.clone(), ..Default::default() }; + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("responses", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-proxy/src/usage_attr.rs b/crates/aisix-proxy/src/usage_attr.rs index d249406a..a0fd6b6b 100644 --- a/crates/aisix-proxy/src/usage_attr.rs +++ b/crates/aisix-proxy/src/usage_attr.rs @@ -116,6 +116,26 @@ pub(crate) fn apply_pk_telemetry( event.byo_label = sanitize_tag(tags.byo_label.unwrap_or_default()); } +/// Stamp the JWT identity attribution fields onto an in-progress +/// UsageEvent (AISIX-Cloud#564). A `None` identity (the API-key path) +/// leaves the fields empty, which skip-serialize to wire NULL. One +/// source of truth for the mapping so the handler family can't drift — +/// same rationale as [`apply_pk_telemetry`]. The values are sanitised +/// like every other externally-influenced tag: the subject is a claim +/// from a verified token, but the identity provider is still not a +/// trusted emitter of control characters or unbounded strings. +pub(crate) fn apply_jwt_identity( + event: &mut UsageEvent, + jwt: Option<&std::sync::Arc>, +) { + let Some(jwt) = jwt else { + return; + }; + event.jwt_subject = sanitize_tag(jwt.subject.clone()); + event.jwt_provider = sanitize_tag(jwt.provider.clone()); + event.jwt_claim_mapping = sanitize_tag(jwt.claim_mapping.clone().unwrap_or_default()); +} + /// Emit ONE zero-token `UsageEvent` for a FAILED request on a non-chat handler /// (completions / embeddings / rerank / audio / images / passthrough / jobs / /// realtime), so the dashboard Logs and budget ledger surface the failure @@ -144,7 +164,7 @@ pub(crate) fn emit_error_usage_event( error_class: &str, client: &ClientContext, ) { - let event = UsageEvent { + let mut event = UsageEvent { request_id: request_id.to_string(), occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), api_key_id: api_key_id.to_string(), @@ -156,6 +176,7 @@ pub(crate) fn emit_error_usage_event( client_user_agent: client.user_agent.clone(), ..Default::default() }; + apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit(label, event.clone()); let snap = state.snapshot.load(); let exporters = snap.observability_exporters.entries(); diff --git a/crates/aisix-proxy/src/videos.rs b/crates/aisix-proxy/src/videos.rs index 34f85f50..87c01ea9 100644 --- a/crates/aisix-proxy/src/videos.rs +++ b/crates/aisix-proxy/src/videos.rs @@ -1982,6 +1982,7 @@ fn emit_submit_usage_event( ..Default::default() }; crate::usage_attr::apply_pk_telemetry(&mut event, &snap, provider_key_id); + crate::usage_attr::apply_jwt_identity(&mut event, client.jwt.as_ref()); state.usage_sink.try_emit("videos", event.clone()); let exporters = snap.observability_exporters.entries(); state diff --git a/crates/aisix-server/src/export/document.rs b/crates/aisix-server/src/export/document.rs index dc80ed66..cac0c449 100644 --- a/crates/aisix-server/src/export/document.rs +++ b/crates/aisix-server/src/export/document.rs @@ -318,6 +318,21 @@ pub fn build_export_document(snapshot: &AisixSnapshot, reveal_secrets: bool) -> ), ); + // claim_mappings — identity: name; `resolve.api_key_id` resugars to + // the referenced key's file name so the reference survives reload. + push_kind( + &mut collections, + "claim_mappings", + emit_entries( + &snapshot.claim_mappings, + |m| m.name.clone(), + "claim_mappings", + &mut diag, + |doc, identity, diag| resugar_claim_resolve(doc, identity, &api_key_names, diag), + |_, _| {}, + ), + ); + // guardrail_attachments are consumed above to decide which guardrails // are gateway-wide; they are not a file collection of their own. @@ -523,6 +538,39 @@ fn resugar_provider_key( } } +/// `claim_mapping.resolve.api_key_id` (etcd id) → `resolve.api_key` +/// (name), the file's sugar form, which the loader resolves back to the +/// key's derived id. Emitting the raw etcd UUID would resolve to nothing +/// at runtime (a silently dead mapping), so a dangling id is blocking. +fn resugar_claim_resolve( + doc: &mut Value, + mapping: &str, + api_key_names: &BTreeMap, + diag: &mut Diagnostics, +) { + let Some(resolve) = doc.get_mut("resolve").and_then(Value::as_object_mut) else { + return; + }; + let Some(id) = resolve + .get("api_key_id") + .and_then(Value::as_str) + .map(str::to_string) + else { + return; + }; + match api_key_names.get(&id) { + Some(name) => { + resolve.remove("api_key_id"); + resolve.insert("api_key".into(), Value::String(name.clone())); + } + None => diag.blocking.push(format!( + "claim_mapping {mapping:?} resolve.api_key_id references api key id {id:?}, which is \ + not among the exported api keys — kept as a raw id (dangling reference in the source \ + data; the file will not load until it is resolved)" + )), + } +} + /// `cache_policy.applies_to = "api_key:"` → the id the file will /// derive for that api_key, so the policy still matches after reload. /// diff --git a/schemas/resources/claim_mapping.schema.json b/schemas/resources/claim_mapping.schema.json new file mode 100644 index 00000000..72a2201c --- /dev/null +++ b/schemas/resources/claim_mapping.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "ClaimMatch": { + "additionalProperties": false, + "description": "One claim condition. A mapping matches only when every condition holds (logical AND); within one condition, `values` are alternatives (logical OR).", + "properties": { + "claim": { + "description": "Claim to inspect. Dots traverse nested objects (for example `realm_access.roles`). A missing claim never matches.", + "minLength": 1, + "type": "string" + }, + "op": { + "allOf": [ + { + "$ref": "#/definitions/ClaimMatchOp" + } + ], + "description": "Comparison operator. A claim whose JSON type does not fit the operator (an array for `exact`, a string for `contains`) never matches — mistyped claims deny rather than surprise." + }, + "values": { + "description": "Accepted values; the condition holds when any one matches.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "claim", + "op", + "values" + ], + "type": "object" + }, + "ClaimMatchOp": { + "description": "How one [`ClaimMatch`] compares the claim's value against `values`.", + "oneOf": [ + { + "description": "The claim must be a string equal to one of `values`. An array claim never matches `exact`.", + "enum": [ + "exact" + ], + "type": "string" + }, + { + "description": "The claim must be an array of strings containing one of `values`. A string claim never matches `contains`.", + "enum": [ + "contains" + ], + "type": "string" + } + ] + }, + "ClaimResolve": { + "additionalProperties": false, + "description": "What a matched mapping resolves to. Targets always reference existing resources — a dangling reference rejects the request.", + "properties": { + "api_key_id": { + "description": "Id of the API key the request runs as. The key's model and tool access, rate limits, and budget apply exactly as if the caller had presented the key itself.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "api_key_id" + ], + "type": "object" + } + }, + "properties": { + "enabled": { + "default": true, + "description": "Whether the mapping participates in evaluation. A disabled mapping is kept but skipped. Treated as `true` when omitted.", + "type": "boolean" + }, + "jwt_provider": { + "description": "Name of the OIDC provider whose tokens this mapping applies to. A mapping never matches a token verified by a different provider, so two providers cannot select each other's keys.", + "minLength": 1, + "type": "string" + }, + "match": { + "description": "Claim conditions, all of which must hold for the mapping to match.", + "items": { + "$ref": "#/definitions/ClaimMatch" + }, + "minItems": 1, + "type": "array" + }, + "name": { + "description": "Human-readable mapping name, unique within the environment.", + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Evaluation order among the provider's mappings: lower values are evaluated first, ties are broken by `name`. Defaults to 0.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "resolve": { + "allOf": [ + { + "$ref": "#/definitions/ClaimResolve" + } + ], + "description": "The API key a matching token resolves to." + } + }, + "required": [ + "jwt_provider", + "match", + "name", + "resolve" + ], + "title": "ClaimMapping", + "type": "object" +} diff --git a/tests/e2e/src/cases/claim-mapping-e2e.test.ts b/tests/e2e/src/cases/claim-mapping-e2e.test.ts new file mode 100644 index 00000000..38e752b5 --- /dev/null +++ b/tests/e2e/src/cases/claim-mapping-e2e.test.ts @@ -0,0 +1,417 @@ +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + agentClaims, + EtcdClient, + pickFreePort, + SeedClient, + spawnApp, + startMockIdp, + startOpenAiUpstream, + waitConfigPropagation, + type MockIdp, + type OpenAiUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: JWT claim mappings — claims → existing API key (AISIX-Cloud#564). +// +// The environment trusts a mock identity provider and defines +// `claim_mappings` rules. Pinned journeys: +// +// 1. A token whose claims match a rule runs as the rule's API key — +// that key's allowed_models apply (allowed model 200, other 403). +// 2. Rules evaluate in priority order (lower first) and the first +// match wins; a disabled rule never matches even at the best +// priority. +// 3. `contains` matches membership in an array claim at a nested +// (dotted) path. +// 4. The direct `(jwt_provider, jwt_subject)` key binding stays +// authoritative: a subject with a bound key never falls through to +// the rules, even when its claims match one. +// 5. A token matching no rule is rejected (`jwt_identity_unmapped`) — +// never an anonymous or default pass. +// 6. A rule resolving to a nonexistent key rejects; a rule resolving +// to a disabled key rejects with `api_key_disabled`. +// 7. Usage events carry the identity: `aisix.jwt_subject` / +// `aisix.jwt_provider` / `aisix.jwt_claim_mapping` span attributes +// (mapping name absent for a directly-bound subject), and plain +// API-key requests carry none of them. +// +// References: +// - RFC 7519 (JWT) §4.1 registered claims +// + +const MODEL = "cm-finance-model"; +const OTHER_MODEL = "cm-admin-model"; + +interface OtlpReceiver { + url: string; + spans: Array>; + close(): Promise; +} + +async function startOtlpReceiver(): Promise { + const spans: Array> = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (c: Buffer) => (raw += c.toString("utf8"))); + req.on("end", () => { + try { + const body = JSON.parse(raw); + for (const rs of body.resourceSpans ?? []) { + for (const ss of rs.scopeSpans ?? []) { + for (const span of ss.spans ?? []) { + const attrs: Record = {}; + for (const a of span.attributes ?? []) { + const v = a.value ?? {}; + attrs[a.key] = + v.stringValue ?? String(v.intValue ?? v.boolValue ?? ""); + } + spans.push(attrs); + } + } + } + } catch { + // ignore malformed bodies — assertions fail on missing spans + } + res.statusCode = 200; + res.end("{}"); + }); + }); + const port = await pickFreePort(); + await new Promise((resolve) => + server.listen(port, "127.0.0.1", resolve), + ); + return { + url: `http://127.0.0.1:${port}/v1/traces`, + spans, + async close() { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +async function waitForSpan( + recv: OtlpReceiver, + requestId: string, + timeoutMs = 10_000, +): Promise> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const hit = recv.spans.find((a) => a["aisix.request_id"] === requestId); + if (hit) return hit; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`no usage span for request_id=${requestId}`); +} + +function chatBody(model = MODEL): string { + return JSON.stringify({ + model, + messages: [{ role: "user", content: "claim mapping probe" }], + }); +} + +async function chat( + app: SpawnedApp, + token: string, + model = MODEL, +): Promise { + return fetch(`${app.proxyUrl}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: chatBody(model), + }); +} + +async function errorCode(res: Response): Promise { + const body = (await res.json()) as { error?: { code?: string } }; + return body.error?.code; +} + +describe("claim mapping e2e: verified claims resolve to an existing api key", () => { + let app: SpawnedApp | undefined; + let upstream: OpenAiUpstream | undefined; + let idp: MockIdp | undefined; + let seed: SeedClient | undefined; + let otlp: OtlpReceiver | undefined; + let etcdReachable = false; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + upstream = await startOpenAiUpstream(); + idp = await startMockIdp(); + otlp = await startOtlpReceiver(); + app = await spawnApp({}); + seed = new SeedClient(etcd, app.etcdPrefix); + + const pk = await seed.createProviderKey({ + display_name: "cm-pk", + api_key: "sk-mock", + api_base: `${upstream.baseUrl}/v1`, + }); + for (const name of [MODEL, OTHER_MODEL]) { + await seed.createModel({ + display_name: name, + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + } + await seed.createObservabilityExporter({ + name: "cm-otlp", + kind: "otlp_http", + endpoint: otlp.url, + }); + + await seed.createOidcProvider({ + name: "mock-idp", + issuer: idp.url, + audiences: ["aisix-gateway"], + jwks_uri: idp.jwksUrl, + }); + + // The finance policy key: MODEL only. Shared by every identity the + // `finance-dept` rule admits. + const financeKey = await seed.createApiKey({ + key_hash: createHash("sha256").update("sk-cm-finance").digest("hex"), + allowed_models: [MODEL], + }); + // The admin policy key: unrestricted. + const adminKey = await seed.createApiKey({ + key_hash: createHash("sha256").update("sk-cm-admin").digest("hex"), + allowed_models: ["*"], + }); + // A disabled policy key — a rule resolving here must reject. + const frozenKey = await seed.createApiKey({ + key_hash: createHash("sha256").update("sk-cm-frozen").digest("hex"), + allowed_models: ["*"], + disabled: true, + }); + // agent-bound has a DIRECT key binding allowing OTHER_MODEL only — + // it must never fall through to the rules even though its claims + // also match `finance-dept`. + await seed.createApiKey({ + key_hash: createHash("sha256").update("sk-cm-bound").digest("hex"), + allowed_models: [OTHER_MODEL], + jwt_subject: "agent-bound", + jwt_provider: "mock-idp", + }); + + // department=finance → the finance policy key. + await seed.createClaimMapping({ + name: "finance-dept", + jwt_provider: "mock-idp", + priority: 100, + match: [{ claim: "department", op: "exact", values: ["finance"] }], + resolve: { api_key_id: financeKey.id }, + }); + // Nested array claim, better priority: platform admins win over the + // department rule when both match. + await seed.createClaimMapping({ + name: "platform-admins", + jwt_provider: "mock-idp", + priority: 50, + match: [ + { + claim: "realm_access.groups", + op: "contains", + values: ["platform-admin"], + }, + ], + resolve: { api_key_id: adminKey.id }, + }); + // Would beat both on priority, but is disabled — must never match. + await seed.createClaimMapping({ + name: "disabled-rule", + jwt_provider: "mock-idp", + priority: 1, + enabled: false, + match: [{ claim: "department", op: "exact", values: ["finance"] }], + resolve: { api_key_id: adminKey.id }, + }); + // A rule whose target key does not exist — fails closed. + await seed.createClaimMapping({ + name: "dead-target", + jwt_provider: "mock-idp", + priority: 10, + match: [{ claim: "department", op: "exact", values: ["ghost"] }], + resolve: { api_key_id: "99999999-9999-9999-9999-999999999999" }, + }); + // A rule resolving to a disabled key — rejected at the same + // lifecycle gate as the direct-binding path. + await seed.createClaimMapping({ + name: "frozen-dept", + jwt_provider: "mock-idp", + priority: 20, + match: [{ claim: "department", op: "exact", values: ["frozen"] }], + resolve: { api_key_id: frozenKey.id }, + }); + + await waitConfigPropagation(async () => { + const res = await chat(app!, idp!.sign(financeClaims())); + await res.text(); + return res.status === 200; + }); + }); + + afterAll(async () => { + await app?.exit(); + await upstream?.close(); + await idp?.close(); + await otlp?.close(); + }); + + function financeClaims( + overrides: Record = {}, + ): Record { + return agentClaims(idp!.url, { + sub: "dev-alice", + department: "finance", + ...overrides, + }); + } + + function requireSetup(ctx: { skip: () => void }): boolean { + if (!etcdReachable || !app || !idp || !seed || !otlp) { + ctx.skip(); + return false; + } + return true; + } + + test("matching claims run as the rule's key — its model ACL applies", async (ctx) => { + if (!requireSetup(ctx)) return; + const ok = await chat(app!, idp!.sign(financeClaims())); + expect(ok.status).toBe(200); + await ok.text(); + + // The finance key allows MODEL only, so the identity cannot reach + // OTHER_MODEL — proof the request really runs under that key, and + // (priority 1) `disabled-rule` → admin key was skipped. + const denied = await chat(app!, idp!.sign(financeClaims()), OTHER_MODEL); + expect(denied.status).toBe(403); + await denied.text(); + }); + + test("lower priority wins when several rules match (nested contains)", async (ctx) => { + if (!requireSetup(ctx)) return; + // Claims match BOTH finance-dept (100) and platform-admins (50) — + // the admin key wins, so OTHER_MODEL is reachable. + const claims = financeClaims({ + sub: "dev-bob", + realm_access: { groups: ["dev", "platform-admin"] }, + }); + const res = await chat(app!, idp!.sign(claims), OTHER_MODEL); + expect(res.status).toBe(200); + await res.text(); + }); + + test("a directly-bound subject never falls through to the rules", async (ctx) => { + if (!requireSetup(ctx)) return; + // agent-bound's claims match finance-dept, but its direct binding + // (OTHER_MODEL only) is authoritative: MODEL is denied… + const viaRule = await chat( + app!, + idp!.sign(financeClaims({ sub: "agent-bound" })), + ); + expect(viaRule.status).toBe(403); + await viaRule.text(); + // …and OTHER_MODEL (which the finance rule's key would deny) works. + const viaBinding = await chat( + app!, + idp!.sign(financeClaims({ sub: "agent-bound" })), + OTHER_MODEL, + ); + expect(viaBinding.status).toBe(200); + await viaBinding.text(); + }); + + test("claims matching no rule are rejected, never defaulted", async (ctx) => { + if (!requireSetup(ctx)) return; + const res = await chat(app!, idp!.sign(financeClaims({ department: "hr" }))); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("jwt_identity_unmapped"); + }); + + test("a rule with a dangling key target fails closed", async (ctx) => { + if (!requireSetup(ctx)) return; + const res = await chat( + app!, + idp!.sign(financeClaims({ department: "ghost" })), + ); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("jwt_identity_unmapped"); + + // The metric distinguishes the misconfiguration from an unmapped + // identity so an operator can find the broken rule. + const metrics = await fetch(`${app!.metricsUrl}/metrics`).then((r) => + r.text(), + ); + expect(metrics).toContain('reason="claim_mapping_target_missing"'); + }); + + test("a rule resolving to a disabled key rejects like the bound path", async (ctx) => { + if (!requireSetup(ctx)) return; + const res = await chat( + app!, + idp!.sign(financeClaims({ department: "frozen" })), + ); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("api_key_disabled"); + }); + + test("usage events attribute the JWT identity and the matched rule", async (ctx) => { + if (!requireSetup(ctx)) return; + const res = await chat(app!, idp!.sign(financeClaims())); + expect(res.status).toBe(200); + const requestId = res.headers.get("x-aisix-call-id"); + expect(requestId).toBeTruthy(); + await res.text(); + + const span = await waitForSpan(otlp!, requestId!); + expect(span["aisix.jwt_subject"]).toBe("dev-alice"); + expect(span["aisix.jwt_provider"]).toBe("mock-idp"); + expect(span["aisix.jwt_claim_mapping"]).toBe("finance-dept"); + }); + + test("a directly-bound identity attributes subject but no rule name", async (ctx) => { + if (!requireSetup(ctx)) return; + const res = await chat( + app!, + idp!.sign(financeClaims({ sub: "agent-bound" })), + OTHER_MODEL, + ); + expect(res.status).toBe(200); + const requestId = res.headers.get("x-aisix-call-id"); + await res.text(); + + const span = await waitForSpan(otlp!, requestId!); + expect(span["aisix.jwt_subject"]).toBe("agent-bound"); + expect(span["aisix.jwt_provider"]).toBe("mock-idp"); + expect(span["aisix.jwt_claim_mapping"]).toBeUndefined(); + }); + + test("plain api-key requests carry no jwt attribution", async (ctx) => { + if (!requireSetup(ctx)) return; + const res = await chat(app!, "sk-cm-admin", OTHER_MODEL); + expect(res.status).toBe(200); + const requestId = res.headers.get("x-aisix-call-id"); + await res.text(); + + const span = await waitForSpan(otlp!, requestId!); + expect(span["aisix.jwt_subject"]).toBeUndefined(); + expect(span["aisix.jwt_provider"]).toBeUndefined(); + expect(span["aisix.jwt_claim_mapping"]).toBeUndefined(); + }); +}); diff --git a/tests/e2e/src/harness/seed.ts b/tests/e2e/src/harness/seed.ts index 7591534c..074604ec 100644 --- a/tests/e2e/src/harness/seed.ts +++ b/tests/e2e/src/harness/seed.ts @@ -77,6 +77,12 @@ export class SeedClient { return this.put("oidc_providers", provider); } + async createClaimMapping( + mapping: Record, + ): Promise<{ id: string; value: Record }> { + return this.put("claim_mappings", mapping); + } + /** * Overwrite the document at `//` — the seed-side * equivalent of an Admin API PUT. Propagation is asynchronous; probe From 93bf621e1ea91f729f1702518effbfb6d402f083 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 11 Aug 2026 18:27:42 +0800 Subject: [PATCH 2/3] fix(auth): fail closed on ambiguous jwt bindings + audit-driven test hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent audit follow-ups: - An AMBIGUOUS (jwt_provider, jwt_subject) binding — two keys claiming one identity — previously fell through to claim-mapping evaluation, weakening the pre-existing fail-closed invariant. key_for_subject now reports ambiguity and the auth path rejects before the rules, with a distinct jwt_binding_ambiguous metrics reason (wire code unchanged). - Mapping evaluation order gains the entry id as a final tie-break, so even duplicate (priority, name) rows (a CP-invariant violation) pick the same rule on every replica. - New e2e: a disabled direct binding is not re-enabled by a matching rule; an ambiguous binding fails closed instead of through the rules. - The propagation probe now pins the LAST-written rule (its error code is distinguishable), closing a slow-CI window where later rules hadn't landed when the first test ran. - The supervisor's per-kind watch guards (apply_put/apply_delete propagate every resource kind) now include claim_mappings. - Export coverage: the real-file-loader round-trip test now carries an api_key + oidc_provider + claim_mapping (resugar → re-resolve), and a dangling resolve.api_key_id is pinned as a blocking diagnostic. --- crates/aisix-etcd/src/supervisor.rs | 24 +++++ crates/aisix-proxy/src/jwt.rs | 90 ++++++++++++++----- .../aisix-server/src/export/document_tests.rs | 79 ++++++++++++++++ tests/e2e/src/cases/claim-mapping-e2e.test.ts | 69 +++++++++++++- 4 files changed, 234 insertions(+), 28 deletions(-) diff --git a/crates/aisix-etcd/src/supervisor.rs b/crates/aisix-etcd/src/supervisor.rs index 6ca8552e..399fa3c3 100644 --- a/crates/aisix-etcd/src/supervisor.rs +++ b/crates/aisix-etcd/src/supervisor.rs @@ -1430,6 +1430,14 @@ mod tests { "issuer": "https://idp.example.com/realms/agents", "audiences": ["aisix-gateway"] }"#; + // A claim mapping created mid-run (AISIX-Cloud#564) — same + // guard: a rule added via watch must be live without a resync. + const VALID_CLAIM_MAPPING: &[u8] = br#"{ + "name": "watch-rule", + "jwt_provider": "watch-idp", + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "ak-1"} + }"#; let provider = Arc::new(FakeProvider::new(vec![], 0)); let sup = Supervisor::new(provider, "/aisix"); @@ -1458,6 +1466,11 @@ mod tests { VALID_OIDC_PROVIDER, "OidcProvider", ), + ( + "/aisix/claim_mappings/cm-1", + VALID_CLAIM_MAPPING, + "ClaimMapping", + ), ] { assert!( sup.apply_put(&entry(key, body, 2)), @@ -1480,6 +1493,7 @@ mod tests { "ObservabilityExporter not merged" ); assert_eq!(snap.oidc_providers.len(), 1, "OidcProvider not merged"); + assert_eq!(snap.claim_mappings.len(), 1, "ClaimMapping not merged"); } #[tokio::test] @@ -1506,6 +1520,13 @@ mod tests { br#"{"name":"idp","issuer":"https://idp.example.com","audiences":["aisix"]}"#, 1, ), + // AISIX-Cloud#564: deleting a claim mapping must reach + // the snapshot, or revoking a rule never takes effect. + entry( + "/aisix/claim_mappings/cm-1", + br#"{"name":"r","jwt_provider":"idp","match":[{"claim":"d","op":"exact","values":["v"]}],"resolve":{"api_key_id":"ak-1"}}"#, + 1, + ), ], 1, )); @@ -1520,6 +1541,9 @@ mod tests { assert!(sup.handle().load().guardrail_attachments.is_empty()); assert!(sup.apply_delete("/aisix/oidc_providers/op-1")); assert!(sup.handle().load().oidc_providers.is_empty()); + assert_eq!(sup.handle().load().claim_mappings.len(), 1); + assert!(sup.apply_delete("/aisix/claim_mappings/cm-1")); + assert!(sup.handle().load().claim_mappings.is_empty()); } #[tokio::test] diff --git a/crates/aisix-proxy/src/jwt.rs b/crates/aisix-proxy/src/jwt.rs index 68d7dccb..ed09e19e 100644 --- a/crates/aisix-proxy/src/jwt.rs +++ b/crates/aisix-proxy/src/jwt.rs @@ -152,19 +152,21 @@ fn provider_for_issuer( found } -/// The API key bound to `subject` **as asserted by `provider_name`**. A -/// key whose `jwt_provider` names a different trust provider is never a -/// candidate: subjects are namespaced by the provider that vouched for -/// them, so a second trusted provider cannot mint a token impersonating -/// the first provider's identity of the same name. Fails closed on -/// ambiguity for the same reason as [`provider_for_issuer`] — the CP -/// enforces `(jwt_provider, jwt_subject)` uniqueness and the file loader -/// rejects duplicates, so this only guards a transient race. +/// The API key bound to `subject` **as asserted by `provider_name`**, +/// plus whether the binding was ambiguous. A key whose `jwt_provider` +/// names a different trust provider is never a candidate: subjects are +/// namespaced by the provider that vouched for them, so a second +/// trusted provider cannot mint a token impersonating the first +/// provider's identity of the same name. Ambiguity (two keys sharing +/// one binding) is surfaced to the caller so it can fail closed rather +/// than fall through to the claim mappings — the CP enforces +/// `(jwt_provider, jwt_subject)` uniqueness and the file loader rejects +/// duplicates, so this only guards a transient race. fn key_for_subject( snapshot: &AisixSnapshot, provider_name: &str, subject: &str, -) -> Option>> { +) -> (Option>>, bool) { let (found, ambiguous) = snapshot.apikeys.find_unique_by(|e| { e.value.jwt_subject.as_deref() == Some(subject) && e.value.jwt_provider.as_deref() == Some(provider_name) @@ -177,14 +179,15 @@ fn key_for_subject( is ambiguous and neither key's limits can be applied", ); } - found + (found, ambiguous) } /// The highest-priority enabled claim mapping for `provider_name` whose /// conditions all hold against the verified claims. Candidates are -/// ordered by `priority` (ascending) with `name` as the tie-break, so -/// evaluation is deterministic across replicas and across snapshot -/// updates — the same token always resolves the same mapping. +/// ordered by `(priority, name, id)` — a total order, so evaluation is +/// deterministic across replicas and across snapshot updates even if a +/// control-plane bug ever produced duplicate names — and the same token +/// always resolves the same mapping. fn matching_claim_mapping( snapshot: &AisixSnapshot, provider_name: &str, @@ -197,7 +200,11 @@ fn matching_claim_mapping( .filter(|e| e.value.enabled && e.value.jwt_provider == provider_name) .collect(); candidates.sort_by(|a, b| { - (a.value.priority, a.value.name.as_str()).cmp(&(b.value.priority, b.value.name.as_str())) + (a.value.priority, a.value.name.as_str(), a.id.as_str()).cmp(&( + b.value.priority, + b.value.name.as_str(), + b.id.as_str(), + )) }); candidates .into_iter() @@ -363,8 +370,28 @@ pub(crate) async fn authenticate_jwt( // authoritative for its subject — including its disabled/expired // lifecycle. Claim mappings only admit identities no key binds // explicitly, so adding a mapping can never reroute (or re-enable) - // an identity an operator pinned to a specific key. - let (entry, claim_mapping) = match key_for_subject(snapshot, &prov.name, subject) { + // an identity an operator pinned to a specific key. An AMBIGUOUS + // binding fails closed here for the same reason: the subject *is* + // bound, just not resolvably, and letting it fall through to the + // mappings would hand a mis-provisioned identity whatever a rule + // grants. + let (bound, ambiguous) = key_for_subject(snapshot, &prov.name, subject); + if ambiguous { + tracing::warn!( + target: "aisix::auth", + method = "jwt", + reason = "jwt_binding_ambiguous", + provider = %clip(&prov.name), + issuer = %clip(&iss), + subject = ?clip(subject), + "rejected inbound JWT: two API keys claim this identity's binding", + ); + state + .metrics + .record_auth_decision("jwt", false, "jwt_binding_ambiguous"); + return Err(ProxyError::JwtIdentityUnmapped); + } + let (entry, claim_mapping) = match bound { Some(entry) => (entry, None), None => match matching_claim_mapping(snapshot, &prov.name, &claims) { Some(mapping) => { @@ -1367,24 +1394,39 @@ jyxumGxNpoIV8LlzsMsaWQ== // the cross-provider impersonation guard (audit H1). mk_key("k-5", Some("agent-1"), Some("partner")); assert_eq!( - key_for_subject(&snapshot, "corp", "agent-1").unwrap().id, + key_for_subject(&snapshot, "corp", "agent-1").0.unwrap().id, "k-1" ); assert_eq!( - key_for_subject(&snapshot, "corp", "agent-2").unwrap().id, + key_for_subject(&snapshot, "corp", "agent-2").0.unwrap().id, "k-3" ); assert_eq!( - key_for_subject(&snapshot, "partner", "agent-1").unwrap().id, + key_for_subject(&snapshot, "partner", "agent-1") + .0 + .unwrap() + .id, "k-5" ); - // No provider match -> no key, even though the subject exists. - assert!(key_for_subject(&snapshot, "unknown", "agent-1").is_none()); - assert!(key_for_subject(&snapshot, "corp", "agent-9").is_none()); + // No provider match -> no key, even though the subject exists — + // and no ambiguity signal either. + assert!(matches!( + key_for_subject(&snapshot, "unknown", "agent-1"), + (None, false) + )); + assert!(matches!( + key_for_subject(&snapshot, "corp", "agent-9"), + (None, false) + )); - // A duplicate (provider, subject) pair -> fail closed. + // A duplicate (provider, subject) pair -> fail closed, and the + // ambiguity is REPORTED so the auth path can reject instead of + // falling through to the claim mappings. mk_key("k-1-dup", Some("agent-1"), Some("corp")); - assert!(key_for_subject(&snapshot, "corp", "agent-1").is_none()); + assert!(matches!( + key_for_subject(&snapshot, "corp", "agent-1"), + (None, true) + )); } #[test] diff --git a/crates/aisix-server/src/export/document_tests.rs b/crates/aisix-server/src/export/document_tests.rs index e8fd5401..c9e90429 100644 --- a/crates/aisix-server/src/export/document_tests.rs +++ b/crates/aisix-server/src/export/document_tests.rs @@ -442,6 +442,40 @@ fn export_output_reloads_through_the_real_file_loader() { serde_json::from_value(attachment("g-1", "env", None)).unwrap(), 1, )); + // A claim mapping whose `resolve.api_key_id` must resugar to the + // key's (synthetic) file name and re-resolve on load — plus the key + // and trust provider it references, so the loader's cross-checks + // hold. + snap.apikeys.insert(ResourceEntry::new( + "ak-1", + serde_json::from_value(json!({ + "key_hash": "91ed2dbc407561556f3e7be98ba0bd2a57986d6a868c482d867d19c6d40d201c", + "allowed_models": ["gpt-4o"] + })) + .unwrap(), + 1, + )); + snap.oidc_providers.insert(ResourceEntry::new( + "op-1", + serde_json::from_value(json!({ + "name": "corp", + "issuer": "https://sso.example.com/realms/agents", + "audiences": ["aisix"] + })) + .unwrap(), + 1, + )); + snap.claim_mappings.insert(ResourceEntry::new( + "cm-1", + serde_json::from_value(json!({ + "name": "finance-dept", + "jwt_provider": "corp", + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "ak-1"} + })) + .unwrap(), + 1, + )); let doc = build_export_document(&snap, false); let yaml = crate::export::yaml_emit::emit_yaml(&doc).expect("emit"); @@ -470,4 +504,49 @@ fn export_output_reloads_through_the_real_file_loader() { let guardrail = loaded.guardrails.get_by_name("log4shell").unwrap(); let value = serde_json::to_value(&guardrail.value).unwrap(); assert_eq!(value["patterns"][0]["value"], json!("${jndi:ldap}")); + + // The claim mapping's key reference resugared to the synthetic file + // name and re-resolved to the id the loader derives for that key — + // the whole reason the exporter cannot emit the raw etcd uuid. + assert_eq!(loaded.oidc_providers.len(), 1); + assert_eq!(loaded.claim_mappings.len(), 1); + let cm = loaded.claim_mappings.get_by_name("finance-dept").unwrap(); + assert_eq!(cm.value.jwt_provider, "corp"); + assert_eq!( + cm.value.resolve.api_key_id, + derive_id("api_keys", "apikey-91ed2dbc40756155") + ); +} + +#[test] +fn dangling_claim_mapping_target_is_kept_and_blocking() { + let snap = AisixSnapshot::new(); + snap.claim_mappings.insert(ResourceEntry::new( + "cm-1", + serde_json::from_value(json!({ + "name": "finance-dept", + "jwt_provider": "corp", + "match": [{"claim": "department", "op": "exact", "values": ["finance"]}], + "resolve": {"api_key_id": "ak-does-not-exist"} + })) + .unwrap(), + 1, + )); + let doc = build_export_document(&snap, false); + let mappings = find(&doc, "claim_mappings"); + assert_eq!(mappings.len(), 1); + // Raw id kept, no name sugar minted for a key that isn't there. + assert_eq!( + mappings[0]["resolve"]["api_key_id"], + json!("ak-does-not-exist") + ); + assert!(mappings[0]["resolve"].get("api_key").is_none()); + // A dangling target makes the file non-loadable → blocking. + assert!( + doc.blocking + .iter() + .any(|w| w.contains("dangling") && w.contains("finance-dept")), + "{:?}", + doc.blocking + ); } diff --git a/tests/e2e/src/cases/claim-mapping-e2e.test.ts b/tests/e2e/src/cases/claim-mapping-e2e.test.ts index 38e752b5..22dee897 100644 --- a/tests/e2e/src/cases/claim-mapping-e2e.test.ts +++ b/tests/e2e/src/cases/claim-mapping-e2e.test.ts @@ -29,7 +29,9 @@ import { // (dotted) path. // 4. The direct `(jwt_provider, jwt_subject)` key binding stays // authoritative: a subject with a bound key never falls through to -// the rules, even when its claims match one. +// the rules, even when its claims match one — including a DISABLED +// binding (rules cannot re-enable a pinned identity) and an +// AMBIGUOUS one (two keys claiming the binding fail closed). // 5. A token matching no rule is rejected (`jwt_identity_unmapped`) — // never an anonymous or default pass. // 6. A rule resolving to a nonexistent key rejects; a rule resolving @@ -207,6 +209,27 @@ describe("claim mapping e2e: verified claims resolve to an existing api key", () jwt_subject: "agent-bound", jwt_provider: "mock-idp", }); + // agent-bound-off is bound to a DISABLED key: the binding must stay + // authoritative (401 api_key_disabled), never fall through to a + // matching rule — a mapping cannot re-enable a pinned identity. + await seed.createApiKey({ + key_hash: createHash("sha256").update("sk-cm-bound-off").digest("hex"), + allowed_models: ["*"], + jwt_subject: "agent-bound-off", + jwt_provider: "mock-idp", + disabled: true, + }); + // agent-dup is bound TWICE (a CP-invariant violation the etcd path + // cannot rule out): the identity is ambiguous and must be rejected, + // never resolved through the rules. + for (const plaintext of ["sk-cm-dup-a", "sk-cm-dup-b"]) { + await seed.createApiKey({ + key_hash: createHash("sha256").update(plaintext).digest("hex"), + allowed_models: ["*"], + jwt_subject: "agent-dup", + jwt_provider: "mock-idp", + }); + } // department=finance → the finance policy key. await seed.createClaimMapping({ @@ -258,10 +281,22 @@ describe("claim mapping e2e: verified claims resolve to an existing api key", () resolve: { api_key_id: frozenKey.id }, }); + // Probe the LAST-written rule (`frozen-dept`, distinguishable by + // its error code flipping from jwt_identity_unmapped to + // api_key_disabled): watch events apply in revision order, so the + // final seed being live implies every earlier one is too. Probing + // the first rule would leave a window where later rules haven't + // landed yet. await waitConfigPropagation(async () => { - const res = await chat(app!, idp!.sign(financeClaims())); - await res.text(); - return res.status === 200; + const res = await chat( + app!, + idp!.sign(financeClaims({ department: "frozen" })), + ); + if (res.status !== 401) { + await res.text(); + return false; + } + return (await errorCode(res)) === "api_key_disabled"; }); }); @@ -337,6 +372,32 @@ describe("claim mapping e2e: verified claims resolve to an existing api key", () await viaBinding.text(); }); + test("a disabled direct binding is not re-enabled by a matching rule", async (ctx) => { + if (!requireSetup(ctx)) return; + // agent-bound-off's claims match finance-dept, but its disabled + // binding stays authoritative. + const res = await chat( + app!, + idp!.sign(financeClaims({ sub: "agent-bound-off" })), + ); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("api_key_disabled"); + }); + + test("an ambiguous direct binding fails closed, not through the rules", async (ctx) => { + if (!requireSetup(ctx)) return; + // agent-dup is bound to two keys; its claims match finance-dept — + // the request must still be rejected. + const res = await chat(app!, idp!.sign(financeClaims({ sub: "agent-dup" }))); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("jwt_identity_unmapped"); + + const metrics = await fetch(`${app!.metricsUrl}/metrics`).then((r) => + r.text(), + ); + expect(metrics).toContain('reason="jwt_binding_ambiguous"'); + }); + test("claims matching no rule are rejected, never defaulted", async (ctx) => { if (!requireSetup(ctx)) return; const res = await chat(app!, idp!.sign(financeClaims({ department: "hr" }))); From 4e30a4632d876998b9bd7d76fa9662f70427e294 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 11 Aug 2026 18:57:53 +0800 Subject: [PATCH 3/3] ci: retrigger checks for 93bf621e (push event was dropped)