refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate

- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦
- 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块
- 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支
- 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合
- 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor
- 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
fawney19
2026-04-07 02:50:19 +08:00
parent 763ff03a7b
commit 5d96d6673b
732 changed files with 28589 additions and 20662 deletions
@@ -0,0 +1,50 @@
use super::super::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_optional_api_key_name, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, query_param_bool,
query_param_optional_bool, query_param_value,
};
use crate::handlers::admin::system::shared::serialize_admin_system_users_export_wallet;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
const ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL: &str = "Admin standalone API key data unavailable";
mod mutation_routes;
mod read_routes;
mod routes;
mod shared;
use self::mutation_routes::{
build_admin_create_api_key_response, build_admin_delete_api_key_response,
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
};
use self::read_routes::{build_admin_api_key_detail_response, build_admin_list_api_keys_response};
use self::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_operator_id,
admin_api_keys_parse_limit, admin_api_keys_parse_skip, build_admin_api_key_detail_payload,
build_admin_api_key_list_item_payload, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyFieldPresence,
AdminStandaloneApiKeyToggleRequest, AdminStandaloneApiKeyUpdateRequest,
};
pub(crate) async fn maybe_build_local_admin_api_keys_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
routes::maybe_build_local_admin_api_keys_routes_response(state, request_context, request_body)
.await
}
@@ -0,0 +1,350 @@
use super::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_operator_id,
build_admin_api_key_detail_payload, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
AdminStandaloneApiKeyCreateRequest, AdminStandaloneApiKeyFieldPresence,
AdminStandaloneApiKeyToggleRequest, AdminStandaloneApiKeyUpdateRequest,
};
use super::{
default_admin_user_api_key_name, encrypt_catalog_secret_with_fallbacks,
format_optional_unix_secs_iso8601, generate_admin_user_api_key_plaintext,
hash_admin_user_api_key, masked_user_api_key_display, normalize_admin_optional_api_key_name,
normalize_admin_user_api_formats, normalize_admin_user_string_list,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn build_admin_create_api_key_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
let Some(operator_id) = admin_api_keys_operator_id(request_context) else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
let Some(request_body) = request_body else {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
));
};
let payload = match serde_json::from_slice::<AdminStandaloneApiKeyCreateRequest>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
))
}
};
if payload.initial_balance_usd.is_some()
|| payload.unlimited_balance.is_some()
|| payload.expire_days.is_some()
|| payload.expires_at.is_some()
|| payload.auto_delete_on_expiry.is_some()
{
return Ok(build_admin_api_keys_bad_request_response(
"当前仅支持 name、rate_limit、allowed_providers、allowed_api_formats、allowed_models 字段",
));
}
let name = match normalize_admin_optional_api_key_name(payload.name) {
Ok(Some(value)) => value,
Ok(None) => default_admin_user_api_key_name(),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let allowed_providers =
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let allowed_api_formats = match normalize_admin_user_api_formats(payload.allowed_api_formats) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let allowed_models =
match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let rate_limit = payload.rate_limit.unwrap_or(0);
if rate_limit < 0 {
return Ok(build_admin_api_keys_bad_request_response(
"rate_limit 必须大于等于 0",
));
}
let plaintext_key = generate_admin_user_api_key_plaintext();
let Some(key_encrypted) = encrypt_catalog_secret_with_fallbacks(state, &plaintext_key) else {
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": "API密钥加密失败" })),
)
.into_response());
};
let Some(created) = state
.create_standalone_api_key(
aether_data::repository::auth::CreateStandaloneApiKeyRecord {
user_id: operator_id,
api_key_id: uuid::Uuid::new_v4().to_string(),
key_hash: hash_admin_user_api_key(&plaintext_key),
key_encrypted: Some(key_encrypted),
name: Some(name),
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
concurrent_limit: 5,
},
)
.await?
else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
Ok(attach_admin_audit_response(
Json(json!({
"id": created.api_key_id,
"key": plaintext_key,
"name": created.name,
"key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()),
"is_standalone": true,
"is_active": created.is_active,
"rate_limit": created.rate_limit,
"allowed_providers": created.allowed_providers,
"allowed_api_formats": created.allowed_api_formats,
"allowed_models": created.allowed_models,
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"wallet": serde_json::Value::Null,
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
}))
.into_response(),
"admin_standalone_api_key_created",
"create_standalone_api_key",
"api_key",
&created.api_key_id,
))
}
pub(super) async fn build_admin_update_api_key_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
let Some(api_key_id) = admin_api_keys_id_from_path(&request_context.request_path) else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
let Some(request_body) = request_body else {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
));
};
let raw_payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(serde_json::Value::Object(map)) => map,
_ => {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
))
}
};
let field_presence = AdminStandaloneApiKeyFieldPresence {
allowed_providers: raw_payload.contains_key("allowed_providers"),
allowed_api_formats: raw_payload.contains_key("allowed_api_formats"),
allowed_models: raw_payload.contains_key("allowed_models"),
};
let payload = match serde_json::from_value::<AdminStandaloneApiKeyUpdateRequest>(
serde_json::Value::Object(raw_payload),
) {
Ok(value) => value,
Err(_) => {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
))
}
};
if payload.initial_balance_usd.is_some()
|| payload.unlimited_balance.is_some()
|| payload.expire_days.is_some()
|| payload.expires_at.is_some()
|| payload.auto_delete_on_expiry.is_some()
{
return Ok(build_admin_api_keys_bad_request_response(
"当前仅支持 name、rate_limit、allowed_providers、allowed_api_formats、allowed_models 字段",
));
}
let name = match normalize_admin_optional_api_key_name(payload.name) {
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",
));
}
let allowed_providers = if field_presence.allowed_providers {
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
}
} else {
None
};
let allowed_api_formats = if field_presence.allowed_api_formats {
match normalize_admin_user_api_formats(payload.allowed_api_formats) {
Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
}
} else {
None
};
let allowed_models = if field_presence.allowed_models {
match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") {
Ok(value) => Some(value),
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
}
} else {
None
};
let Some(updated) = state
.update_standalone_api_key_basic(
aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord {
api_key_id: api_key_id.clone(),
name,
rate_limit: payload.rate_limit,
allowed_providers,
allowed_api_formats,
allowed_models,
},
)
.await?
else {
return Ok(build_admin_api_keys_not_found_response());
};
let wallet = state
.list_wallet_snapshots_by_api_key_ids(std::slice::from_ref(&api_key_id))
.await?
.into_iter()
.find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id.as_str()));
let total_tokens_by_api_key_id =
admin_api_key_total_tokens_by_ids(state, std::slice::from_ref(&api_key_id)).await?;
let total_tokens = total_tokens_by_api_key_id
.get(&api_key_id)
.copied()
.unwrap_or(0);
let mut payload =
build_admin_api_key_detail_payload(state, &updated, total_tokens, wallet.as_ref());
payload["message"] = json!("API密钥已更新");
Ok(attach_admin_audit_response(
Json(payload).into_response(),
"admin_standalone_api_key_updated",
"update_standalone_api_key",
"api_key",
&api_key_id,
))
}
pub(super) async fn build_admin_toggle_api_key_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
let Some(api_key_id) = admin_api_keys_id_from_path(&request_context.request_path) else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
let requested_active = match request_body {
None => None,
Some(request_body) if request_body.is_empty() => None,
Some(request_body) => {
match serde_json::from_slice::<AdminStandaloneApiKeyToggleRequest>(request_body) {
Ok(value) => value.is_active,
Err(_) => {
return Ok(build_admin_api_keys_bad_request_response(
"请求数据验证失败",
))
}
}
}
};
let Some(snapshot) = state
.data
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.find(|snapshot| snapshot.api_key_id == api_key_id)
else {
return Ok(build_admin_api_keys_not_found_response());
};
if !snapshot.api_key_is_standalone {
return Ok(build_admin_api_keys_bad_request_response("仅支持独立密钥"));
}
let is_active = requested_active.unwrap_or(!snapshot.api_key_is_active);
let Some(updated) = state
.set_standalone_api_key_active(&api_key_id, is_active)
.await?
else {
return Ok(build_admin_api_keys_not_found_response());
};
Ok(attach_admin_audit_response(
Json(json!({
"id": updated.api_key_id,
"is_active": updated.is_active,
"message": if updated.is_active { "API密钥已启用" } else { "API密钥已禁用" },
}))
.into_response(),
"admin_standalone_api_key_toggled",
"toggle_standalone_api_key",
"api_key",
&api_key_id,
))
}
pub(super) async fn build_admin_delete_api_key_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
if !state.data.has_auth_api_key_writer() {
return Ok(build_admin_api_keys_data_unavailable_response());
}
let Some(api_key_id) = admin_api_keys_id_from_path(&request_context.request_path) else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
match state.delete_standalone_api_key(&api_key_id).await? {
true => Ok(attach_admin_audit_response(
Json(json!({ "message": "API密钥已删除" })).into_response(),
"admin_standalone_api_key_deleted",
"delete_standalone_api_key",
"api_key",
&api_key_id,
)),
false => Ok(build_admin_api_keys_not_found_response()),
}
}
@@ -0,0 +1,190 @@
use super::shared::{
admin_api_key_total_tokens_by_ids, admin_api_keys_id_from_path, admin_api_keys_parse_limit,
admin_api_keys_parse_skip, build_admin_api_key_detail_payload,
build_admin_api_key_list_item_payload, build_admin_api_keys_bad_request_response,
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
};
use super::{decrypt_catalog_secret_with_fallbacks, query_param_bool, query_param_optional_bool};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::time::Instant;
use tracing::info;
pub(super) async fn build_admin_list_api_keys_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let handler_started_at = Instant::now();
let query = request_context.request_query_string.as_deref();
let skip = match admin_api_keys_parse_skip(query) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let limit = match admin_api_keys_parse_limit(query) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
};
let is_active = query_param_optional_bool(query, "is_active");
let include_usage_summary = query_param_bool(query, "include_usage_summary", false);
let list_query = aether_data::repository::auth::StandaloneApiKeyExportListQuery {
skip,
limit,
is_active,
};
let count_and_page_started_at = Instant::now();
let (total, paged_records) = tokio::try_join!(
state.count_auth_api_key_export_standalone_records(is_active),
state.list_auth_api_key_export_standalone_records_page(&list_query),
)?;
let count_and_page_ms = count_and_page_started_at.elapsed().as_millis() as u64;
let api_key_ids = paged_records
.iter()
.map(|record| record.api_key_id.clone())
.collect::<Vec<_>>();
let wallet_lookup_started_at = Instant::now();
let wallets_by_api_key_id = state
.list_wallet_snapshots_by_api_key_ids(&api_key_ids)
.await?
.into_iter()
.filter_map(|wallet| {
wallet
.api_key_id
.clone()
.map(|api_key_id| (api_key_id, wallet))
})
.collect::<std::collections::BTreeMap<_, _>>();
let wallet_lookup_ms = wallet_lookup_started_at.elapsed().as_millis() as u64;
let usage_summary_started_at = Instant::now();
let total_tokens_by_api_key_id = if include_usage_summary {
Some(admin_api_key_total_tokens_by_ids(state, &api_key_ids).await?)
} else {
None
};
let usage_summary_ms = usage_summary_started_at.elapsed().as_millis() as u64;
let api_keys = paged_records
.iter()
.map(|record| {
build_admin_api_key_list_item_payload(
state,
record,
total_tokens_by_api_key_id
.as_ref()
.and_then(|totals| totals.get(&record.api_key_id).copied()),
wallets_by_api_key_id.get(&record.api_key_id),
)
})
.collect::<Vec<_>>();
info!(
event_name = "admin_api_keys_list_timing",
log_type = "event",
trace_id = request_context.trace_id.as_str(),
returned_items = api_keys.len(),
total,
include_usage_summary,
count_and_page_ms,
wallet_lookup_ms,
usage_summary_ms,
handler_ms = handler_started_at.elapsed().as_millis() as u64,
"measured admin api keys list handler timing"
);
Ok(Json(json!({
"api_keys": api_keys,
"total": total as usize,
"limit": limit,
"skip": skip,
}))
.into_response())
}
pub(super) async fn build_admin_api_key_detail_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(api_key_id) = admin_api_keys_id_from_path(&request_context.request_path) else {
return Ok(build_admin_api_keys_data_unavailable_response());
};
if state
.data
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.into_iter()
.any(|snapshot| snapshot.api_key_id == api_key_id && !snapshot.api_key_is_standalone)
{
return Ok(build_admin_api_keys_bad_request_response(
"仅支持查看独立密钥",
));
}
let Some(record) = state
.find_auth_api_key_export_standalone_record_by_id(&api_key_id)
.await?
else {
return Ok(build_admin_api_keys_not_found_response());
};
if query_param_bool(
request_context.request_query_string.as_deref(),
"include_key",
false,
) {
let Some(ciphertext) = record
.key_encrypted
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(build_admin_api_keys_bad_request_response(
"该密钥没有存储完整密钥信息",
));
};
let Some(key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
else {
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": "解密密钥失败" })),
)
.into_response());
};
return Ok(attach_admin_audit_response(
Json(json!({ "key": key })).into_response(),
"admin_standalone_api_key_revealed",
"reveal_standalone_api_key",
"api_key",
&api_key_id,
));
}
let wallet = state
.list_wallet_snapshots_by_api_key_ids(std::slice::from_ref(&api_key_id))
.await?
.into_iter()
.find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id.as_str()));
let total_tokens_by_api_key_id =
admin_api_key_total_tokens_by_ids(state, std::slice::from_ref(&api_key_id)).await?;
let total_tokens = total_tokens_by_api_key_id
.get(&api_key_id)
.copied()
.unwrap_or(0);
Ok(Json(build_admin_api_key_detail_payload(
state,
&record,
total_tokens,
wallet.as_ref(),
))
.into_response())
}
@@ -0,0 +1,83 @@
use super::mutation_routes::{
build_admin_create_api_key_response, build_admin_delete_api_key_response,
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
};
use super::read_routes::{build_admin_api_key_detail_response, build_admin_list_api_keys_response};
use super::shared::build_admin_api_keys_data_unavailable_response;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{body::Body, http, response::Response};
pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&axum::body::Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("api_keys_manage") {
return Ok(None);
}
let path = request_context.request_path.as_str();
let is_api_keys_route = matches!(path, "/api/admin/api-keys" | "/api/admin/api-keys/")
|| (path.starts_with("/api/admin/api-keys/") && path.matches('/').count() == 4);
if !is_api_keys_route {
return Ok(None);
}
match decision.route_kind.as_deref() {
Some("list_api_keys")
if request_context.request_method == http::Method::GET
&& matches!(path, "/api/admin/api-keys" | "/api/admin/api-keys/") =>
{
Ok(Some(
build_admin_list_api_keys_response(state, request_context).await?,
))
}
Some("api_key_detail")
if request_context.request_method == http::Method::GET
&& path.starts_with("/api/admin/api-keys/") =>
{
Ok(Some(
build_admin_api_key_detail_response(state, request_context).await?,
))
}
Some("create_api_key")
if request_context.request_method == http::Method::POST
&& matches!(path, "/api/admin/api-keys" | "/api/admin/api-keys/") =>
{
Ok(Some(
build_admin_create_api_key_response(state, request_context, request_body).await?,
))
}
Some("update_api_key")
if request_context.request_method == http::Method::PUT
&& path.starts_with("/api/admin/api-keys/") =>
{
Ok(Some(
build_admin_update_api_key_response(state, request_context, request_body).await?,
))
}
Some("toggle_api_key")
if request_context.request_method == http::Method::PATCH
&& path.starts_with("/api/admin/api-keys/") =>
{
Ok(Some(
build_admin_toggle_api_key_response(state, request_context, request_body).await?,
))
}
Some("delete_api_key")
if request_context.request_method == http::Method::DELETE
&& path.starts_with("/api/admin/api-keys/") =>
{
Ok(Some(
build_admin_delete_api_key_response(state, request_context).await?,
))
}
_ => Ok(Some(build_admin_api_keys_data_unavailable_response())),
}
}
@@ -0,0 +1,189 @@
use super::ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL;
use super::{
format_optional_unix_secs_iso8601, http, json, masked_user_api_key_display, query_param_value,
serialize_admin_system_users_export_wallet, AppState, Body, GatewayError,
GatewayPublicRequestContext, IntoResponse, Json, Response,
};
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct AdminStandaloneApiKeyCreateRequest {
pub(super) name: Option<String>,
pub(super) allowed_providers: Option<Vec<String>>,
pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>,
pub(super) rate_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>,
pub(super) unlimited_balance: Option<bool>,
pub(super) expire_days: Option<i32>,
pub(super) expires_at: Option<String>,
pub(super) auto_delete_on_expiry: Option<bool>,
}
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct AdminStandaloneApiKeyUpdateRequest {
pub(super) name: Option<String>,
pub(super) allowed_providers: Option<Vec<String>>,
pub(super) allowed_api_formats: Option<Vec<String>>,
pub(super) allowed_models: Option<Vec<String>>,
pub(super) rate_limit: Option<i32>,
pub(super) initial_balance_usd: Option<f64>,
pub(super) unlimited_balance: Option<bool>,
pub(super) expire_days: Option<i32>,
pub(super) expires_at: Option<String>,
pub(super) auto_delete_on_expiry: Option<bool>,
}
#[derive(Debug, Default, serde::Deserialize)]
pub(super) struct AdminStandaloneApiKeyToggleRequest {
pub(super) is_active: Option<bool>,
}
#[derive(Debug, Default)]
pub(super) struct AdminStandaloneApiKeyFieldPresence {
pub(super) allowed_providers: bool,
pub(super) allowed_api_formats: bool,
pub(super) allowed_models: bool,
}
pub(super) fn build_admin_api_keys_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_API_KEYS_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}
pub(super) fn build_admin_api_keys_bad_request_response(
detail: impl Into<String>,
) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
pub(super) fn build_admin_api_keys_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "API密钥不存在" })),
)
.into_response()
}
pub(super) fn admin_api_keys_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/api-keys/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}
pub(super) fn admin_api_keys_operator_id(
request_context: &GatewayPublicRequestContext,
) -> Option<String> {
request_context
.control_decision
.as_ref()
.and_then(|decision| decision.admin_principal.as_ref())
.map(|principal| principal.user_id.clone())
}
pub(super) fn admin_api_keys_parse_skip(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "skip") {
None => Ok(0),
Some(value) => value
.parse::<usize>()
.map_err(|_| "skip must be a non-negative integer".to_string()),
}
}
pub(super) fn admin_api_keys_parse_limit(query: Option<&str>) -> Result<usize, String> {
match query_param_value(query, "limit") {
None => Ok(100),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "limit must be a positive integer".to_string())?;
if parsed == 0 || parsed > 500 {
return Err("limit must be between 1 and 500".to_string());
}
Ok(parsed)
}
}
}
pub(super) fn build_admin_api_key_list_item_payload(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
total_tokens: Option<u64>,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
json!({
"id": record.api_key_id,
"user_id": record.user_id,
"name": record.name,
"key_display": masked_user_api_key_display(state, record.key_encrypted.as_deref()),
"is_active": record.is_active,
"is_standalone": true,
"total_requests": record.total_requests,
"total_tokens": total_tokens,
"total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit,
"allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models,
"last_used_at": serde_json::Value::Null,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": serde_json::Value::Null,
"updated_at": serde_json::Value::Null,
"auto_delete_on_expiry": record.auto_delete_on_expiry,
"wallet": serialize_admin_system_users_export_wallet(wallet),
})
}
pub(super) fn build_admin_api_key_detail_payload(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
total_tokens: u64,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
json!({
"id": record.api_key_id,
"user_id": record.user_id,
"name": record.name,
"key_display": masked_user_api_key_display(state, record.key_encrypted.as_deref()),
"is_active": record.is_active,
"is_standalone": true,
"total_requests": record.total_requests,
"total_tokens": total_tokens,
"total_cost_usd": record.total_cost_usd,
"rate_limit": record.rate_limit,
"allowed_providers": record.allowed_providers,
"allowed_api_formats": record.allowed_api_formats,
"allowed_models": record.allowed_models,
"last_used_at": serde_json::Value::Null,
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": serde_json::Value::Null,
"updated_at": serde_json::Value::Null,
"wallet": serialize_admin_system_users_export_wallet(wallet),
})
}
pub(super) async fn admin_api_key_total_tokens_by_ids(
state: &AppState,
api_key_ids: &[String],
) -> Result<std::collections::BTreeMap<String, u64>, GatewayError> {
if api_key_ids.is_empty() || !state.has_usage_data_reader() {
return Ok(std::collections::BTreeMap::new());
}
state
.summarize_usage_total_tokens_by_api_key_ids(api_key_ids)
.await
}
@@ -0,0 +1,353 @@
use super::shared::*;
use crate::handlers::admin::shared::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks,
};
use crate::{AppState, GatewayError};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(super) struct AdminLdapConfigUpdateRequest {
server_url: String,
bind_dn: String,
#[serde(default)]
bind_password: Option<String>,
base_dn: String,
#[serde(default = "admin_ldap_default_search_filter")]
user_search_filter: String,
#[serde(default = "admin_ldap_default_username_attr")]
username_attr: String,
#[serde(default = "admin_ldap_default_email_attr")]
email_attr: String,
#[serde(default = "admin_ldap_default_display_name_attr")]
display_name_attr: String,
#[serde(default)]
is_enabled: bool,
#[serde(default)]
is_exclusive: bool,
#[serde(default)]
use_starttls: bool,
#[serde(default = "admin_ldap_default_connect_timeout")]
connect_timeout: i32,
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct AdminLdapConfigTestRequest {
#[serde(default)]
server_url: Option<String>,
#[serde(default)]
bind_dn: Option<String>,
#[serde(default)]
bind_password: Option<String>,
#[serde(default)]
base_dn: Option<String>,
#[serde(default)]
user_search_filter: Option<String>,
#[serde(default)]
username_attr: Option<String>,
#[serde(default)]
email_attr: Option<String>,
#[serde(default)]
display_name_attr: Option<String>,
#[serde(default)]
is_enabled: Option<bool>,
#[serde(default)]
is_exclusive: Option<bool>,
#[serde(default)]
use_starttls: Option<bool>,
#[serde(default)]
connect_timeout: Option<i32>,
}
#[derive(Debug, Clone)]
pub(super) struct AdminLdapConnectionTestConfig {
server_url: String,
bind_dn: String,
bind_password: String,
base_dn: String,
use_starttls: bool,
connect_timeout: i32,
}
pub(super) async fn build_admin_ldap_update_config(
state: &AppState,
payload: AdminLdapConfigUpdateRequest,
) -> Result<aether_data::repository::auth_modules::StoredLdapModuleConfig, String> {
let server_url = admin_ldap_trim_required(payload.server_url, "LDAP 服务器地址不能为空")?;
let bind_dn = admin_ldap_trim_required(payload.bind_dn, "绑定 DN 不能为空")?;
let base_dn = admin_ldap_trim_required(payload.base_dn, "Base DN 不能为空")?;
let user_search_filter =
admin_ldap_trim_required(payload.user_search_filter, "搜索过滤器不能为空")?;
admin_ldap_validate_search_filter(&user_search_filter)?;
let username_attr = admin_ldap_trim_required(payload.username_attr, "用户名属性不能为空")?;
let email_attr = admin_ldap_trim_required(payload.email_attr, "邮箱属性不能为空")?;
let display_name_attr =
admin_ldap_trim_required(payload.display_name_attr, "显示名称属性不能为空")?;
if !(1..=60).contains(&payload.connect_timeout) {
return Err("连接超时时间必须在 1 到 60 秒之间".to_string());
}
let existing = state
.get_ldap_module_config()
.await
.map_err(|err| format!("{err:?}"))?;
let bind_password_update_requested = payload
.bind_password
.as_ref()
.is_some_and(|value| !value.is_empty());
let bind_password = match payload.bind_password {
Some(value) if value.is_empty() => Some(String::new()),
Some(value) => Some(admin_ldap_trim_required(value, "绑定密码不能为空")?),
None => None,
};
let is_new_config = existing.is_none();
if is_new_config && bind_password.as_deref().unwrap_or("").is_empty() {
return Err("首次配置 LDAP 时必须设置绑定密码".to_string());
}
let will_have_password = bind_password
.as_ref()
.map(|value| !value.is_empty())
.unwrap_or_else(|| {
existing
.as_ref()
.and_then(|config| config.bind_password_encrypted.as_deref())
.map(str::trim)
.is_some_and(|value: &str| !value.is_empty())
});
if payload.is_exclusive && !payload.is_enabled {
return Err("仅允许 LDAP 登录 需要先启用 LDAP 认证".to_string());
}
if payload.is_enabled && !will_have_password {
return Err("启用 LDAP 认证 需要先设置绑定密码".to_string());
}
if payload.is_exclusive && !will_have_password {
return Err("仅允许 LDAP 登录 需要先设置绑定密码".to_string());
}
if payload.is_enabled && payload.is_exclusive {
let local_admin_count = state
.count_active_local_admin_users_with_valid_password()
.await
.map_err(|err| format!("{err:?}"))?;
if local_admin_count < 1 {
return Err(
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
.to_string(),
);
}
}
let bind_password_encrypted = match bind_password {
Some(value) if value.is_empty() => None,
Some(value) => encrypt_catalog_secret_with_fallbacks(state, &value),
None => existing.and_then(|config| config.bind_password_encrypted),
};
if bind_password_update_requested && bind_password_encrypted.is_none() {
return Err("LDAP 绑定密码加密失败,请检查 Rust 数据加密配置".to_string());
}
Ok(
aether_data::repository::auth_modules::StoredLdapModuleConfig {
server_url,
bind_dn,
bind_password_encrypted,
base_dn,
user_search_filter: Some(user_search_filter),
username_attr: Some(username_attr),
email_attr: Some(email_attr),
display_name_attr: Some(display_name_attr),
is_enabled: payload.is_enabled,
is_exclusive: payload.is_exclusive,
use_starttls: payload.use_starttls,
connect_timeout: Some(payload.connect_timeout),
},
)
}
pub(super) async fn build_admin_ldap_test_config(
state: &AppState,
payload: AdminLdapConfigTestRequest,
) -> Result<Option<AdminLdapConnectionTestConfig>, String> {
if let Some(value) = payload.user_search_filter.as_deref() {
admin_ldap_validate_search_filter(value.trim())?;
}
if let Some(connect_timeout) = payload.connect_timeout {
if !(1..=60).contains(&connect_timeout) {
return Err("连接超时时间必须在 1 到 60 秒之间".to_string());
}
}
let saved = state
.get_ldap_module_config()
.await
.map_err(|err| format!("{err:?}"))?;
let mut server_url = saved
.as_ref()
.map(|config| config.server_url.trim().to_string())
.filter(|value: &String| !value.is_empty());
let mut bind_dn = saved
.as_ref()
.map(|config| config.bind_dn.trim().to_string())
.filter(|value: &String| !value.is_empty());
let mut base_dn = saved
.as_ref()
.map(|config| config.base_dn.trim().to_string())
.filter(|value: &String| !value.is_empty());
let mut use_starttls = saved
.as_ref()
.map(|config| config.use_starttls)
.unwrap_or(false);
let mut connect_timeout = saved
.as_ref()
.and_then(|config| config.connect_timeout)
.unwrap_or_else(admin_ldap_default_connect_timeout);
let mut bind_password = saved
.as_ref()
.and_then(|config| admin_ldap_read_saved_bind_password(state, config));
if let Some(value) = payload.server_url {
server_url = Some(admin_ldap_trim_required(value, "LDAP 服务器地址不能为空")?);
}
if let Some(value) = payload.bind_dn {
bind_dn = Some(admin_ldap_trim_required(value, "绑定 DN 不能为空")?);
}
if let Some(value) = payload.base_dn {
base_dn = Some(admin_ldap_trim_required(value, "Base DN 不能为空")?);
}
if let Some(value) = payload.bind_password {
bind_password = Some(admin_ldap_trim_required(value, "绑定密码不能为空")?);
}
if let Some(value) = payload.use_starttls {
use_starttls = value;
}
if let Some(value) = payload.connect_timeout {
connect_timeout = value;
}
let mut missing = Vec::new();
if server_url.is_none() {
missing.push("server_url");
}
if bind_dn.is_none() {
missing.push("bind_dn");
}
if base_dn.is_none() {
missing.push("base_dn");
}
if bind_password.is_none() {
missing.push("bind_password");
}
if !missing.is_empty() {
return Ok(None);
}
Ok(Some(AdminLdapConnectionTestConfig {
server_url: server_url.expect("server_url already checked"),
bind_dn: bind_dn.expect("bind_dn already checked"),
bind_password: bind_password.expect("bind_password already checked"),
base_dn: base_dn.expect("base_dn already checked"),
use_starttls,
connect_timeout,
}))
}
pub(super) async fn admin_ldap_test_connection(
config: AdminLdapConnectionTestConfig,
) -> Result<(bool, String), GatewayError> {
#[cfg(test)]
if config.server_url.starts_with("mockldap://") {
return Ok((
config.bind_password == "secret123",
if config.bind_password == "secret123" {
"连接成功".to_string()
} else {
ADMIN_LDAP_TEST_FAILURE_MESSAGE.to_string()
},
));
}
tokio::task::spawn_blocking(move || admin_ldap_test_connection_blocking(config))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
fn admin_ldap_test_connection_blocking(config: AdminLdapConnectionTestConfig) -> (bool, String) {
let Some(server_url): Option<String> = admin_ldap_normalize_server_url(&config.server_url)
else {
return (false, ADMIN_LDAP_TEST_FAILURE_MESSAGE.to_string());
};
let timeout_secs = u64::try_from(config.connect_timeout.max(1)).unwrap_or(10);
let settings = ldap3::LdapConnSettings::new()
.set_conn_timeout(std::time::Duration::from_secs(timeout_secs))
.set_starttls(config.use_starttls && !server_url.starts_with("ldaps://"));
let Ok(mut conn) = ldap3::LdapConn::with_settings(settings, &server_url) else {
return (false, ADMIN_LDAP_TEST_FAILURE_MESSAGE.to_string());
};
let bind_result = conn
.simple_bind(&config.bind_dn, &config.bind_password)
.and_then(|response| response.success());
let _ = conn.unbind();
if bind_result.is_ok() {
(true, "连接成功".to_string())
} else {
(false, ADMIN_LDAP_TEST_FAILURE_MESSAGE.to_string())
}
}
fn admin_ldap_trim_required(value: String, detail: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(detail.to_string());
}
Ok(trimmed.to_string())
}
fn admin_ldap_validate_search_filter(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("搜索过滤器不能为空".to_string());
}
if !value.contains("{username}") {
return Err("搜索过滤器必须包含 {username} 占位符".to_string());
}
let mut depth = 0i32;
let mut max_depth = 0i32;
for ch in value.chars() {
if ch == '(' {
depth += 1;
max_depth = max_depth.max(depth);
} else if ch == ')' {
depth -= 1;
if depth < 0 {
return Err("搜索过滤器括号不匹配".to_string());
}
}
}
if depth != 0 {
return Err("搜索过滤器括号不匹配".to_string());
}
if max_depth > 5 {
return Err("搜索过滤器嵌套层数过深(最多5层)".to_string());
}
if value.len() > 200 {
return Err("搜索过滤器过长(最多200字符)".to_string());
}
Ok(())
}
fn admin_ldap_read_saved_bind_password(
state: &AppState,
config: &aether_data::repository::auth_modules::StoredLdapModuleConfig,
) -> Option<String> {
config
.bind_password_encrypted
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), value)
.or_else(|| Some(value.to_string()))
})
.filter(|value| !value.trim().is_empty())
}
@@ -0,0 +1,18 @@
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
response::Response,
};
mod builders;
mod routes;
mod shared;
pub(crate) async fn maybe_build_local_admin_ldap_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
routes::maybe_build_local_admin_ldap_response(state, request_context, request_body).await
}
@@ -0,0 +1,112 @@
use super::builders::{
admin_ldap_test_connection, build_admin_ldap_test_config, build_admin_ldap_update_config,
AdminLdapConfigTestRequest, AdminLdapConfigUpdateRequest,
};
use super::shared::*;
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn maybe_build_local_admin_ldap_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("ldap_manage") {
return Ok(None);
}
match decision.route_kind.as_deref() {
Some("get_config")
if request_context.request_method == http::Method::GET
&& is_admin_ldap_config_root(&request_context.request_path) =>
{
return Ok(Some(attach_admin_audit_response(
Json(build_admin_ldap_config_payload(
state.get_ldap_module_config().await?.as_ref(),
))
.into_response(),
"admin_ldap_config_viewed",
"view_ldap_config",
"ldap_config",
"ldap",
)));
}
Some("set_config")
if request_context.request_method == http::Method::PUT
&& is_admin_ldap_config_root(&request_context.request_path) =>
{
if !state.has_auth_module_writer() {
return Ok(Some(admin_ldap_unavailable_response()));
}
let Some(request_body) = request_body else {
return Ok(Some(admin_ldap_bad_request_response("请求数据验证失败")));
};
let payload = match serde_json::from_slice::<AdminLdapConfigUpdateRequest>(request_body)
{
Ok(payload) => payload,
Err(_) => return Ok(Some(admin_ldap_bad_request_response("请求数据验证失败"))),
};
let update = match build_admin_ldap_update_config(state, payload).await {
Ok(config) => config,
Err(detail) => return Ok(Some(admin_ldap_bad_request_response(detail))),
};
let saved = state.upsert_ldap_module_config(&update).await?;
if saved.is_none() {
return Ok(Some(admin_ldap_unavailable_response()));
}
return Ok(Some(
Json(json!({ "message": "LDAP配置更新成功" })).into_response(),
));
}
Some("test_connection")
if request_context.request_method == http::Method::POST
&& is_admin_ldap_test_root(&request_context.request_path) =>
{
let payload = match request_body {
Some(body) if !body.is_empty() => match serde_json::from_slice::<
AdminLdapConfigTestRequest,
>(body)
{
Ok(value) => value,
Err(_) => return Ok(Some(admin_ldap_bad_request_response("请求数据验证失败"))),
},
_ => AdminLdapConfigTestRequest::default(),
};
let merged = match build_admin_ldap_test_config(state, payload).await {
Ok(config) => config,
Err(detail) => return Ok(Some(admin_ldap_bad_request_response(detail))),
};
let response = match merged {
Some(config) => {
let (success, message) = admin_ldap_test_connection(config).await?;
json!({ "success": success, "message": message })
}
None => json!({
"success": false,
"message": "缺少必要字段: server_url, bind_dn, base_dn, bind_password",
}),
};
return Ok(Some(attach_admin_audit_response(
Json(response).into_response(),
"admin_ldap_connection_tested",
"test_ldap_connection",
"ldap_config",
"ldap",
)));
}
_ => {}
}
Ok(None)
}
@@ -0,0 +1,122 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) const ADMIN_LDAP_DATA_UNAVAILABLE_DETAIL: &str = "Admin LDAP data unavailable";
pub(super) const ADMIN_LDAP_TEST_FAILURE_MESSAGE: &str = "连接失败,请检查服务器地址、端口和凭据";
pub(super) fn admin_ldap_default_search_filter() -> String {
"(uid={username})".to_string()
}
pub(super) fn admin_ldap_default_username_attr() -> String {
"uid".to_string()
}
pub(super) fn admin_ldap_default_email_attr() -> String {
"mail".to_string()
}
pub(super) fn admin_ldap_default_display_name_attr() -> String {
"cn".to_string()
}
pub(super) fn admin_ldap_default_connect_timeout() -> i32 {
10
}
pub(super) fn is_admin_ldap_config_root(request_path: &str) -> bool {
matches!(
request_path,
"/api/admin/ldap/config" | "/api/admin/ldap/config/"
)
}
pub(super) fn is_admin_ldap_test_root(request_path: &str) -> bool {
matches!(
request_path,
"/api/admin/ldap/test" | "/api/admin/ldap/test/"
)
}
pub(super) fn build_admin_ldap_config_payload(
config: Option<&aether_data::repository::auth_modules::StoredLdapModuleConfig>,
) -> serde_json::Value {
match config {
Some(config) => json!({
"server_url": config.server_url,
"bind_dn": config.bind_dn,
"base_dn": config.base_dn,
"has_bind_password": config
.bind_password_encrypted
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty()),
"user_search_filter": config
.user_search_filter
.clone()
.unwrap_or_else(admin_ldap_default_search_filter),
"username_attr": config
.username_attr
.clone()
.unwrap_or_else(admin_ldap_default_username_attr),
"email_attr": config
.email_attr
.clone()
.unwrap_or_else(admin_ldap_default_email_attr),
"display_name_attr": config
.display_name_attr
.clone()
.unwrap_or_else(admin_ldap_default_display_name_attr),
"is_enabled": config.is_enabled,
"is_exclusive": config.is_exclusive,
"use_starttls": config.use_starttls,
"connect_timeout": config.connect_timeout.unwrap_or(admin_ldap_default_connect_timeout()),
}),
None => json!({
"server_url": serde_json::Value::Null,
"bind_dn": serde_json::Value::Null,
"base_dn": serde_json::Value::Null,
"has_bind_password": false,
"user_search_filter": admin_ldap_default_search_filter(),
"username_attr": admin_ldap_default_username_attr(),
"email_attr": admin_ldap_default_email_attr(),
"display_name_attr": admin_ldap_default_display_name_attr(),
"is_enabled": false,
"is_exclusive": false,
"use_starttls": false,
"connect_timeout": admin_ldap_default_connect_timeout(),
}),
}
}
pub(super) fn admin_ldap_bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
pub(super) fn admin_ldap_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_LDAP_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}
pub(super) fn admin_ldap_normalize_server_url(server_url: &str) -> Option<String> {
let server_url = server_url.trim();
if server_url.is_empty() {
return None;
}
if server_url.contains("://") {
return Some(server_url.to_string());
}
Some(format!("ldap://{server_url}"))
}
@@ -0,0 +1,14 @@
mod api_keys;
mod ldap;
mod oauth_config;
mod oauth_routes;
mod security;
pub(crate) use self::api_keys::maybe_build_local_admin_api_keys_response;
pub(crate) use self::ldap::maybe_build_local_admin_ldap_response;
pub(crate) use self::oauth_config::{
build_admin_oauth_provider_payload, build_admin_oauth_supported_types_payload,
build_admin_oauth_upsert_record, build_proxy_error_response,
};
pub(crate) use self::oauth_routes::maybe_build_local_admin_oauth_response;
pub(crate) use self::security::maybe_build_local_admin_security_response;
@@ -0,0 +1,278 @@
use crate::handlers::admin::shared::encrypt_catalog_secret_with_fallbacks;
use crate::AppState;
use aether_data::repository::oauth_providers::{
EncryptedSecretUpdate, UpsertOAuthProviderConfigRecord,
};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use url::Url;
#[derive(Debug, Deserialize)]
pub(crate) struct AdminOAuthProviderUpsertRequest {
pub(crate) display_name: String,
pub(crate) client_id: String,
#[serde(default)]
pub(crate) client_secret: Option<String>,
#[serde(default)]
pub(crate) authorization_url_override: Option<String>,
#[serde(default)]
pub(crate) token_url_override: Option<String>,
#[serde(default)]
pub(crate) userinfo_url_override: Option<String>,
#[serde(default)]
pub(crate) scopes: Option<Vec<String>>,
pub(crate) redirect_uri: String,
pub(crate) frontend_callback_url: String,
#[serde(default)]
pub(crate) attribute_mapping: Option<serde_json::Value>,
#[serde(default)]
pub(crate) extra_config: Option<serde_json::Value>,
#[serde(default)]
pub(crate) is_enabled: bool,
#[serde(default)]
pub(crate) force: bool,
}
pub(crate) fn build_admin_oauth_supported_types_payload() -> Vec<serde_json::Value> {
vec![json!({
"provider_type": "linuxdo",
"display_name": "Linux Do",
"default_authorization_url": "https://connect.linux.do/oauth2/authorize",
"default_token_url": "https://connect.linux.do/oauth2/token",
"default_userinfo_url": "https://connect.linux.do/api/user",
"default_scopes": [],
})]
}
pub(crate) fn build_admin_oauth_provider_payload(
provider: &aether_data::repository::oauth_providers::StoredOAuthProviderConfig,
) -> serde_json::Value {
json!({
"provider_type": provider.provider_type,
"display_name": provider.display_name,
"client_id": provider.client_id,
"has_secret": provider.client_secret_encrypted.as_ref().is_some(),
"authorization_url_override": provider.authorization_url_override,
"token_url_override": provider.token_url_override,
"userinfo_url_override": provider.userinfo_url_override,
"scopes": provider.scopes,
"redirect_uri": provider.redirect_uri,
"frontend_callback_url": provider.frontend_callback_url,
"attribute_mapping": provider.attribute_mapping,
"extra_config": provider.extra_config,
"is_enabled": provider.is_enabled,
})
}
pub(crate) fn build_proxy_error_response(
status: http::StatusCode,
error_type: &str,
message: impl Into<String>,
details: Option<serde_json::Value>,
) -> Response<Body> {
let message = message.into();
let mut error = serde_json::Map::new();
error.insert("type".to_string(), json!(error_type));
error.insert("message".to_string(), json!(message));
if let Some(details) = details {
error.insert("details".to_string(), details);
}
(
status,
Json(json!({ "error": serde_json::Value::Object(error) })),
)
.into_response()
}
pub(crate) fn admin_oauth_provider_type_from_path(request_path: &str) -> Option<String> {
let provider_type = request_path.strip_prefix("/api/admin/oauth/providers/")?;
(!provider_type.is_empty() && !provider_type.contains('/')).then_some(provider_type.to_string())
}
pub(crate) fn admin_oauth_test_provider_type_from_path(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/oauth/providers/")?
.strip_suffix("/test")
.filter(|provider_type| !provider_type.is_empty() && !provider_type.contains('/'))
.map(ToOwned::to_owned)
}
fn admin_oauth_is_supported_provider(provider_type: &str) -> bool {
provider_type.eq_ignore_ascii_case("linuxdo")
}
fn admin_oauth_allowed_domains(provider_type: &str) -> Option<&'static [&'static str]> {
if provider_type.eq_ignore_ascii_case("linuxdo") {
Some(&["linux.do", "connect.linux.do", "connect.linuxdo.org"])
} else {
None
}
}
fn validate_admin_oauth_frontend_callback_url(url: &str) -> Result<(), String> {
let parsed = Url::parse(url).map_err(|_| "frontend_callback_url 必须是绝对 URL".to_string())?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err("frontend_callback_url scheme 必须是 http/https".to_string());
}
if parsed.host_str().is_none() {
return Err("frontend_callback_url 必须是绝对 URL".to_string());
}
let path = parsed.path().trim_end_matches('/');
if !path.ends_with("/auth/callback") {
return Err("frontend_callback_url 路径必须以 /auth/callback 结尾".to_string());
}
Ok(())
}
fn validate_admin_oauth_redirect_uri(url: &str) -> Result<(), String> {
let parsed = Url::parse(url).map_err(|_| "redirect_uri 必须是绝对 URL".to_string())?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err("redirect_uri scheme 必须是 http/https".to_string());
}
if parsed.host_str().is_none() {
return Err("redirect_uri 必须是绝对 URL".to_string());
}
Ok(())
}
fn validate_admin_oauth_url_override(url: &str, allowed_domains: &[&str]) -> Result<(), String> {
let parsed = Url::parse(url).map_err(|_| "端点覆盖必须是 https 绝对 URL".to_string())?;
if parsed.scheme() != "https" || parsed.host_str().is_none() {
return Err("端点覆盖必须是 https 绝对 URL".to_string());
}
let host = parsed
.host_str()
.map(|value| value.trim().trim_end_matches('.').to_ascii_lowercase())
.unwrap_or_default();
let allowed = allowed_domains.iter().any(|domain| {
let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase();
host == domain || host.ends_with(&format!(".{domain}"))
});
if !allowed {
return Err("端点覆盖不在允许的域名白名单中".to_string());
}
Ok(())
}
pub(crate) fn build_admin_oauth_upsert_record(
state: &AppState,
provider_type: &str,
payload: AdminOAuthProviderUpsertRequest,
) -> Result<UpsertOAuthProviderConfigRecord, String> {
if !admin_oauth_is_supported_provider(provider_type) {
return Err("不支持的 provider_type".to_string());
}
let display_name = payload.display_name.trim();
if display_name.is_empty() {
return Err("显示名称不能为空".to_string());
}
let client_id = payload.client_id.trim();
if client_id.is_empty() {
return Err("Client ID 不能为空".to_string());
}
let redirect_uri = payload.redirect_uri.trim();
if redirect_uri.is_empty() {
return Err("redirect_uri 不能为空".to_string());
}
let frontend_callback_url = payload.frontend_callback_url.trim();
if frontend_callback_url.is_empty() {
return Err("frontend_callback_url 不能为空".to_string());
}
validate_admin_oauth_frontend_callback_url(frontend_callback_url)?;
validate_admin_oauth_redirect_uri(redirect_uri)?;
let allowed_domains = admin_oauth_allowed_domains(provider_type)
.ok_or_else(|| "不支持的 provider_type".to_string())?;
if let Some(value) = payload.authorization_url_override.as_deref().map(str::trim) {
if !value.is_empty() {
validate_admin_oauth_url_override(value, allowed_domains)?;
}
}
if let Some(value) = payload.token_url_override.as_deref().map(str::trim) {
if !value.is_empty() {
validate_admin_oauth_url_override(value, allowed_domains)?;
}
}
if let Some(value) = payload.userinfo_url_override.as_deref().map(str::trim) {
if !value.is_empty() {
validate_admin_oauth_url_override(value, allowed_domains)?;
}
}
if payload
.attribute_mapping
.as_ref()
.is_some_and(|value| !value.is_object())
{
return Err("attribute_mapping 必须是对象".to_string());
}
if payload
.extra_config
.as_ref()
.is_some_and(|value| !value.is_object())
{
return Err("extra_config 必须是对象".to_string());
}
if payload
.scopes
.as_ref()
.is_some_and(|items| items.iter().any(|value| value.trim().is_empty()))
{
return Err("scopes 不能为空".to_string());
}
let client_secret_encrypted = match payload.client_secret.as_deref() {
None => EncryptedSecretUpdate::Preserve,
Some(raw) => {
let secret = raw.trim();
if secret == "__CLEAR__" {
EncryptedSecretUpdate::Clear
} else if secret.is_empty() {
EncryptedSecretUpdate::Preserve
} else {
let encrypted = encrypt_catalog_secret_with_fallbacks(state, secret)
.ok_or_else(|| "gateway 未配置 OAuth provider 加密密钥".to_string())?;
EncryptedSecretUpdate::Set(encrypted)
}
}
};
Ok(UpsertOAuthProviderConfigRecord {
provider_type: provider_type.to_string(),
display_name: display_name.to_string(),
client_id: client_id.to_string(),
client_secret_encrypted,
authorization_url_override: payload.authorization_url_override.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}),
token_url_override: payload.token_url_override.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}),
userinfo_url_override: payload.userinfo_url_override.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}),
scopes: payload.scopes.map(|items| {
items
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
}),
redirect_uri: redirect_uri.to_string(),
frontend_callback_url: frontend_callback_url.to_string(),
attribute_mapping: payload.attribute_mapping,
extra_config: payload.extra_config,
is_enabled: payload.is_enabled,
})
}
@@ -0,0 +1,325 @@
use super::oauth_config::{
admin_oauth_provider_type_from_path, admin_oauth_test_provider_type_from_path,
build_admin_oauth_provider_payload, build_admin_oauth_supported_types_payload,
build_admin_oauth_upsert_record, build_proxy_error_response, AdminOAuthProviderUpsertRequest,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(crate) async fn maybe_build_local_admin_oauth_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("oauth_manage") {
return Ok(None);
}
if decision.route_kind.as_deref() == Some("supported_types")
&& request_context.request_method == http::Method::GET
&& request_context.request_path == "/api/admin/oauth/supported-types"
{
return Ok(Some(
Json(build_admin_oauth_supported_types_payload()).into_response(),
));
}
if decision.route_kind.as_deref() == Some("list_providers")
&& request_context.request_method == http::Method::GET
&& matches!(
request_context.request_path.as_str(),
"/api/admin/oauth/providers" | "/api/admin/oauth/providers/"
)
{
let providers = state.list_oauth_provider_configs().await?;
return Ok(Some(attach_admin_audit_response(
Json(
providers
.iter()
.map(build_admin_oauth_provider_payload)
.collect::<Vec<_>>(),
)
.into_response(),
"admin_oauth_provider_configs_viewed",
"list_oauth_provider_configs",
"oauth_provider",
"all",
)));
}
if decision.route_kind.as_deref() == Some("get_provider")
&& request_context.request_method == http::Method::GET
{
let Some(provider_type) =
admin_oauth_provider_type_from_path(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 配置不存在" })),
)
.into_response(),
));
};
return Ok(Some(
match state.get_oauth_provider_config(&provider_type).await? {
Some(provider) => attach_admin_audit_response(
Json(build_admin_oauth_provider_payload(&provider)).into_response(),
"admin_oauth_provider_config_viewed",
"view_oauth_provider_config",
"oauth_provider",
&provider_type,
),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 配置不存在" })),
)
.into_response(),
},
));
}
if decision.route_kind.as_deref() == Some("upsert_provider")
&& request_context.request_method == http::Method::PUT
{
let Some(provider_type) =
admin_oauth_provider_type_from_path(&request_context.request_path)
else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
};
let Some(request_body) = request_body else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"请求数据验证失败",
None,
)));
};
let payload = match serde_json::from_slice::<AdminOAuthProviderUpsertRequest>(request_body)
{
Ok(payload) => payload,
Err(_) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"请求数据验证失败",
None,
)));
}
};
let existing = state.get_oauth_provider_config(&provider_type).await?;
let ldap_exclusive = state.get_ldap_module_config().await?.is_some_and(|config| {
config.is_enabled
&& config.is_exclusive
&& config
.bind_password_encrypted
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
});
if existing
.as_ref()
.is_some_and(|provider| provider.is_enabled && !payload.is_enabled)
{
let affected_count = state
.count_locked_users_if_oauth_provider_disabled(&provider_type, ldap_exclusive)
.await?;
if affected_count > 0 && !payload.force {
return Ok(Some(build_proxy_error_response(
http::StatusCode::CONFLICT,
"confirmation_required",
format!("禁用该 Provider 会导致 {affected_count} 个用户无法登录"),
Some(json!({
"affected_count": affected_count,
"action": "disable_oauth_provider",
})),
)));
}
}
let record = match build_admin_oauth_upsert_record(state, &provider_type, payload) {
Ok(record) => record,
Err(message) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
message,
None,
)));
}
};
let Some(provider) = state.upsert_oauth_provider_config(&record).await? else {
return Ok(None);
};
return Ok(Some(
Json(build_admin_oauth_provider_payload(&provider)).into_response(),
));
}
if decision.route_kind.as_deref() == Some("delete_provider")
&& request_context.request_method == http::Method::DELETE
{
let Some(provider_type) =
admin_oauth_provider_type_from_path(&request_context.request_path)
else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
};
let Some(existing) = state.get_oauth_provider_config(&provider_type).await? else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
};
if existing.is_enabled {
let ldap_exclusive = state.get_ldap_module_config().await?.is_some_and(|config| {
config.is_enabled
&& config.is_exclusive
&& config
.bind_password_encrypted
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
});
let affected_count = state
.count_locked_users_if_oauth_provider_disabled(&provider_type, ldap_exclusive)
.await?;
if affected_count > 0 {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
format!(
"删除该 Provider 会导致部分用户无法登录(数量: {affected_count}),已阻止操作"
),
None,
)));
}
}
let deleted = state.delete_oauth_provider_config(&provider_type).await?;
if !deleted {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
}
return Ok(Some(Json(json!({ "message": "删除成功" })).into_response()));
}
if decision.route_kind.as_deref() == Some("test_provider")
&& request_context.request_method == http::Method::POST
{
let Some(provider_type) =
admin_oauth_test_provider_type_from_path(&request_context.request_path)
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 配置不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
};
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
}
};
let client_id = payload
.get("client_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let redirect_uri = payload
.get("redirect_uri")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if client_id.is_none() || redirect_uri.is_none() {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
}
let provided_secret = payload
.get("client_secret")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let persisted_secret = state
.get_oauth_provider_config(&provider_type)
.await?
.and_then(|provider| provider.client_secret_encrypted);
let supported_provider = provider_type.eq_ignore_ascii_case("linuxdo");
let secret_status = if supported_provider {
if provided_secret.is_some() || persisted_secret.is_some() {
"unsupported"
} else {
"not_provided"
}
} else {
"unknown"
};
let details = if supported_provider {
"OAuth 配置测试仅支持 Rust execution runtime"
} else {
"provider 未安装/不可用"
};
return Ok(Some(attach_admin_audit_response(
Json(json!({
"authorization_url_reachable": false,
"token_url_reachable": false,
"secret_status": secret_status,
"details": details,
}))
.into_response(),
"admin_oauth_provider_tested",
"test_oauth_provider_config",
"oauth_provider",
&provider_type,
)));
}
Ok(None)
}
@@ -0,0 +1,334 @@
use crate::control::GatewayPublicRequestContext;
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{AppState, GatewayError};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
const ADMIN_SECURITY_DATA_UNAVAILABLE_DETAIL: &str = "Admin security data unavailable";
#[derive(Debug, serde::Deserialize)]
struct AdminSecurityBlacklistAddRequest {
ip_address: String,
reason: String,
#[serde(default)]
ttl: Option<u64>,
}
#[derive(Debug, serde::Deserialize)]
struct AdminSecurityWhitelistAddRequest {
ip_address: String,
}
fn build_admin_security_data_unavailable_response() -> Response<Body> {
(
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": ADMIN_SECURITY_DATA_UNAVAILABLE_DETAIL })),
)
.into_response()
}
fn build_admin_security_bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn build_admin_security_not_found_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn admin_security_blacklist_ip_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/security/ip/blacklist/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}
fn admin_security_whitelist_ip_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/security/ip/whitelist/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') {
None
} else {
Some(value)
}
}
fn admin_security_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,
}
}
async fn build_admin_security_blacklist_add_response(
state: &AppState,
request_body: Option<&Bytes>,
) -> Result<Response<Body>, GatewayError> {
let Some(request_body) = request_body else {
return Ok(build_admin_security_bad_request_response(
"请求数据验证失败",
));
};
let payload = match serde_json::from_slice::<AdminSecurityBlacklistAddRequest>(request_body) {
Ok(value) if !value.ip_address.trim().is_empty() && !value.reason.trim().is_empty() => {
value
}
_ => {
return Ok(build_admin_security_bad_request_response(
"请求数据验证失败",
))
}
};
if !state
.add_admin_security_blacklist(
payload.ip_address.trim(),
payload.reason.trim(),
payload.ttl,
)
.await?
{
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": "添加 IP 到黑名单失败(Redis 不可用)" })),
)
.into_response());
}
Ok(attach_admin_audit_response(
Json(json!({
"success": true,
"message": format!("IP {} 已加入黑名单", payload.ip_address.trim()),
"reason": payload.reason.trim(),
"ttl": payload.ttl.map(serde_json::Value::from).unwrap_or_else(|| json!("永久")),
}))
.into_response(),
"admin_security_blacklist_added",
"add_security_blacklist_entry",
"security_blacklist_entry",
payload.ip_address.trim(),
))
}
async fn build_admin_security_blacklist_remove_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(ip_address) = admin_security_blacklist_ip_from_path(&request_context.request_path)
else {
return Ok(build_admin_security_bad_request_response("缺少 ip_address"));
};
if !state.remove_admin_security_blacklist(&ip_address).await? {
return Ok(build_admin_security_not_found_response(format!(
"IP {ip_address} 不在黑名单中"
)));
}
Ok(attach_admin_audit_response(
Json(json!({
"success": true,
"message": format!("IP {ip_address} 已从黑名单移除"),
}))
.into_response(),
"admin_security_blacklist_removed",
"remove_security_blacklist_entry",
"security_blacklist_entry",
&ip_address,
))
}
async fn build_admin_security_blacklist_stats_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
let (available, total, error) = state.admin_security_blacklist_stats().await?;
let mut payload = json!({
"available": available,
"total": total,
});
if let Some(error) = error {
payload["error"] = json!(error);
}
Ok(attach_admin_audit_response(
Json(payload).into_response(),
"admin_security_blacklist_stats_viewed",
"view_security_blacklist_stats",
"security_blacklist",
"global",
))
}
async fn build_admin_security_blacklist_list_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
let entries = state.list_admin_security_blacklist().await?;
let total = entries.len();
Ok(attach_admin_audit_response(
Json(json!({ "items": entries, "total": total })).into_response(),
"admin_security_blacklist_viewed",
"view_security_blacklist",
"security_blacklist",
"global",
))
}
async fn build_admin_security_whitelist_add_response(
state: &AppState,
request_body: Option<&Bytes>,
) -> Result<Response<Body>, GatewayError> {
let Some(request_body) = request_body else {
return Ok(build_admin_security_bad_request_response(
"请求数据验证失败",
));
};
let payload = match serde_json::from_slice::<AdminSecurityWhitelistAddRequest>(request_body) {
Ok(value) => value,
Err(_) => {
return Ok(build_admin_security_bad_request_response(
"请求数据验证失败",
))
}
};
let ip_address = payload.ip_address.trim();
if !admin_security_validate_ip_or_cidr(ip_address)
|| !state.add_admin_security_whitelist(ip_address).await?
{
return Ok(build_admin_security_bad_request_response(
"添加 IP 到白名单失败(无效的 IP 格式或 Redis 不可用)",
));
}
Ok(attach_admin_audit_response(
Json(json!({
"success": true,
"message": format!("IP {ip_address} 已加入白名单"),
}))
.into_response(),
"admin_security_whitelist_added",
"add_security_whitelist_entry",
"security_whitelist_entry",
ip_address,
))
}
async fn build_admin_security_whitelist_remove_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Result<Response<Body>, GatewayError> {
let Some(ip_address) = admin_security_whitelist_ip_from_path(&request_context.request_path)
else {
return Ok(build_admin_security_bad_request_response("缺少 ip_address"));
};
if !state.remove_admin_security_whitelist(&ip_address).await? {
return Ok(build_admin_security_not_found_response(format!(
"IP {ip_address} 不在白名单中"
)));
}
Ok(attach_admin_audit_response(
Json(json!({
"success": true,
"message": format!("IP {ip_address} 已从白名单移除"),
}))
.into_response(),
"admin_security_whitelist_removed",
"remove_security_whitelist_entry",
"security_whitelist_entry",
&ip_address,
))
}
async fn build_admin_security_whitelist_list_response(
state: &AppState,
) -> Result<Response<Body>, GatewayError> {
let whitelist = state.list_admin_security_whitelist().await?;
let total = whitelist.len();
Ok(attach_admin_audit_response(
Json(json!({
"whitelist": whitelist,
"total": total,
}))
.into_response(),
"admin_security_whitelist_viewed",
"view_security_whitelist",
"security_whitelist",
"global",
))
}
pub(crate) async fn maybe_build_local_admin_security_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.control_decision.as_ref() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("security_manage") {
return Ok(None);
}
match decision.route_kind.as_deref() {
Some("blacklist_add") => Ok(Some(
build_admin_security_blacklist_add_response(state, request_body).await?,
)),
Some("blacklist_remove") => Ok(Some(
build_admin_security_blacklist_remove_response(state, request_context).await?,
)),
Some("blacklist_stats") => Ok(Some(
build_admin_security_blacklist_stats_response(state).await?,
)),
Some("blacklist_list") => Ok(Some(
build_admin_security_blacklist_list_response(state).await?,
)),
Some("whitelist_add") => Ok(Some(
build_admin_security_whitelist_add_response(state, request_body).await?,
)),
Some("whitelist_remove") => Ok(Some(
build_admin_security_whitelist_remove_response(state, request_context).await?,
)),
Some("whitelist_list") => Ok(Some(
build_admin_security_whitelist_list_response(state).await?,
)),
_ => Ok(Some(build_admin_security_data_unavailable_response())),
}
}