mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 19:29:50 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
const ADMIN_LDAP_RUST_BACKEND_DETAIL: &str = "Admin LDAP routes require Rust maintenance backend";
|
||||
const ADMIN_LDAP_TEST_FAILURE_MESSAGE: &str = "连接失败,请检查服务器地址、端口和凭据";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
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)]
|
||||
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)]
|
||||
struct AdminLdapConnectionTestConfig {
|
||||
server_url: String,
|
||||
bind_dn: String,
|
||||
bind_password: String,
|
||||
base_dn: String,
|
||||
use_starttls: bool,
|
||||
connect_timeout: i32,
|
||||
}
|
||||
|
||||
async fn maybe_build_local_admin_ldap_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("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(
|
||||
Json(build_admin_ldap_config_payload(
|
||||
state.get_ldap_module_config().await?.as_ref(),
|
||||
))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
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(payload) => payload,
|
||||
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(Json(response).into_response()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn admin_ldap_default_search_filter() -> String {
|
||||
"(uid={username})".to_string()
|
||||
}
|
||||
|
||||
fn admin_ldap_default_username_attr() -> String {
|
||||
"uid".to_string()
|
||||
}
|
||||
|
||||
fn admin_ldap_default_email_attr() -> String {
|
||||
"mail".to_string()
|
||||
}
|
||||
|
||||
fn admin_ldap_default_display_name_attr() -> String {
|
||||
"cn".to_string()
|
||||
}
|
||||
|
||||
fn admin_ldap_default_connect_timeout() -> i32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn is_admin_ldap_config_root(request_path: &str) -> bool {
|
||||
matches!(request_path, "/api/admin/ldap/config" | "/api/admin/ldap/config/")
|
||||
}
|
||||
|
||||
fn is_admin_ldap_test_root(request_path: &str) -> bool {
|
||||
matches!(request_path, "/api/admin/ldap/test" | "/api/admin/ldap/test/")
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
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.as_deref().is_none() {
|
||||
missing.push("server_url");
|
||||
}
|
||||
if bind_dn.as_deref().is_none() {
|
||||
missing.push("bind_dn");
|
||||
}
|
||||
if base_dn.as_deref().is_none() {
|
||||
missing.push("base_dn");
|
||||
}
|
||||
if bind_password.as_deref().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,
|
||||
}))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
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) = admin_ldap_normalize_server_url(&config.server_url) else {
|
||||
return (false, ADMIN_LDAP_TEST_FAILURE_MESSAGE.to_string());
|
||||
};
|
||||
let _ = &config.base_dn;
|
||||
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_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}"))
|
||||
}
|
||||
|
||||
fn admin_ldap_bad_request_response(detail: impl Into<String>) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail.into() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn admin_ldap_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": ADMIN_LDAP_RUST_BACKEND_DETAIL })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Reference in New Issue
Block a user