feat: support api key ip restriction rules

This commit is contained in:
fawney19
2026-05-20 16:11:49 +08:00
parent b6bdc08267
commit f76bbaab52
55 changed files with 698 additions and 551 deletions
@@ -10,7 +10,8 @@ use crate::handlers::admin::users::{
default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
normalize_admin_feature_settings, normalize_admin_optional_api_key_name,
normalize_admin_user_api_formats, normalize_admin_user_string_list,
normalize_admin_user_api_formats, normalize_admin_user_ip_rules,
normalize_admin_user_string_list,
};
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::GatewayError;
@@ -109,6 +110,10 @@ pub(super) async fn build_admin_create_api_key_response(
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let ip_rules = match normalize_admin_user_ip_rules(payload.ip_rules) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
if payload.rate_limit.is_some_and(|value| value < 0) {
return Ok(build_admin_api_keys_bad_request_response(
"rate_limit 必须大于等于 0",
@@ -162,7 +167,7 @@ pub(super) async fn build_admin_create_api_key_response(
allowed_providers,
allowed_api_formats,
allowed_models,
allowed_ips: None,
ip_rules,
rate_limit: payload.rate_limit,
concurrent_limit,
force_capabilities: None,
@@ -328,6 +333,19 @@ pub(super) async fn build_admin_update_api_key_response(
} else {
None
};
let ip_rules_present =
field_presence.contains("ip_rules") || field_presence.contains("allowed_ips");
let ip_rules = if ip_rules_present {
match payload.ip_rules {
Some(value) => match normalize_admin_user_ip_rules(value) {
Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
},
None => Some(None),
}
} else {
None
};
let effective_expires_at_unix_secs = if field_presence.contains("expires_at") {
match parse_standalone_api_key_expires_at(payload.expires_at.as_deref()) {
Ok(value) => value,
@@ -395,7 +413,7 @@ pub(super) async fn build_admin_update_api_key_response(
allowed_providers,
allowed_api_formats,
allowed_models,
allowed_ips: None,
ip_rules,
expires_at_present: field_presence.contains("expires_at"),
expires_at_unix_secs: if field_presence.contains("expires_at") {
effective_expires_at_unix_secs
@@ -3,6 +3,7 @@ use crate::handlers::admin::shared::{query_param_value, AdminTypedObjectPatch};
use crate::handlers::admin::users::{
format_optional_unix_secs_iso8601, masked_user_api_key_display,
};
use crate::handlers::shared::deserialize_optional_string_list_patch;
use aether_admin::system::serialize_admin_system_users_export_wallet;
use axum::{
body::Body,
@@ -20,6 +21,8 @@ pub(super) struct AdminStandaloneApiKeyCreateRequest {
pub(super) allowed_providers: Option<Vec<String>>,
pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>,
#[serde(default, alias = "allowed_ips")]
pub(super) ip_rules: Option<Vec<String>>,
pub(super) rate_limit: Option<i32>,
pub(super) concurrent_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>,
@@ -36,6 +39,12 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
pub(super) allowed_providers: Option<Vec<String>>,
pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>,
#[serde(
default,
alias = "allowed_ips",
deserialize_with = "deserialize_optional_string_list_patch"
)]
pub(super) ip_rules: Option<Option<Vec<String>>>,
pub(super) rate_limit: Option<i32>,
pub(super) concurrent_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>,
@@ -161,6 +170,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
"allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models,
"ip_rules": record.ip_rules,
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
@@ -191,6 +201,7 @@ pub(super) fn build_admin_api_key_detail_payload(
"allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models,
"ip_rules": record.ip_rules,
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
@@ -344,6 +344,7 @@ impl<'a> AdminAppState<'a> {
"allowed_models".to_string(),
json!(key.allowed_models.clone()),
),
("ip_rules".to_string(), json!(key.ip_rules.clone())),
("rate_limit".to_string(), json!(key.rate_limit)),
("concurrent_limit".to_string(), json!(key.concurrent_limit)),
(
@@ -14,7 +14,7 @@ use crate::handlers::admin::system::shared::configs::apply_admin_system_config_u
use crate::handlers::admin::users::{
hash_admin_user_api_key, normalize_admin_feature_settings, normalize_admin_list_policy_mode,
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
normalize_admin_user_ip_rules, normalize_admin_user_string_list,
};
use crate::handlers::public::normalize_admin_base_url;
use crate::GatewayError;
@@ -705,6 +705,27 @@ fn normalize_imported_user_api_formats(
)?)
}
fn imported_ip_rules_field<'a>(
object: &'a Map<String, Value>,
) -> (&'static str, Option<&'a Value>) {
if let Some(value) = object.get("ip_rules") {
("ip_rules", Some(value))
} else {
("allowed_ips", object.get("allowed_ips"))
}
}
fn imported_ip_rules_present(object: &Map<String, Value>) -> bool {
object.contains_key("ip_rules") || object.contains_key("allowed_ips")
}
fn normalize_imported_user_ip_rules(
object: &Map<String, Value>,
) -> Result<Option<Vec<String>>, String> {
let (field_name, value) = imported_ip_rules_field(object);
normalize_admin_user_ip_rules(imported_string_list_from_value(value, field_name)?)
}
fn build_imported_user_group_record(
group: &Map<String, Value>,
field_name: &str,
@@ -2493,6 +2514,7 @@ impl<'a> AdminAppState<'a> {
));
let allowed_models =
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
let rate_limit =
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
.unwrap_or(0);
@@ -2558,7 +2580,8 @@ impl<'a> AdminAppState<'a> {
} else {
None
},
allowed_ips: None,
ip_rules: imported_ip_rules_present(key)
.then(|| ip_rules.clone()),
},
)
.await?;
@@ -2627,7 +2650,7 @@ impl<'a> AdminAppState<'a> {
allowed_providers,
allowed_api_formats,
allowed_models,
allowed_ips: None,
ip_rules,
rate_limit,
concurrent_limit,
force_capabilities,
@@ -2712,6 +2735,7 @@ impl<'a> AdminAppState<'a> {
));
let allowed_models =
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
let rate_limit =
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
.unwrap_or(0);
@@ -2783,7 +2807,8 @@ impl<'a> AdminAppState<'a> {
allowed_providers: Some(allowed_providers.clone()),
allowed_api_formats: Some(allowed_api_formats.clone()),
allowed_models: Some(allowed_models.clone()),
allowed_ips: None,
ip_rules: imported_ip_rules_present(key)
.then(|| ip_rules.clone()),
expires_at_present: false,
expires_at_unix_secs: None,
auto_delete_on_expiry_present: false,
@@ -2844,7 +2869,7 @@ impl<'a> AdminAppState<'a> {
allowed_providers,
allowed_api_formats,
allowed_models,
allowed_ips: None,
ip_rules,
rate_limit: Some(rate_limit),
concurrent_limit,
force_capabilities,
@@ -10,7 +10,7 @@ use crate::handlers::admin::system::shared::paths::{
admin_management_token_status_id_from_path, is_admin_management_tokens_root,
};
use crate::handlers::internal::build_management_token_payload;
use crate::handlers::shared::generate_gateway_secret_plaintext;
use crate::handlers::shared::{generate_gateway_secret_plaintext, parse_json_ip_rules};
use crate::{GatewayError, LocalMutationOutcome};
use aether_data::repository::management_tokens::{
CreateManagementTokenRecord, ManagementTokenListQuery, RegenerateManagementTokenSecret,
@@ -97,59 +97,10 @@ fn admin_management_token_prefix(value: &str) -> Option<String> {
.then(|| value[..value.len().min(ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN)].to_string())
}
fn admin_validate_ip_or_cidr(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
if value.parse::<std::net::IpAddr>().is_ok() {
return true;
}
let Some((host, prefix)) = value.split_once('/') else {
return false;
};
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
return false;
};
let Ok(prefix) = prefix.trim().parse::<u8>() else {
return false;
};
match ip {
std::net::IpAddr::V4(_) => prefix <= 32,
std::net::IpAddr::V6(_) => prefix <= 128,
}
}
fn admin_parse_management_token_allowed_ips(
value: Option<&serde_json::Value>,
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = value else {
return Ok(None);
};
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::Array(items) => {
if items.is_empty() {
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
}
let mut normalized = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
let Some(raw) = item.as_str() else {
return Err("IP 白名单必须是字符串数组".to_string());
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("IP 白名单第 {} 项为空", index + 1));
}
if !admin_validate_ip_or_cidr(trimmed) {
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
}
normalized.push(trimmed.to_string());
}
Ok(Some(json!(normalized)))
}
_ => Err("IP 白名单必须是字符串数组".to_string()),
}
parse_json_ip_rules(value)
}
fn admin_parse_management_token_expires_at(
@@ -46,7 +46,7 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
AdminModuleDefinition {
name: "management_tokens",
display_name: "访问令牌",
description: "管理 API 访问令牌,支持细粒度权限控制和 IP 白名单",
description: "管理 API 访问令牌,支持细粒度权限控制和 IP 限制",
category: "security",
env_key: "MANAGEMENT_TOKENS_AVAILABLE",
default_available: true,
@@ -44,7 +44,7 @@ pub(super) fn build_admin_user_api_key_detail_payload(
"total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit,
"concurrent_limit": record.concurrent_limit,
"allowed_ips": record.allowed_ips,
"ip_rules": record.ip_rules,
"feature_settings": record.feature_settings,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
@@ -1,7 +1,7 @@
use super::super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, normalize_admin_feature_settings,
normalize_admin_user_allowed_ips, AdminCreateUserApiKeyRequest,
normalize_admin_user_ip_rules, AdminCreateUserApiKeyRequest,
};
use super::super::helpers::{
attach_audit_response, default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
@@ -71,7 +71,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
{
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers、allowed_ips 字段" })),
Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers、ip_rules 字段" })),
)
.into_response());
}
@@ -107,7 +107,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
.into_response());
}
};
let allowed_ips = match normalize_admin_user_allowed_ips(payload.allowed_ips) {
let ip_rules = match normalize_admin_user_ip_rules(payload.ip_rules) {
Ok(value) => value,
Err(detail) => {
return Ok((
@@ -156,7 +156,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
allowed_providers: None,
allowed_api_formats: None,
allowed_models: None,
allowed_ips,
ip_rules,
rate_limit,
concurrent_limit,
force_capabilities: None,
@@ -207,7 +207,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
"key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()),
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"allowed_ips": created.allowed_ips,
"ip_rules": created.ip_rules,
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
@@ -1,7 +1,6 @@
use super::super::super::{
build_admin_users_bad_request_response, build_admin_users_read_only_response,
normalize_admin_feature_settings, normalize_admin_user_allowed_ips,
AdminUpdateUserApiKeyRequest,
normalize_admin_feature_settings, normalize_admin_user_ip_rules, AdminUpdateUserApiKeyRequest,
};
use super::super::helpers::{
attach_audit_response, build_admin_user_api_key_detail_payload,
@@ -95,8 +94,8 @@ pub(crate) async fn build_admin_update_user_api_key_response(
.into_response());
}
};
let allowed_ips = match payload.allowed_ips {
Some(value) => match normalize_admin_user_allowed_ips(value) {
let ip_rules = match payload.ip_rules {
Some(value) => match normalize_admin_user_ip_rules(value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
@@ -116,7 +115,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
name,
rate_limit: payload.rate_limit,
concurrent_limit,
allowed_ips,
ip_rules,
})
.await?
else {
@@ -56,7 +56,7 @@ use self::shared::{
};
pub(crate) use self::shared::{
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
normalize_admin_user_allowed_ips, normalize_admin_user_api_formats,
normalize_admin_user_api_formats, normalize_admin_user_ip_rules,
normalize_admin_user_string_list,
};
pub(crate) use crate::handlers::shared::normalize_feature_settings as normalize_admin_feature_settings;
@@ -1,6 +1,6 @@
use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::shared::AdminTypedObjectPatch;
use crate::handlers::shared::deserialize_optional_string_list_patch;
use crate::handlers::shared::{deserialize_optional_string_list_patch, normalize_ip_rules};
use axum::{
body::Body,
http,
@@ -20,8 +20,8 @@ pub(super) struct AdminCreateUserApiKeyRequest {
pub(super) allowed_api_formats: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_models: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_ips: Option<Vec<String>>,
#[serde(default, alias = "allowed_ips")]
pub(super) ip_rules: Option<Vec<String>>,
#[serde(default)]
pub(super) rate_limit: Option<i32>,
#[serde(default)]
@@ -52,8 +52,12 @@ pub(super) struct AdminUpdateUserApiKeyRequest {
pub(super) concurrent_limit: Option<i32>,
#[serde(default)]
pub(super) feature_settings: Option<Option<Value>>,
#[serde(default, deserialize_with = "deserialize_optional_string_list_patch")]
pub(super) allowed_ips: Option<Option<Vec<String>>>,
#[serde(
default,
alias = "allowed_ips",
deserialize_with = "deserialize_optional_string_list_patch"
)]
pub(super) ip_rules: Option<Option<Vec<String>>>,
}
#[derive(Debug, serde::Deserialize)]
@@ -285,50 +289,10 @@ pub(crate) fn normalize_admin_user_api_formats(
Ok(Some(normalized))
}
pub(crate) fn normalize_admin_user_allowed_ips(
pub(crate) fn normalize_admin_user_ip_rules(
value: Option<Vec<String>>,
) -> Result<Option<Vec<String>>, String> {
let Some(values) = value else {
return Ok(None);
};
if values.is_empty() {
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
}
let mut normalized = Vec::with_capacity(values.len());
for (index, raw) in values.into_iter().enumerate() {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("IP 白名单第 {} 项为空", index + 1));
}
if !validate_admin_user_ip_or_cidr(trimmed) {
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
}
normalized.push(trimmed.to_string());
}
Ok(Some(normalized))
}
fn validate_admin_user_ip_or_cidr(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
if value.parse::<std::net::IpAddr>().is_ok() {
return true;
}
let Some((host, prefix)) = value.split_once('/') else {
return false;
};
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
return false;
};
let Ok(prefix) = prefix.trim().parse::<u8>() else {
return false;
};
match ip {
std::net::IpAddr::V4(_) => prefix <= 32,
std::net::IpAddr::V6(_) => prefix <= 128,
}
normalize_ip_rules(value)
}
pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
@@ -429,25 +393,25 @@ mod tests {
}
#[test]
fn admin_update_api_key_distinguishes_missing_null_and_present_allowed_ips() {
fn admin_update_api_key_distinguishes_missing_null_and_present_ip_rules() {
let missing = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
"name": "unchanged-whitelist",
"name": "unchanged-ip-rules",
}))
.expect("missing allowed_ips should deserialize");
assert_eq!(missing.allowed_ips, None);
.expect("missing ip_rules should deserialize");
assert_eq!(missing.ip_rules, None);
let cleared = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
"allowed_ips": null,
"ip_rules": null,
}))
.expect("null allowed_ips should deserialize");
assert_eq!(cleared.allowed_ips, Some(None));
.expect("null ip_rules should deserialize");
assert_eq!(cleared.ip_rules, Some(None));
let updated = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
"allowed_ips": ["203.0.113.10", "10.0.0.0/24"],
"ip_rules": ["203.0.113.10", "10.0.0.0/24"],
}))
.expect("present allowed_ips should deserialize");
.expect("present ip_rules should deserialize");
assert_eq!(
updated.allowed_ips,
updated.ip_rules,
Some(Some(vec![
"203.0.113.10".to_string(),
"10.0.0.0/24".to_string(),