mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -6,6 +6,9 @@ use chrono::{TimeZone, Utc};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::redaction::{
|
||||
admin_provider_status_snapshot_safe_json, admin_secret_safe_json, admin_secret_safe_proxy,
|
||||
};
|
||||
use super::status as provider_status;
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
@@ -18,7 +21,7 @@ pub struct AdminPoolResolveSelectionRequest {
|
||||
pub quick_selectors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct AdminPoolBatchActionRequest {
|
||||
#[serde(default)]
|
||||
pub key_ids: Vec<String>,
|
||||
@@ -28,6 +31,17 @@ pub struct AdminPoolBatchActionRequest {
|
||||
pub payload: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPoolBatchActionRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchActionRequest")
|
||||
.field("key_ids", &self.key_ids)
|
||||
.field("action", &self.action)
|
||||
.field("payload", &self.payload.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminPoolBatchActionKind {
|
||||
Enable,
|
||||
@@ -39,7 +53,7 @@ pub enum AdminPoolBatchActionKind {
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct AdminPoolBatchActionPlan {
|
||||
pub key_ids: Vec<String>,
|
||||
pub action: AdminPoolBatchActionKind,
|
||||
@@ -48,6 +62,25 @@ pub struct AdminPoolBatchActionPlan {
|
||||
pub settings_payload: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPoolBatchActionPlan {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchActionPlan")
|
||||
.field("key_ids", &self.key_ids)
|
||||
.field("action", &self.action)
|
||||
.field("action_label", &self.action_label)
|
||||
.field(
|
||||
"proxy_payload",
|
||||
&self.proxy_payload.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field(
|
||||
"settings_payload",
|
||||
&self.settings_payload.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdminPoolKeyPayloadContext {
|
||||
pub cooldown_reason: Option<String>,
|
||||
@@ -58,7 +91,7 @@ pub struct AdminPoolKeyPayloadContext {
|
||||
pub cost_limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct AdminPoolBatchImportRequest {
|
||||
#[serde(default)]
|
||||
pub keys: Vec<AdminPoolBatchImportItem>,
|
||||
@@ -70,7 +103,19 @@ pub struct AdminPoolBatchImportRequest {
|
||||
pub settings: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
impl std::fmt::Debug for AdminPoolBatchImportRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchImportRequest")
|
||||
.field("keys", &self.keys)
|
||||
.field("proxy_node_id", &self.proxy_node_id)
|
||||
.field("api_formats", &self.api_formats)
|
||||
.field("settings", &self.settings.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, serde::Deserialize)]
|
||||
pub struct AdminPoolBatchImportItem {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
@@ -84,6 +129,19 @@ pub struct AdminPoolBatchImportItem {
|
||||
pub settings: Option<Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdminPoolBatchImportItem {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminPoolBatchImportItem")
|
||||
.field("name", &self.name)
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.field("auth_type", &self.auth_type)
|
||||
.field("api_formats", &self.api_formats)
|
||||
.field("settings", &self.settings.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_reason_indicates_ban(reason: &str) -> bool {
|
||||
let normalized = reason.trim().to_ascii_lowercase();
|
||||
!normalized.is_empty()
|
||||
@@ -178,6 +236,14 @@ fn admin_pool_json_object(value: Option<&Value>) -> Option<serde_json::Map<Strin
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn admin_pool_secret_safe_json_object(value: Option<&Value>) -> Value {
|
||||
admin_pool_json_object(value)
|
||||
.map(Value::Object)
|
||||
.as_ref()
|
||||
.map(|value| admin_secret_safe_json(Some(value)))
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn admin_pool_health_score(key: &StoredProviderCatalogKey) -> f64 {
|
||||
let scores = key
|
||||
.health_by_format
|
||||
@@ -680,7 +746,8 @@ mod tests {
|
||||
apply_admin_pool_key_settings, build_admin_pool_batch_action_plan,
|
||||
build_admin_pool_batch_import_key_record, build_admin_pool_key_payload,
|
||||
resolve_admin_pool_key_settings, validate_admin_pool_key_settings_payload,
|
||||
AdminPoolBatchActionKind, AdminPoolBatchActionRequest, AdminPoolKeyPayloadContext,
|
||||
AdminPoolBatchActionKind, AdminPoolBatchActionRequest, AdminPoolBatchImportItem,
|
||||
AdminPoolBatchImportRequest, AdminPoolKeyPayloadContext,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
@@ -699,6 +766,31 @@ mod tests {
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_import_debug_output_redacts_api_keys_and_settings() {
|
||||
let request = AdminPoolBatchImportRequest {
|
||||
keys: vec![AdminPoolBatchImportItem {
|
||||
name: "key".to_string(),
|
||||
api_key: "pool-api-key-canary".to_string(),
|
||||
auth_type: "api_key".to_string(),
|
||||
api_formats: vec!["openai:chat".to_string()],
|
||||
settings: Some(json!({"credential": "item-settings-canary"})),
|
||||
}],
|
||||
proxy_node_id: None,
|
||||
api_formats: Vec::new(),
|
||||
settings: Some(json!({"password": "request-settings-canary"})),
|
||||
};
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
for secret in [
|
||||
"pool-api-key-canary",
|
||||
"item-settings-canary",
|
||||
"request-settings-canary",
|
||||
] {
|
||||
assert!(!debug.contains(secret), "debug output leaked {secret}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_codex_exhaustion_from_metadata() {
|
||||
assert!(admin_pool_key_account_quota_exhausted(
|
||||
@@ -887,6 +979,52 @@ mod tests {
|
||||
assert_eq!(payload["scheduling_label"], json!("可用"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_payload_projects_historical_status_and_cooldown_diagnostics() {
|
||||
let mut key = sample_key(None);
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": {
|
||||
"code": "invalid",
|
||||
"reason": "Authorization: Bearer upstream-secret"
|
||||
},
|
||||
"account": {
|
||||
"code": "account_disabled",
|
||||
"blocked": true,
|
||||
"reason": "https://user:[email protected]?q=secret"
|
||||
},
|
||||
"quota": {
|
||||
"code": "cooldown",
|
||||
"exhausted": false,
|
||||
"reason": "Authorization: Bearer upstream-secret",
|
||||
"reset_credits": {
|
||||
"detail_error": "https://user:[email protected]?q=secret"
|
||||
}
|
||||
}
|
||||
}));
|
||||
let context = AdminPoolKeyPayloadContext {
|
||||
cooldown_reason: Some(
|
||||
"Authorization: Bearer upstream-secret https://user:[email protected]?q=secret"
|
||||
.to_string(),
|
||||
),
|
||||
..AdminPoolKeyPayloadContext::default()
|
||||
};
|
||||
|
||||
let payload = build_admin_pool_key_payload(&key, &context);
|
||||
|
||||
assert_eq!(
|
||||
payload["cooldown_reason"],
|
||||
json!("Provider key is cooling down")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.pointer("/status_snapshot/oauth/reason"),
|
||||
Some(&json!("OAuth token is invalid"))
|
||||
);
|
||||
let serialized = payload.to_string();
|
||||
assert!(!serialized.contains("upstream-secret"));
|
||||
assert!(!serialized.contains("user:password"));
|
||||
assert!(!serialized.contains("q=secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_and_applies_shared_key_settings() {
|
||||
let settings = json!({
|
||||
@@ -996,10 +1134,14 @@ pub fn build_admin_pool_key_payload(
|
||||
) -> Value {
|
||||
let health_score = admin_pool_health_score(key);
|
||||
let circuit_breaker_open = false;
|
||||
let cooldown_reason = context
|
||||
.cooldown_reason
|
||||
.as_ref()
|
||||
.map(|_| "Provider key is cooling down".to_string());
|
||||
let (scheduling_status, scheduling_reason, scheduling_label, scheduling_reasons) =
|
||||
admin_pool_scheduling_payload(
|
||||
key,
|
||||
context.cooldown_reason.as_deref(),
|
||||
cooldown_reason.as_deref(),
|
||||
context.cooldown_ttl_seconds,
|
||||
);
|
||||
|
||||
@@ -1008,25 +1150,29 @@ pub fn build_admin_pool_key_payload(
|
||||
"key_name": key.name,
|
||||
"is_active": key.is_active,
|
||||
"auth_type": key.auth_type,
|
||||
"status_snapshot": key.status_snapshot.clone().unwrap_or_else(|| json!({})),
|
||||
"status_snapshot": key
|
||||
.status_snapshot
|
||||
.as_ref()
|
||||
.map(|value| admin_provider_status_snapshot_safe_json(Some(value)))
|
||||
.unwrap_or_else(|| json!({})),
|
||||
"health_score": health_score,
|
||||
"circuit_breaker_open": circuit_breaker_open,
|
||||
"api_formats": admin_pool_api_formats(key),
|
||||
"rate_multipliers": admin_pool_json_object(key.rate_multipliers.as_ref()),
|
||||
"rate_multipliers": admin_pool_secret_safe_json_object(key.rate_multipliers.as_ref()),
|
||||
"internal_priority": key.internal_priority,
|
||||
"rpm_limit": key.rpm_limit,
|
||||
"cache_ttl_minutes": key.cache_ttl_minutes,
|
||||
"max_probe_interval_minutes": key.max_probe_interval_minutes,
|
||||
"note": key.note,
|
||||
"allowed_models": admin_pool_string_list(key.allowed_models.as_ref()),
|
||||
"capabilities": admin_pool_json_object(key.capabilities.as_ref()),
|
||||
"capabilities": admin_pool_secret_safe_json_object(key.capabilities.as_ref()),
|
||||
"auto_fetch_models": key.auto_fetch_models,
|
||||
"locked_models": admin_pool_string_list(key.locked_models.as_ref()),
|
||||
"model_include_patterns": admin_pool_string_list(key.model_include_patterns.as_ref()),
|
||||
"model_exclude_patterns": admin_pool_string_list(key.model_exclude_patterns.as_ref()),
|
||||
"proxy": key.proxy.clone(),
|
||||
"fingerprint": key.fingerprint.clone(),
|
||||
"cooldown_reason": context.cooldown_reason,
|
||||
"proxy": admin_secret_safe_proxy(key.proxy.as_ref()),
|
||||
"fingerprint": admin_secret_safe_json(key.fingerprint.as_ref()),
|
||||
"cooldown_reason": cooldown_reason,
|
||||
"cooldown_ttl_seconds": context.cooldown_ttl_seconds,
|
||||
"cost_window_usage": context.cost_window_usage,
|
||||
"cost_limit": context.cost_limit,
|
||||
|
||||
Reference in New Issue
Block a user