fix: align user group access controls

This commit is contained in:
fawney19
2026-05-10 17:28:23 +08:00
parent a8ac944959
commit d246f6b42c
33 changed files with 1955 additions and 1015 deletions
@@ -172,6 +172,10 @@ impl<'a> AdminAppState<'a> {
let user_api_keys = self
.list_auth_api_key_export_records_by_user_ids(&user_ids)
.await?;
let groups = self.list_user_groups().await?;
let memberships = self
.list_user_group_memberships_by_user_ids(&user_ids)
.await?;
let standalone_api_keys = self.list_auth_api_key_export_standalone_records().await?;
let standalone_api_key_ids = standalone_api_keys
.iter()
@@ -205,12 +209,49 @@ impl<'a> AdminAppState<'a> {
.or_default()
.push(key);
}
let mut memberships_by_user_id = BTreeMap::<
String,
Vec<aether_data::repository::users::StoredUserGroupMembership>,
>::new();
for membership in memberships {
memberships_by_user_id
.entry(membership.user_id.clone())
.or_default()
.push(membership);
}
let user_groups_data = groups
.iter()
.map(|group| {
json!({
"id": group.id.clone(),
"name": group.name.clone(),
"description": group.description.clone(),
"allowed_providers": group.allowed_providers.clone(),
"allowed_providers_mode": group.allowed_providers_mode.clone(),
"allowed_api_formats": group.allowed_api_formats.clone(),
"allowed_api_formats_mode": group.allowed_api_formats_mode.clone(),
"allowed_models": group.allowed_models.clone(),
"allowed_models_mode": group.allowed_models_mode.clone(),
"rate_limit": group.rate_limit,
"rate_limit_mode": group.rate_limit_mode.clone(),
})
})
.collect::<Vec<_>>();
let users_data = users
.iter()
.map(|user| {
let wallet = wallets_by_user_id.get(&user.id);
let wallet_payload = serialize_admin_system_users_export_wallet(wallet);
let memberships = memberships_by_user_id.remove(&user.id).unwrap_or_default();
let group_ids = memberships
.iter()
.map(|membership| membership.group_id.clone())
.collect::<Vec<_>>();
let group_names = memberships
.iter()
.map(|membership| membership.group_name.clone())
.collect::<Vec<_>>();
let api_keys = api_keys_by_user_id.remove(&user.id).unwrap_or_default();
let api_keys_payload = api_keys
.iter()
@@ -226,10 +267,16 @@ impl<'a> AdminAppState<'a> {
"password_hash": user.password_hash.clone(),
"role": user.role.clone(),
"allowed_providers": user.allowed_providers.clone(),
"allowed_providers_mode": user.allowed_providers_mode.clone(),
"allowed_api_formats": user.allowed_api_formats.clone(),
"allowed_api_formats_mode": user.allowed_api_formats_mode.clone(),
"allowed_models": user.allowed_models.clone(),
"allowed_models_mode": user.allowed_models_mode.clone(),
"rate_limit": user.rate_limit,
"rate_limit_mode": user.rate_limit_mode.clone(),
"model_capability_settings": user.model_capability_settings.clone(),
"group_ids": group_ids,
"group_names": group_names,
"unlimited": wallet
.map(|entry| entry.limit_mode.eq_ignore_ascii_case("unlimited"))
.unwrap_or(false),
@@ -254,6 +301,7 @@ impl<'a> AdminAppState<'a> {
Ok(json!({
"version": ADMIN_SYSTEM_USERS_EXPORT_VERSION,
"exported_at": Utc::now().to_rfc3339(),
"user_groups": user_groups_data,
"users": users_data,
"standalone_keys": standalone_keys_data,
}))
@@ -10,7 +10,9 @@ use crate::handlers::admin::shared::{
};
use crate::handlers::admin::system::shared::configs::apply_admin_system_config_update;
use crate::handlers::admin::users::{
hash_admin_user_api_key, normalize_admin_user_api_formats, normalize_admin_user_string_list,
hash_admin_user_api_key, normalize_admin_list_policy_mode,
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
normalize_admin_user_string_list,
};
use crate::handlers::public::normalize_admin_base_url;
use crate::GatewayError;
@@ -397,6 +399,7 @@ fn build_import_provider_model_record(
#[derive(Debug, Clone, Default, serde::Serialize)]
struct AdminSystemUsersImportStats {
user_groups: AdminSystemConfigImportCounter,
users: AdminSystemConfigImportCounter,
api_keys: AdminSystemConfigImportCounter,
standalone_keys: AdminSystemConfigImportCounter,
@@ -542,6 +545,78 @@ fn imported_optional_value(value: Option<&Value>) -> Option<Value> {
value.cloned().filter(|value| !value.is_null())
}
fn imported_optional_list_policy_mode(
value: Option<&Value>,
field_name: &str,
) -> Result<Option<String>, String> {
let Some(value) = imported_optional_string(value)? else {
return Ok(None);
};
let value = value.to_ascii_lowercase();
normalize_admin_list_policy_mode(&value)
.map(Some)
.map_err(|_| format!("{field_name} 不合法"))
}
fn imported_optional_rate_limit_policy_mode(
value: Option<&Value>,
field_name: &str,
) -> Result<Option<String>, String> {
let Some(value) = imported_optional_string(value)? else {
return Ok(None);
};
let value = value.to_ascii_lowercase();
normalize_admin_rate_limit_policy_mode(&value)
.map(Some)
.map_err(|_| format!("{field_name} 不合法"))
}
fn legacy_imported_list_policy_mode(values: &Option<Vec<String>>) -> String {
if values.is_some() {
"specific".to_string()
} else {
"unrestricted".to_string()
}
}
fn legacy_imported_rate_limit_policy_mode(value: Option<i32>) -> String {
if value.is_some() {
"custom".to_string()
} else {
"system".to_string()
}
}
fn imported_user_list_policy_mode(
object: &Map<String, Value>,
mode_field: &str,
value_field: &str,
values: &Option<Vec<String>>,
) -> Result<Option<String>, String> {
imported_optional_list_policy_mode(object.get(mode_field), mode_field).map(|mode| {
mode.or_else(|| {
object
.contains_key(value_field)
.then(|| legacy_imported_list_policy_mode(values))
})
})
}
fn imported_user_rate_limit_policy_mode(
object: &Map<String, Value>,
mode_field: &str,
value_field: &str,
value: Option<i32>,
) -> Result<Option<String>, String> {
imported_optional_rate_limit_policy_mode(object.get(mode_field), mode_field).map(|mode| {
mode.or_else(|| {
object
.contains_key(value_field)
.then(|| legacy_imported_rate_limit_policy_mode(value))
})
})
}
fn imported_rfc3339_to_unix_secs(
value: Option<&Value>,
field_name: &str,
@@ -601,6 +676,130 @@ fn normalize_imported_user_api_formats(
)?)
}
fn build_imported_user_group_record(
group: &Map<String, Value>,
field_name: &str,
) -> Result<
(
Option<String>,
String,
aether_data::repository::users::UpsertUserGroupRecord,
),
String,
> {
let export_id = imported_optional_string(group.get("id"))?;
let name = imported_optional_string(group.get("name"))?
.ok_or_else(|| format!("{field_name}.name 不能为空"))?;
let name = aether_data::repository::users::normalize_user_group_name(&name);
if name.is_empty() {
return Err(format!("{field_name}.name 不能为空"));
}
let description = imported_optional_string(group.get("description"))?;
let allowed_providers = normalize_imported_user_string_list(group, "allowed_providers")?;
let allowed_api_formats = normalize_imported_user_api_formats(group, "allowed_api_formats")?;
let allowed_models = normalize_imported_user_string_list(group, "allowed_models")?;
let rate_limit = imported_optional_i32(group.get("rate_limit"), "rate_limit")?;
let allowed_providers_mode = imported_optional_list_policy_mode(
group.get("allowed_providers_mode"),
"allowed_providers_mode",
)?
.unwrap_or_else(|| {
if group.contains_key("allowed_providers") {
legacy_imported_list_policy_mode(&allowed_providers)
} else {
"inherit".to_string()
}
});
let allowed_api_formats_mode = imported_optional_list_policy_mode(
group.get("allowed_api_formats_mode"),
"allowed_api_formats_mode",
)?
.unwrap_or_else(|| {
if group.contains_key("allowed_api_formats") {
legacy_imported_list_policy_mode(&allowed_api_formats)
} else {
"inherit".to_string()
}
});
let allowed_models_mode = imported_optional_list_policy_mode(
group.get("allowed_models_mode"),
"allowed_models_mode",
)?
.unwrap_or_else(|| {
if group.contains_key("allowed_models") {
legacy_imported_list_policy_mode(&allowed_models)
} else {
"inherit".to_string()
}
});
let rate_limit_mode =
imported_optional_rate_limit_policy_mode(group.get("rate_limit_mode"), "rate_limit_mode")?
.unwrap_or_else(|| {
if group.contains_key("rate_limit") {
legacy_imported_rate_limit_policy_mode(rate_limit)
} else {
"inherit".to_string()
}
});
let normalized_name = name.to_ascii_lowercase();
Ok((
export_id,
normalized_name,
aether_data::repository::users::UpsertUserGroupRecord {
name,
description,
priority: 0,
allowed_providers,
allowed_providers_mode,
allowed_api_formats,
allowed_api_formats_mode,
allowed_models,
allowed_models_mode,
rate_limit,
rate_limit_mode,
},
))
}
fn resolve_imported_user_group_ids(
user: &Map<String, Value>,
imported_group_id_map: &BTreeMap<String, String>,
imported_group_name_map: &BTreeMap<String, String>,
groups_by_name: &BTreeMap<String, aether_data::repository::users::StoredUserGroup>,
) -> Result<Vec<String>, String> {
let raw_group_ids =
imported_string_list_from_value(user.get("group_ids"), "group_ids")?.unwrap_or_default();
let raw_group_names = imported_string_list_from_value(user.get("group_names"), "group_names")?
.unwrap_or_default();
let mut group_ids = BTreeSet::new();
for raw_group_id in raw_group_ids {
if let Some(group_id) = imported_group_id_map.get(&raw_group_id) {
group_ids.insert(group_id.clone());
continue;
}
group_ids.insert(raw_group_id);
}
for raw_group_name in raw_group_names {
let normalized_name =
aether_data::repository::users::normalize_user_group_name(&raw_group_name)
.to_ascii_lowercase();
if normalized_name.is_empty() {
continue;
}
if let Some(group_id) = imported_group_name_map.get(&normalized_name) {
group_ids.insert(group_id.clone());
continue;
}
if let Some(group) = groups_by_name.get(&normalized_name) {
group_ids.insert(group.id.clone());
}
}
Ok(group_ids.into_iter().collect())
}
fn normalize_imported_wallet_target(
wallet: Option<&Map<String, Value>>,
unlimited: bool,
@@ -1686,6 +1885,11 @@ impl<'a> AdminAppState<'a> {
Some(_) => return Ok(Err(invalid_request("standalone_keys 必须是数组"))),
None => &empty,
};
let imported_user_groups = match root.get("user_groups") {
Some(Value::Array(items)) => items,
Some(_) => return Ok(Err(invalid_request("user_groups 必须是数组"))),
None => &empty,
};
let standalone_owner_id = match operator_id {
Some(candidate) => match self.find_user_auth_by_id(candidate).await? {
@@ -1709,6 +1913,86 @@ impl<'a> AdminAppState<'a> {
));
let mut stats = AdminSystemUsersImportStats::default();
let default_group_id = self.effective_default_user_group_id().await?;
let existing_groups = self.list_user_groups().await?;
let mut groups_by_name = existing_groups
.into_iter()
.map(|group| {
(
aether_data::repository::users::normalize_user_group_name(&group.name)
.to_ascii_lowercase(),
group,
)
})
.collect::<BTreeMap<_, _>>();
let mut imported_group_id_map = BTreeMap::<String, String>::new();
let mut imported_group_name_map = BTreeMap::<String, String>::new();
for (index, raw_group) in imported_user_groups.iter().enumerate() {
let group = match imported_object_field(raw_group, &format!("user_groups[{index}]")) {
Ok(value) => value,
Err(detail) => return Ok(Err(invalid_request(detail))),
};
let (export_id, normalized_name, record) = invalid_value!(
build_imported_user_group_record(group, &format!("user_groups[{index}]"))
);
if default_group_id
.as_deref()
.is_some_and(|group_id| export_id.as_deref() == Some(group_id))
|| normalized_name == "default"
{
if let Some(default_group_id) = default_group_id.as_ref() {
if let Some(export_id) = export_id {
imported_group_id_map.insert(export_id, default_group_id.clone());
}
imported_group_name_map.insert(normalized_name, default_group_id.clone());
}
stats.user_groups.skipped += 1;
continue;
}
if let Some(existing) = groups_by_name.get(&normalized_name).cloned() {
if let Some(export_id) = export_id {
imported_group_id_map.insert(export_id, existing.id.clone());
}
imported_group_name_map.insert(normalized_name.clone(), existing.id.clone());
match merge_mode {
AdminImportMergeMode::Skip => {
stats.user_groups.skipped += 1;
}
AdminImportMergeMode::Error => {
return Ok(Err(invalid_request(format!(
"用户组 '{}' 已存在",
existing.name
))));
}
AdminImportMergeMode::Overwrite => {
let Some(updated) = self.update_user_group(&existing.id, record).await?
else {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
};
groups_by_name.insert(normalized_name, updated);
stats.user_groups.updated += 1;
}
}
continue;
}
let Some(created) = self.create_user_group(record).await? else {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
};
if let Some(export_id) = export_id {
imported_group_id_map.insert(export_id, created.id.clone());
}
imported_group_name_map.insert(normalized_name.clone(), created.id.clone());
groups_by_name.insert(normalized_name, created);
stats.user_groups.created += 1;
}
for (index, raw_user) in users.iter().enumerate() {
let user = match imported_object_field(raw_user, &format!("users[{index}]")) {
@@ -1760,6 +2044,53 @@ impl<'a> AdminAppState<'a> {
invalid_value!(normalize_imported_user_string_list(user, "allowed_models"));
let rate_limit =
invalid_value!(imported_optional_i32(user.get("rate_limit"), "rate_limit"));
let allowed_providers_mode = invalid_value!(imported_user_list_policy_mode(
user,
"allowed_providers_mode",
"allowed_providers",
&allowed_providers,
));
let allowed_api_formats_mode = invalid_value!(imported_user_list_policy_mode(
user,
"allowed_api_formats_mode",
"allowed_api_formats",
&allowed_api_formats,
));
let allowed_models_mode = invalid_value!(imported_user_list_policy_mode(
user,
"allowed_models_mode",
"allowed_models",
&allowed_models,
));
let rate_limit_mode = invalid_value!(imported_user_rate_limit_policy_mode(
user,
"rate_limit_mode",
"rate_limit",
rate_limit,
));
let imported_user_group_ids = invalid_value!(resolve_imported_user_group_ids(
user,
&imported_group_id_map,
&imported_group_name_map,
&groups_by_name,
));
let group_ids = if user.contains_key("group_ids") || user.contains_key("group_names") {
let group_ids = self
.include_default_user_group_ids(&imported_user_group_ids)
.await?;
if !group_ids.is_empty() {
let existing_groups = self.list_user_groups_by_ids(&group_ids).await?;
if existing_groups.len() != group_ids.len() {
return Ok(Err(invalid_request(format!(
"用户 '{}' 的用户组不存在",
email.clone().unwrap_or(username.clone())
))));
}
}
Some(group_ids)
} else {
None
};
let is_active =
invalid_value!(imported_optional_bool(user.get("is_active"))).unwrap_or(true);
let model_capability_settings = invalid_value!(imported_optional_json_object(
@@ -1883,6 +2214,31 @@ impl<'a> AdminAppState<'a> {
)
.await?;
}
if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
let updated_policy_modes = self
.update_local_auth_user_policy_modes(
&existing.id,
allowed_providers_mode.clone(),
allowed_api_formats_mode.clone(),
allowed_models_mode.clone(),
rate_limit_mode.clone(),
)
.await?;
if updated_policy_modes.is_none() {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
}
}
if let Some(group_ids) = group_ids.as_ref() {
self.replace_user_groups_for_user(&existing.id, group_ids)
.await?;
}
self.sync_imported_user_wallet(
&existing.id,
&wallet_target,
@@ -1921,6 +2277,34 @@ impl<'a> AdminAppState<'a> {
)
.await?;
}
let created = if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
let Some(updated_policy_modes) = self
.update_local_auth_user_policy_modes(
&created.id,
allowed_providers_mode.clone(),
allowed_api_formats_mode.clone(),
allowed_models_mode.clone(),
rate_limit_mode.clone(),
)
.await?
else {
return Ok(Err((
http::StatusCode::SERVICE_UNAVAILABLE,
json!({ "detail": "Admin system data unavailable" }),
)));
};
updated_policy_modes
} else {
created
};
if let Some(group_ids) = group_ids.as_ref() {
self.replace_user_groups_for_user(&created.id, group_ids)
.await?;
}
self.sync_imported_user_wallet(
&created.id,
&wallet_target,
@@ -2454,9 +2838,10 @@ mod tests {
#[test]
fn users_import_requires_supported_export_version() {
assert!(validate_imported_system_users_export_version(Some(&json!("1.3"))).is_ok());
assert!(validate_imported_system_users_export_version(Some(&json!("1.4"))).is_ok());
assert_eq!(
validate_imported_system_users_export_version(Some(&json!("2.2"))).unwrap_err(),
"不支持的用户数据版本: 2.2,支持的版本: 1.3"
"不支持的用户数据版本: 2.2,支持的版本: 1.3, 1.4"
);
assert_eq!(
validate_imported_system_users_export_version(Some(&json!(null))).unwrap_err(),