mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
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:
@@ -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())),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user