Merge remote-tracking branch 'origin/pr/394' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-08 02:46:35 +08:00
27 changed files with 2202 additions and 74 deletions

View File

@@ -490,6 +490,32 @@ pub(super) fn classify_admin_operations_family_route(
"admin:users",
false,
))
} else if method == http::Method::POST
&& matches!(
normalized_path,
"/api/admin/users/resolve-selection" | "/api/admin/users/resolve-selection/"
)
{
Some(classified(
"admin_proxy",
"users_manage",
"resolve_user_selection",
"admin:users",
false,
))
} else if method == http::Method::POST
&& matches!(
normalized_path,
"/api/admin/users/batch-action" | "/api/admin/users/batch-action/"
)
{
Some(classified(
"admin_proxy",
"users_manage",
"batch_action_users",
"admin:users",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/admin/users/")
&& normalized_path.ends_with("/sessions")

View File

@@ -36,6 +36,38 @@ fn classifies_admin_users_create_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_user_batch_routes_as_admin_proxy_route() {
let headers = headers(&[]);
let resolve_uri: Uri = "/api/admin/users/resolve-selection"
.parse()
.expect("uri should parse");
let resolve = classify_control_route(&http::Method::POST, &resolve_uri, &headers)
.expect("route should classify");
assert_eq!(resolve.route_family.as_deref(), Some("users_manage"));
assert_eq!(
resolve.route_kind.as_deref(),
Some("resolve_user_selection")
);
assert_eq!(
resolve.auth_endpoint_signature.as_deref(),
Some("admin:users")
);
let batch_uri: Uri = "/api/admin/users/batch-action"
.parse()
.expect("uri should parse");
let batch = classify_control_route(&http::Method::POST, &batch_uri, &headers)
.expect("route should classify");
assert_eq!(batch.route_family.as_deref(), Some("users_manage"));
assert_eq!(batch.route_kind.as_deref(), Some("batch_action_users"));
assert_eq!(
batch.auth_endpoint_signature.as_deref(),
Some("admin:users")
);
}
#[test]
fn classifies_admin_user_detail_routes_as_admin_proxy_route() {
let headers = headers(&[]);

View File

@@ -399,6 +399,7 @@ impl GatewayDataState {
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit_present: bool,
rate_limit: Option<i32>,
is_active: Option<bool>,
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
@@ -415,6 +416,7 @@ impl GatewayDataState {
allowed_api_formats,
allowed_models_present,
allowed_models,
rate_limit_present,
rate_limit,
is_active,
)

View File

@@ -1847,6 +1847,7 @@ impl<'a> AdminAppState<'a> {
allowed_api_formats.clone(),
user.contains_key("allowed_models"),
allowed_models.clone(),
user.contains_key("rate_limit"),
rate_limit,
Some(is_active),
)

View File

@@ -166,6 +166,7 @@ impl<'a> AdminAppState<'a> {
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit_present: bool,
rate_limit: Option<i32>,
is_active: Option<bool>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
@@ -179,6 +180,7 @@ impl<'a> AdminAppState<'a> {
allowed_api_formats,
allowed_models_present,
allowed_models,
rate_limit_present,
rate_limit,
is_active,
)
@@ -581,4 +583,10 @@ impl<'a> AdminAppState<'a> {
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
self.app.list_non_admin_export_users().await
}
pub(crate) async fn list_export_users(
&self,
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
self.app.list_export_users().await
}
}

View File

@@ -0,0 +1,648 @@
use super::{
build_admin_users_bad_request_response, build_admin_users_read_only_response,
normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::GatewayError;
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct AdminUserSelectionFilters {
#[serde(default)]
search: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
is_active: Option<bool>,
}
#[derive(Debug, Clone, Default)]
struct AdminUserSelectionRequest {
user_ids: Vec<String>,
filters: Option<AdminUserSelectionFilters>,
filters_scope_present: bool,
}
#[derive(Debug)]
struct AdminUserBatchActionRequest {
selection: AdminUserSelectionRequest,
action: String,
payload: Option<Value>,
}
#[derive(Debug, serde::Deserialize)]
struct RawAdminUserBatchActionRequest {
selection: Value,
action: String,
#[serde(default)]
payload: Option<Value>,
}
#[derive(Debug, Clone, Default)]
struct NormalizedAdminUserSelectionFilters {
search: Option<String>,
role: Option<String>,
is_active: Option<bool>,
}
#[derive(Debug, Clone, serde::Serialize)]
struct AdminUserSelectionItem {
user_id: String,
username: String,
email: Option<String>,
role: String,
is_active: bool,
}
#[derive(Debug, Clone, Default)]
struct ResolvedAdminUserSelection {
items: Vec<AdminUserSelectionItem>,
missing_user_ids: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct AdminUserBatchMutation {
role: Option<String>,
allowed_providers_present: bool,
allowed_providers: Option<Vec<String>>,
allowed_api_formats_present: bool,
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit_present: bool,
rate_limit: Option<i32>,
is_active: Option<bool>,
unlimited: Option<bool>,
modified_fields: Vec<&'static str>,
}
impl AdminUserBatchMutation {
fn has_auth_user_fields(&self) -> bool {
self.role.is_some()
|| self.allowed_providers_present
|| self.allowed_api_formats_present
|| self.allowed_models_present
|| self.rate_limit_present
|| self.is_active.is_some()
}
}
pub(in super::super) async fn build_admin_resolve_user_selection_response(
state: &AdminAppState<'_>,
_request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Response<Body>, GatewayError> {
let selection = match parse_resolve_selection_request(request_body) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let resolved = match resolve_admin_user_selection(state, selection).await {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
Ok(Json(json!({
"total": resolved.items.len(),
"items": resolved.items,
}))
.into_response())
}
pub(in super::super) async fn build_admin_user_batch_action_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Response<Body>, GatewayError> {
let request = match parse_batch_action_request(request_body) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let mutation = match parse_batch_mutation(&request.action, request.payload) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
if mutation.has_auth_user_fields() && !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法批量更新用户",
));
}
if mutation.unlimited.is_some() && !state.has_auth_wallet_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法批量更新用户钱包",
));
}
let resolved = match resolve_admin_user_selection(state, request.selection).await {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let active_admin_demotions = count_active_admin_demotions(&mutation, &resolved.items);
let active_admin_count = if active_admin_demotions > 0 {
state.count_active_admin_users().await?
} else {
0
};
let current_admin_user_id = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
.map(|principal| principal.user_id.as_str());
let mut success = 0usize;
let mut failures = resolved
.missing_user_ids
.iter()
.map(|user_id| json!({ "user_id": user_id, "reason": "用户不存在或已删除" }))
.collect::<Vec<_>>();
for item in &resolved.items {
if state.find_user_auth_by_id(&item.user_id).await?.is_none() {
failures.push(json!({
"user_id": item.user_id,
"reason": "用户不存在或已删除",
}));
continue;
}
if let Some(reason) = batch_role_demotion_failure_reason(
&mutation,
item,
active_admin_count,
active_admin_demotions,
current_admin_user_id,
) {
failures.push(json!({
"user_id": item.user_id,
"reason": reason,
}));
continue;
}
if let Some(unlimited) = mutation.unlimited {
if !apply_batch_user_wallet_limit_mode(state, &item.user_id, unlimited).await? {
failures.push(json!({
"user_id": item.user_id,
"reason": "用户钱包不可用",
}));
continue;
}
}
if mutation.has_auth_user_fields()
&& state
.update_local_auth_user_admin_fields(
&item.user_id,
mutation.role.clone(),
mutation.allowed_providers_present,
mutation.allowed_providers.clone(),
mutation.allowed_api_formats_present,
mutation.allowed_api_formats.clone(),
mutation.allowed_models_present,
mutation.allowed_models.clone(),
mutation.rate_limit_present,
mutation.rate_limit,
mutation.is_active,
)
.await?
.is_none()
{
failures.push(json!({
"user_id": item.user_id,
"reason": "用户不存在或已删除",
}));
continue;
}
success += 1;
}
let failed = failures.len();
let total = success + failed;
let response = Json(json!({
"total": total,
"success": success,
"failed": failed,
"failures": failures,
"action": request.action.trim().to_ascii_lowercase(),
"modified_fields": mutation.modified_fields,
}))
.into_response();
Ok(attach_admin_audit_response(
response,
"admin_users_batch_action_executed",
"batch_update_users",
"user_batch",
"users",
))
}
fn parse_resolve_selection_request(
request_body: Option<&Bytes>,
) -> Result<AdminUserSelectionRequest, String> {
match request_body {
None => Ok(AdminUserSelectionRequest::default()),
Some(body) if body.is_empty() => Ok(AdminUserSelectionRequest::default()),
Some(body) => {
let value = serde_json::from_slice::<Value>(body)
.map_err(|_| "Invalid JSON request body".to_string())?;
parse_selection_request_value(value)
}
}
}
fn parse_batch_action_request(
request_body: Option<&Bytes>,
) -> Result<AdminUserBatchActionRequest, String> {
match request_body {
Some(body) if !body.is_empty() => {
let raw = serde_json::from_slice::<RawAdminUserBatchActionRequest>(body)
.map_err(|_| "Invalid JSON request body".to_string())?;
Ok(AdminUserBatchActionRequest {
selection: parse_selection_request_value(raw.selection)?,
action: raw.action,
payload: raw.payload,
})
}
_ => Err("Invalid JSON request body".to_string()),
}
}
fn parse_selection_request_value(value: Value) -> Result<AdminUserSelectionRequest, String> {
let Value::Object(map) = value else {
return Err("selection 必须是对象".to_string());
};
let user_ids = match map.get("user_ids") {
None | Some(Value::Null) => Vec::new(),
Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
.map_err(|_| "user_ids 必须是字符串数组".to_string())?,
};
let (filters_scope_present, filters) = match map.get("filters") {
Some(Value::Object(_)) => {
let filters = serde_json::from_value::<AdminUserSelectionFilters>(
map.get("filters").cloned().unwrap_or(Value::Null),
)
.map_err(|_| "filters 参数不合法".to_string())?;
(true, Some(filters))
}
None | Some(Value::Null) => (false, None),
Some(_) => return Err("filters 必须是对象".to_string()),
};
Ok(AdminUserSelectionRequest {
user_ids,
filters,
filters_scope_present,
})
}
async fn resolve_admin_user_selection(
state: &AdminAppState<'_>,
selection: AdminUserSelectionRequest,
) -> Result<ResolvedAdminUserSelection, String> {
let filters = normalize_selection_filters(selection.filters)?;
let explicit_user_ids = normalize_user_ids(selection.user_ids);
if explicit_user_ids.is_empty() && !selection.filters_scope_present {
return Err("至少需要选择一个用户或明确提供筛选条件".to_string());
}
let should_resolve_filters = selection.filters_scope_present;
let mut items_by_id = BTreeMap::new();
let mut missing_user_ids = Vec::new();
if !explicit_user_ids.is_empty() {
let users = state
.resolve_auth_user_summaries_by_ids(&explicit_user_ids)
.await
.map_err(|_| "用户数据不可用".to_string())?;
for user_id in explicit_user_ids {
match users.get(&user_id).filter(|user| !user.is_deleted) {
Some(user) => {
items_by_id.insert(
user.id.clone(),
AdminUserSelectionItem {
user_id: user.id.clone(),
username: user.username.clone(),
email: user.email.clone(),
role: user.role.clone(),
is_active: user.is_active,
},
);
}
None => missing_user_ids.push(user_id),
}
}
}
if should_resolve_filters {
let users = state
.list_export_users()
.await
.map_err(|_| "用户数据不可用".to_string())?;
for user in users
.into_iter()
.filter(|user| admin_user_matches_filters(user, filters.as_ref()))
{
items_by_id.insert(
user.id.clone(),
AdminUserSelectionItem {
user_id: user.id,
username: user.username,
email: user.email,
role: user.role,
is_active: user.is_active,
},
);
}
}
let mut items = items_by_id.into_values().collect::<Vec<_>>();
items.sort_by(|left, right| {
left.username
.to_ascii_lowercase()
.cmp(&right.username.to_ascii_lowercase())
.then_with(|| left.user_id.cmp(&right.user_id))
});
Ok(ResolvedAdminUserSelection {
items,
missing_user_ids,
})
}
fn normalize_selection_filters(
filters: Option<AdminUserSelectionFilters>,
) -> Result<Option<NormalizedAdminUserSelectionFilters>, String> {
let Some(filters) = filters else {
return Ok(None);
};
let search = filters
.search
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let role = match filters
.role
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty() && value != "all")
{
Some(role) if matches!(role.as_str(), "user" | "admin") => Some(role),
Some(_) => return Err("role 参数不合法".to_string()),
None => None,
};
Ok(Some(NormalizedAdminUserSelectionFilters {
search,
role,
is_active: filters.is_active,
}))
}
fn admin_user_matches_filters(
user: &aether_data::repository::users::StoredUserExportRow,
filters: Option<&NormalizedAdminUserSelectionFilters>,
) -> bool {
let Some(filters) = filters else {
return true;
};
if filters
.role
.as_deref()
.is_some_and(|role| !user.role.eq_ignore_ascii_case(role))
{
return false;
}
if filters
.is_active
.is_some_and(|is_active| user.is_active != is_active)
{
return false;
}
if let Some(search) = filters.search.as_deref() {
let searchable_text = format!(
"{} {}",
user.username,
user.email.as_deref().unwrap_or_default()
)
.to_ascii_lowercase();
let keywords = search
.to_ascii_lowercase()
.split_whitespace()
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
if !keywords
.iter()
.all(|keyword| searchable_text.contains(keyword))
{
return false;
}
}
true
}
fn normalize_user_ids(user_ids: Vec<String>) -> Vec<String> {
user_ids
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn parse_batch_mutation(
action: &str,
payload: Option<Value>,
) -> Result<AdminUserBatchMutation, String> {
match action.trim().to_ascii_lowercase().as_str() {
"enable" => Ok(AdminUserBatchMutation {
is_active: Some(true),
modified_fields: vec!["is_active"],
..AdminUserBatchMutation::default()
}),
"disable" => Ok(AdminUserBatchMutation {
is_active: Some(false),
modified_fields: vec!["is_active"],
..AdminUserBatchMutation::default()
}),
"update_access_control" => parse_access_control_mutation(payload),
"update_role" => parse_role_mutation(payload),
_ => Err("不支持的批量操作".to_string()),
}
}
fn parse_role_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
let Some(Value::Object(payload)) = payload else {
return Err("payload 必须是对象".to_string());
};
let Some(value) = payload.get("role") else {
return Err("role 参数不能为空".to_string());
};
let Some(role) = value.as_str() else {
return Err("role 参数不合法".to_string());
};
let role = role.trim();
if role.is_empty() {
return Err("role 参数不能为空".to_string());
}
Ok(AdminUserBatchMutation {
role: Some(normalize_admin_user_role(Some(role))?),
modified_fields: vec!["role"],
..AdminUserBatchMutation::default()
})
}
fn parse_access_control_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
let Some(Value::Object(payload)) = payload else {
return Err("payload 必须是对象".to_string());
};
let mut mutation = AdminUserBatchMutation::default();
if let Some(value) = payload.get("allowed_providers") {
mutation.allowed_providers_present = true;
mutation.allowed_providers = parse_optional_string_list(value, "allowed_providers")?;
mutation.modified_fields.push("allowed_providers");
}
if let Some(value) = payload.get("allowed_api_formats") {
mutation.allowed_api_formats_present = true;
mutation.allowed_api_formats = parse_optional_api_formats(value)?;
mutation.modified_fields.push("allowed_api_formats");
}
if let Some(value) = payload.get("allowed_models") {
mutation.allowed_models_present = true;
mutation.allowed_models = parse_optional_string_list(value, "allowed_models")?;
mutation.modified_fields.push("allowed_models");
}
if let Some(value) = payload.get("rate_limit") {
mutation.rate_limit_present = true;
mutation.rate_limit = parse_optional_rate_limit(value)?;
mutation.modified_fields.push("rate_limit");
}
if let Some(value) = payload.get("unlimited") {
mutation.unlimited = Some(parse_unlimited(value)?);
mutation.modified_fields.push("unlimited");
}
if mutation.modified_fields.is_empty() {
return Err("至少需要选择一个要修改的访问控制字段".to_string());
}
Ok(mutation)
}
fn parse_optional_string_list(
value: &Value,
field_name: &str,
) -> Result<Option<Vec<String>>, String> {
if value.is_null() {
return Ok(None);
}
let values = serde_json::from_value::<Vec<String>>(value.clone())
.map_err(|_| format!("{field_name} 必须是字符串数组或 null"))?;
normalize_admin_user_string_list(Some(values), field_name)
}
fn parse_optional_api_formats(value: &Value) -> Result<Option<Vec<String>>, String> {
if value.is_null() {
return Ok(None);
}
let values = serde_json::from_value::<Vec<String>>(value.clone())
.map_err(|_| "allowed_api_formats 必须是字符串数组或 null".to_string())?;
normalize_admin_user_api_formats(Some(values))
}
fn parse_optional_rate_limit(value: &Value) -> Result<Option<i32>, String> {
if value.is_null() {
return Ok(None);
}
let rate_limit = serde_json::from_value::<i32>(value.clone())
.map_err(|_| "rate_limit 必须是整数或 null".to_string())?;
if rate_limit < 0 {
return Err("rate_limit 必须大于等于 0".to_string());
}
Ok(Some(rate_limit))
}
fn parse_unlimited(value: &Value) -> Result<bool, String> {
serde_json::from_value::<bool>(value.clone()).map_err(|_| "unlimited 必须是布尔值".to_string())
}
fn count_active_admin_demotions(
mutation: &AdminUserBatchMutation,
items: &[AdminUserSelectionItem],
) -> usize {
if mutation.role.as_deref() != Some("user") {
return 0;
}
items
.iter()
.filter(|item| item.is_active && item.role.eq_ignore_ascii_case("admin"))
.count()
}
fn batch_role_demotion_failure_reason(
mutation: &AdminUserBatchMutation,
item: &AdminUserSelectionItem,
active_admin_count: u64,
active_admin_demotions: usize,
current_admin_user_id: Option<&str>,
) -> Option<&'static str> {
if mutation.role.as_deref() != Some("user")
|| !item.is_active
|| !item.role.eq_ignore_ascii_case("admin")
{
return None;
}
if current_admin_user_id.is_some_and(|user_id| user_id == item.user_id) {
return Some("不能降级当前管理员账户");
}
if active_admin_count <= active_admin_demotions as u64 {
return Some("不能降级最后一个管理员账户");
}
None
}
async fn apply_batch_user_wallet_limit_mode(
state: &AdminAppState<'_>,
user_id: &str,
unlimited: bool,
) -> Result<bool, GatewayError> {
let desired_limit_mode = if unlimited { "unlimited" } else { "finite" };
match state
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
user_id,
))
.await?
{
Some(wallet) => {
if wallet.limit_mode.eq_ignore_ascii_case(desired_limit_mode) {
return Ok(true);
}
Ok(state
.update_auth_user_wallet_limit_mode(user_id, desired_limit_mode)
.await?
.is_some())
}
None => Ok(state
.initialize_auth_user_wallet(user_id, 0.0, unlimited)
.await?
.is_some()),
}
}
fn build_admin_user_batch_bad_request_response(detail: String) -> Response<Body> {
if detail.as_str() == "缺少 user_id" {
return build_admin_users_bad_request_response("缺少 user_id");
}
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response()
}

View File

@@ -184,7 +184,7 @@ pub(in super::super) async fn build_admin_update_user_response(
|| field_presence.contains("allowed_providers")
|| field_presence.contains("allowed_api_formats")
|| field_presence.contains("allowed_models")
|| payload.rate_limit.is_some()
|| field_presence.contains("rate_limit")
|| payload.is_active.is_some();
if needs_auth_user_write && !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
@@ -247,7 +247,7 @@ pub(in super::super) async fn build_admin_update_user_response(
|| field_presence.contains("allowed_providers")
|| field_presence.contains("allowed_api_formats")
|| field_presence.contains("allowed_models")
|| payload.rate_limit.is_some()
|| field_presence.contains("rate_limit")
|| payload.is_active.is_some()
{
if state
@@ -260,6 +260,7 @@ pub(in super::super) async fn build_admin_update_user_response(
allowed_api_formats,
field_presence.contains("allowed_models"),
allowed_models,
field_presence.contains("rate_limit"),
payload.rate_limit,
payload.is_active,
)

View File

@@ -3,6 +3,7 @@ use crate::handlers::admin::request::{AdminRouteRequest, AdminRouteResult};
const ADMIN_USERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin user management data unavailable";
mod api_keys;
mod batch;
mod lifecycle;
mod route_seam;
mod routes;
@@ -19,6 +20,9 @@ pub(crate) use self::api_keys::{
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
normalize_admin_optional_api_key_name,
};
use self::batch::{
build_admin_resolve_user_selection_response, build_admin_user_batch_action_response,
};
use self::lifecycle::{
build_admin_create_user_response, build_admin_delete_user_response,
build_admin_get_user_response, build_admin_list_users_response,

View File

@@ -4,8 +4,9 @@ use super::{
build_admin_delete_user_session_response, build_admin_delete_user_sessions_response,
build_admin_get_user_response, build_admin_list_user_api_keys_response,
build_admin_list_user_sessions_response, build_admin_list_users_response,
build_admin_reveal_user_api_key_response, build_admin_toggle_user_api_key_lock_response,
build_admin_update_user_api_key_response, build_admin_update_user_response,
build_admin_resolve_user_selection_response, build_admin_reveal_user_api_key_response,
build_admin_toggle_user_api_key_lock_response, build_admin_update_user_api_key_response,
build_admin_update_user_response, build_admin_user_batch_action_response,
build_admin_users_data_unavailable_response,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -18,6 +19,14 @@ fn is_admin_users_route(request_context: &AdminRequestContext<'_>) -> bool {
&& matches!(path, "/api/admin/users" | "/api/admin/users/"))
|| (request_context.method() == http::Method::POST
&& matches!(path, "/api/admin/users" | "/api/admin/users/"))
|| (request_context.method() == http::Method::POST
&& matches!(
path,
"/api/admin/users/resolve-selection"
| "/api/admin/users/resolve-selection/"
| "/api/admin/users/batch-action"
| "/api/admin/users/batch-action/"
))
|| ((request_context.method() == http::Method::GET
|| request_context.method() == http::Method::PUT
|| request_context.method() == http::Method::DELETE)
@@ -81,6 +90,13 @@ pub(super) async fn maybe_build_local_admin_users_routes_response(
Some("list_users") => Ok(Some(
build_admin_list_users_response(state, request_context).await?,
)),
Some("resolve_user_selection") => Ok(Some(
build_admin_resolve_user_selection_response(state, request_context, request_body)
.await?,
)),
Some("batch_action_users") => Ok(Some(
build_admin_user_batch_action_response(state, request_context, request_body).await?,
)),
Some("get_user") => Ok(Some(
build_admin_get_user_response(state, request_context).await?,
)),

View File

@@ -320,6 +320,8 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("security_manage"), http::Method::POST, Some("blacklist_add"))
| (Some("security_manage"), http::Method::POST, Some("whitelist_add"))
| (Some("users_manage"), http::Method::POST, Some("create_user"))
| (Some("users_manage"), http::Method::POST, Some("resolve_user_selection"))
| (Some("users_manage"), http::Method::POST, Some("batch_action_users"))
| (Some("users_manage"), http::Method::PUT, Some("update_user"))
| (Some("users_manage"), http::Method::POST, Some("create_user_api_key"))
| (Some("users_manage"), http::Method::PUT, Some("update_user_api_key"))

View File

@@ -371,6 +371,7 @@ impl AppState {
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit_present: bool,
rate_limit: Option<i32>,
is_active: Option<bool>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
@@ -395,7 +396,7 @@ impl AppState {
if let Some(is_active) = is_active {
user.is_active = is_active;
}
let _ = rate_limit;
let _ = (rate_limit_present, rate_limit);
return Ok(Some(user.clone()));
}
@@ -409,6 +410,7 @@ impl AppState {
allowed_api_formats,
allowed_models_present,
allowed_models,
rate_limit_present,
rate_limit,
is_active,
)

View File

@@ -361,6 +361,327 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
create_gateway_handle.abort();
}
#[tokio::test]
async fn gateway_resolves_admin_user_batch_selection_locally() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().fallback(any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}));
let user_repository = Arc::new(
InMemoryUserReadRepository::seed_auth_users(vec![
sample_admin_user("user-1"),
sample_admin_user_with_role("user-2", "admin", "root@example.com", "root"),
sample_admin_user_with_role("user-3", "user", "carol@example.com", "carol"),
])
.with_export_users(vec![
sample_admin_export_user("user-1"),
sample_admin_export_user_with("admin", true, "user-2", "root@example.com", "root"),
sample_admin_export_user_with("user", false, "user-3", "carol@example.com", "carol"),
]),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
user_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"filters": {
"search": "ali",
"role": "user",
"is_active": true
}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total"], 1);
let items = payload["items"].as_array().expect("items should be array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["user_id"], "user-1");
assert_eq!(items[0]["username"], "alice");
assert_eq!(items[0]["email"], "alice@example.com");
assert_eq!(items[0]["role"], "user");
assert_eq!(items[0]["is_active"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
let all_filtered_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ "filters": {} }))
.send()
.await
.expect("request should succeed");
assert_eq!(all_filtered_response.status(), StatusCode::OK);
let all_filtered_payload: serde_json::Value = all_filtered_response
.json()
.await
.expect("json body should parse");
assert_eq!(all_filtered_payload["total"], 3);
let empty_selection_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/users/resolve-selection"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({}))
.send()
.await
.expect("request should succeed");
assert_eq!(empty_selection_response.status(), StatusCode::BAD_REQUEST);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_user_batch_actions_locally() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().fallback(any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_auth_users_for_tests([sample_admin_user("user-1")])
.with_auth_wallets_for_tests([sample_admin_wallet("user-1", "unlimited")]),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let disable_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {
"user_ids": ["user-1", "user-1", "missing-user"]
},
"action": "disable"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(disable_response.status(), StatusCode::OK);
let disable_payload: serde_json::Value = disable_response
.json()
.await
.expect("json body should parse");
assert_eq!(disable_payload["total"], 2);
assert_eq!(disable_payload["success"], 1);
assert_eq!(disable_payload["failed"], 1);
assert_eq!(disable_payload["failures"][0]["user_id"], "missing-user");
let empty_selection_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {},
"action": "disable"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(empty_selection_response.status(), StatusCode::BAD_REQUEST);
let access_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {
"user_ids": ["user-1"]
},
"action": "update_access_control",
"payload": {
"allowed_providers": null,
"allowed_api_formats": ["OPENAI:RESPONSES"],
"allowed_models": [],
"rate_limit": 0,
"unlimited": false
}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(access_response.status(), StatusCode::OK);
let access_payload: serde_json::Value = access_response
.json()
.await
.expect("json body should parse");
assert_eq!(access_payload["total"], 1);
assert_eq!(access_payload["success"], 1);
assert_eq!(access_payload["failed"], 0);
assert_eq!(
access_payload["modified_fields"],
json!([
"allowed_providers",
"allowed_api_formats",
"allowed_models",
"rate_limit",
"unlimited"
])
);
let role_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {
"user_ids": ["user-1"]
},
"action": "update_role",
"payload": {
"role": "admin"
}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(role_response.status(), StatusCode::OK);
let role_payload: serde_json::Value =
role_response.json().await.expect("json body should parse");
assert_eq!(role_payload["total"], 1);
assert_eq!(role_payload["success"], 1);
assert_eq!(role_payload["modified_fields"], json!(["role"]));
let blank_role_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {
"user_ids": ["user-1"]
},
"action": "update_role",
"payload": {
"role": ""
}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(blank_role_response.status(), StatusCode::BAD_REQUEST);
let enable_admin_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {
"user_ids": ["user-1"]
},
"action": "enable"
}))
.send()
.await
.expect("request should succeed");
assert_eq!(enable_admin_response.status(), StatusCode::OK);
let last_admin_demotion_response = client
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"selection": {
"user_ids": ["user-1"]
},
"action": "update_role",
"payload": {
"role": "user"
}
}))
.send()
.await
.expect("request should succeed");
assert_eq!(last_admin_demotion_response.status(), StatusCode::OK);
let last_admin_demotion_payload: serde_json::Value = last_admin_demotion_response
.json()
.await
.expect("json body should parse");
assert_eq!(last_admin_demotion_payload["success"], 0);
assert_eq!(last_admin_demotion_payload["failed"], 1);
assert_eq!(
last_admin_demotion_payload["failures"][0]["reason"],
"不能降级最后一个管理员账户"
);
let detail_response = client
.get(format!("{gateway_url}/api/admin/users/user-1"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(detail_response.status(), StatusCode::OK);
let detail_payload: serde_json::Value = detail_response
.json()
.await
.expect("json body should parse");
assert_eq!(detail_payload["role"], "admin");
assert_eq!(detail_payload["is_active"], true);
assert_eq!(detail_payload["unlimited"], false);
assert_eq!(detail_payload["allowed_providers"], serde_json::Value::Null);
assert_eq!(
detail_payload["allowed_api_formats"],
json!(["openai:responses"])
);
assert_eq!(detail_payload["allowed_models"], json!([]));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_users_root_locally_with_bearer_admin_session() {
let upstream_hits = Arc::new(Mutex::new(0usize));