diff --git a/backend/crates/gateway-admin/src/model/client_keys.rs b/backend/crates/gateway-admin/src/model/client_keys.rs index 6532773df..39d59c448 100644 --- a/backend/crates/gateway-admin/src/model/client_keys.rs +++ b/backend/crates/gateway-admin/src/model/client_keys.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use gateway_core::{ engine::budget::{ClientBudgetLimits, ClientBudgetStatus}, - policy::{ClientApiKeyId, RateLimits}, + policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}, routing::{AccountGroupId, ProviderKind}, }; @@ -144,6 +144,7 @@ impl fmt::Debug for ClientKeySecret { /// API 提交的 Client Key 创建命令。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateClientKey { + pub custom_key: Option, pub name: String, pub label: Option, pub group_ids: Vec, diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index f13586f35..d75b92113 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind { Invalid, NotFound, StaleRevision, + DuplicateName, Conflict, Unavailable, } diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index 4a8c4bbfe..af7317fd8 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -18,7 +18,7 @@ use crate::{ DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey, }, }, - ports::store::ClientKeyStore, + ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore}, }; use super::{map_store_error, publish_committed}; @@ -87,9 +87,13 @@ impl ClientKeyService for DefaultClientKeyService { ) -> Result { let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple())) .map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?; - let mut bytes = [0_u8; 32]; - OsRng.fill_bytes(&mut bytes); - let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes)); + let plaintext = if let Some(key) = command.custom_key { + key.expose_for_auth().to_owned() + } else { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes)) + }; let (config_revision, record) = self .store .create_client_key( @@ -105,7 +109,7 @@ impl ClientKeyService for DefaultClientKeyService { context, ) .await - .map_err(|error| map_store_error(error, "client API key"))?; + .map_err(map_client_key_write_error)?; publish_committed(self.snapshot.as_ref(), config_revision).await?; Ok(CreatedClientKey { config_revision, @@ -123,7 +127,7 @@ impl ClientKeyService for DefaultClientKeyService { .store .update_client_key(command, context) .await - .map_err(|error| map_store_error(error, "client API key"))?; + .map_err(map_client_key_write_error)?; publish_committed(self.snapshot.as_ref(), config_revision).await?; Ok(ClientKeyMutation { config_revision, @@ -171,6 +175,14 @@ impl ClientKeyService for DefaultClientKeyService { } } +fn map_client_key_write_error(error: AdminStoreError) -> AdminError { + match error.kind() { + AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"), + AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"), + _ => map_store_error(error, "client API key"), + } +} + fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> { let Some(cursor) = &query.cursor else { return Ok(()); diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index 7dc40c274..0db3ff4a2 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -42,9 +42,9 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError let kind = match error.kind() { AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid, AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound, - AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => { - AdminErrorKind::Conflict - } + AdminStoreErrorKind::StaleRevision + | AdminStoreErrorKind::DuplicateName + | AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict, AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable, }; tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed"); diff --git a/backend/crates/gateway-admin/tests/use_case/client_keys.rs b/backend/crates/gateway-admin/tests/use_case/client_keys.rs index ae15cd563..1f2f722bd 100644 --- a/backend/crates/gateway-admin/tests/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/tests/use_case/client_keys.rs @@ -1,22 +1,31 @@ +use std::sync::{Arc, Mutex}; + use async_trait::async_trait; -use gateway_core::policy::ClientApiKeyId; +use chrono::Utc; +use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}; use gateway_admin::{ model::{ - AdminErrorKind, MutationContext, Revision, + AdminErrorKind, MutationActor, MutationContext, Revision, client_keys::{ ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField, - DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey, + CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, + UpdateClientKey, }, }, ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore}, }; -struct UnusedClientKeyStore; +#[derive(Default)] +struct TestClientKeyStore { + plaintexts: Mutex>, + create_error: Option, + update_error: Option, +} #[async_trait] -impl ClientKeyStore for UnusedClientKeyStore { +impl ClientKeyStore for TestClientKeyStore { async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult { assert_eq!(query.page_size.get(), u16::MAX); Ok(ClientKeyPage { @@ -36,10 +45,32 @@ impl ClientKeyStore for UnusedClientKeyStore { async fn create_client_key( &self, - _: NewClientKey, + key: NewClientKey, _: &MutationContext, ) -> AdminStoreResult<(Revision, ClientKeyRecord)> { - Err(unused()) + if let Some(kind) = self.create_error { + return Err(AdminStoreError::new( + kind, + "client key", + "test create failure", + )); + } + self.plaintexts.lock().unwrap().push(key.plaintext); + let record = ClientKeyRecord { + id: key.id, + name: key.name, + label: key.label, + groups: Vec::new(), + provider_kinds: Vec::new(), + prefix: String::new(), + enabled: true, + limits: key.limits, + budget: Default::default(), + last_used_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + Ok((Revision::new(2).unwrap(), record)) } async fn update_client_key( @@ -47,6 +78,13 @@ impl ClientKeyStore for UnusedClientKeyStore { _: UpdateClientKey, _: &MutationContext, ) -> AdminStoreResult<(Revision, ClientKeyRecord)> { + if let Some(kind) = self.update_error { + return Err(AdminStoreError::new( + kind, + "client key", + "test update failure", + )); + } Err(unused()) } @@ -70,7 +108,7 @@ impl ClientKeyStore for UnusedClientKeyStore { #[tokio::test] async fn client_key_cursor_should_reject_value_that_does_not_match_sort() { let services = super::AdminHarness::new() - .client_keys(std::sync::Arc::new(UnusedClientKeyStore)) + .client_keys(Arc::new(TestClientKeyStore::default())) .build() .await; let sort = ClientKeySort { @@ -98,7 +136,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() { #[tokio::test] async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() { let services = super::AdminHarness::new() - .client_keys(std::sync::Arc::new(UnusedClientKeyStore)) + .client_keys(Arc::new(TestClientKeyStore::default())) .build() .await; let page = services @@ -118,6 +156,105 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() { assert_eq!(page.total, 0); } +#[tokio::test] +async fn create_preserves_migrated_keys_and_keeps_default_generation() { + let store = Arc::new(TestClientKeyStore::default()); + let services = super::AdminHarness::new() + .client_keys(store.clone()) + .build() + .await; + for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] { + let created = services + .client_keys() + .create(&mutation_context(), create_command(value.as_deref())) + .await + .unwrap(); + let plaintext = created.secret.expose_for_response(); + assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext); + if let Some(value) = value { + assert_eq!(plaintext, value); + } else { + assert!(plaintext.starts_with("sk_")); + assert_eq!(plaintext.len(), 46); + } + } +} + +#[tokio::test] +async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() { + let services = super::AdminHarness::new() + .client_keys(Arc::new(TestClientKeyStore { + create_error: Some(AdminStoreErrorKind::Conflict), + ..Default::default() + })) + .build() + .await; + let key = "legacy-duplicate-must-stay-private"; + let error = services + .client_keys() + .create(&mutation_context(), create_command(Some(key))) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!(error.message(), "API Key 已存在,请使用其他密钥"); + assert!(!format!("{error:?}").contains(key)); +} + +#[tokio::test] +async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() { + let services = super::AdminHarness::new() + .client_keys(Arc::new(TestClientKeyStore { + create_error: Some(AdminStoreErrorKind::DuplicateName), + update_error: Some(AdminStoreErrorKind::DuplicateName), + ..Default::default() + })) + .build() + .await; + let created = services + .client_keys() + .create(&mutation_context(), create_command(None)) + .await + .unwrap_err(); + let updated = services + .client_keys() + .update( + &mutation_context(), + UpdateClientKey { + id: ClientApiKeyId::new("key_existing").unwrap(), + name: "Migration".to_owned(), + label: None, + group_ids: Vec::new(), + limits: RateLimits::unlimited(), + daily_limit_usd: None, + weekly_limit_usd: None, + }, + ) + .await + .unwrap_err(); + for error in [created, updated] { + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!(error.message(), "名称已存在"); + } +} + +fn create_command(key: Option<&str>) -> CreateClientKey { + CreateClientKey { + custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()), + name: "Migration".to_owned(), + label: None, + group_ids: Vec::new(), + limits: RateLimits::unlimited(), + budget: Default::default(), + } +} + +fn mutation_context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "custom-key-test".to_owned(), + } +} + fn unused() -> AdminStoreError { AdminStoreError::new( AdminStoreErrorKind::Unavailable, diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index 8370d0a51..0b1fb7f4f 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -13,7 +13,7 @@ use gateway_admin::model::client_keys::{ use gateway_core::{ engine::budget::ClientBudgetLimits, metering::Decimal, - policy::{ClientApiKeyId, RateLimits}, + policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}, routing::AccountGroupId, }; use serde::{Deserialize, Serialize}; @@ -70,9 +70,7 @@ impl ListClientKeysQuery { } let search = self.search.map(|search| search.trim().to_owned()); if search.as_deref().is_some_and(|search| { - search.len() > MAX_SEARCH_BYTES - || search.chars().any(char::is_control) - || contains_client_key_material(search) + search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control) }) { return Err(WireValidationError::new("search")); } @@ -143,9 +141,10 @@ impl ClientKeySort { } /// 创建 Client Key 请求。 -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, PartialEq, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateClientKeyRequest { + custom_key: Option, name: String, label: Option, group_ids: Vec, @@ -155,6 +154,16 @@ pub struct CreateClientKeyRequest { weekly_limit_usd: Option, } +impl fmt::Debug for CreateClientKeyRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CreateClientKeyRequest") + .field("name", &self.name) + .field("custom_key", &"[REDACTED]") + .finish_non_exhaustive() + } +} + impl CreateClientKeyRequest { /// 校验 wire 边界并直接构造管理用例命令。 pub fn into_command(self) -> Result { @@ -164,6 +173,12 @@ impl CreateClientKeyRequest { validate_limit(self.max_concurrency, "maxConcurrency")?; validate_limit(self.requests_per_minute, "requestsPerMinute")?; Ok(CreateClientKey { + custom_key: self + .custom_key + .filter(|key| !key.is_empty()) + .map(PlaintextClientApiKey::new) + .transpose() + .map_err(|_| WireValidationError::new("customKey"))?, name: self.name, label: self.label, group_ids, @@ -661,15 +676,6 @@ fn validate_optional_text( Ok(()) } -fn contains_client_key_material(value: &str) -> bool { - value.as_bytes().windows(46).any(|window| { - &window[..3] == b"sk_" - && window[3..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - }) -} - /// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。 pub fn router() -> Router where diff --git a/backend/crates/gateway-api/src/openai/auth.rs b/backend/crates/gateway-api/src/openai/auth.rs index 7ea21cb20..8b89f7b58 100644 --- a/backend/crates/gateway-api/src/openai/auth.rs +++ b/backend/crates/gateway-api/src/openai/auth.rs @@ -86,7 +86,7 @@ impl ClientApiKeyAuthError { /// /// # Errors /// -/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。 +/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。 pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> { let raw = headers .get(AUTHORIZATION) @@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu if token.is_empty() { return Err(ClientApiKeyAuthError::MalformedAuthorization); } - if !token.starts_with("sk_") { + if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() { return Err(ClientApiKeyAuthError::InvalidKeyFormat); } Ok(token) diff --git a/backend/crates/gateway-api/tests/admin/client_keys.rs b/backend/crates/gateway-api/tests/admin/client_keys.rs index 50e02e511..9b98e6fe4 100644 --- a/backend/crates/gateway-api/tests/admin/client_keys.rs +++ b/backend/crates/gateway-api/tests/admin/client_keys.rs @@ -10,6 +10,71 @@ use serde_json::json; use super::{AdminTestFixture, AdminTestState}; +#[test] +fn custom_client_keys_preserve_migrated_values_and_redact_debug() { + for value in [ + "q".to_owned(), + "sk-old!@/key+=value".to_owned(), + "x".repeat(8192), + ] { + let request: CreateClientKeyRequest = serde_json::from_value(json!({ + "name": "migration", "customKey": value, "groupIds": [], + "maxConcurrency": 2, "requestsPerMinute": 0 + })) + .unwrap(); + let debug = format!("{request:?}"); + assert!(debug.contains("[REDACTED]")); + let command = request.into_command().unwrap(); + assert_eq!(command.custom_key.unwrap().expose_for_auth(), value); + } + let secret = "legacy-key-should-not-leak"; + let request: CreateClientKeyRequest = serde_json::from_value(json!({ + "name": "migration", "customKey": secret, "groupIds": [], + "maxConcurrency": 0, "requestsPerMinute": 0 + })) + .unwrap(); + assert!(!format!("{request:?}").contains(secret)); + assert!(!format!("{:?}", request.into_command().unwrap()).contains(secret)); +} + +#[test] +fn custom_client_key_is_optional_and_only_rejects_untransportable_values() { + let payload = json!({"name": "migration", "groupIds": [], "maxConcurrency": 0, + "requestsPerMinute": 0}); + for value in [None, Some(json!(null)), Some(json!(""))] { + let mut payload = payload.clone(); + if let Some(value) = value { + payload["customKey"] = value; + } + let command = serde_json::from_value::(payload) + .unwrap() + .into_command() + .unwrap(); + assert!(command.custom_key.is_none()); + } + for value in [ + " key", + "key ", + "key with spaces", + "key\n", + "key\tvalue", + "密钥", + ] { + let mut payload = payload.clone(); + payload["customKey"] = json!(value); + let error = serde_json::from_value::(payload) + .unwrap() + .into_command() + .unwrap_err(); + assert_eq!(error.field(), "customKey"); + assert!(!format!("{error:?}").contains(value)); + } + let mut update = payload; + update["id"] = json!("key_existing"); + update["customKey"] = json!("replacement-credential"); + assert!(serde_json::from_value::(update).is_err()); +} + #[test] fn budget_inputs_preserve_decimal_precision_and_omitted_updates() { let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3, @@ -63,10 +128,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() { "search": "a".repeat(257) })) .expect("deserialize oversized search"); - let secret_search = serde_json::from_value::(json!({ - "search": format!("sk_{}", "a".repeat(43)) - })) - .expect("deserialize secret search"); let invalid_sort = serde_json::from_value::(json!({ "sortBy": "plaintextKey", "sortDirection": "asc" @@ -92,13 +153,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() { .field(), "search" ); - assert_eq!( - secret_search - .into_command() - .expect_err("reject full key in search") - .field(), - "search" - ); assert_eq!( invalid_sort .into_command() @@ -140,6 +194,16 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() { ); } +#[test] +fn client_key_name_search_does_not_interpret_names_as_credentials() { + let name = format!("sk_{}", "a".repeat(43)); + let command = serde_json::from_value::(json!({ "search": name })) + .unwrap() + .into_command() + .unwrap(); + assert_eq!(command.search.as_deref(), Some(name.as_str())); +} + #[test] fn client_key_mutations_should_validate_text_limits_and_unknown_fields() { let valid = serde_json::from_value::(json!({ diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 4bda051b2..7a24938c8 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -76,10 +76,28 @@ fn bearer_client_api_key_should_reject_empty_bearer_token() { } #[test] -fn bearer_client_api_key_should_reject_non_gateway_key_prefix() { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret")); +fn bearer_client_api_key_should_accept_migrated_formats_without_rewriting() { + for key in [ + "q".to_owned(), + "sk-old/key+value=:!@".to_owned(), + "x".repeat(8192), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {key}")).unwrap(), + ); + assert_eq!(bearer_client_api_key(&headers), Ok(key.as_str())); + } +} +#[test] +fn bearer_client_api_key_should_reject_whitespace_inside_tokens() { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer key with spaces"), + ); assert_eq!( bearer_client_api_key(&headers), Err(ClientApiKeyAuthError::InvalidKeyFormat) diff --git a/backend/crates/gateway-core/src/policy/mod.rs b/backend/crates/gateway-core/src/policy/mod.rs index 26e28cb32..468bb8b53 100644 --- a/backend/crates/gateway-core/src/policy/mod.rs +++ b/backend/crates/gateway-core/src/policy/mod.rs @@ -53,13 +53,28 @@ impl PlaintextClientApiKey { /// /// # Errors /// - /// Key 为空、过长或包含控制字符时返回错误。 + /// Key 为空或无法作为 HTTP Bearer 值发送时返回错误。 pub fn new(value: impl Into) -> Result { let value = value.into(); - validate_text(&value, 512, false, None)?; + Self::validate(&value)?; Ok(Self(value)) } + /// 迁入的 Key 不限定前缀或长度;保持原值,仅校验 HTTP 可传输的非空可见 ASCII。 + /// + /// # Errors + /// + /// Key 为空或包含空白、控制字符、非 ASCII 字符时返回错误。 + pub fn validate(value: &str) -> Result<(), IdentifierError> { + if value.is_empty() { + return Err(IdentifierError::Empty); + } + if !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(IdentifierError::InvalidFormat); + } + Ok(()) + } + /// 仅借给同步认证器做常量时间比较。 #[must_use] pub fn expose_for_auth(&self) -> &str { diff --git a/backend/crates/gateway-core/tests/policy/mod.rs b/backend/crates/gateway-core/tests/policy/mod.rs index 32975f84d..e397b22d9 100644 --- a/backend/crates/gateway-core/tests/policy/mod.rs +++ b/backend/crates/gateway-core/tests/policy/mod.rs @@ -57,6 +57,29 @@ fn plaintext_client_key_debug_should_be_redacted() { assert_eq!(key.expose_for_auth(), "sk_must_not_appear"); } +#[test] +fn migrated_client_keys_keep_their_exact_value_without_prefix_or_length_limits() { + for value in [ + "q".to_owned(), + "legacy-!@#$%^&*()[]{}:;,.?/+=~|\\".to_owned(), + "x".repeat(8192), + ] { + assert_eq!(plaintext(&value).expose_for_auth(), value); + } + for value in [ + "", + " ", + " key", + "key ", + "key\n", + "key\tvalue", + "key\u{7f}", + "密钥", + ] { + assert!(PlaintextClientApiKey::new(value).is_err()); + } +} + #[test] fn client_version_should_require_strict_semver() { assert!(CodexClientVersion::parse("0.152.0").is_ok()); diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index f279ca220..af68aaf6c 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -47,7 +47,6 @@ use crate::{ use super::{ControlPlaneRepository, PgControlPlaneRepository}; const ENTITY: &str = "client API key"; -const KEY_LENGTH: usize = 46; const CLIENT_API_KEY_LAST_USED_FLUSH_DELAY: Duration = Duration::from_secs(1); const CLIENT_API_KEY_LAST_USED_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); @@ -355,7 +354,8 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { query.validate()?; let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?; let mut statement = QueryBuilder::::new( - "select k.id, k.name, k.label, left(k.key, 10) as prefix, k.enabled, + "select k.id, k.name, k.label, + left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds from client_api_keys k @@ -414,7 +414,8 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { async fn get_client_api_key(&self, id: &str) -> StoreResult> { require_nonempty(ENTITY, "id", id)?; let record = sqlx::query( - "select k.id, k.name, k.label, left(k.key, 10) as prefix, k.enabled, + "select k.id, k.name, k.label, + left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups, case @@ -977,6 +978,7 @@ pub(crate) async fn insert_client_api_key_in_transaction( key: &NewClientApiKey, ) -> StoreResult<()> { key.validate()?; + ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?; sqlx::query( "insert into client_api_keys ( id, name, label, key, enabled, max_concurrency, requests_per_minute, @@ -984,7 +986,7 @@ pub(crate) async fn insert_client_api_key_in_transaction( ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric)", ) .bind(&key.id) - .bind(&key.name) + .bind(key.name.trim()) .bind(&key.label) .bind(&key.key) .bind(to_i64(key.max_concurrency)?) @@ -993,7 +995,20 @@ pub(crate) async fn insert_client_api_key_in_transaction( .bind(key.budget.weekly_usd.canonical()) .execute(&mut **transaction) .await - .map_err(|_| postgres_unavailable("insert client API key in transaction"))?; + .map_err(|error| { + if error.as_database_error().is_some_and(|database| { + database.is_unique_violation() + && database.constraint() == Some("client_api_keys_key_sha256_idx") + }) { + StoreError::Conflict { + entity: ENTITY, + id: key.id.clone(), + kind: crate::ConflictKind::InvalidTransition, + } + } else { + postgres_unavailable("insert client API key in transaction") + } + })?; replace_client_api_key_groups_in_transaction(transaction, &key.id, &key.group_ids).await?; Ok(()) } @@ -1003,6 +1018,7 @@ pub(crate) async fn update_client_api_key_in_transaction( key: &UpdateClientApiKeyDetails, ) -> StoreResult<()> { key.validate()?; + ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?; let result = sqlx::query( "update client_api_keys set name = $2, label = $3, max_concurrency = $4, @@ -1012,7 +1028,7 @@ pub(crate) async fn update_client_api_key_in_transaction( where id = $1", ) .bind(&key.id) - .bind(&key.name) + .bind(key.name.trim()) .bind(&key.label) .bind(to_i64(key.max_concurrency)?) .bind(to_i64(key.requests_per_minute)?) @@ -1025,6 +1041,32 @@ pub(crate) async fn update_client_api_key_in_transaction( replace_client_api_key_groups_in_transaction(transaction, &key.id, &key.group_ids).await } +async fn ensure_client_key_name_available( + transaction: &mut Transaction<'_, Postgres>, + id: &str, + name: &str, +) -> StoreResult<()> { + // 调用方已通过递增配置版本持有控制面行锁,查重与写入在同一事务内串行执行。 + // 不回填历史重名数据;创建和保存时统一校验,更新排除当前记录。 + let duplicate: bool = sqlx::query_scalar( + "select exists(select 1 from client_api_keys + where lower(btrim(name)) = lower($1) and id <> $2)", + ) + .bind(name) + .bind(id) + .fetch_one(&mut **transaction) + .await + .map_err(|_| postgres_unavailable("check client API key name"))?; + if duplicate { + return Err(StoreError::Conflict { + entity: ENTITY, + id: id.to_owned(), + kind: crate::ConflictKind::DuplicateName, + }); + } + Ok(()) +} + pub(crate) async fn set_client_api_key_enabled_in_transaction( transaction: &mut Transaction<'_, Postgres>, id: &str, @@ -1119,18 +1161,8 @@ fn validate_group_ids(group_ids: &[String]) -> StoreResult<()> { } fn validate_key(key: &str) -> StoreResult<()> { - let valid = key.len() == KEY_LENGTH - && key.starts_with("sk_") - && key[3..] - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); - if valid { - Ok(()) - } else { - Err(invalid( - "key must be sk_ followed by 43 URL-safe characters", - )) - } + PlaintextClientApiKey::validate(key) + .map_err(|_| invalid("key must contain nonempty visible ASCII characters")) } fn client_secret_from_row( @@ -1204,9 +1236,6 @@ fn push_client_key_search(statement: &mut QueryBuilder, search: Option statement.push_bind(prefix.clone()); statement.push(" escape '\\'"); statement.push(" or lower(coalesce(label, '')) like "); - statement.push_bind(prefix.clone()); - statement.push(" escape '\\'"); - statement.push(" or lower(left(key, 10)) like "); statement.push_bind(prefix); statement.push(" escape '\\'"); statement.push(")"); diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs index def825a55..f7f82148c 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs @@ -254,5 +254,6 @@ fn push_prefix_search(statement: &mut QueryBuilder, columns: &[&str], statement.push_bind(pattern.clone()); statement.push(" escape '\\'"); } + push_client_key_name_search(statement, "mr.client_api_key_ref", pattern); statement.push(")"); } diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index ec677f3aa..2367f783a 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -94,22 +94,25 @@ pub(crate) fn push_usage_filter( query.push(format!(" escape '\\' or {alias}.{column} like ")); query.push_bind(pattern.clone()); } - if value.starts_with("sk_") { - query.push(format!( - " escape '\\' or exists ( - select 1 from client_api_keys searched_client_key - where searched_client_key.id = {alias}.client_api_key_ref - and searched_client_key.key like " - )); - query.push_bind(pattern); - query.push(" escape '\\')"); - } else { - query.push(" escape '\\'"); - } + query.push(" escape '\\'"); + push_client_key_name_search(query, &format!("{alias}.client_api_key_ref"), pattern); query.push(")"); } } +pub(crate) fn push_client_key_name_search( + query: &mut QueryBuilder, + key_ref: &str, + pattern: String, +) { + query.push(format!( + " or exists (select 1 from client_api_keys searched_client_key + where searched_client_key.id = {key_ref} and searched_client_key.name ilike " + )); + query.push_bind(pattern); + query.push(" escape '\\')"); +} + pub(crate) fn literal_prefix_pattern(value: &str) -> String { format!( "{}%", diff --git a/backend/crates/gateway-store/src/value.rs b/backend/crates/gateway-store/src/value.rs index fbddf55f4..c8acf2815 100644 --- a/backend/crates/gateway-store/src/value.rs +++ b/backend/crates/gateway-store/src/value.rs @@ -13,6 +13,7 @@ pub enum StoreBackend { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConflictKind { StaleRevision, + DuplicateName, AlreadyFinalized, DownstreamAlreadyCommitted, RequestNotRunning, @@ -105,6 +106,10 @@ pub(crate) fn admin_store_error(resource: &'static str, error: StoreError) -> Ad kind: ConflictKind::StaleRevision, .. } => AdminStoreErrorKind::StaleRevision, + StoreError::Conflict { + kind: ConflictKind::DuplicateName, + .. + } => AdminStoreErrorKind::DuplicateName, StoreError::Conflict { .. } => AdminStoreErrorKind::Conflict, StoreError::InvalidData { .. } => AdminStoreErrorKind::Invalid, StoreError::Unavailable { .. } => AdminStoreErrorKind::Unavailable, diff --git a/backend/crates/gateway-store/tests/postgres/client_keys.rs b/backend/crates/gateway-store/tests/postgres/client_keys.rs index 38c01d876..3977f72e3 100644 --- a/backend/crates/gateway-store/tests/postgres/client_keys.rs +++ b/backend/crates/gateway-store/tests/postgres/client_keys.rs @@ -21,8 +21,122 @@ use gateway_store::postgres::{ use super::TestDatabase; +#[tokio::test] +async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() { + use gateway_admin::model::{MutationActor, MutationContext, client_keys::NewClientKey}; + use gateway_core::policy::RateLimits; + use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository}; + + let Some(database) = TestDatabase::create("migrated_keys").await else { + return; + }; + let store = PgAdminClientKeyStore::new(database.pool.clone()); + let context = MutationContext { + actor: MutationActor::System, + request_id: "migration-test".to_owned(), + }; + let long_key: String = (0..160) + .map(|_| uuid::Uuid::new_v4().simple().to_string()) + .collect(); + let values = [ + "q".to_owned(), + "abc".to_owned(), + "sk-legacy/key+value=:!@\\".to_owned(), + long_key, + ]; + for (index, key) in values.iter().enumerate() { + let id = ClientApiKeyId::new(format!("key_migrated_{index}")).unwrap(); + let (_, record) = store + .create_client_key( + NewClientKey { + id: id.clone(), + name: format!("Migrated {index}"), + label: None, + group_ids: Vec::new(), + limits: RateLimits { + max_concurrency: 3, + requests_per_minute: 25, + }, + budget: Default::default(), + plaintext: key.clone(), + }, + &context, + ) + .await + .unwrap(); + assert_eq!(record.prefix.len(), 10.min(key.len() / 2)); + assert_ne!(record.prefix, *key); + let revealed = store.reveal_client_key(&id).await.unwrap().unwrap(); + assert_eq!(revealed.expose_for_response(), key); + } + let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) + .load_runtime_snapshot() + .await + .unwrap(); + assert_eq!(snapshot.client_api_keys.len(), values.len()); + for key in &values { + let record = snapshot + .client_api_keys + .iter() + .find(|item| item.plaintext_key.expose_for_auth() == key) + .unwrap(); + assert_eq!(record.limits.max_concurrency, 3); + } + database.close().await; +} + +#[tokio::test] +async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() { + use gateway_admin::{ + model::{MutationActor, MutationContext, client_keys::NewClientKey}, + ports::store::AdminStoreErrorKind, + }; + use gateway_core::policy::RateLimits; + + let Some(database) = TestDatabase::create("duplicate_keys").await else { + return; + }; + let store = PgAdminClientKeyStore::new(database.pool.clone()); + let context = MutationContext { + actor: MutationActor::System, + request_id: "duplicate-test".to_owned(), + }; + let key = "legacy-key-case-sensitive!"; + let command = |id: &str| NewClientKey { + id: ClientApiKeyId::new(id).unwrap(), + name: id.to_owned(), + label: None, + group_ids: Vec::new(), + limits: RateLimits::unlimited(), + budget: Default::default(), + plaintext: key.to_owned(), + }; + let (first, second) = tokio::join!( + store.create_client_key(command("key_first"), &context), + store.create_client_key(command("key_second"), &context) + ); + assert_ne!(first.is_ok(), second.is_ok()); + let error = first.err().or_else(|| second.err()).unwrap(); + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + assert!(!format!("{error:?}").contains(key)); + let counts: (i64, i64) = sqlx::query_as( + "select (select count(*) from client_api_keys), (select count(*) from admin_audit_events)", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(counts, (1, 1)); + let mut different_case = command("key_case_sensitive"); + different_case.plaintext = key.to_uppercase(); + store + .create_client_key(different_case, &context) + .await + .unwrap(); + database.close().await; +} + #[test] -fn client_key_requires_the_frozen_plaintext_format() { +fn generated_client_key_format_remains_valid() { let key = NewClientApiKey { budget: Default::default(), id: "key-1".to_owned(), @@ -36,6 +150,142 @@ fn client_key_requires_the_frozen_plaintext_format() { assert!(key.validate().is_ok()); } +#[tokio::test] +async fn client_key_names_are_checked_atomically_on_create_and_rename() { + use gateway_admin::{ + model::{ + MutationActor, MutationContext, + client_keys::{NewClientKey, UpdateClientKey}, + }, + ports::store::AdminStoreErrorKind, + }; + use gateway_core::policy::RateLimits; + + let Some(database) = TestDatabase::create("duplicate_key_names").await else { + return; + }; + let store = PgAdminClientKeyStore::new(database.pool.clone()); + let context = MutationContext { + actor: MutationActor::System, + request_id: "duplicate-name-test".to_owned(), + }; + let create = |id: &str, name: &str| NewClientKey { + id: ClientApiKeyId::new(id).unwrap(), + name: name.to_owned(), + label: None, + group_ids: Vec::new(), + limits: RateLimits::unlimited(), + budget: Default::default(), + plaintext: format!("synthetic-credential-{id}"), + }; + let update = |id: ClientApiKeyId, name: &str| UpdateClientKey { + id, + name: name.to_owned(), + label: None, + group_ids: Vec::new(), + limits: RateLimits::unlimited(), + daily_limit_usd: None, + weekly_limit_usd: None, + }; + let (first, second) = tokio::join!( + store.create_client_key(create("key_name_first", "Production"), &context), + store.create_client_key(create("key_name_second", " production "), &context), + ); + let (winner, conflict) = match (first, second) { + (Ok((_, record)), Err(error)) | (Err(error), Ok((_, record))) => (record, error), + results => panic!("expected one committed create: {results:?}"), + }; + assert_eq!(conflict.kind(), AdminStoreErrorKind::DuplicateName); + assert_eq!(winner.name.trim(), winner.name); + let counts: (i64, i64) = sqlx::query_as( + "select (select count(*) from client_api_keys), (select count(*) from admin_audit_events)", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(counts, (1, 1)); + + let (revision, _) = store + .update_client_key(update(winner.id.clone(), "PRODUCTION"), &context) + .await + .unwrap(); + let (_, other) = store + .create_client_key(create("key_name_other", "Staging"), &context) + .await + .unwrap(); + let error = store + .update_client_key(update(other.id.clone(), " production "), &context) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminStoreErrorKind::DuplicateName); + let unchanged = store.reveal_client_key(&other.id).await.unwrap().unwrap(); + assert_eq!(unchanged.record.name, "Staging"); + let persisted_revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id = 1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(persisted_revision as u64, revision.get() + 1); + + let (first, second) = tokio::join!( + store.update_client_key(update(winner.id, "Shared"), &context), + store.update_client_key(update(other.id, "SHARED"), &context), + ); + assert_ne!(first.is_ok(), second.is_ok()); + assert_eq!( + first.err().or_else(|| second.err()).unwrap().kind(), + AdminStoreErrorKind::DuplicateName + ); + database.close().await; +} + +#[tokio::test] +async fn client_key_search_matches_names_and_labels_but_never_credential_values() { + let Some(database) = TestDatabase::create("key_name_search").await else { + return; + }; + let generated = format!("sk_{}", "K".repeat(43)); + for (id, name, key) in [ + ("key_generated", "Production", generated.as_str()), + ("key_custom", "生产_%专用", "legacy-token+/123"), + ] { + sqlx::query( + "insert into client_api_keys (id, name, label, key, created_at, updated_at) + values ($1, $2, 'backend label', $3, now(), now())", + ) + .bind(id) + .bind(name) + .bind(key) + .execute(&database.pool) + .await + .unwrap(); + } + let repository = PgClientApiKeyRepository::new(database.pool.clone()); + for (search, expected) in [ + ("PROD", 1), + ("生产_%", 1), + ("生产X", 0), + ("backend", 2), + (generated.as_str(), 0), + (&generated[..10], 0), + ("legacy-token+/123", 0), + ("legacy-", 0), + ] { + let page = repository + .list_client_api_keys(ClientApiKeyListQuery { + cursor: None, + page_size: 10, + search: Some(search.to_owned()), + sort: ClientApiKeySort::default(), + }) + .await + .unwrap(); + assert_eq!(page.total, expected, "search: {search}"); + assert_eq!(page.items.len() as u64, expected); + } + database.close().await; +} + #[tokio::test] async fn client_key_list_uses_safe_keyset_search_and_filtered_total() { let Some(database) = TestDatabase::create("client_key_page").await else { diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index 79d75e77e..53a1d6e6d 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -331,7 +331,7 @@ async fn assert_usage_search_ids( } #[tokio::test] -async fn usage_search_should_match_client_api_key_prefix() { +async fn usage_and_error_search_match_key_names_instead_of_credentials() { let Some(database) = TestDatabase::create("usage_client_api_key_search").await else { return; }; @@ -342,7 +342,7 @@ async fn usage_search_should_match_client_api_key_prefix() { let plaintext_key = format!("sk_{}", "K".repeat(43)); sqlx::query( "insert into client_api_keys (id, name, key, enabled, created_at, updated_at) - values ('key_observe', 'usage search', $1, true, $2, $2)", + values ('key_observe', 'Production_%专用', $1, true, $2, $2)", ) .bind(&plaintext_key) .bind(now) @@ -352,22 +352,53 @@ async fn usage_search_should_match_client_api_key_prefix() { let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1)) .expect("observability range"); - for search in [plaintext_key.as_str(), &plaintext_key[..10]] { - let page = observability_repository(&database.pool) - .list_usage_records(UsageRecordQuery { - range, - filter: UsageRecordFilter { - search: Some(search.to_owned()), - ..UsageRecordFilter::default() - }, - current_page: 1, - page_size: ObservabilityPageSize::new(10).expect("page size"), - }) + let repository = observability_repository(&database.pool); + for key in [plaintext_key.as_str(), "legacy-key+/123"] { + sqlx::query("update client_api_keys set key = $1 where id = 'key_observe'") + .bind(key) + .execute(&database.pool) .await - .expect("search usage by client API key"); + .unwrap(); + for (search, expected) in [ + ("production", 1), + ("PRODUCTION_%", 1), + ("ProductionX", 0), + (key, 0), + (&key[..10], 0), + ] { + let page = repository + .list_usage_records(UsageRecordQuery { + range, + filter: UsageRecordFilter { + search: Some(search.to_owned()), + ..UsageRecordFilter::default() + }, + current_page: 1, + page_size: ObservabilityPageSize::new(10).expect("page size"), + }) + .await + .expect("search usage by client API key name"); - assert_eq!(page.total, 1); - assert_eq!(page.items[0].id, "req_observe_success"); + assert_eq!(page.total, expected, "search: {search}"); + assert_eq!(page.items.len() as u64, expected); + if expected == 1 { + assert_eq!(page.items[0].id, "req_observe_success"); + } + let errors = repository + .list_ops_errors(OpsErrorQuery { + range, + filter: OpsErrorFilter { + search: Some(search.to_owned()), + ..Default::default() + }, + current_page: 1, + page_size: ObservabilityPageSize::new(10).unwrap(), + }) + .await + .unwrap(); + assert_eq!(errors.total, expected * 2, "error search: {search}"); + assert_eq!(errors.items.len() as u64, expected * 2); + } } database.close().await; } diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 26cb42d5b..4a24975e6 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -3,3 +3,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s 46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql 5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql 2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql +393968715af911bb09f6738472678fbedcdc0a0967c39711610f52b9a2994c3f 0006_custom_client_keys.sql diff --git a/backend/migrations/0006_custom_client_keys.sql b/backend/migrations/0006_custom_client_keys.sql new file mode 100644 index 000000000..a3ff48f07 --- /dev/null +++ b/backend/migrations/0006_custom_client_keys.sql @@ -0,0 +1,17 @@ +-- 保留旧 Key,允许迁入不同前缀、长度和标点;约束与 HTTP Bearer 可见字符一致。 +alter table client_api_keys + drop constraint client_api_keys_key_ck, + add constraint client_api_keys_key_ck check ( + key <> '' and key collate "C" !~ '[^!-~]' + ); + +-- 可见 ASCII 在 UTF-8 中编码固定,因此这里可声明 immutable。 +-- 对完整值建固定大小的摘要索引,避免长 Key 超过 PostgreSQL B-tree 索引项上限。 +create function client_api_key_sha256(value text) returns bytea + language sql immutable strict parallel safe + return sha256(convert_to(value, 'UTF8')); + +create unique index client_api_keys_key_sha256_idx + on client_api_keys (client_api_key_sha256(key)); + +alter table client_api_keys drop constraint client_api_keys_key_key; diff --git a/docs/api.md b/docs/api.md index cef701a6f..e22bb0b43 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,9 @@ Authorization: Bearer sk_... ``` +自动生成的 Key 保持 `sk_` 格式;迁入的自定义 Key 使用保存时的原值,不限制前缀或固定长度。 +无论格式如何,只有已保存且启用的 Client Key 能通过鉴权。 + Codex 原生生图配置还会携带 `X-OpenAI-Actor-Authorization: proxy-managed`。 它仅用于客户端识别服务端托管认证,不能代替 Client Key。网关和 OpenAI Provider 都会过滤该请求头, 上游账号身份只由服务端选中的账号提供;不要把真实账号 token 放进该标记。 @@ -620,12 +623,41 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用 | `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 | 创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选 -`dailyLimitUsd` 和 `weeklyLimitUsd`,更新请求再增加 +`dailyLimitUsd`、`weeklyLimitUsd` 和 `customKey`,更新请求增加 `id`。`groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生 `routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的 `providerKinds`;Client Key 不再保存 `providerKind`。创建和 reveal 响应会返回完整明文 Key,调用方 必须立即安全保存。 +密钥列表的 `search` 仅匹配名称和标签,不匹配密钥值或可见前缀;搜索不区分大小写,使用字面量前缀匹配。 +创建和更新时去除名称首尾空白,并按忽略大小写、首尾空格的名称查重,重复返回 `409`。 +更新排除当前记录;并发写入复用控制面事务锁,失败不会留下审计或配置版本变更。 +历史重名数据不自动改名,已有凭据继续有效;再次保存时需使用未被其他密钥占用的名称。 + +`customKey` 仅用于创建:省略、`null` 或空字符串时继续自动生成;非空时按原值保存,不追加前缀、 +不截断、不修剪空白,也不要求固定长度。Key 须为 HTTP Bearer 可传输的非空可见 ASCII 字符, +支持标点,空格、控制字符和非 ASCII 文本会返回 `400`。重复 Key 返回 `409`,包括并发创建时。 +应用不另设 Key 长度上限;创建请求和 `Authorization` 头仍受 Web 服务器及反向代理的通用大小限制。 +更新接口不接受 `customKey`,防止修改策略时意外替换正在使用的凭据。 +列表仅展示最多前 10 个字符,且至少隐藏一半字符;单字符 Key 的可见前缀为空。 +完整值仍只通过创建和显式 reveal 返回,不进入普通 Debug 或审计。 + +迁移示例(其他创建字段同上): + +```json +{ + "name": "迁入的客户端", + "customKey": "legacy-platform-key/example+=", + "groupIds": [], + "maxConcurrency": 2, + "requestsPerMinute": 0 +} +``` + +客户端沿用原 Key,将 Base URL 指向本平台即可。分组权限、日/周限额和并发规则按本平台配置执行, +不会导入旧平台的历史用量。部署升级会新增 `0006_custom_client_keys.sql`,保留全部已有 Key, +并以 SHA-256 唯一索引支持长 Key;鉴权仍校验完整原值。 + 金额字段为非负十进制字符串,最多 10 位整数与 10 位小数,`"0"` 表示不限额。 创建时省略金额字段默认为零;更新时省略或 `null` 保留当前值,修改限额不会清空已用金额。 `maxConcurrency` 和 `requestsPerMinute` 是非负整数,零表示不限。 @@ -805,13 +837,15 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、 `apiKey`、`provider`、`transport`、`failureClass`、`status`。 -请求记录列表的 `search` 使用区分大小写的字面量前缀匹配,支持请求 ID、Client Key ID / Key 前缀、 -账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。账号邮箱与名称按请求记录的历史快照检索, +请求记录列表的 `search` 使用字面量前缀匹配,支持请求 ID、Client Key ID / 名称、 +账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。密钥名称不区分大小写,其他字段区分大小写。 +密钥名称按当前密钥记录检索,改名后使用新名称,删除后仍可按 Client Key ID 查询历史记录。 +账号邮箱与名称按请求记录的历史快照检索, 不随当前账号修改或删除而改变;`%`、`_` 和 `\` 均按普通字符处理,不作为搜索通配符。 请求 ID 和上游 ID 继续通过既有字段查询;不增加入口 ID 字段,也不扫描 trace 建立查询映射。 旧响应中只有入口 ID 时仍需结合时间与入口日志定位,不能回填不存在的关联。 -管理端搜索完整 `sk_` Client Key 时仅提交其可见前缀,不将完整密钥放入 URL。 +请求记录和错误列表支持按密钥名称搜索,不支持密钥值或可见前缀搜索。 错误列表的主动刷新、搜索和平台/时间条件变化会取得新的结束时间;翻页沿用该次查询快照。 已进入模型执行会话、但在首次合法 ProviderStream 建立前失败的请求也进入现有错误及详情查询, diff --git a/frontend/src/api/modules/api-keys.ts b/frontend/src/api/modules/api-keys.ts index bb2b4d77a..26e2bfb00 100644 --- a/frontend/src/api/modules/api-keys.ts +++ b/frontend/src/api/modules/api-keys.ts @@ -70,6 +70,10 @@ interface ApiKeyUpdateParam extends ApiKeyWriteParam { id: string } +interface ApiKeyCreateParam extends ApiKeyWriteParam { + customKey?: string +} + interface ApiKeyIdParam { id: string } @@ -83,7 +87,7 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {}) }) } -export function createApiKey(data: ApiKeyWriteParam, options: RequestOptions = {}) { +export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/create', method: 'POST', diff --git a/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue b/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue index b496ee9e1..51868ce24 100644 --- a/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue +++ b/frontend/src/views/api-keys/components/ApiKeyCreateModal.vue @@ -61,6 +61,21 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key' /> + + + + ('search', { required: true }) aria-label="API Key 筛选与操作" >
- + diff --git a/frontend/src/views/api-keys/composables/useApiKeyMutations.ts b/frontend/src/views/api-keys/composables/useApiKeyMutations.ts index 9763bd597..28b360f90 100644 --- a/frontend/src/views/api-keys/composables/useApiKeyMutations.ts +++ b/frontend/src/views/api-keys/composables/useApiKeyMutations.ts @@ -17,6 +17,7 @@ import { useIdSet } from '@/composables/useIdSet' type ApiKeyRow = Awaited>['items'][number] export interface ApiKeyFormValue { + customKey: string name: string label: string groupIds: string[] @@ -61,6 +62,7 @@ export function useApiKeyMutations(options: { function openEdit(key: ApiKeyRow) { editingKey.value = key form.value = { + customKey: '', name: key.name, label: key.label ?? '', groupIds: key.groups.map(group => group.id), @@ -107,7 +109,10 @@ export function useApiKeyMutations(options: { await updateApiKey({ id: current.id, ...payload }) } else { - const result = await createApiKey(payload) + const result = await createApiKey({ + ...payload, + customKey: form.value.customKey || undefined, + }) createdKey.value = result.plaintextKey createdKeyName.value = payload.name } @@ -139,6 +144,10 @@ export function useApiKeyMutations(options: { toast.warning('请输入 API Key 名称') return false } + if (!editingKey.value && form.value.customKey && !/^[\x21-\x7E]+$/.test(form.value.customKey)) { + toast.warning('自定义 Key 只能包含 HTTP 可传输的可见字符,不能包含空格或换行') + return false + } for (const [label, value] of [ ['最大并发', form.value.maxConcurrency], ['每分钟请求数', form.value.requestsPerMinute], @@ -285,6 +294,7 @@ export function useApiKeyMutations(options: { function emptyForm(): ApiKeyFormValue { return { + customKey: '', name: '', label: '', groupIds: [], diff --git a/frontend/src/views/usage/components/OpsErrorPanel.vue b/frontend/src/views/usage/components/OpsErrorPanel.vue index 04f9b1357..2d38369f6 100644 --- a/frontend/src/views/usage/components/OpsErrorPanel.vue +++ b/frontend/src/views/usage/components/OpsErrorPanel.vue @@ -91,8 +91,8 @@ function upstreamSendStateText(value: string | null | undefined) {