diff --git a/Cargo.lock b/Cargo.lock index dce67f4e..573970fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,7 @@ dependencies = [ "uuid", "wiremock", "x509-parser", + "yaml-rust2", ] [[package]] diff --git a/crates/aisix-server/Cargo.toml b/crates/aisix-server/Cargo.toml index e9e9a9d2..d66d1bbc 100644 --- a/crates/aisix-server/Cargo.toml +++ b/crates/aisix-server/Cargo.toml @@ -35,6 +35,9 @@ clap.workspace = true config.workspace = true serde.workspace = true serde_json.workspace = true +# `aisix export` emits the resources file with the same YAML library the +# file source parses with, so the output is guaranteed to re-parse. +yaml-rust2.workspace = true anyhow.workspace = true tracing.workspace = true etcd-client.workspace = true diff --git a/crates/aisix-server/src/export/document.rs b/crates/aisix-server/src/export/document.rs new file mode 100644 index 00000000..bafec3fb --- /dev/null +++ b/crates/aisix-server/src/export/document.rs @@ -0,0 +1,603 @@ +//! Canonical etcd snapshot → resources-file document (the inverse of +//! `aisix_core::filesource`). +//! +//! For each resource kind the exporter re-emits every entry through the +//! same typed model the loader decodes, then rewrites it into idiomatic +//! file form: +//! +//! - **id-stripping** — canonical documents carry no `id` (the typed +//! models `#[serde(skip)]` their runtime id); the file derives every id +//! from the entry's name, so nothing to strip beyond what serialization +//! already omits. +//! - **reference resugaring** — a model's `provider_key_id` becomes +//! `provider_key: `; a rate-limit policy's `scope_ref` +//! for `api_key` / `model` scopes becomes the referenced entry's name +//! (team / member / team_member scopes pass through). A reference that +//! resolves to no entry in the export set is kept verbatim and a +//! warning is raised — a dangling reference is a real data issue, not +//! something to hide. +//! - **api-key identity** — canonical api-key documents have no name, but +//! the file keys every entry by one; a deterministic `apikey-` +//! display_name is synthesized from the already-safe `key_hash`. +//! - **secret redaction** — see [`super::secrets`]. +//! +//! Two entries of one kind that would collapse to the same file identity +//! raise a warning: the file cannot represent both (identities are unique +//! per kind), so surfacing the collision beats silently dropping one. + +use std::collections::{BTreeMap, BTreeSet}; + +use aisix_core::models::GuardrailScopeType; +use aisix_core::AisixSnapshot; +use serde::Serialize; +use serde_json::Value; + +use super::secrets::{ + redact_by_key, redact_headers, redact_string_map, redact_top_level, RedactionCtx, + SecretPlaceholder, +}; + +/// Guardrail credential field names, at any nesting depth, that are +/// always secrets in the guardrail schema. +const GUARDRAIL_SECRET_KEYS: &[&str] = &["api_key", "access_key_secret", "secret_access_key"]; + +/// Issues surfaced during a build, split by whether they leave the +/// exported file loadable. `blocking` issues mean the file cannot be +/// loaded back as-is (an identity collision or a dangling reference the +/// loader rejects); the command exits non-zero so a scripted migration +/// can't mistake a broken file for a finished one. `warnings` are +/// everything the operator should see but that still yields a loadable +/// file (a scope-changing guardrail omitted, an inert cache scope, a +/// placeholder-variable collision). +#[derive(Default)] +struct Diagnostics { + warnings: Vec, + blocking: Vec, +} + +/// The assembled export, ready to emit as YAML plus the side-channel +/// information the command reports on stderr. +pub struct ExportDocument { + /// `(kind, entries)` in the file's fixed collection order; only + /// non-empty kinds are included. + pub collections: Vec<(&'static str, Vec)>, + /// Placeholders substituted for live credentials (empty when + /// `reveal_secrets` is set). + pub secret_placeholders: Vec, + /// Issues that still yield a loadable file (omitted scope-changing + /// guardrails, inert cache scopes, placeholder collisions). + pub warnings: Vec, + /// Issues that leave the file non-loadable (identity collisions, + /// dangling references the loader rejects). Non-empty → the command + /// writes the file for inspection but exits non-zero. + pub blocking: Vec, +} + +/// Build the resources-file document from a decoded etcd snapshot. +pub fn build_export_document(snapshot: &AisixSnapshot, reveal_secrets: bool) -> ExportDocument { + let mut diag = Diagnostics::default(); + let mut placeholders = Vec::new(); + + // Reference resolution maps: etcd id → the name the file keys the + // entry by. Built once so every resugared reference resolves against + // the same identities the entries are emitted under. + let provider_key_names = id_to_name(&snapshot.provider_keys, |pk| pk.display_name.clone()); + let model_names = id_to_name(&snapshot.models, |m| m.display_name.clone()); + let api_key_names = id_to_name(&snapshot.apikeys, |k| synthetic_api_key_name(&k.key_hash)); + + let mut collections: Vec<(&'static str, Vec)> = Vec::new(); + + // provider_keys — identity: display_name; secret: api_key. + push_kind( + &mut collections, + "provider_keys", + emit_entries( + &snapshot.provider_keys, + |pk| pk.display_name.clone(), + "provider_keys", + &mut diag, + |_, _, _| {}, + |doc, identity| { + let mut ctx = RedactionCtx { + kind_token: "PROVIDER_KEY", + kind: "provider_keys", + identity, + reveal: reveal_secrets, + out: &mut placeholders, + }; + redact_top_level(doc, "api_key", &mut ctx); + // `request.default_headers` are outbound auth headers to the + // upstream and `request.default_body_fields` can carry a + // secondary credential — neither is covered by `api_key`, and + // the OTLP-exporter `headers` map (same shape) is redacted, so + // these must be too. String values only (a `safe_prompt: true` + // flag stays intact). + if let Some(request) = doc.get_mut("request") { + redact_string_map(request, "default_headers", "default header", &mut ctx); + redact_string_map( + request, + "default_body_fields", + "default body field", + &mut ctx, + ); + } + }, + ), + ); + + // models — identity: display_name; resugar provider_key_id → provider_key. + push_kind( + &mut collections, + "models", + emit_entries( + &snapshot.models, + |m| m.display_name.clone(), + "models", + &mut diag, + |doc, identity, diag| resugar_provider_key(doc, identity, &provider_key_names, diag), + |_, _| {}, + ), + ); + + // api_keys — identity: synthesized display_name; key_hash emitted verbatim. + push_kind( + &mut collections, + "api_keys", + emit_entries( + &snapshot.apikeys, + |k| synthetic_api_key_name(&k.key_hash), + "api_keys", + &mut diag, + |doc, identity, _warnings| { + if let Value::Object(map) = doc { + map.insert("display_name".into(), Value::String(identity.to_string())); + } + }, + |_, _| {}, + ), + ); + + // guardrails — identity: name; recursive credential redaction. + // + // The file has no attachment collection, so every file-defined + // guardrail applies gateway-wide. Only a guardrail already gateway-wide + // in etcd (via an env-scoped attachment) round-trips without changing + // its effect; an attachment-scoped or unattached guardrail would widen + // to ALL traffic on import (or activate a rule that was never attached). + // Those are omitted with a warning — the file still loads without them. + let gateway_wide = gateway_wide_guardrail_ids(snapshot); + let mut gateway_wide_names: BTreeSet = BTreeSet::new(); + for entry in snapshot.guardrails.entries() { + if gateway_wide.contains(&entry.id) { + gateway_wide_names.insert(entry.value.name.clone()); + } else { + diag.warnings.push(format!( + "guardrail {:?} is attachment-scoped or unattached in etcd; the resources file \ + has no attachment collection, so exporting it would apply it to ALL traffic — \ + omitted. Re-declare it in the file only if a gateway-wide rule is intended.", + entry.value.name + )); + } + } + let mut guardrails = emit_entries( + &snapshot.guardrails, + |g| g.name.clone(), + "guardrails", + &mut diag, + |_, _, _| {}, + |doc, identity| { + let mut ctx = RedactionCtx { + kind_token: "GUARDRAIL", + kind: "guardrails", + identity, + reveal: reveal_secrets, + out: &mut placeholders, + }; + redact_by_key(doc, GUARDRAIL_SECRET_KEYS, &mut ctx); + }, + ); + guardrails.retain(|v| { + v.get("name") + .and_then(Value::as_str) + .is_some_and(|n| gateway_wide_names.contains(n)) + }); + push_kind(&mut collections, "guardrails", guardrails); + + // mcp_servers — identity: name; secret: secret. + push_kind( + &mut collections, + "mcp_servers", + emit_entries( + &snapshot.mcp_servers, + |s| s.name.clone(), + "mcp_servers", + &mut diag, + |_, _, _| {}, + |doc, identity| { + let mut ctx = RedactionCtx { + kind_token: "MCP_SERVER", + kind: "mcp_servers", + identity, + reveal: reveal_secrets, + out: &mut placeholders, + }; + redact_top_level(doc, "secret", &mut ctx); + }, + ), + ); + + // a2a_agents — identity: name; secret: secret. + push_kind( + &mut collections, + "a2a_agents", + emit_entries( + &snapshot.a2a_agents, + |a| a.name.clone(), + "a2a_agents", + &mut diag, + |_, _, _| {}, + |doc, identity| { + let mut ctx = RedactionCtx { + kind_token: "A2A_AGENT", + kind: "a2a_agents", + identity, + reveal: reveal_secrets, + out: &mut placeholders, + }; + redact_top_level(doc, "secret", &mut ctx); + }, + ), + ); + + // cache_policies — identity: name; no secrets. + push_kind( + &mut collections, + "cache_policies", + emit_entries( + &snapshot.cache_policies, + |c| c.name.clone(), + "cache_policies", + &mut diag, + |doc, identity, diag| resugar_cache_applies_to(doc, identity, &api_key_names, diag), + |_, _| {}, + ), + ); + + // observability_exporters — identity: name; redact OTLP headers. + push_kind( + &mut collections, + "observability_exporters", + emit_entries( + &snapshot.observability_exporters, + |e| e.name.clone(), + "observability_exporters", + &mut diag, + |_, _, _| {}, + |doc, identity| { + let mut ctx = RedactionCtx { + kind_token: "OBSERVABILITY_EXPORTER", + kind: "observability_exporters", + identity, + reveal: reveal_secrets, + out: &mut placeholders, + }; + redact_headers(doc, &mut ctx); + }, + ), + ); + + // rate_limit_policies — identity: name; resugar scope_ref for + // api_key / model scopes to the referenced entry's name. + push_kind( + &mut collections, + "rate_limit_policies", + emit_entries( + &snapshot.rate_limit_policies, + |p| p.name.clone(), + "rate_limit_policies", + &mut diag, + |doc, identity, diag| { + resugar_scope_ref(doc, identity, &model_names, &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. + + // Two entries whose identities differ only in characters `sanitize` + // folds to `_` (e.g. `openai-prod` vs `openai.prod`) derive the SAME + // placeholder variable, so the operator can only supply one value for + // both secrets — silently feeding one credential to the wrong entry. + // Surface it like the duplicate-identity check rather than hide it. + let mut var_owner: BTreeMap<&str, &str> = BTreeMap::new(); + for placeholder in &placeholders { + match var_owner.get(placeholder.env_var.as_str()) { + Some(&owner) if owner != placeholder.identity => diag.warnings.push(format!( + "secret placeholder ${{{}}} is derived for both {:?} and {:?}; their names \ + collapse to the same environment variable, so one real credential cannot be \ + supplied to each — rename one entry to disambiguate", + placeholder.env_var, owner, placeholder.identity + )), + Some(_) => {} + None => { + var_owner.insert(&placeholder.env_var, &placeholder.identity); + } + } + } + + ExportDocument { + collections, + secret_placeholders: placeholders, + warnings: diag.warnings, + blocking: diag.blocking, + } +} + +/// Ids of guardrails that already apply gateway-wide in etcd — i.e. that +/// have at least one env-scoped guardrail attachment (`scope_type: env` +/// matches every request). These are the only guardrails whose effect is +/// unchanged when exported into the attachment-less file format. +fn gateway_wide_guardrail_ids(snapshot: &AisixSnapshot) -> BTreeSet { + snapshot + .guardrail_attachments + .entries() + .into_iter() + .filter(|a| matches!(a.value.scope_type, GuardrailScopeType::Env)) + .map(|a| a.value.guardrail_id.clone()) + .collect() +} + +/// Deterministic file identity for a canonical api-key document, which +/// carries no name of its own. `key_hash` is already a SHA-256 hash +/// (safe to surface) and unique per credential, so a hash prefix keys the +/// entry stably without exposing anything sensitive. 16 hex chars (64 +/// bits) keeps the label short while making a cross-key prefix collision +/// (which would surface as a duplicate-identity warning anyway) vanishing. +fn synthetic_api_key_name(key_hash: &str) -> String { + let short: String = key_hash.chars().take(16).collect(); + format!("apikey-{short}") +} + +fn push_kind( + collections: &mut Vec<(&'static str, Vec)>, + kind: &'static str, + entries: Vec, +) { + if !entries.is_empty() { + collections.push((kind, entries)); + } +} + +/// Build an `etcd id → file identity` map for one table. +fn id_to_name( + table: &aisix_core::snapshot::ResourceTable, + identity: F, +) -> BTreeMap +where + T: aisix_core::resource::Resource, + F: Fn(&T) -> String, +{ + table + .entries() + .into_iter() + .map(|entry| (entry.id.clone(), identity(&entry.value))) + .collect() +} + +/// Serialize every entry of a table (sorted by identity for stable +/// output) and shape it into file form in three ordered steps: +/// `resugar` (rewrite id references to names, synthesize identities), +/// then `$`-escaping of every literal string, then `redact` (swap +/// secrets for `${VAR}` placeholders). +/// +/// The order matters. The file loader interpolates `${VAR}` and unescapes +/// `$$` on every string scalar it reads, so a stored value that literally +/// contains `$` (e.g. a guardrail regex matching `${jndi:`) has to be +/// escaped to survive the round-trip — but the placeholders `redact` +/// inserts are the one thing that *should* interpolate, so they are added +/// after escaping and left intact. `resugar` runs before escaping so the +/// reference names it inserts are escaped identically to the identities +/// they point at. +/// +/// `resugar` is handed the [`Diagnostics`] sink (threaded through rather +/// than captured, so it and this function share one sink without a double +/// borrow); `redact` collects its placeholders through captures. +fn emit_entries( + table: &aisix_core::snapshot::ResourceTable, + identity: I, + kind: &'static str, + diag: &mut Diagnostics, + mut resugar: Pre, + mut redact: Post, +) -> Vec +where + T: aisix_core::resource::Resource + Serialize, + I: Fn(&T) -> String, + Pre: FnMut(&mut Value, &str, &mut Diagnostics), + Post: FnMut(&mut Value, &str), +{ + let mut entries: Vec<_> = table.entries(); + // Stable, human-diffable order independent of DashMap shard layout. + entries.sort_by(|a, b| identity(&a.value).cmp(&identity(&b.value))); + + let mut out = Vec::with_capacity(entries.len()); + let mut seen: BTreeSet = BTreeSet::new(); + for entry in entries { + let id = identity(&entry.value); + let mut doc = match serde_json::to_value(&entry.value) { + Ok(Value::Object(map)) => Value::Object(map), + Ok(other) => { + diag.warnings.push(format!( + "{kind} entry {id:?} did not serialize to a document ({other}); skipped" + )); + continue; + } + Err(e) => { + diag.warnings.push(format!( + "{kind} entry {id:?} could not be serialized ({e}); skipped" + )); + continue; + } + }; + if !seen.insert(id.clone()) { + diag.blocking.push(format!( + "two {kind} entries share the identity {id:?}; the resources file keys entries by \ + name and rejects duplicates, so the exported file will fail to load until the \ + source collision is resolved" + )); + } + resugar(&mut doc, &id, diag); + escape_dollars(&mut doc); + redact(&mut doc, &id); + out.push(doc); + } + out +} + +/// Escape every `$` as `$$` in every string *value* (not object keys — +/// the file loader never interpolates keys). This inverts the loader's +/// `$$` → `$` unescaping so a stored value containing `$` — or a literal +/// `${…}` — round-trips unchanged instead of being read as an +/// interpolation directive. Runs before secret redaction so the +/// `${VAR}` placeholders inserted afterward are the only strings meant +/// to interpolate. +fn escape_dollars(value: &mut Value) { + match value { + Value::String(s) => { + if s.contains('$') { + *s = s.replace('$', "$$"); + } + } + Value::Array(items) => items.iter_mut().for_each(escape_dollars), + Value::Object(map) => map.values_mut().for_each(escape_dollars), + _ => {} + } +} + +/// `provider_key_id` (canonical) → `provider_key: ` (file sugar). +fn resugar_provider_key( + doc: &mut Value, + model: &str, + provider_key_names: &BTreeMap, + diag: &mut Diagnostics, +) { + let Some(map) = doc.as_object_mut() else { + return; + }; + let Some(id) = map + .get("provider_key_id") + .and_then(Value::as_str) + .map(str::to_string) + else { + return; + }; + match provider_key_names.get(&id) { + Some(name) => { + map.remove("provider_key_id"); + map.insert("provider_key".into(), Value::String(name.clone())); + } + // A raw provider_key_id the file can't resolve is rejected by the + // loader (an explicit id must match a file-defined key) — blocking. + None => diag.blocking.push(format!( + "model {model:?} references provider_key_id {id:?}, which is not among the exported \ + provider 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. +/// +/// Unlike the other references, the file source has no desugar for +/// `applies_to`: the proxy matches an `api_key` scope against the api +/// key's runtime id, which is name-derived in file mode. Emitting the raw +/// etcd UUID would match nothing (a silent no-op cache policy), so the +/// exporter substitutes the id the loader will assign. `"model:"` +/// matches by the stable model alias and `"all"` needs nothing, so both +/// pass through. A dangling id is kept raw and warned. +fn resugar_cache_applies_to( + doc: &mut Value, + policy: &str, + api_key_names: &BTreeMap, + diag: &mut Diagnostics, +) { + let Some(map) = doc.as_object_mut() else { + return; + }; + // Clone to release the immutable borrow before the insert below. + let Some(applies_to) = map + .get("applies_to") + .and_then(Value::as_str) + .map(str::to_string) + else { + return; + }; + let Some(etcd_id) = applies_to.trim().strip_prefix("api_key:").map(str::trim) else { + return; + }; + match api_key_names.get(etcd_id) { + Some(name) => { + let file_id = aisix_core::filesource::derive_id("api_keys", name); + map.insert( + "applies_to".into(), + Value::String(format!("api_key:{file_id}")), + ); + } + // `applies_to` is a free-form string the loader accepts as-is (an + // unknown api_key scope parses to a no-op), so the file still + // loads — the policy just silently matches nothing. Warning, not + // blocking. + None => diag.warnings.push(format!( + "cache_policy {policy:?} applies_to references api_key id {etcd_id:?}, which is not \ + among the exported api keys — kept as a raw id; the policy will match nothing after \ + reload (dangling reference in the source data)" + )), + } +} + +/// `scope_ref` (canonical id) → the referenced entry's name for +/// `api_key` / `model` scopes. Team-family scopes pass through verbatim. +fn resugar_scope_ref( + doc: &mut Value, + policy: &str, + model_names: &BTreeMap, + api_key_names: &BTreeMap, + diag: &mut Diagnostics, +) { + let Some(map) = doc.as_object_mut() else { + return; + }; + let (lookup, label) = match map.get("scope").and_then(Value::as_str) { + Some("model") => (model_names, "model"), + Some("api_key") => (api_key_names, "api key"), + // team / member / team_member reference external ids the file + // carries verbatim; anything else is left for schema validation. + _ => return, + }; + let Some(id) = map + .get("scope_ref") + .and_then(Value::as_str) + .map(str::to_string) + else { + return; + }; + match lookup.get(&id) { + Some(name) => { + map.insert("scope_ref".into(), Value::String(name.clone())); + } + // The file loader resolves an api_key/model scope_ref by name; a + // raw id left here resolves to nothing and fails the load — blocking. + None => diag.blocking.push(format!( + "rate_limit_policy {policy:?} scope_ref references {label} id {id:?}, which is not \ + among the exported {label}s — kept as a raw id (dangling reference in the source \ + data; the file will not load until it is resolved)" + )), + } +} + +#[cfg(test)] +#[path = "document_tests.rs"] +mod tests; diff --git a/crates/aisix-server/src/export/document_tests.rs b/crates/aisix-server/src/export/document_tests.rs new file mode 100644 index 00000000..e8fd5401 --- /dev/null +++ b/crates/aisix-server/src/export/document_tests.rs @@ -0,0 +1,473 @@ +use super::*; +use aisix_core::resource::ResourceEntry; +use serde_json::json; + +fn provider_key(display_name: &str, api_key: &str) -> aisix_core::models::ProviderKey { + serde_json::from_value(json!({"display_name": display_name, "api_key": api_key})).unwrap() +} + +fn model_value(json: Value) -> aisix_core::models::Model { + serde_json::from_value(json).unwrap() +} + +fn find<'a>(doc: &'a ExportDocument, kind: &str) -> &'a [Value] { + doc.collections + .iter() + .find(|(k, _)| *k == kind) + .map(|(_, v)| v.as_slice()) + .unwrap_or(&[]) +} + +#[test] +fn provider_key_ref_resugars_to_name() { + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(ResourceEntry::new( + "pk-uuid-1", + provider_key("openai-prod", "sk-live"), + 1, + )); + snap.models.insert(ResourceEntry::new( + "m-uuid-1", + model_value(json!({ + "display_name": "gpt-4o", + "provider": "openai", + "model_name": "gpt-4o-2024-11-20", + "provider_key_id": "pk-uuid-1" + })), + 1, + )); + + let doc = build_export_document(&snap, false); + let models = find(&doc, "models"); + assert_eq!(models.len(), 1); + // Canonical id reference gone; file name sugar in its place. + assert!(models[0].get("provider_key_id").is_none()); + assert_eq!(models[0]["provider_key"], json!("openai-prod")); +} + +#[test] +fn dangling_provider_key_ref_is_kept_and_warned() { + let snap = AisixSnapshot::new(); + snap.models.insert(ResourceEntry::new( + "m-uuid-1", + model_value(json!({ + "display_name": "orphan", + "provider": "openai", + "model_name": "gpt-4o", + "provider_key_id": "pk-does-not-exist" + })), + 1, + )); + let doc = build_export_document(&snap, false); + let models = find(&doc, "models"); + assert_eq!(models[0]["provider_key_id"], json!("pk-does-not-exist")); + assert!(models[0].get("provider_key").is_none()); + // A dangling provider_key_id makes the file non-loadable → blocking. + assert!( + doc.blocking + .iter() + .any(|w| w.contains("dangling") && w.contains("orphan")), + "{:?}", + doc.blocking + ); +} + +#[test] +fn api_key_gets_synthetic_name_and_keeps_key_hash() { + let snap = AisixSnapshot::new(); + let key_hash = "91ed2dbc407561556f3e7be98ba0bd2a57986d6a868c482d867d19c6d40d201c"; + snap.apikeys.insert(ResourceEntry::new( + "k-uuid-1", + serde_json::from_value(json!({"key_hash": key_hash, "allowed_models": ["*"]})).unwrap(), + 1, + )); + let doc = build_export_document(&snap, false); + let keys = find(&doc, "api_keys"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0]["display_name"], json!("apikey-91ed2dbc40756155")); + // key_hash is already hashed — emitted verbatim, no placeholder. + assert_eq!(keys[0]["key_hash"], json!(key_hash)); + assert!(doc.secret_placeholders.is_empty()); +} + +#[test] +fn scope_ref_resolves_for_model_and_api_key_scopes() { + let snap = AisixSnapshot::new(); + let key_hash = "aa".repeat(32); + snap.models.insert(ResourceEntry::new( + "m-uuid-1", + model_value(json!({"display_name": "gpt-4o", "provider": "openai", "model_name": "x", "provider_key_id": "pk"})), + 1, + )); + snap.apikeys.insert(ResourceEntry::new( + "k-uuid-1", + serde_json::from_value(json!({"key_hash": key_hash, "allowed_models": ["*"]})).unwrap(), + 1, + )); + snap.provider_keys + .insert(ResourceEntry::new("pk", provider_key("pk", "sk"), 1)); + for (name, scope, scope_ref) in [ + ("cap-model", "model", "m-uuid-1"), + ("cap-key", "api_key", "k-uuid-1"), + ("cap-team", "team", "team-uuid-9"), + ] { + snap.rate_limit_policies.insert(ResourceEntry::new( + format!("rlp-{name}"), + serde_json::from_value(json!({ + "name": name, "scope": scope, "scope_ref": scope_ref, + "window": "minute", "max_requests": 10 + })) + .unwrap(), + 1, + )); + } + + let doc = build_export_document(&snap, false); + let policies = find(&doc, "rate_limit_policies"); + let by_name = |n: &str| policies.iter().find(|p| p["name"] == json!(n)).unwrap(); + assert_eq!(by_name("cap-model")["scope_ref"], json!("gpt-4o")); + assert_eq!( + by_name("cap-key")["scope_ref"], + json!(synthetic_api_key_name(&"aa".repeat(32))) + ); + // team scope passes through verbatim. + assert_eq!(by_name("cap-team")["scope_ref"], json!("team-uuid-9")); +} + +#[test] +fn duplicate_identity_within_a_kind_warns() { + let snap = AisixSnapshot::new(); + // Two provider keys with the same display_name but distinct ids — + // possible in raw etcd, impossible in the file. + snap.provider_keys + .insert(ResourceEntry::new("pk-a", provider_key("dup", "sk-a"), 1)); + snap.provider_keys + .insert(ResourceEntry::new("pk-b", provider_key("dup", "sk-b"), 1)); + let doc = build_export_document(&snap, false); + // Duplicate identity makes the file non-loadable → blocking. + assert!( + doc.blocking + .iter() + .any(|w| w.contains("share the identity") && w.contains("dup")), + "{:?}", + doc.blocking + ); +} + +#[test] +fn provider_key_request_default_headers_and_body_fields_are_redacted() { + let snap = AisixSnapshot::new(); + let pk: aisix_core::models::ProviderKey = serde_json::from_value(json!({ + "display_name": "pk", + "api_key": "sk-main-SECRET", + "request": { + "default_headers": { "x-tenant-token": "hdr-SECRET" }, + "default_body_fields": { "api_key": "body-SECRET", "safe_prompt": true } + } + })) + .unwrap(); + snap.provider_keys.insert(ResourceEntry::new("pk-1", pk, 1)); + let doc = build_export_document(&snap, false); + let rendered = serde_json::to_string(&find(&doc, "provider_keys")).unwrap(); + for secret in ["sk-main-SECRET", "hdr-SECRET", "body-SECRET"] { + assert!(!rendered.contains(secret), "leaked {secret}: {rendered}"); + } + // Non-string body field preserved. + let pk_out = &find(&doc, "provider_keys")[0]; + assert_eq!( + pk_out["request"]["default_body_fields"]["safe_prompt"], + json!(true) + ); +} + +#[test] +fn cache_policy_api_key_applies_to_resugars_to_derived_id() { + use aisix_core::filesource::derive_id; + let snap = AisixSnapshot::new(); + let key_hash = "cd".repeat(32); + snap.apikeys.insert(ResourceEntry::new( + "k-uuid-1", + serde_json::from_value(json!({"key_hash": key_hash, "allowed_models": ["*"]})).unwrap(), + 1, + )); + snap.cache_policies.insert(ResourceEntry::new( + "cp-1", + serde_json::from_value(json!({"name": "cap-key", "applies_to": "api_key:k-uuid-1"})) + .unwrap(), + 1, + )); + snap.cache_policies.insert(ResourceEntry::new( + "cp-2", + serde_json::from_value(json!({"name": "cap-model", "applies_to": "model:gpt-4o"})).unwrap(), + 1, + )); + let doc = build_export_document(&snap, false); + let policies = find(&doc, "cache_policies"); + let by_name = |n: &str| policies.iter().find(|p| p["name"] == json!(n)).unwrap(); + // api_key id → the id the file loader will derive from the api key's + // synthesized name, so the policy still matches after reload. + let expected = format!( + "api_key:{}", + derive_id("api_keys", &synthetic_api_key_name(&"cd".repeat(32))) + ); + assert_eq!(by_name("cap-key")["applies_to"], json!(expected)); + // model scope matches by alias — unchanged. + assert_eq!(by_name("cap-model")["applies_to"], json!("model:gpt-4o")); +} + +#[test] +fn cache_policy_dangling_api_key_applies_to_is_kept_and_warned() { + let snap = AisixSnapshot::new(); + snap.cache_policies.insert(ResourceEntry::new( + "cp-1", + serde_json::from_value(json!({"name": "orphan", "applies_to": "api_key:missing-uuid"})) + .unwrap(), + 1, + )); + let doc = build_export_document(&snap, false); + assert_eq!( + find(&doc, "cache_policies")[0]["applies_to"], + json!("api_key:missing-uuid") + ); + assert!( + doc.warnings + .iter() + .any(|w| w.contains("dangling") && w.contains("orphan")), + "{:?}", + doc.warnings + ); +} + +#[test] +fn placeholder_env_var_collision_across_identities_warns() { + let snap = AisixSnapshot::new(); + // Two provider keys whose display_names differ only in a character + // `sanitize` folds to `_` → the same derived env var. + snap.provider_keys.insert(ResourceEntry::new( + "pk-a", + provider_key("openai-prod", "sk-a"), + 1, + )); + snap.provider_keys.insert(ResourceEntry::new( + "pk-b", + provider_key("openai.prod", "sk-b"), + 1, + )); + let doc = build_export_document(&snap, false); + assert!( + doc.warnings + .iter() + .any(|w| w.contains("same environment variable")), + "{:?}", + doc.warnings + ); +} + +#[test] +fn default_export_emits_no_live_provider_secret() { + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(ResourceEntry::new( + "pk-1", + provider_key("openai-prod", "sk-super-secret-do-not-leak"), + 1, + )); + let doc = build_export_document(&snap, false); + let pks = find(&doc, "provider_keys"); + assert_eq!( + pks[0]["api_key"], + json!("${AISIXSECRET_PROVIDER_KEY_OPENAI_PROD_API_KEY}") + ); + // Secret must appear nowhere in the assembled collections. + let rendered = + serde_json::to_string(&doc.collections.iter().map(|(_, v)| v).collect::>()).unwrap(); + assert!( + !rendered.contains("sk-super-secret-do-not-leak"), + "{rendered}" + ); + assert_eq!(doc.secret_placeholders.len(), 1); +} + +#[test] +fn reveal_secrets_emits_the_real_value_inline() { + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(ResourceEntry::new( + "pk-1", + provider_key("openai-prod", "sk-real-value"), + 1, + )); + let doc = build_export_document(&snap, true); + let pks = find(&doc, "provider_keys"); + assert_eq!(pks[0]["api_key"], json!("sk-real-value")); + assert!(doc.secret_placeholders.is_empty()); +} + +fn keyword_guardrail(name: &str) -> aisix_core::models::Guardrail { + serde_json::from_value(json!({ + "name": name, "kind": "keyword", + "patterns": [{ "kind": "literal", "value": "blocked-phrase" }] + })) + .unwrap() +} + +fn attachment(guardrail_id: &str, scope_type: &str, scope_id: Option<&str>) -> Value { + let mut a = json!({"guardrail_id": guardrail_id, "scope_type": scope_type, "priority": 1}); + if let Some(id) = scope_id { + a["scope_id"] = json!(id); + } + a +} + +#[test] +fn env_scoped_guardrail_is_exported_gateway_wide() { + // An env-scoped attachment already applies to every request, so + // exporting the guardrail (which the file applies gateway-wide) + // is behavior-equivalent. + let snap = AisixSnapshot::new(); + snap.guardrails.insert(ResourceEntry::new( + "g-1", + keyword_guardrail("global-guard"), + 1, + )); + snap.guardrail_attachments.insert(ResourceEntry::new( + "att-1", + serde_json::from_value(attachment("g-1", "env", None)).unwrap(), + 1, + )); + let doc = build_export_document(&snap, false); + let guardrails = find(&doc, "guardrails"); + assert_eq!(guardrails.len(), 1); + assert_eq!(guardrails[0]["name"], json!("global-guard")); +} + +#[test] +fn attachment_scoped_or_unattached_guardrail_is_omitted_with_a_warning() { + // A guardrail attached to one model — or attached to nothing — would + // widen (or activate) gateway-wide on import, so it must be omitted. + let snap = AisixSnapshot::new(); + snap.guardrails.insert(ResourceEntry::new( + "g-scoped", + keyword_guardrail("model-guard"), + 1, + )); + snap.guardrail_attachments.insert(ResourceEntry::new( + "att-1", + serde_json::from_value(attachment("g-scoped", "model", Some("m-1"))).unwrap(), + 1, + )); + snap.guardrails.insert(ResourceEntry::new( + "g-inert", + keyword_guardrail("inert-guard"), + 1, + )); + let doc = build_export_document(&snap, false); + // Neither guardrail is exported… + assert!(doc.collections.iter().all(|(k, _)| *k != "guardrails")); + // …and both are surfaced as would-widen-to-all-traffic warnings. + for name in ["model-guard", "inert-guard"] { + assert!( + doc.warnings + .iter() + .any(|w| w.contains(name) && w.contains("ALL traffic")), + "missing warning for {name}: {:?}", + doc.warnings + ); + } +} + +#[test] +fn empty_snapshot_yields_only_a_header_later() { + let snap = AisixSnapshot::new(); + let doc = build_export_document(&snap, false); + assert!(doc.collections.is_empty()); + assert!(doc.secret_placeholders.is_empty()); + assert!(doc.warnings.is_empty()); +} + +#[test] +fn escape_dollars_doubles_dollars_in_string_values_only() { + let mut v = json!({ + "plain": "no dollars", + "regex": "price=\\$5 and ${jndi:x}", + "nested": { "list": ["a$b", 3, true] } + }); + escape_dollars(&mut v); + assert_eq!(v["plain"], json!("no dollars")); + assert_eq!(v["regex"], json!("price=\\$$5 and $${jndi:x}")); + assert_eq!(v["nested"]["list"][0], json!("a$$b")); + // Non-strings untouched. + assert_eq!(v["nested"]["list"][1], json!(3)); + assert_eq!(v["nested"]["list"][2], json!(true)); +} + +#[test] +fn export_output_reloads_through_the_real_file_loader() { + use aisix_core::filesource::{derive_id, load_from_str}; + use std::collections::HashMap; + + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(ResourceEntry::new( + "pk-1", + provider_key("openai-prod", "sk-live-value"), + 1, + )); + snap.models.insert(ResourceEntry::new( + "m-1", + model_value(json!({ + "display_name": "gpt-4o", + "provider": "openai", + "model_name": "gpt-4o-2024-11-20", + "provider_key_id": "pk-1" + })), + 1, + )); + // A guardrail whose literal contains a real `${...}` — the exact + // shape a Log4Shell/template-injection blocklist rule takes. If it + // were emitted unescaped the loader would try to interpolate it and + // the whole file would fail to load; escaping is what lets it + // survive. + snap.guardrails.insert(ResourceEntry::new( + "g-1", + serde_json::from_value(json!({ + "name": "log4shell", + "kind": "keyword", + "patterns": [{ "kind": "literal", "value": "${jndi:ldap}" }] + })) + .unwrap(), + 1, + )); + // env-scoped attachment → the guardrail is gateway-wide, so it is + // exported (and its `${jndi:ldap}` literal must round-trip). + snap.guardrail_attachments.insert(ResourceEntry::new( + "att-1", + serde_json::from_value(attachment("g-1", "env", None)).unwrap(), + 1, + )); + + let doc = build_export_document(&snap, false); + let yaml = crate::export::yaml_emit::emit_yaml(&doc).expect("emit"); + + // Feed the placeholders the file loader will interpolate. + let env: HashMap = doc + .secret_placeholders + .iter() + .map(|p| (p.env_var.clone(), "sk-real".to_string())) + .collect(); + let loaded = load_from_str(&yaml, "exported.yaml", 1, &|n| env.get(n).cloned()) + .expect("the exported file must re-load through the file source"); + + // Same resource set, and the reference resugared then re-resolved to + // the same derived id the loader assigns. + assert_eq!(loaded.provider_keys.len(), 1); + assert_eq!(loaded.models.len(), 1); + assert_eq!(loaded.guardrails.len(), 1); + let model = loaded.models.get_by_name("gpt-4o").unwrap(); + assert_eq!( + model.value.provider_key_id.as_deref(), + Some(derive_id("provider_keys", "openai-prod").as_str()) + ); + // The `${jndi:ldap}` literal came back byte-for-byte — not + // interpolated, not corrupted. + 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}")); +} diff --git a/crates/aisix-server/src/export/mod.rs b/crates/aisix-server/src/export/mod.rs new file mode 100644 index 00000000..beeefce7 --- /dev/null +++ b/crates/aisix-server/src/export/mod.rs @@ -0,0 +1,219 @@ +//! `aisix export` — emit a resources file from a running etcd store. +//! +//! The inverse of the file source (`aisix_core::filesource`): it reads +//! every canonical resource document under the configured prefix, decodes +//! it through the same typed models the gateway loads, and re-emits the +//! set as a `resources.yaml` the file source can load back — the +//! migration / backup path for a standalone deployment moving from +//! Admin-API-plus-etcd to the declarative file. +//! +//! Pipeline: +//! +//! ```text +//! etcd range read → decode (aisix_etcd::build_snapshot, the loader path) +//! → resugar references + strip ids + redact secrets (document) +//! → YAML emit (yaml_emit) → stdout / -o file +//! → secret-placeholder list + warnings → stderr +//! ``` +//! +//! Secrets are replaced with `${VAR}` placeholders by default; the real +//! values are never written unless `--reveal-secrets` is passed. + +mod document; +mod secrets; +mod yaml_emit; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use aisix_etcd::{build_snapshot, ConfigProvider, ConnectPolicy, EtcdConfigProvider}; + +use document::build_export_document; +use yaml_emit::emit_yaml; + +/// Arguments for the `export` subcommand, parsed by `clap` in `main`. +pub struct ExportArgs { + pub endpoints: Vec, + pub prefix: String, + pub reveal_secrets: bool, + pub output: Option, +} + +/// A CLI-appropriate connect policy: fail after a few quick attempts +/// rather than the gateway's 25s boot budget. +const CLI_CONNECT_POLICY: ConnectPolicy = ConnectPolicy { + interval: Duration::from_secs(1), + attempts: 3, +}; + +/// Run the export end to end. Reads etcd, writes the resources file to +/// `output` (or stdout), and reports the secret-placeholder map and any +/// warnings on stderr. +pub async fn run(args: ExportArgs) -> anyhow::Result<()> { + if args.endpoints.iter().all(|e| e.trim().is_empty()) { + anyhow::bail!("--etcd requires at least one endpoint"); + } + + let provider = EtcdConfigProvider::connect_with_policy( + &args.endpoints, + &args.prefix, + None, + CLI_CONNECT_POLICY, + ) + .await + .map_err(|e| anyhow::anyhow!("etcd connect failed: {e}"))?; + + let (entries, _revision) = provider + .load_all() + .await + .map_err(|e| anyhow::anyhow!("etcd range read under {:?} failed: {e}", args.prefix))?; + + // Decode through the identical loader path the gateway uses, so the + // exported set is exactly what the running gateway would serve. + let (snapshot, stats) = build_snapshot(&args.prefix, &entries); + + let document = build_export_document(&snapshot, args.reveal_secrets); + let yaml = emit_yaml(&document).map_err(|e| anyhow::anyhow!(e))?; + + let resource_count = document + .collections + .iter() + .map(|(_, entries)| entries.len()) + .sum::(); + + match &args.output { + Some(path) => { + write_owner_only(path, &yaml)?; + eprintln!( + "Exported {resource_count} resource(s) to {}", + path.display() + ); + } + None => { + print!("{yaml}"); + eprintln!("Exported {resource_count} resource(s) to stdout"); + } + } + + report_rejections(&stats); + report_warnings(&document.warnings); + report_secrets(&document, args.reveal_secrets); + + // The file is written for inspection either way, but a scripted + // migration must not mistake a non-loadable export for a finished one: + // exit non-zero when a collision or dangling reference means the file + // cannot be loaded back as-is. + if !document.blocking.is_empty() { + eprintln!( + "\n{} issue(s) leave the exported file non-loadable — fix them in the source and \ + re-export (or run `aisix validate` on the file):", + document.blocking.len() + ); + for issue in &document.blocking { + eprintln!(" - {issue}"); + } + anyhow::bail!( + "export completed with {} blocking issue(s); the resources file will not load as-is", + document.blocking.len() + ); + } + + Ok(()) +} + +/// Write the resources file readable only by its owner (mode `0600` on +/// unix). With `--reveal-secrets` the file holds live upstream +/// credentials; even the default placeholder file is infrastructure the +/// operator populates with secrets, so a group/world-readable default +/// (the process umask) is the wrong posture. The mode is applied on +/// creation and re-asserted so re-exporting over a looser pre-existing +/// file also tightens it. +fn write_owner_only(path: &Path, contents: &str) -> anyhow::Result<()> { + let ctx = |e| anyhow::anyhow!("writing {}: {e}", path.display()); + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .map_err(ctx)?; + // Re-assert for the case where `path` already existed with looser + // permissions (mode() above only applies to freshly-created files). + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(ctx)?; + file.write_all(contents.as_bytes()).map_err(ctx)?; + Ok(()) + } + #[cfg(not(unix))] + { + std::fs::write(path, contents).map_err(ctx) + } +} + +/// Surface entries the loader dropped during decode (bad key / non-JSON / +/// schema / unknown kind) so a silently-skipped resource is visible. +fn report_rejections(stats: &aisix_etcd::BuildStats) { + if stats.rejections.is_empty() { + return; + } + eprintln!( + "warning: {} etcd entr(ies) were rejected during decode and omitted from the export:", + stats.rejections.len() + ); + for rejection in &stats.rejections { + eprintln!( + " - {} ({}): {}", + rejection.key, + rejection.kind.as_str(), + rejection.error + ); + } +} + +fn report_warnings(warnings: &[String]) { + for warning in warnings { + eprintln!("warning: {warning}"); + } +} + +/// Print the operator's "set these before loading" list on stderr — never +/// into the file. Deduplicated and sorted for a stable report. +fn report_secrets(document: &document::ExportDocument, reveal_secrets: bool) { + if reveal_secrets { + if document_has_any_entry(document) { + eprintln!( + "warning: --reveal-secrets emitted live credentials inline; treat the output as a \ + secret and do not commit it." + ); + } + return; + } + if document.secret_placeholders.is_empty() { + return; + } + + let mut lines: Vec = document + .secret_placeholders + .iter() + .map(|p| format!(" {} — {} {:?} {}", p.env_var, p.kind, p.identity, p.field)) + .collect(); + lines.sort(); + lines.dedup(); + + eprintln!( + "\n{} secret value(s) were replaced with ${{VAR}} placeholders. Set each variable to the \ + real credential in the data plane's environment before loading this file:", + lines.len() + ); + for line in lines { + eprintln!("{line}"); + } +} + +fn document_has_any_entry(document: &document::ExportDocument) -> bool { + document.collections.iter().any(|(_, e)| !e.is_empty()) +} diff --git a/crates/aisix-server/src/export/secrets.rs b/crates/aisix-server/src/export/secrets.rs new file mode 100644 index 00000000..aa5970cd --- /dev/null +++ b/crates/aisix-server/src/export/secrets.rs @@ -0,0 +1,366 @@ +//! Secret redaction for `aisix export`. +//! +//! Stored resource documents hold live upstream credentials in the +//! clear — a provider key's `api_key` and per-key request overrides, an +//! MCP / A2A server's `secret`, a guardrail's moderation-service +//! `api_key` / `access_key_secret` / `secret_access_key`, an OTLP +//! exporter's auth `headers`. The default export must never write any of +//! these verbatim: each is replaced with an `${ENV_VAR}` placeholder the +//! resources file interpolates at load time, and the operator populates +//! the variable out of band. +//! +//! The placeholder name is derived deterministically from the entry's +//! identity and the field so it is stable across exports and greppable. +//! It deliberately does **not** start with `AISIX_`: the gateway's +//! config loader claims that prefix (`Environment::with_prefix("AISIX")`) +//! and rejects unknown keys, so an `AISIX_…` secret variable set in the +//! data plane's environment would be misread as a bad config override +//! and fail boot. The same reason the e2e harness and the codebase's own +//! `SLS_CRED_…` / `OBJSTORE_CRED_…` conventions keep secret variables off +//! the `AISIX_` prefix. + +use serde_json::Value; + +/// Namespace every derived secret variable shares. Config-safe (does not +/// begin with the `AISIX_` config-override prefix) and greppable. +pub const SECRET_ENV_PREFIX: &str = "AISIXSECRET"; + +/// One placeholder emitted in place of a live credential. Collected so +/// the command can print the operator a "set these before loading" list +/// on stderr — never into the file itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecretPlaceholder { + /// Environment variable the resources file now references. + pub env_var: String, + /// Resource kind the credential belongs to (`provider_keys`, …). + pub kind: &'static str, + /// The entry's file identity (display_name / name). + pub identity: String, + /// Human label for the redacted field (e.g. `api_key`, `header x-…`). + pub field: String, +} + +/// Shared context every redaction helper carries: which kind / entry is +/// being redacted, whether to reveal instead of redact, and the sink to +/// record placeholders in. Bundled into one struct so the helpers stay +/// two- to four-argument and the same-typed `kind_token` / `kind` / +/// `identity` strings cannot be transposed at a call site. +pub struct RedactionCtx<'a> { + /// Uppercased kind token in the env-var name (e.g. `PROVIDER_KEY`). + pub kind_token: &'a str, + /// Canonical kind name recorded on the placeholder (`provider_keys`). + pub kind: &'static str, + /// The entry's file identity. + pub identity: &'a str, + /// Emit the real values inline instead of placeholders. + pub reveal: bool, + /// Where recorded placeholders accumulate. + pub out: &'a mut Vec, +} + +impl RedactionCtx<'_> { + /// Record a placeholder and return its interpolation value. + fn placeholder(&mut self, field_key: &str, field_label: String) -> Value { + let env_var = secret_var(self.kind_token, self.identity, field_key); + self.out.push(SecretPlaceholder { + env_var: env_var.clone(), + kind: self.kind, + identity: self.identity.to_string(), + field: field_label, + }); + Value::String(format!("${{{env_var}}}")) + } +} + +/// Uppercase a label and replace every non-alphanumeric byte with `_` +/// so it is a legal, stable environment-variable fragment. Not collapsed +/// or trimmed — determinism matters more than prettiness, and the raw +/// shape stays recognizable (`openai-prod` → `OPENAI_PROD`). +pub fn sanitize(label: &str) -> String { + label + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +/// `AISIXSECRET___` — the derived variable name. +pub fn secret_var(kind_token: &str, identity: &str, field: &str) -> String { + format!( + "{SECRET_ENV_PREFIX}_{kind_token}_{}_{}", + sanitize(identity), + sanitize(field), + ) +} + +/// True when `value` is a non-empty string. +fn is_nonempty_str(value: &Value) -> bool { + value.as_str().is_some_and(|s| !s.is_empty()) +} + +/// Replace one top-level string field with a placeholder. No-op when +/// `reveal` is set, when the field is absent, or when it is not a +/// non-empty string. +pub fn redact_top_level(doc: &mut Value, field: &str, ctx: &mut RedactionCtx<'_>) { + if ctx.reveal { + return; + } + let Some(slot) = doc.get_mut(field).filter(|v| is_nonempty_str(v)) else { + return; + }; + *slot = ctx.placeholder(field, field.to_string()); +} + +/// Recursively replace every non-empty string whose object key is in +/// `secret_keys`, at any depth. Used for guardrails, whose credential +/// fields (`api_key`, `access_key_secret`, and the Bedrock +/// `aws_credentials.secret_access_key` one level down) are always +/// secrets in that kind's closed schema. No-op when `reveal` is set. +pub fn redact_by_key(value: &mut Value, secret_keys: &[&str], ctx: &mut RedactionCtx<'_>) { + if ctx.reveal { + return; + } + match value { + Value::Object(map) => { + for (key, child) in map.iter_mut() { + if secret_keys.contains(&key.as_str()) && is_nonempty_str(child) { + *child = ctx.placeholder(key, key.clone()); + } else { + redact_by_key(child, secret_keys, ctx); + } + } + } + Value::Array(items) => { + for item in items { + redact_by_key(item, secret_keys, ctx); + } + } + _ => {} + } +} + +/// Replace every value under a top-level `headers` object (an OTLP +/// exporter's request headers, which routinely carry a vendor auth +/// token) with a per-header placeholder. No-op when `reveal` is set or +/// there is no `headers` object. +pub fn redact_headers(doc: &mut Value, ctx: &mut RedactionCtx<'_>) { + if ctx.reveal { + return; + } + let Some(Value::Object(headers)) = doc.get_mut("headers") else { + return; + }; + for (name, value) in headers.iter_mut() { + if is_nonempty_str(value) { + let field_key = format!("HEADER_{}", sanitize(name)); + *value = ctx.placeholder(&field_key, format!("header {name}")); + } + } +} + +/// Replace every non-empty **string** value under `doc[field]` (an object) +/// with a per-entry placeholder. Non-string values are left intact, so a +/// `default_body_fields: { safe_prompt: true }` flag survives while a +/// string credential in the same map is redacted. Used for a provider +/// key's `request.default_headers` (outbound auth headers to the upstream) +/// and `request.default_body_fields`, whose values are operator-supplied +/// and may carry a secondary credential the dedicated `api_key` field does +/// not cover. No-op when `reveal` is set or `field` is absent / not an +/// object. +pub fn redact_string_map(doc: &mut Value, field: &str, label: &str, ctx: &mut RedactionCtx<'_>) { + if ctx.reveal { + return; + } + let Some(Value::Object(map)) = doc.get_mut(field) else { + return; + }; + for (name, value) in map.iter_mut() { + if is_nonempty_str(value) { + let field_key = format!("{}_{}", sanitize(field), sanitize(name)); + *value = ctx.placeholder(&field_key, format!("{label} {name}")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Build a redaction context over a fresh placeholder sink. + fn ctx<'a>( + identity: &'a str, + reveal: bool, + out: &'a mut Vec, + ) -> RedactionCtx<'a> { + RedactionCtx { + kind_token: "PROVIDER_KEY", + kind: "provider_keys", + identity, + reveal, + out, + } + } + + #[test] + fn sanitize_uppercases_and_replaces_non_alphanumeric() { + assert_eq!(sanitize("openai-prod"), "OPENAI_PROD"); + assert_eq!(sanitize("gpt-4o"), "GPT_4O"); + assert_eq!(sanitize("x.honeycomb/team"), "X_HONEYCOMB_TEAM"); + } + + #[test] + fn secret_var_is_deterministic_and_prefixed() { + let a = secret_var("PROVIDER_KEY", "openai-prod", "api_key"); + assert_eq!(a, "AISIXSECRET_PROVIDER_KEY_OPENAI_PROD_API_KEY"); + assert_eq!(a, secret_var("PROVIDER_KEY", "openai-prod", "api_key")); + // Never the reserved config-override prefix. + assert!(!a.starts_with("AISIX_")); + } + + #[test] + fn redact_top_level_replaces_value_and_records_placeholder() { + let mut doc = json!({"display_name": "openai-prod", "api_key": "sk-live-SECRET"}); + let mut out = Vec::new(); + redact_top_level( + &mut doc, + "api_key", + &mut ctx("openai-prod", false, &mut out), + ); + assert_eq!( + doc["api_key"], + json!("${AISIXSECRET_PROVIDER_KEY_OPENAI_PROD_API_KEY}") + ); + assert!(!doc.to_string().contains("sk-live-SECRET")); + assert_eq!(out.len(), 1); + assert_eq!( + out[0].env_var, + "AISIXSECRET_PROVIDER_KEY_OPENAI_PROD_API_KEY" + ); + assert_eq!(out[0].field, "api_key"); + } + + #[test] + fn redact_top_level_reveal_leaves_value_untouched() { + let mut doc = json!({"api_key": "sk-live-SECRET"}); + let mut out = Vec::new(); + redact_top_level(&mut doc, "api_key", &mut ctx("pk", true, &mut out)); + assert_eq!(doc["api_key"], json!("sk-live-SECRET")); + assert!(out.is_empty()); + } + + #[test] + fn redact_top_level_ignores_absent_or_empty_field() { + let mut out = Vec::new(); + let mut absent = json!({"display_name": "pk"}); + redact_top_level(&mut absent, "api_key", &mut ctx("pk", false, &mut out)); + let mut empty = json!({"api_key": ""}); + redact_top_level(&mut empty, "api_key", &mut ctx("pk", false, &mut out)); + assert!(out.is_empty()); + assert_eq!(empty["api_key"], json!("")); + } + + #[test] + fn redact_by_key_reaches_nested_bedrock_secret() { + // The Bedrock guardrail nests its secret one level down under + // aws_credentials; the recursive walk must still catch it while + // leaving the sibling access_key_id (an identifier, not a secret) + // alone. + let mut doc = json!({ + "name": "bedrock-guard", + "kind": "bedrock", + "api_key": "top-secret", + "aws_credentials": { + "kind": "static", + "access_key_id": "AKIAEXAMPLE", + "secret_access_key": "wJalr-SECRET" + } + }); + let mut out = Vec::new(); + let mut c = RedactionCtx { + kind_token: "GUARDRAIL", + kind: "guardrails", + identity: "bedrock-guard", + reveal: false, + out: &mut out, + }; + redact_by_key( + &mut doc, + &["api_key", "access_key_secret", "secret_access_key"], + &mut c, + ); + let text = doc.to_string(); + assert!(!text.contains("top-secret"), "{text}"); + assert!(!text.contains("wJalr-SECRET"), "{text}"); + // access_key_id is not in the secret set — preserved verbatim. + assert_eq!( + doc["aws_credentials"]["access_key_id"], + json!("AKIAEXAMPLE") + ); + assert_eq!( + doc["aws_credentials"]["secret_access_key"], + json!("${AISIXSECRET_GUARDRAIL_BEDROCK_GUARD_SECRET_ACCESS_KEY}") + ); + assert_eq!(out.len(), 2); + } + + #[test] + fn redact_string_map_masks_string_values_and_keeps_non_strings() { + let mut request = json!({ + "default_headers": { "x-tenant-token": "tok-SECRET", "x-request-source": "" }, + "default_body_fields": { "api_key": "body-SECRET", "safe_prompt": true } + }); + let mut out = Vec::new(); + let mut c = ctx("pk", false, &mut out); + redact_string_map(&mut request, "default_headers", "default header", &mut c); + redact_string_map( + &mut request, + "default_body_fields", + "default body field", + &mut c, + ); + let text = request.to_string(); + assert!(!text.contains("tok-SECRET"), "{text}"); + assert!(!text.contains("body-SECRET"), "{text}"); + assert_eq!( + request["default_headers"]["x-tenant-token"], + json!("${AISIXSECRET_PROVIDER_KEY_PK_DEFAULT_HEADERS_X_TENANT_TOKEN}") + ); + // Empty string skipped; non-string (bool) left intact. + assert_eq!(request["default_headers"]["x-request-source"], json!("")); + assert_eq!(request["default_body_fields"]["safe_prompt"], json!(true)); + assert_eq!(out.len(), 2); + } + + #[test] + fn redact_headers_masks_each_value() { + let mut doc = json!({ + "name": "honeycomb", + "kind": "otlp_http", + "endpoint": "https://api.honeycomb.io/v1/traces", + "headers": { "x-honeycomb-team": "hcaik_SECRET" } + }); + let mut out = Vec::new(); + let mut c = RedactionCtx { + kind_token: "OBSERVABILITY_EXPORTER", + kind: "observability_exporters", + identity: "honeycomb", + reveal: false, + out: &mut out, + }; + redact_headers(&mut doc, &mut c); + assert!(!doc.to_string().contains("hcaik_SECRET")); + assert_eq!( + doc["headers"]["x-honeycomb-team"], + json!("${AISIXSECRET_OBSERVABILITY_EXPORTER_HONEYCOMB_HEADER_X_HONEYCOMB_TEAM}") + ); + assert_eq!(out.len(), 1); + // The endpoint (not a credential) is untouched. + assert_eq!(doc["endpoint"], json!("https://api.honeycomb.io/v1/traces")); + } +} diff --git a/crates/aisix-server/src/export/yaml_emit.rs b/crates/aisix-server/src/export/yaml_emit.rs new file mode 100644 index 00000000..5f3120d3 --- /dev/null +++ b/crates/aisix-server/src/export/yaml_emit.rs @@ -0,0 +1,159 @@ +//! Render an [`ExportDocument`] as a resources-file YAML string. +//! +//! Emission uses `yaml-rust2`'s own `YamlEmitter` — the same crate whose +//! `YamlLoader` the file source parses with — so the output is guaranteed +//! to re-parse to an equivalent tree. In particular the emitter quotes +//! scalars that would otherwise change type (the mandatory +//! `_format_version: "1"` re-parses as the string `"1"`, not the integer +//! `1`), and quotes the `${VAR}` secret placeholders as needed. +//! +//! The top-level mapping is built directly so the header sits first and +//! collections follow in the file's fixed kind order; per-entry field +//! order is whatever `serde_json` produced (deterministic). + +use serde_json::Value; +use yaml_rust2::yaml::Hash; +use yaml_rust2::{Yaml, YamlEmitter}; + +use super::document::ExportDocument; + +/// The mandatory format-version header value, emitted as a quoted string. +const FORMAT_VERSION: &str = "1"; + +/// Serialize the document to a resources-file YAML string. +pub fn emit_yaml(document: &ExportDocument) -> Result { + let mut root = Hash::new(); + root.insert( + Yaml::String("_format_version".into()), + Yaml::String(FORMAT_VERSION.into()), + ); + for (kind, entries) in &document.collections { + let array = Yaml::Array(entries.iter().map(json_to_yaml).collect()); + root.insert(Yaml::String((*kind).into()), array); + } + + let mut out = String::new(); + YamlEmitter::new(&mut out) + .dump(&Yaml::Hash(root)) + .map_err(|e| format!("YAML emit failed: {e}"))?; + // The emitter writes a leading `---` document marker but no trailing + // newline; add one so the file is POSIX-clean. + out.push('\n'); + Ok(out) +} + +/// Convert a JSON value to the `yaml-rust2` tree, preserving object key +/// order. Numbers that exceed `i64` fall back to their textual form as a +/// YAML real, which re-parses to the same number. +fn json_to_yaml(value: &Value) -> Yaml { + match value { + Value::Null => Yaml::Null, + Value::Bool(b) => Yaml::Boolean(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Yaml::Integer(i) + } else { + Yaml::Real(n.to_string()) + } + } + Value::String(s) => Yaml::String(s.clone()), + Value::Array(items) => Yaml::Array(items.iter().map(json_to_yaml).collect()), + Value::Object(map) => { + let mut hash = Hash::new(); + for (key, child) in map { + hash.insert(Yaml::String(key.clone()), json_to_yaml(child)); + } + Yaml::Hash(hash) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use yaml_rust2::YamlLoader; + + fn doc_with(collections: Vec<(&'static str, Vec)>) -> ExportDocument { + ExportDocument { + collections, + secret_placeholders: Vec::new(), + warnings: Vec::new(), + blocking: Vec::new(), + } + } + + #[test] + fn format_version_reparses_as_a_string_not_an_integer() { + let yaml = emit_yaml(&doc_with(vec![])).unwrap(); + let docs = YamlLoader::load_from_str(&yaml).unwrap(); + assert_eq!(docs.len(), 1, "must be a single document"); + // The loader keys off `Yaml::String("1")`; an unquoted `1` would + // parse as an integer and be rejected. + assert_eq!( + docs[0]["_format_version"], + Yaml::String("1".into()), + "emitted:\n{yaml}" + ); + } + + #[test] + fn secret_placeholder_scalar_round_trips_intact() { + let collections = vec![( + "provider_keys", + vec![json!({ + "display_name": "openai-prod", + "api_key": "${AISIXSECRET_PROVIDER_KEY_OPENAI_PROD_API_KEY}" + })], + )]; + let yaml = emit_yaml(&doc_with(collections)).unwrap(); + let docs = YamlLoader::load_from_str(&yaml).unwrap(); + let pk = &docs[0]["provider_keys"][0]; + assert_eq!( + pk["api_key"], + Yaml::String("${AISIXSECRET_PROVIDER_KEY_OPENAI_PROD_API_KEY}".into()), + "emitted:\n{yaml}" + ); + assert_eq!(pk["display_name"], Yaml::String("openai-prod".into())); + } + + #[test] + fn scalar_types_and_nesting_survive_a_round_trip() { + let collections = vec![( + "rate_limit_policies", + vec![json!({ + "name": "cap", + "scope": "model", + "scope_ref": "gpt-4o", + "window": "minute", + "max_requests": 300, + "enabled": true + })], + )]; + let yaml = emit_yaml(&doc_with(collections)).unwrap(); + let docs = YamlLoader::load_from_str(&yaml).unwrap(); + let p = &docs[0]["rate_limit_policies"][0]; + // Integer stays an integer; string stays a string; bool a bool. + assert_eq!(p["max_requests"], Yaml::Integer(300)); + assert_eq!(p["scope_ref"], Yaml::String("gpt-4o".into())); + assert_eq!(p["enabled"], Yaml::Boolean(true)); + } + + #[test] + fn collections_are_emitted_in_the_supplied_order() { + let yaml = emit_yaml(&doc_with(vec![ + ( + "provider_keys", + vec![json!({"display_name": "pk", "api_key": "x"})], + ), + ( + "models", + vec![json!({"display_name": "m", "provider_key": "pk"})], + ), + ])) + .unwrap(); + let pk_at = yaml.find("provider_keys:").expect("has provider_keys"); + let models_at = yaml.find("models:").expect("has models"); + assert!(pk_at < models_at, "provider_keys before models:\n{yaml}"); + } +} diff --git a/crates/aisix-server/src/main.rs b/crates/aisix-server/src/main.rs index 0973c342..3537a927 100644 --- a/crates/aisix-server/src/main.rs +++ b/crates/aisix-server/src/main.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; mod cert_bundle; +mod export; mod heartbeat; mod managed_bundle; mod telemetry; @@ -73,6 +74,30 @@ enum CliCommand { #[arg(long)] resources: PathBuf, }, + /// Export the resources currently stored in etcd as a `resources.yaml` + /// the file source (`resources_file`) can load back — the migration / + /// backup path for a standalone deployment moving from the Admin API + /// plus etcd to the declarative file. References are resugared to + /// names, ids are dropped (the file derives them), and live + /// credentials are replaced with `${VAR}` placeholders unless + /// `--reveal-secrets` is given. + Export { + /// etcd endpoints to read from (comma-separated or repeated). + #[arg(long, value_delimiter = ',', required = true)] + etcd: Vec, + /// Key prefix the resources are stored under. Defaults to the same + /// canonical prefix the gateway reads from (`etcd.prefix`). + #[arg(long, default_value_t = EtcdConfig::default().prefix)] + prefix: String, + /// Emit real stored credential values inline instead of `${VAR}` + /// placeholders. UNSAFE — the output then contains live secrets; + /// intended only for air-gapped, same-host migration. + #[arg(long)] + reveal_secrets: bool, + /// Write the resources file here instead of stdout. + #[arg(short = 'o', long)] + output: Option, + }, } #[tokio::main] @@ -93,8 +118,23 @@ async fn main() -> anyhow::Result<()> { // Subcommands run without loading the bootstrap config or booting // any listener. - if let Some(CliCommand::Validate { resources }) = cli.command { - return run_validate(&resources); + match cli.command { + Some(CliCommand::Validate { resources }) => return run_validate(&resources), + Some(CliCommand::Export { + etcd, + prefix, + reveal_secrets, + output, + }) => { + return export::run(export::ExportArgs { + endpoints: etcd, + prefix, + reveal_secrets, + output, + }) + .await; + } + None => {} } let config_path = cli diff --git a/tests/e2e/src/cases/export-roundtrip-e2e.test.ts b/tests/e2e/src/cases/export-roundtrip-e2e.test.ts new file mode 100644 index 00000000..c281c216 --- /dev/null +++ b/tests/e2e/src/cases/export-roundtrip-e2e.test.ts @@ -0,0 +1,278 @@ +import { execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + ProxyClient, + SeedClient, + spawnApp, + startOpenAiUpstream, + waitConfigPropagation, + type OpenAiUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: the `aisix export` round-trip — the migration path that closes the +// loop etcd → export → file → same behavior. +// +// A gateway is populated via the seed front door (canonical documents +// written straight to etcd, as the control plane does in managed mode). +// `aisix export` reads that etcd store and emits a resources.yaml; a +// second, file-mode gateway loads the exported file and must be +// observably identical to the etcd-backed one — same /v1/models listing, +// same chat completion, same allowed_models 403. The exported file also +// must carry no live credential at default settings; the provider +// secret is replaced with a ${VAR} placeholder the file-mode gateway +// interpolates from its environment. +// +// Reference: OpenAI Chat Completions API spec +// (https://platform.openai.com/docs/api-reference/chat/create) for the +// proxy surface the case drives. + +const execFileP = promisify(execFile); + +const BIN_PATH = + process.env.AISIX_BIN ?? join(process.cwd(), "..", "..", "target", "debug", "aisix"); +const ETCD_ENDPOINT = process.env.AISIX_E2E_ETCD ?? "http://127.0.0.1:2379"; + +const CALLER_PLAINTEXT = "sk-export-roundtrip-caller"; +const CALLER_KEY_HASH = createHash("sha256").update(CALLER_PLAINTEXT).digest("hex"); +// A distinctive marker so the no-leak assertion is unambiguous. +const PROVIDER_SECRET = "sk-provider-secret-must-not-leak-1a2b3c"; + +/** Extract every `${VAR}` interpolation reference the export emitted. */ +function placeholderVars(yaml: string): string[] { + return [...yaml.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1]!); +} + +describe("aisix export: etcd → export → file round-trip", () => { + let etcdApp: SpawnedApp | undefined; + let fileApp: SpawnedApp | undefined; + let upstream: OpenAiUpstream | undefined; + let etcdReachable = false; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) { + // Follow the harness convention: defer to each test's ctx.skip() + // rather than throwing when etcd is unavailable. + return; + } + + upstream = await startOpenAiUpstream(); + + // Populate the source-of-truth gateway's etcd exactly as the control + // plane would: one provider key (with a real secret), an allowed + // model, a forbidden model, and a caller key scoped to the allowed + // model only. + etcdApp = await spawnApp(); + const seed = new SeedClient(etcd, etcdApp.etcdPrefix); + const pk = await seed.createProviderKey({ + display_name: "rt-pk", + api_key: PROVIDER_SECRET, + api_base: `${upstream.baseUrl}/v1`, + }); + await seed.createModel({ + display_name: "rt-allowed", + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + await seed.createModel({ + display_name: "rt-forbidden", + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + await seed.createApiKey({ + key_hash: CALLER_KEY_HASH, + allowed_models: ["rt-allowed"], + }); + + // etcd propagates asynchronously — gate on the allowed model serving + // before the test reads etcd via export. + const probe = new ProxyClient(etcdApp.proxyUrl, CALLER_PLAINTEXT); + await waitConfigPropagation(async () => { + const r = await probe.chat({ + model: "rt-allowed", + messages: [{ role: "user", content: "ready-probe" }], + }); + return r.status === 200; + }); + }); + + afterAll(async () => { + await fileApp?.exit(); + await etcdApp?.exit(); + await upstream?.close(); + }); + + test("exported file has no live secret and reproduces the gateway's behavior", async (ctx) => { + if (!etcdReachable || !etcdApp || !upstream) { + ctx.skip(); + return; + } + + // 1) Run `aisix export` against the seeded etcd store. + const { stdout: yaml, stderr } = await execFileP(BIN_PATH, [ + "export", + "--etcd", + ETCD_ENDPOINT, + "--prefix", + etcdApp.etcdPrefix, + ]); + + // 2) No live credential at default settings — the provider secret is + // replaced with a placeholder, and the companion list on stderr tells + // the operator which variable to set. + expect(yaml).not.toContain(PROVIDER_SECRET); + // Diagnostics on stderr must not leak the secret into CI logs either. + expect(stderr).not.toContain(PROVIDER_SECRET); + expect(yaml).toContain("_format_version"); + const vars = placeholderVars(yaml); + expect(vars.length).toBeGreaterThan(0); + expect(stderr).toContain("rt-pk"); + for (const v of vars) { + expect(stderr).toContain(v); + } + + // 3) Load the exported file into a file-mode gateway, supplying the + // real secret through the placeholder variables the export emitted. + const extraEnv = Object.fromEntries(vars.map((v) => [v, PROVIDER_SECRET])); + fileApp = await spawnApp({ resourcesFile: yaml, extraEnv }); + + const fileProxy = new ProxyClient(fileApp.proxyUrl, CALLER_PLAINTEXT); + const etcdProxy = new ProxyClient(etcdApp.proxyUrl, CALLER_PLAINTEXT); + + // 4a) /v1/models — same listing (modulo the per-response timestamp). + const fileModels = await fileProxy.listModels(); + const etcdModels = await etcdProxy.listModels(); + expect(fileModels.status).toBe(200); + expect(etcdModels.status).toBe(200); + const normalize = (body: unknown) => { + const b = body as { object: string; data: Array> }; + return { + object: b.object, + data: b.data + .map(({ id, object, owned_by }) => ({ id, object, owned_by })) + .sort((a, z) => String(a.id).localeCompare(String(z.id))), + }; + }; + expect(normalize(fileModels.body)).toEqual(normalize(etcdModels.body)); + + // 4b) Chat on the allowed model — 200 with the same completion body + // from the shared mock upstream. Proves the ${VAR}-interpolated + // provider credential reached the upstream on the file side. + const fileChat = await fileProxy.chat({ + model: "rt-allowed", + messages: [{ role: "user", content: "round-trip" }], + }); + const etcdChat = await etcdProxy.chat({ + model: "rt-allowed", + messages: [{ role: "user", content: "round-trip" }], + }); + expect(fileChat.status).toBe(200); + expect(etcdChat.status).toBe(200); + const message = (r: unknown) => + (r as { choices: Array<{ message: { role: string; content: string } }> }).choices[0] + ?.message; + expect(message(fileChat.body)).toEqual(message(etcdChat.body)); + + // 4c) allowed_models enforcement — same 403 envelope on the model the + // caller key does not grant. + const fileBlocked = await fileProxy.chat({ + model: "rt-forbidden", + messages: [{ role: "user", content: "blocked" }], + }); + const etcdBlocked = await etcdProxy.chat({ + model: "rt-forbidden", + messages: [{ role: "user", content: "blocked" }], + }); + expect(fileBlocked.status).toBe(403); + expect(etcdBlocked.status).toBe(403); + const envelope = (r: unknown) => { + const e = (r as { error: { type?: string; code?: unknown } }).error; + return { type: e.type, code: e.code }; + }; + expect(envelope(fileBlocked.body)).toEqual(envelope(etcdBlocked.body)); + }); + + test("default export redacts a live secret on every secret-bearing kind", async (ctx) => { + if (!etcdReachable) { + ctx.skip(); + return; + } + // A dedicated prefix seeded with one distinctively-marked secret per + // redacted field/kind. Reads etcd directly — no gateway needed. Each + // entry is asserted present (by name) AND its marker absent, so a + // rejected-and-dropped seed can't make a leak assertion pass vacuously. + const etcd = new EtcdClient(); + const prefix = `/aisix-export-noleak-${randomUUID()}`; + const seed = new SeedClient(etcd, prefix); + const marker = { + providerApiKey: "leak-provider-apikey-AAA111", + providerHeader: "leak-provider-header-BBB222", + mcpSecret: "leak-mcp-secret-CCC333", + guardrailApiKey: "leak-guardrail-apikey-DDD444", + otlpHeader: "leak-otlp-header-EEE555", + }; + try { + await seed.createProviderKey({ + display_name: "noleak-pk", + api_key: marker.providerApiKey, + request: { default_headers: { "x-tenant-token": marker.providerHeader } }, + }); + await seed.update("mcp_servers", randomUUID(), { + name: "noleak-mcp", + url: "https://mcp.example.com/mcp", + auth_type: "bearer", + secret: marker.mcpSecret, + }); + const guardrail = await seed.createGuardrail({ + name: "noleak-guardrail", + kind: "openai_moderation", + api_key: marker.guardrailApiKey, + }); + // Attach it env-wide so it is already gateway-wide in etcd and the + // exporter emits it (exercising guardrail-credential redaction); + // an attachment-scoped guardrail would be omitted by design. + await seed.update("guardrail_attachments", randomUUID(), { + guardrail_id: guardrail.id, + scope_type: "env", + priority: 1, + }); + await seed.createObservabilityExporter({ + name: "noleak-otlp", + kind: "otlp_http", + endpoint: "https://otlp.example.com/v1/traces", + headers: { "x-honeycomb-team": marker.otlpHeader }, + }); + + const { stdout: yaml, stderr } = await execFileP(BIN_PATH, [ + "export", + "--etcd", + ETCD_ENDPOINT, + "--prefix", + prefix, + ]); + + // Each entry made it into the export (not silently dropped)… + for (const name of ["noleak-pk", "noleak-mcp", "noleak-guardrail", "noleak-otlp"]) { + expect(yaml, `${name} missing from export`).toContain(name); + } + // …and not one live secret survived at default settings, in the + // file or in the stderr diagnostics. + for (const [field, value] of Object.entries(marker)) { + expect(yaml, `secret leaked to file: ${field}`).not.toContain(value); + expect(stderr, `secret leaked to stderr: ${field}`).not.toContain(value); + } + expect(yaml).toContain("${AISIXSECRET"); + expect(stderr).toContain("secret value(s) were replaced"); + } finally { + await etcd.deletePrefix(prefix); + } + }); +});