2026-09-04 03:45:52 +08:00
|
|
|
use super::support::{AdminProviderOpsQuotaAlertConfigRequest, AdminProviderOpsSaveConfigRequest};
|
2026-04-09 00:10:38 +08:00
|
|
|
use crate::handlers::admin::request::AdminAppState;
|
2026-09-04 03:45:52 +08:00
|
|
|
use crate::handlers::shared::{
|
|
|
|
|
canonicalize_provider_ops_base_url, masked_secret_display, open_provider_ops_credential,
|
|
|
|
|
provider_ops_credential_binding_from_config, provider_ops_credential_field_is_secret,
|
|
|
|
|
seal_provider_ops_credential, ProviderOpsCredentialBinding,
|
|
|
|
|
PROVIDER_OPS_PERSISTENT_SECRET_FIELDS, PROVIDER_OPS_TRANSIENT_METADATA_FIELDS,
|
|
|
|
|
};
|
2026-04-10 15:06:42 +08:00
|
|
|
use crate::GatewayError;
|
2026-04-09 00:10:38 +08:00
|
|
|
use aether_admin::provider::ops as admin_provider_ops_pure;
|
2026-04-07 02:50:19 +08:00
|
|
|
use aether_data_contracts::repository::provider_catalog::{
|
2026-09-04 03:45:52 +08:00
|
|
|
ProviderCatalogProviderConfigCasUpdate, StoredProviderCatalogEndpoint,
|
|
|
|
|
StoredProviderCatalogProvider,
|
2026-04-04 01:40:24 +08:00
|
|
|
};
|
2026-04-03 14:59:58 +08:00
|
|
|
use serde_json::json;
|
|
|
|
|
|
2026-05-18 23:51:14 +08:00
|
|
|
const PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS: u64 = 30;
|
|
|
|
|
const PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS: u64 = 30;
|
|
|
|
|
const PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS: u64 = 86_400;
|
2026-09-04 03:45:52 +08:00
|
|
|
const PROVIDER_OPS_CREDENTIAL_MIGRATION_RETRIES: usize = 8;
|
|
|
|
|
|
|
|
|
|
struct AdminProviderOpsDecodedCredentials {
|
|
|
|
|
values: serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
protected_values: serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
migration_required: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) struct AdminProviderOpsCredentialSnapshot {
|
|
|
|
|
pub(crate) provider: StoredProviderCatalogProvider,
|
|
|
|
|
pub(crate) credentials: serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
pub(crate) binding: ProviderOpsCredentialBinding,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Debug for AdminProviderOpsCredentialSnapshot {
|
|
|
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
formatter
|
|
|
|
|
.debug_struct("AdminProviderOpsCredentialSnapshot")
|
|
|
|
|
.field("provider_id", &self.provider.id)
|
|
|
|
|
.field("credentials", &"[REDACTED]")
|
|
|
|
|
.field("binding", &"[REDACTED]")
|
|
|
|
|
.finish_non_exhaustive()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) struct AdminProviderOpsMergedCredentialSnapshot {
|
|
|
|
|
pub(super) provider: StoredProviderCatalogProvider,
|
|
|
|
|
pub(super) credentials: serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
pub(super) saved_binding: ProviderOpsCredentialBinding,
|
|
|
|
|
pub(super) reused_saved_secret: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) struct AdminProviderOpsSavedConfigSnapshot {
|
|
|
|
|
pub(super) provider: StoredProviderCatalogProvider,
|
|
|
|
|
pub(super) provider_ops_config: serde_json::Value,
|
|
|
|
|
}
|
2026-05-18 23:51:14 +08:00
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
pub(super) fn admin_provider_ops_config_object(
|
|
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
2026-04-09 00:10:38 +08:00
|
|
|
admin_provider_ops_pure::admin_provider_ops_config_object(provider)
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) fn admin_provider_ops_connector_object(
|
|
|
|
|
provider_ops_config: &serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
2026-04-09 00:10:38 +08:00
|
|
|
admin_provider_ops_pure::admin_provider_ops_connector_object(provider_ops_config)
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
pub(super) fn admin_provider_ops_binding_from_config(
|
|
|
|
|
provider_id: &str,
|
|
|
|
|
provider_ops_config: &serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
effective_base_url: &str,
|
|
|
|
|
) -> Result<ProviderOpsCredentialBinding, String> {
|
|
|
|
|
provider_ops_credential_binding_from_config(
|
|
|
|
|
provider_id,
|
|
|
|
|
provider_ops_config,
|
|
|
|
|
effective_base_url,
|
|
|
|
|
)
|
|
|
|
|
.map_err(ToString::to_string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn admin_provider_ops_binding_for_provider(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-09-04 03:45:52 +08:00
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
) -> Result<(ProviderOpsCredentialBinding, bool), GatewayError> {
|
|
|
|
|
let provider_ops_config = admin_provider_ops_config_object(provider)
|
|
|
|
|
.ok_or_else(|| GatewayError::Internal("Provider Ops 配置格式无效".to_string()))?;
|
|
|
|
|
let explicit_base_url = provider_ops_config
|
|
|
|
|
.get("base_url")
|
|
|
|
|
.and_then(serde_json::Value::as_str)
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty());
|
|
|
|
|
let endpoints = if explicit_base_url.is_some() {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
state
|
|
|
|
|
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
|
|
|
|
|
.await?
|
|
|
|
|
};
|
|
|
|
|
let effective_base_url =
|
|
|
|
|
resolve_admin_provider_ops_base_url(provider, &endpoints, Some(provider_ops_config))
|
|
|
|
|
.ok_or_else(|| GatewayError::Internal("Provider Ops 未配置 base_url".to_string()))?;
|
|
|
|
|
let binding = admin_provider_ops_binding_from_config(
|
|
|
|
|
&provider.id,
|
|
|
|
|
provider_ops_config,
|
|
|
|
|
&effective_base_url,
|
|
|
|
|
)
|
|
|
|
|
.map_err(GatewayError::Internal)?;
|
|
|
|
|
let needs_materialized_base_url = explicit_base_url != Some(binding.destination.base_url());
|
|
|
|
|
Ok((binding, needs_materialized_base_url))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn admin_provider_ops_masked_secret(field: &str, plaintext: &str) -> serde_json::Value {
|
2026-04-03 14:59:58 +08:00
|
|
|
if plaintext.is_empty() {
|
|
|
|
|
return serde_json::Value::String(String::new());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let masked = if field == "password" {
|
|
|
|
|
"********".to_string()
|
|
|
|
|
} else {
|
2026-09-04 03:45:52 +08:00
|
|
|
masked_secret_display(plaintext, 4, 4, "****")
|
2026-04-03 14:59:58 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
serde_json::Value::String(masked)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn admin_provider_ops_masked_credentials(
|
2026-09-04 03:45:52 +08:00
|
|
|
credentials: &serde_json::Map<String, serde_json::Value>,
|
2026-04-03 14:59:58 +08:00
|
|
|
) -> serde_json::Value {
|
|
|
|
|
let mut masked = serde_json::Map::new();
|
|
|
|
|
for (key, value) in credentials {
|
2026-04-10 15:06:42 +08:00
|
|
|
if key.starts_with('_') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
if provider_ops_credential_field_is_secret(key) {
|
2026-04-03 14:59:58 +08:00
|
|
|
if let Some(ciphertext) = value.as_str().filter(|value| !value.is_empty()) {
|
|
|
|
|
masked.insert(
|
|
|
|
|
key.clone(),
|
2026-09-04 03:45:52 +08:00
|
|
|
admin_provider_ops_masked_secret(key, ciphertext),
|
2026-04-03 14:59:58 +08:00
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
masked.insert(key.clone(), value.clone());
|
|
|
|
|
}
|
|
|
|
|
serde_json::Value::Object(masked)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn admin_provider_ops_is_supported_auth_type(auth_type: &str) -> bool {
|
2026-04-09 00:10:38 +08:00
|
|
|
admin_provider_ops_pure::admin_provider_ops_is_supported_auth_type(auth_type)
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn admin_provider_ops_decode_credentials(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-09-04 03:45:52 +08:00
|
|
|
binding: &ProviderOpsCredentialBinding,
|
2026-04-03 14:59:58 +08:00
|
|
|
raw_credentials: Option<&serde_json::Value>,
|
2026-09-04 03:45:52 +08:00
|
|
|
) -> Result<AdminProviderOpsDecodedCredentials, String> {
|
2026-04-03 14:59:58 +08:00
|
|
|
let Some(credentials) = raw_credentials.and_then(serde_json::Value::as_object) else {
|
2026-09-04 03:45:52 +08:00
|
|
|
return Ok(AdminProviderOpsDecodedCredentials {
|
|
|
|
|
values: serde_json::Map::new(),
|
|
|
|
|
protected_values: serde_json::Map::new(),
|
|
|
|
|
migration_required: false,
|
|
|
|
|
});
|
2026-04-03 14:59:58 +08:00
|
|
|
};
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
let mut values = serde_json::Map::new();
|
|
|
|
|
let mut protected_values = credentials.clone();
|
|
|
|
|
let mut migration_required = false;
|
2026-04-03 14:59:58 +08:00
|
|
|
for (key, value) in credentials {
|
2026-09-04 03:45:52 +08:00
|
|
|
if provider_ops_credential_field_is_secret(key) {
|
|
|
|
|
if let Some(stored_value) = value.as_str() {
|
|
|
|
|
if stored_value.is_empty() {
|
|
|
|
|
values.insert(key.clone(), value.clone());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let projection =
|
|
|
|
|
open_provider_ops_credential(state.app(), binding, key, stored_value).map_err(
|
|
|
|
|
|message| format!("已保存的 Provider Ops 凭据无法解密: {message}"),
|
|
|
|
|
)?;
|
|
|
|
|
migration_required |= projection.migration_required;
|
|
|
|
|
protected_values
|
|
|
|
|
.insert(key.clone(), serde_json::Value::String(projection.protected));
|
|
|
|
|
values.insert(key.clone(), serde_json::Value::String(projection.plaintext));
|
2026-04-03 14:59:58 +08:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
values.insert(key.clone(), value.clone());
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
Ok(AdminProviderOpsDecodedCredentials {
|
|
|
|
|
values,
|
|
|
|
|
protected_values,
|
|
|
|
|
migration_required,
|
|
|
|
|
})
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn admin_provider_ops_sensitive_placeholder_or_empty(value: Option<&serde_json::Value>) -> bool {
|
2026-04-09 00:10:38 +08:00
|
|
|
admin_provider_ops_pure::admin_provider_ops_sensitive_placeholder_or_empty(value)
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
pub(super) async fn admin_provider_ops_merge_credentials(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-04-10 15:06:42 +08:00
|
|
|
architecture_id: &str,
|
2026-04-03 14:59:58 +08:00
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
mut request_credentials: serde_json::Map<String, serde_json::Value>,
|
2026-09-04 03:45:52 +08:00
|
|
|
) -> Result<AdminProviderOpsMergedCredentialSnapshot, String> {
|
|
|
|
|
let snapshot = admin_provider_ops_credential_snapshot(state, provider)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|_| "已保存的 Provider Ops 凭据无法解密或迁移".to_string())?;
|
|
|
|
|
let mut saved_credentials = snapshot.credentials;
|
2026-04-10 15:06:42 +08:00
|
|
|
let preserve_internal_runtime_fields =
|
|
|
|
|
admin_provider_ops_pure::normalize_architecture_id(architecture_id) == "sub2api";
|
|
|
|
|
if !preserve_internal_runtime_fields {
|
|
|
|
|
saved_credentials.retain(|key, _| !key.starts_with('_'));
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
let mut reused_saved_secret = false;
|
|
|
|
|
for field in PROVIDER_OPS_PERSISTENT_SECRET_FIELDS {
|
2026-04-10 15:06:42 +08:00
|
|
|
if field.starts_with('_') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
if admin_provider_ops_sensitive_placeholder_or_empty(request_credentials.get(*field))
|
|
|
|
|
&& saved_credentials.contains_key(*field)
|
|
|
|
|
{
|
|
|
|
|
if let Some(saved_value) = saved_credentials.get(*field) {
|
|
|
|
|
request_credentials.insert((*field).to_string(), saved_value.clone());
|
2026-09-04 03:45:52 +08:00
|
|
|
reused_saved_secret = true;
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:06:42 +08:00
|
|
|
if preserve_internal_runtime_fields {
|
|
|
|
|
for (key, value) in saved_credentials {
|
|
|
|
|
if key.starts_with('_') && !request_credentials.contains_key(&key) {
|
|
|
|
|
request_credentials.insert(key, value);
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
Ok(AdminProviderOpsMergedCredentialSnapshot {
|
|
|
|
|
provider: snapshot.provider,
|
|
|
|
|
credentials: request_credentials,
|
|
|
|
|
saved_binding: snapshot.binding,
|
|
|
|
|
reused_saved_secret,
|
|
|
|
|
})
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn admin_provider_ops_encrypt_credentials(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-09-04 03:45:52 +08:00
|
|
|
binding: &ProviderOpsCredentialBinding,
|
2026-04-03 14:59:58 +08:00
|
|
|
credentials: serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
|
|
|
|
let mut encrypted = serde_json::Map::new();
|
|
|
|
|
for (key, value) in credentials {
|
2026-09-04 03:45:52 +08:00
|
|
|
if provider_ops_credential_field_is_secret(&key) {
|
2026-04-03 14:59:58 +08:00
|
|
|
if let Some(plaintext) = value.as_str() {
|
|
|
|
|
if plaintext.is_empty() {
|
|
|
|
|
encrypted.insert(key, value);
|
|
|
|
|
} else {
|
2026-09-04 03:45:52 +08:00
|
|
|
let ciphertext =
|
|
|
|
|
seal_provider_ops_credential(state.app(), binding, &key, plaintext)
|
|
|
|
|
.map_err(ToString::to_string)?;
|
2026-04-03 14:59:58 +08:00
|
|
|
encrypted.insert(key, serde_json::Value::String(ciphertext));
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
encrypted.insert(key, value);
|
|
|
|
|
}
|
|
|
|
|
Ok(encrypted)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
fn admin_provider_ops_config_with_credentials(
|
|
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
credentials: serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
binding: &ProviderOpsCredentialBinding,
|
|
|
|
|
) -> Result<Option<serde_json::Value>, String> {
|
|
|
|
|
let mut provider_config = provider
|
|
|
|
|
.config
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(serde_json::Value::as_object)
|
|
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| "Provider Ops 配置格式无效".to_string())?;
|
|
|
|
|
let mut provider_ops_config = provider_config
|
|
|
|
|
.get("provider_ops")
|
|
|
|
|
.and_then(serde_json::Value::as_object)
|
|
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| "Provider Ops 配置格式无效".to_string())?;
|
|
|
|
|
let mut connector_config = provider_ops_config
|
|
|
|
|
.get("connector")
|
|
|
|
|
.and_then(serde_json::Value::as_object)
|
|
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| "Provider Ops connector 配置格式无效".to_string())?;
|
|
|
|
|
|
|
|
|
|
connector_config.insert(
|
|
|
|
|
"credentials".to_string(),
|
|
|
|
|
serde_json::Value::Object(credentials),
|
|
|
|
|
);
|
|
|
|
|
provider_ops_config.insert(
|
|
|
|
|
"connector".to_string(),
|
|
|
|
|
serde_json::Value::Object(connector_config),
|
|
|
|
|
);
|
|
|
|
|
provider_ops_config.insert(
|
|
|
|
|
"base_url".to_string(),
|
|
|
|
|
serde_json::Value::String(binding.destination.base_url().to_string()),
|
|
|
|
|
);
|
|
|
|
|
provider_config.insert(
|
|
|
|
|
"provider_ops".to_string(),
|
|
|
|
|
serde_json::Value::Object(provider_ops_config),
|
|
|
|
|
);
|
|
|
|
|
Ok(Some(serde_json::Value::Object(provider_config)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn admin_provider_ops_credential_snapshot(
|
|
|
|
|
state: &AdminAppState<'_>,
|
|
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
) -> Result<AdminProviderOpsCredentialSnapshot, GatewayError> {
|
|
|
|
|
let mut current = provider.clone();
|
|
|
|
|
for _ in 0..PROVIDER_OPS_CREDENTIAL_MIGRATION_RETRIES {
|
|
|
|
|
let (binding, needs_materialized_base_url) =
|
|
|
|
|
admin_provider_ops_binding_for_provider(state, ¤t).await?;
|
|
|
|
|
let raw_credentials = admin_provider_ops_config_object(¤t)
|
|
|
|
|
.and_then(admin_provider_ops_connector_object)
|
|
|
|
|
.and_then(|connector| connector.get("credentials"));
|
|
|
|
|
let decoded = admin_provider_ops_decode_credentials(state, &binding, raw_credentials)
|
|
|
|
|
.map_err(GatewayError::Internal)?;
|
|
|
|
|
if !decoded.migration_required && !needs_materialized_base_url {
|
|
|
|
|
return Ok(AdminProviderOpsCredentialSnapshot {
|
|
|
|
|
provider: current,
|
|
|
|
|
credentials: decoded.values,
|
|
|
|
|
binding,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let migrated_config = admin_provider_ops_config_with_credentials(
|
|
|
|
|
¤t,
|
|
|
|
|
decoded.protected_values,
|
|
|
|
|
&binding,
|
|
|
|
|
)
|
|
|
|
|
.map_err(GatewayError::Internal)?;
|
|
|
|
|
let update = ProviderCatalogProviderConfigCasUpdate {
|
|
|
|
|
provider_id: current.id.clone(),
|
|
|
|
|
expected_config: current.config.clone(),
|
|
|
|
|
config: migrated_config.clone(),
|
|
|
|
|
};
|
|
|
|
|
if state
|
|
|
|
|
.compare_and_swap_provider_catalog_provider_config(&update)
|
|
|
|
|
.await?
|
|
|
|
|
{
|
|
|
|
|
current.config = migrated_config;
|
|
|
|
|
return Ok(AdminProviderOpsCredentialSnapshot {
|
|
|
|
|
provider: current,
|
|
|
|
|
credentials: decoded.values,
|
|
|
|
|
binding,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current = state
|
|
|
|
|
.read_provider_catalog_providers_by_ids(std::slice::from_ref(¤t.id))
|
|
|
|
|
.await?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| GatewayError::Internal("Provider Ops Provider 不存在".to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(GatewayError::Internal(
|
|
|
|
|
"Provider Ops 凭据迁移未能稳定完成".to_string(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:06:42 +08:00
|
|
|
pub(super) async fn persist_admin_provider_ops_runtime_credentials(
|
|
|
|
|
state: &AdminAppState<'_>,
|
|
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
updated_credentials: &serde_json::Map<String, serde_json::Value>,
|
|
|
|
|
) -> Result<Option<StoredProviderCatalogProvider>, GatewayError> {
|
|
|
|
|
if updated_credentials.is_empty() || !state.has_provider_catalog_data_writer() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
2026-09-04 03:45:52 +08:00
|
|
|
for key in updated_credentials.keys() {
|
|
|
|
|
if key != "refresh_token"
|
|
|
|
|
&& key != "_cached_access_token"
|
|
|
|
|
&& !PROVIDER_OPS_TRANSIENT_METADATA_FIELDS.contains(&key.as_str())
|
|
|
|
|
{
|
|
|
|
|
return Err(GatewayError::Internal(format!(
|
|
|
|
|
"不允许持久化未知的 Provider Ops runtime credential 字段 '{key}'"
|
|
|
|
|
)));
|
|
|
|
|
}
|
2026-04-10 15:06:42 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
let mut current = provider.clone();
|
|
|
|
|
for _ in 0..PROVIDER_OPS_CREDENTIAL_MIGRATION_RETRIES {
|
|
|
|
|
let snapshot = admin_provider_ops_credential_snapshot(state, ¤t).await?;
|
|
|
|
|
let mut decrypted_credentials = snapshot.credentials;
|
|
|
|
|
for (key, value) in updated_credentials {
|
|
|
|
|
decrypted_credentials.insert(key.clone(), value.clone());
|
|
|
|
|
}
|
|
|
|
|
let encrypted_credentials =
|
|
|
|
|
admin_provider_ops_encrypt_credentials(state, &snapshot.binding, decrypted_credentials)
|
|
|
|
|
.map_err(GatewayError::Internal)?;
|
|
|
|
|
let config = admin_provider_ops_config_with_credentials(
|
|
|
|
|
&snapshot.provider,
|
|
|
|
|
encrypted_credentials,
|
|
|
|
|
&snapshot.binding,
|
|
|
|
|
)
|
|
|
|
|
.map_err(GatewayError::Internal)?;
|
|
|
|
|
let update = ProviderCatalogProviderConfigCasUpdate {
|
|
|
|
|
provider_id: snapshot.provider.id.clone(),
|
|
|
|
|
expected_config: snapshot.provider.config.clone(),
|
|
|
|
|
config,
|
|
|
|
|
};
|
|
|
|
|
if state
|
|
|
|
|
.compare_and_swap_provider_catalog_provider_config(&update)
|
|
|
|
|
.await?
|
|
|
|
|
{
|
|
|
|
|
return Ok(state
|
|
|
|
|
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider.id))
|
|
|
|
|
.await?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.next());
|
|
|
|
|
}
|
|
|
|
|
current = state
|
|
|
|
|
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider.id))
|
|
|
|
|
.await?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| GatewayError::Internal("Provider Ops Provider 不存在".to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
Err(GatewayError::Internal(
|
|
|
|
|
"Provider Ops runtime credential 并发更新未能稳定完成".to_string(),
|
|
|
|
|
))
|
2026-04-10 15:06:42 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
pub(super) async fn build_admin_provider_ops_saved_config_value(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-04-03 14:59:58 +08:00
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
payload: AdminProviderOpsSaveConfigRequest,
|
2026-09-04 03:45:52 +08:00
|
|
|
) -> Result<AdminProviderOpsSavedConfigSnapshot, String> {
|
|
|
|
|
let architecture_id = payload.architecture_id.trim();
|
|
|
|
|
let normalized_architecture_id =
|
|
|
|
|
admin_provider_ops_pure::normalize_architecture_id(architecture_id);
|
|
|
|
|
if architecture_id.is_empty() || architecture_id != normalized_architecture_id {
|
|
|
|
|
return Err("architecture_id 必须是合法的 Provider Ops 架构".to_string());
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
let auth_type = payload.connector.auth_type.trim().to_string();
|
|
|
|
|
if auth_type.is_empty() || !admin_provider_ops_is_supported_auth_type(auth_type.as_str()) {
|
|
|
|
|
return Err("connector.auth_type 必须是合法的认证类型".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
let merged = admin_provider_ops_merge_credentials(
|
2026-04-10 15:06:42 +08:00
|
|
|
state,
|
2026-09-04 03:45:52 +08:00
|
|
|
normalized_architecture_id,
|
2026-04-10 15:06:42 +08:00
|
|
|
provider,
|
|
|
|
|
payload.connector.credentials,
|
2026-09-04 03:45:52 +08:00
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
let canonical_base_url = payload
|
|
|
|
|
.base_url
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.unwrap_or_else(|| merged.saved_binding.destination.base_url());
|
|
|
|
|
let canonical_destination =
|
|
|
|
|
canonicalize_provider_ops_base_url(canonical_base_url).map_err(ToString::to_string)?;
|
2026-04-03 14:59:58 +08:00
|
|
|
|
|
|
|
|
let actions = payload
|
|
|
|
|
.actions
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(action_type, config)| {
|
|
|
|
|
(
|
|
|
|
|
action_type,
|
|
|
|
|
json!({
|
|
|
|
|
"enabled": config.enabled,
|
|
|
|
|
"config": config.config,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.collect::<serde_json::Map<String, serde_json::Value>>();
|
2026-05-18 23:51:14 +08:00
|
|
|
let quota_alert = normalize_admin_provider_ops_quota_alert(payload.quota_alert)?;
|
2026-09-04 03:45:52 +08:00
|
|
|
let mut provider_ops_config = json!({
|
|
|
|
|
"architecture_id": normalized_architecture_id,
|
|
|
|
|
"base_url": canonical_destination.base_url(),
|
2026-04-03 14:59:58 +08:00
|
|
|
"connector": {
|
|
|
|
|
"auth_type": auth_type,
|
|
|
|
|
"config": payload.connector.config,
|
2026-09-04 03:45:52 +08:00
|
|
|
"credentials": {},
|
2026-04-03 14:59:58 +08:00
|
|
|
},
|
|
|
|
|
"actions": actions,
|
|
|
|
|
"schedule": payload.schedule,
|
2026-05-18 23:51:14 +08:00
|
|
|
"quota_alert": quota_alert,
|
2026-09-04 03:45:52 +08:00
|
|
|
});
|
|
|
|
|
let new_binding = admin_provider_ops_binding_from_config(
|
|
|
|
|
&merged.provider.id,
|
|
|
|
|
provider_ops_config
|
|
|
|
|
.as_object()
|
|
|
|
|
.ok_or_else(|| "Provider Ops 配置格式无效".to_string())?,
|
|
|
|
|
canonical_destination.base_url(),
|
|
|
|
|
)?;
|
|
|
|
|
let same_secret_destination = merged.saved_binding.provider_id == new_binding.provider_id
|
|
|
|
|
&& merged.saved_binding.architecture_id == new_binding.architecture_id
|
|
|
|
|
&& merged.saved_binding.auth_type == new_binding.auth_type
|
|
|
|
|
&& merged.saved_binding.destination == new_binding.destination;
|
|
|
|
|
if merged.reused_saved_secret && !same_secret_destination {
|
|
|
|
|
return Err("修改 Provider Ops 架构、认证类型或目标地址时必须重新填写凭据".to_string());
|
|
|
|
|
}
|
|
|
|
|
let mut merged_credentials = merged.credentials;
|
|
|
|
|
if merged.saved_binding != new_binding {
|
|
|
|
|
for field in PROVIDER_OPS_TRANSIENT_METADATA_FIELDS {
|
|
|
|
|
merged_credentials.remove(*field);
|
|
|
|
|
}
|
|
|
|
|
merged_credentials.retain(|field, _| !field.starts_with("_cached_"));
|
|
|
|
|
}
|
|
|
|
|
let encrypted_credentials =
|
|
|
|
|
admin_provider_ops_encrypt_credentials(state, &new_binding, merged_credentials)?;
|
|
|
|
|
provider_ops_config["connector"]["credentials"] =
|
|
|
|
|
serde_json::Value::Object(encrypted_credentials);
|
|
|
|
|
|
|
|
|
|
Ok(AdminProviderOpsSavedConfigSnapshot {
|
|
|
|
|
provider: merged.provider,
|
|
|
|
|
provider_ops_config,
|
|
|
|
|
})
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 23:51:14 +08:00
|
|
|
fn normalize_admin_provider_ops_quota_alert(
|
|
|
|
|
request: Option<AdminProviderOpsQuotaAlertConfigRequest>,
|
|
|
|
|
) -> Result<serde_json::Value, String> {
|
|
|
|
|
let Some(request) = request else {
|
|
|
|
|
return Ok(default_admin_provider_ops_quota_alert());
|
|
|
|
|
};
|
|
|
|
|
let threshold_amount = request.threshold_amount.unwrap_or(0.0);
|
|
|
|
|
if threshold_amount < 0.0 {
|
|
|
|
|
return Err("quota_alert.threshold_amount 必须大于等于 0".to_string());
|
|
|
|
|
}
|
|
|
|
|
let fetch_interval_seconds = request
|
|
|
|
|
.fetch_interval_seconds
|
|
|
|
|
.unwrap_or(PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS);
|
|
|
|
|
if !(PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS
|
|
|
|
|
..=PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS)
|
|
|
|
|
.contains(&fetch_interval_seconds)
|
|
|
|
|
{
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"quota_alert.fetch_interval_seconds 必须在 {} 到 {} 秒之间",
|
|
|
|
|
PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS,
|
|
|
|
|
PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"enabled": request.enabled,
|
|
|
|
|
"threshold_amount": threshold_amount,
|
|
|
|
|
"fetch_interval_seconds": fetch_interval_seconds,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_admin_provider_ops_quota_alert() -> serde_json::Value {
|
|
|
|
|
json!({
|
|
|
|
|
"enabled": false,
|
|
|
|
|
"threshold_amount": 0.0,
|
|
|
|
|
"fetch_interval_seconds": PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
pub(super) fn resolve_admin_provider_ops_base_url(
|
|
|
|
|
provider: &StoredProviderCatalogProvider,
|
|
|
|
|
endpoints: &[StoredProviderCatalogEndpoint],
|
|
|
|
|
provider_ops_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
|
|
|
|
) -> Option<String> {
|
2026-04-09 00:10:38 +08:00
|
|
|
admin_provider_ops_pure::resolve_admin_provider_ops_base_url(
|
|
|
|
|
provider,
|
|
|
|
|
endpoints,
|
|
|
|
|
provider_ops_config,
|
|
|
|
|
)
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) fn build_admin_provider_ops_status_payload(
|
|
|
|
|
provider_id: &str,
|
|
|
|
|
provider: Option<&StoredProviderCatalogProvider>,
|
|
|
|
|
) -> serde_json::Value {
|
2026-04-09 00:10:38 +08:00
|
|
|
admin_provider_ops_pure::build_admin_provider_ops_status_payload(provider_id, provider)
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
pub(super) async fn build_admin_provider_ops_config_payload(
|
2026-04-09 00:10:38 +08:00
|
|
|
state: &AdminAppState<'_>,
|
2026-04-03 14:59:58 +08:00
|
|
|
provider_id: &str,
|
|
|
|
|
provider: Option<&StoredProviderCatalogProvider>,
|
|
|
|
|
endpoints: &[StoredProviderCatalogEndpoint],
|
2026-09-04 03:45:52 +08:00
|
|
|
) -> Result<serde_json::Value, GatewayError> {
|
2026-04-03 14:59:58 +08:00
|
|
|
let Some(provider) = provider else {
|
2026-09-04 03:45:52 +08:00
|
|
|
return Ok(json!({
|
2026-04-03 14:59:58 +08:00
|
|
|
"provider_id": provider_id,
|
|
|
|
|
"is_configured": false,
|
2026-09-04 03:45:52 +08:00
|
|
|
}));
|
2026-04-03 14:59:58 +08:00
|
|
|
};
|
2026-09-04 03:45:52 +08:00
|
|
|
if admin_provider_ops_config_object(provider).is_none() {
|
|
|
|
|
return Ok(json!({
|
2026-04-03 14:59:58 +08:00
|
|
|
"provider_id": provider_id,
|
|
|
|
|
"is_configured": false,
|
2026-09-04 03:45:52 +08:00
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
let snapshot = admin_provider_ops_credential_snapshot(state, provider).await?;
|
|
|
|
|
let provider = &snapshot.provider;
|
|
|
|
|
let Some(provider_ops_config) = admin_provider_ops_config_object(provider) else {
|
|
|
|
|
return Ok(json!({
|
|
|
|
|
"provider_id": provider_id,
|
|
|
|
|
"is_configured": false,
|
|
|
|
|
}));
|
2026-04-03 14:59:58 +08:00
|
|
|
};
|
|
|
|
|
let connector = admin_provider_ops_connector_object(provider_ops_config);
|
|
|
|
|
|
2026-09-04 03:45:52 +08:00
|
|
|
Ok(json!({
|
2026-04-03 14:59:58 +08:00
|
|
|
"provider_id": provider_id,
|
|
|
|
|
"is_configured": true,
|
|
|
|
|
"architecture_id": provider_ops_config
|
|
|
|
|
.get("architecture_id")
|
|
|
|
|
.and_then(serde_json::Value::as_str)
|
|
|
|
|
.unwrap_or("generic_api"),
|
|
|
|
|
"base_url": resolve_admin_provider_ops_base_url(
|
|
|
|
|
provider,
|
|
|
|
|
endpoints,
|
|
|
|
|
Some(provider_ops_config),
|
|
|
|
|
),
|
|
|
|
|
"connector": {
|
|
|
|
|
"auth_type": connector
|
|
|
|
|
.and_then(|connector| connector.get("auth_type"))
|
|
|
|
|
.and_then(serde_json::Value::as_str)
|
|
|
|
|
.unwrap_or("api_key"),
|
|
|
|
|
"config": connector
|
|
|
|
|
.and_then(|connector| connector.get("config"))
|
|
|
|
|
.filter(|value| value.is_object())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_else(|| json!({})),
|
2026-09-04 03:45:52 +08:00
|
|
|
"credentials": admin_provider_ops_masked_credentials(&snapshot.credentials),
|
2026-04-03 14:59:58 +08:00
|
|
|
},
|
2026-05-18 23:51:14 +08:00
|
|
|
"quota_alert": provider_ops_config
|
|
|
|
|
.get("quota_alert")
|
|
|
|
|
.filter(|value| value.is_object())
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_else(default_admin_provider_ops_quota_alert),
|
2026-09-04 03:45:52 +08:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{admin_provider_ops_credential_snapshot, open_provider_ops_credential};
|
|
|
|
|
use crate::data::GatewayDataState;
|
|
|
|
|
use crate::handlers::admin::request::AdminAppState;
|
|
|
|
|
use crate::AppState;
|
|
|
|
|
use aether_crypto::{
|
|
|
|
|
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext,
|
|
|
|
|
looks_like_python_fernet_ciphertext, DEVELOPMENT_ENCRYPTION_KEY,
|
|
|
|
|
};
|
|
|
|
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
|
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
|
|
|
ProviderCatalogReadRepository, StoredProviderCatalogProvider,
|
|
|
|
|
};
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
const TEST_PROVIDER_ID: &str = "provider-ops-secret-test";
|
|
|
|
|
const TEST_API_KEY: &str = "legacy-provider-ops-api-key";
|
|
|
|
|
|
|
|
|
|
fn provider_with_api_key(api_key: &str) -> StoredProviderCatalogProvider {
|
|
|
|
|
StoredProviderCatalogProvider::new(
|
|
|
|
|
TEST_PROVIDER_ID.to_string(),
|
|
|
|
|
"Provider Ops Secret Test".to_string(),
|
|
|
|
|
None,
|
|
|
|
|
"openai".to_string(),
|
|
|
|
|
)
|
|
|
|
|
.expect("provider should build")
|
|
|
|
|
.with_transport_fields(
|
|
|
|
|
true,
|
|
|
|
|
false,
|
|
|
|
|
false,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
Some(json!({
|
|
|
|
|
"provider_ops": {
|
|
|
|
|
"architecture_id": "generic_api",
|
|
|
|
|
"base_url": "https://provider.example.com",
|
|
|
|
|
"connector": {
|
|
|
|
|
"auth_type": "api_key",
|
|
|
|
|
"config": {},
|
|
|
|
|
"credentials": {
|
|
|
|
|
"api_key": api_key,
|
|
|
|
|
"account_id": "account-1"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"actions": {},
|
|
|
|
|
"schedule": {}
|
|
|
|
|
}
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn state_with_provider(
|
|
|
|
|
provider: StoredProviderCatalogProvider,
|
|
|
|
|
) -> (AppState, Arc<InMemoryProviderCatalogReadRepository>) {
|
|
|
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
|
|
|
vec![provider],
|
|
|
|
|
Vec::new(),
|
|
|
|
|
Vec::new(),
|
|
|
|
|
));
|
|
|
|
|
let state = AppState::new()
|
|
|
|
|
.expect("gateway state should build")
|
|
|
|
|
.with_data_state_for_tests(
|
|
|
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository.clone())
|
|
|
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
|
|
|
);
|
|
|
|
|
(state, repository)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn stored_provider(
|
|
|
|
|
repository: &InMemoryProviderCatalogReadRepository,
|
|
|
|
|
) -> StoredProviderCatalogProvider {
|
|
|
|
|
repository
|
|
|
|
|
.list_providers_by_ids(&[TEST_PROVIDER_ID.to_string()])
|
|
|
|
|
.await
|
|
|
|
|
.expect("provider should read")
|
|
|
|
|
.into_iter()
|
|
|
|
|
.next()
|
|
|
|
|
.expect("provider should exist")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn legacy_provider_ops_credentials_are_lazily_migrated() {
|
|
|
|
|
let provider = provider_with_api_key(TEST_API_KEY);
|
|
|
|
|
let (state, repository) = state_with_provider(provider.clone());
|
|
|
|
|
let admin_state = AdminAppState::new(&state);
|
|
|
|
|
|
|
|
|
|
let snapshot = admin_provider_ops_credential_snapshot(&admin_state, &provider)
|
|
|
|
|
.await
|
|
|
|
|
.expect("legacy Provider Ops credential should migrate");
|
|
|
|
|
assert_eq!(snapshot.credentials["api_key"], TEST_API_KEY);
|
|
|
|
|
assert_eq!(snapshot.credentials["account_id"], "account-1");
|
|
|
|
|
|
|
|
|
|
let stored = stored_provider(repository.as_ref()).await;
|
|
|
|
|
let ciphertext = stored
|
|
|
|
|
.config
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|config| config.pointer("/provider_ops/connector/credentials/api_key"))
|
|
|
|
|
.and_then(serde_json::Value::as_str)
|
|
|
|
|
.expect("stored API key should exist");
|
|
|
|
|
assert_ne!(ciphertext, TEST_API_KEY);
|
|
|
|
|
// New migrations use a binding-aware runtime-secret envelope. Keep
|
|
|
|
|
// the legacy Fernet assertion below only for the tamper fixture; a
|
|
|
|
|
// migrated value must no longer be treated as an unbound Fernet blob.
|
|
|
|
|
assert!(ciphertext.starts_with("aether-provider-ops-credential-v2:"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
open_provider_ops_credential(&state, &snapshot.binding, "api_key", ciphertext)
|
|
|
|
|
.expect("migrated Provider Ops API key should decrypt")
|
|
|
|
|
.plaintext,
|
|
|
|
|
TEST_API_KEY
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn tampered_provider_ops_ciphertext_fails_closed() {
|
|
|
|
|
let mut tampered =
|
|
|
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, TEST_API_KEY)
|
|
|
|
|
.expect("Provider Ops API key should encrypt");
|
|
|
|
|
tampered.replace_range(tampered.len() - 2.., "AA");
|
|
|
|
|
assert!(looks_like_python_fernet_ciphertext(&tampered));
|
|
|
|
|
let provider = provider_with_api_key(&tampered);
|
|
|
|
|
let (state, repository) = state_with_provider(provider.clone());
|
|
|
|
|
let admin_state = AdminAppState::new(&state);
|
|
|
|
|
|
|
|
|
|
let error = admin_provider_ops_credential_snapshot(&admin_state, &provider)
|
|
|
|
|
.await
|
|
|
|
|
.expect_err("tampered Provider Ops ciphertext must not be used as plaintext");
|
|
|
|
|
assert!(format!("{error:?}").contains("无法解密"));
|
|
|
|
|
|
|
|
|
|
let stored = stored_provider(repository.as_ref()).await;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
stored
|
|
|
|
|
.config
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|config| {
|
|
|
|
|
config.pointer("/provider_ops/connector/credentials/api_key")
|
|
|
|
|
})
|
|
|
|
|
.and_then(serde_json::Value::as_str),
|
|
|
|
|
Some(tampered.as_str())
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-03 14:59:58 +08:00
|
|
|
}
|