Support per-format provider key auth

This commit is contained in:
fawney19
2026-04-29 15:46:50 +08:00
parent 07a319259b
commit e751289dfb
67 changed files with 1244 additions and 420 deletions
@@ -1,7 +1,7 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRequest;
use crate::handlers::admin::provider::write::normalize::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
validate_vertex_api_formats,
normalize_auth_type_by_format, validate_vertex_api_formats,
};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
@@ -33,6 +33,15 @@ pub(crate) async fn build_admin_create_provider_key_record(
);
let auth_type = normalize_auth_type(payload.auth_type.as_deref())?;
validate_vertex_api_formats(&provider.provider_type, &auth_type, &api_formats)?;
let auth_type_by_format = if matches!(auth_type.as_str(), "api_key" | "bearer") {
normalize_auth_type_by_format(
payload.auth_type_by_format,
"auth_type_by_format",
&api_formats,
)?
} else {
None
};
let api_key = payload.api_key.unwrap_or_default().trim().to_string();
let auth_config = normalize_json_object(payload.auth_config, "auth_config")?;
@@ -42,9 +51,6 @@ pub(crate) async fn build_admin_create_provider_key_record(
.cloned();
match auth_type.as_str() {
"api_key" if api_key.is_empty() => {
return Err("API Key 认证模式下 api_key 为必填字段".to_string());
}
"service_account" if auth_config_object.is_none() => {
return Err("Service Account 认证模式下 auth_config 为必填字段".to_string());
}
@@ -59,15 +65,18 @@ pub(crate) async fn build_admin_create_provider_key_record(
.await
.map_err(|err| format!("{err:?}"))?;
if auth_type == "api_key" {
if matches!(auth_type.as_str(), "api_key" | "bearer") && !api_key.is_empty() {
for existing in existing_keys
.iter()
.filter(|existing| existing.auth_type.trim().eq_ignore_ascii_case("api_key"))
.filter(|existing| raw_secret_auth_type(&existing.auth_type))
{
let Some(decrypted) = decrypt_catalog_secret_with_fallbacks(
state.encryption_key(),
&existing.encrypted_api_key,
) else {
let Some(decrypted) = existing
.encrypted_api_key
.as_deref()
.and_then(|ciphertext| {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
})
else {
continue;
};
if decrypted != "__placeholder__" && decrypted == api_key {
@@ -116,10 +125,12 @@ pub(crate) async fn build_admin_create_provider_key_record(
}
let encrypted_api_key = match auth_type.as_str() {
"api_key" => encrypt_catalog_secret_with_fallbacks(state, &api_key),
_ => encrypt_catalog_secret_with_fallbacks(state, "__placeholder__"),
}
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?;
"api_key" | "bearer" if !api_key.is_empty() => Some(
encrypt_catalog_secret_with_fallbacks(state, &api_key)
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?,
),
_ => None,
};
let encrypted_auth_config = auth_config
.as_ref()
@@ -180,7 +191,15 @@ pub(crate) async fn build_admin_create_provider_key_record(
normalize_string_list(payload.model_exclude_patterns).map(|value| json!(value));
key.health_by_format = Some(json!({}));
key.circuit_breaker_by_format = Some(json!({}));
key.auth_type_by_format = auth_type_by_format;
key.created_at_unix_ms = Some(now_unix_secs);
key.updated_at_unix_secs = Some(now_unix_secs);
Ok(key)
}
fn raw_secret_auth_type(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"api_key" | "bearer"
)
}
@@ -1,7 +1,7 @@
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
use crate::handlers::admin::provider::write::normalize::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
validate_vertex_api_formats,
normalize_auth_type_by_format, validate_vertex_api_formats,
};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
@@ -48,10 +48,6 @@ pub(crate) async fn build_admin_update_provider_key_record(
.as_deref()
.map(str::trim)
.map(ToOwned::to_owned);
if api_key_present && api_key_value.as_deref() == Some("") {
return Err("api_key 不能为空".to_string());
}
let auth_config_present = fields.contains("auth_config");
let auth_config = normalize_json_object(payload.auth_config, "auth_config")?;
let auth_config_object = auth_config
@@ -65,31 +61,26 @@ pub(crate) async fn build_admin_update_provider_key_record(
.map_err(|err| format!("{err:?}"))?;
match target_auth_type.as_str() {
"api_key" => {
if auth_type_switch
&& matches!(
api_key_value.as_deref(),
None | Some("") | Some("__placeholder__")
)
"api_key" | "bearer" => {
if let Some(api_key) = api_key_value
.as_deref()
.filter(|value| !value.is_empty() && *value != "__placeholder__")
{
return Err("切换到 API Key 认证模式时,必须提供新的 API Key".to_string());
}
if api_key_present
&& matches!(
api_key_value.as_deref(),
None | Some("") | Some("__placeholder__")
)
{
return Err("API Key 认证模式下 api_key 不能为空".to_string());
}
if let Some(api_key) = api_key_value.as_deref() {
for existing_key in existing_keys.iter().filter(|key| {
key.id != existing.id && key.auth_type.trim().eq_ignore_ascii_case("api_key")
}) {
let Some(decrypted) = decrypt_catalog_secret_with_fallbacks(
state.encryption_key(),
&existing_key.encrypted_api_key,
) else {
for existing_key in existing_keys
.iter()
.filter(|key| key.id != existing.id && raw_secret_auth_type(&key.auth_type))
{
let Some(decrypted) =
existing_key
.encrypted_api_key
.as_deref()
.and_then(|ciphertext| {
decrypt_catalog_secret_with_fallbacks(
state.encryption_key(),
ciphertext,
)
})
else {
continue;
};
if decrypted != "__placeholder__" && decrypted == api_key {
@@ -99,9 +90,12 @@ pub(crate) async fn build_admin_update_provider_key_record(
));
}
}
updated.encrypted_api_key =
updated.encrypted_api_key = Some(
encrypt_catalog_secret_with_fallbacks(state, api_key)
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?;
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?,
);
} else if api_key_present {
updated.encrypted_api_key = None;
}
updated.encrypted_auth_config = None;
}
@@ -120,9 +114,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
return Err("Service Account 认证模式下不允许直接填写 api_key".to_string());
}
if auth_type_switch || api_key_present {
updated.encrypted_api_key =
encrypt_catalog_secret_with_fallbacks(state, "__placeholder__")
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?;
updated.encrypted_api_key = None;
}
if let Some(client_email) = auth_config_object
.as_ref()
@@ -181,9 +173,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
return Err("OAuth 认证模式下不允许直接填写 api_key".to_string());
}
if auth_type_switch {
updated.encrypted_api_key =
encrypt_catalog_secret_with_fallbacks(state, "__placeholder__")
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?;
updated.encrypted_api_key = None;
updated.encrypted_auth_config = None;
}
}
@@ -197,6 +187,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
);
if managed_fixed_oauth_key {
updated.api_formats = None;
updated.auth_type_by_format = None;
} else {
validate_vertex_api_formats(&provider.provider_type, &target_auth_type, &api_formats)?;
updated.api_formats = Some(json!(api_formats));
@@ -211,6 +202,26 @@ pub(crate) async fn build_admin_update_provider_key_record(
}
}
let effective_api_formats =
normalize_api_format_list(json_string_list(updated.api_formats.as_ref()));
if matches!(target_auth_type.as_str(), "api_key" | "bearer") {
if fields.contains("auth_type_by_format") {
updated.auth_type_by_format = normalize_auth_type_by_format(
payload.auth_type_by_format,
"auth_type_by_format",
&effective_api_formats,
)?;
} else if fields.contains("api_formats") {
updated.auth_type_by_format = normalize_auth_type_by_format(
updated.auth_type_by_format.clone(),
"auth_type_by_format",
&effective_api_formats,
)?;
}
} else {
updated.auth_type_by_format = None;
}
updated.auth_type = target_auth_type;
if let Some(name) = payload.name {
@@ -285,7 +296,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
if fields.contains("fingerprint") {
updated.fingerprint = normalize_json_object(payload.fingerprint, "fingerprint")?;
}
if auth_config_present && !auth_type_switch && updated.auth_type != "api_key" {
if auth_config_present && !auth_type_switch && !raw_secret_auth_type(&updated.auth_type) {
updated.encrypted_auth_config = auth_config
.as_ref()
.map(serde_json::to_string)
@@ -304,3 +315,10 @@ pub(crate) async fn build_admin_update_provider_key_record(
.map(|duration| duration.as_secs());
Ok(updated)
}
fn raw_secret_auth_type(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"api_key" | "bearer"
)
}
@@ -42,6 +42,45 @@ pub(crate) fn normalize_api_format_json_object_keys(
Ok(Some(serde_json::Value::Object(normalized)))
}
pub(crate) fn normalize_auth_type_by_format(
value: Option<serde_json::Value>,
field_name: &str,
api_formats: &[String],
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = normalize_json_like_object(value, field_name)? else {
return Ok(None);
};
let serde_json::Value::Object(map) = value else {
return Ok(Some(value));
};
let allowed = api_formats.iter().cloned().collect::<BTreeSet<_>>();
let mut normalized = serde_json::Map::new();
for (key, value) in map {
let canonical = crate::ai_pipeline::normalize_api_format_alias(&key);
if !allowed.is_empty() && !allowed.contains(&canonical) {
return Err(format!("{field_name} 包含未选择的 API 格式: {canonical}"));
}
let Some(auth_type) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err(format!("{field_name}.{canonical} 必须是字符串"));
};
let auth_type = match auth_type.to_ascii_lowercase().as_str() {
"api_key" | "apikey" | "api-key" => "api_key",
"bearer" | "bearer_token" | "bearer-token" | "authorization" => "bearer",
_ => return Err(format!("{field_name}.{canonical} 仅支持 api_key / bearer")),
};
normalized.insert(canonical, serde_json::Value::String(auth_type.to_string()));
}
if normalized.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::Value::Object(normalized)))
}
}
pub(crate) fn normalize_auth_type(value: Option<&str>) -> Result<String, String> {
let auth_type = value.unwrap_or("api_key").trim().to_ascii_lowercase();
match auth_type.as_str() {
@@ -111,7 +150,7 @@ fn normalize_json_like_object(
mod tests {
use super::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
normalize_pool_advanced_config, validate_vertex_api_formats,
normalize_auth_type_by_format, normalize_pool_advanced_config, validate_vertex_api_formats,
};
use serde_json::json;
@@ -180,6 +219,28 @@ mod tests {
);
}
#[test]
fn normalize_auth_type_by_format_accepts_per_format_bearer_override() {
assert_eq!(
normalize_auth_type_by_format(
Some(json!({
"claude:messages": "bearer",
"gemini:generate_content": "api-key"
})),
"auth_type_by_format",
&[
"claude:messages".to_string(),
"gemini:generate_content".to_string(),
],
)
.expect("auth map should normalize"),
Some(json!({
"claude:messages": "bearer",
"gemini:generate_content": "api_key"
}))
);
}
#[test]
fn validate_vertex_api_formats_uses_canonical_message_formats() {
assert!(validate_vertex_api_formats(
@@ -2,6 +2,7 @@ fn normalize_reveal_auth_type(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"service_account" | "vertex_ai" => "service_account",
"oauth" => "oauth",
"bearer" => "bearer",
_ => "api_key",
}
}
@@ -44,8 +45,10 @@ pub(crate) fn build_admin_reveal_key_payload(
"auth_config": auth_config,
}));
}
let decrypted = state
.decrypt_catalog_secret_with_fallbacks(&key.encrypted_api_key)
let decrypted = key
.encrypted_api_key
.as_deref()
.and_then(|ciphertext| state.decrypt_catalog_secret_with_fallbacks(ciphertext))
.ok_or_else(|| {
"无法解密认证配置,可能是加密密钥已更改。请重新添加该密钥。".to_string()
})?;
@@ -58,9 +61,14 @@ pub(crate) fn build_admin_reveal_key_payload(
}));
}
let decrypted = state
.decrypt_catalog_secret_with_fallbacks(&key.encrypted_api_key)
.ok_or_else(|| "无法解密 API Key,可能是加密密钥已更改。请重新添加该密钥。".to_string())?;
let decrypted = match key.encrypted_api_key.as_deref().map(str::trim) {
Some(ciphertext) if !ciphertext.is_empty() => state
.decrypt_catalog_secret_with_fallbacks(ciphertext)
.ok_or_else(|| {
"无法解密 API Key,可能是加密密钥已更改。请重新添加该密钥。".to_string()
})?,
_ => String::new(),
};
Ok(json!({
"auth_type": auth_type,
"api_key": decrypted,