feat: add user groups and inherited access policies

This commit is contained in:
Entropy.Xu
2026-05-09 21:47:33 +08:00
parent 4a64d078f3
commit 3a814f3d1f
49 changed files with 6381 additions and 250 deletions

View File

@@ -271,6 +271,8 @@ pub(crate) fn build_local_auth_rejection_response(
control_decision: Option<&GatewayControlDecision>,
rejection: &GatewayLocalAuthRejection,
) -> Result<Response<Body>, GatewayError> {
const ACCESS_POLICY_SUBJECT: &str = "当前用户、用户组或密钥的访问控制策略";
match rejection {
GatewayLocalAuthRejection::InvalidApiKey => build_local_http_error_response(
trace_id,
@@ -298,7 +300,7 @@ pub(crate) fn build_local_auth_rejection_response(
trace_id,
control_decision,
StatusCode::FORBIDDEN,
&format!("当前密钥不允许访问 {provider} 提供商"),
&format!("{ACCESS_POLICY_SUBJECT}不允许访问 {provider} 提供商"),
)
}
GatewayLocalAuthRejection::ApiFormatNotAllowed { api_format } => {
@@ -306,14 +308,14 @@ pub(crate) fn build_local_auth_rejection_response(
trace_id,
control_decision,
StatusCode::FORBIDDEN,
&format!("当前密钥不允许访问 {api_format} 格式"),
&format!("{ACCESS_POLICY_SUBJECT}不允许访问 {api_format} 格式"),
)
}
GatewayLocalAuthRejection::ModelNotAllowed { model } => build_local_http_error_response(
trace_id,
control_decision,
StatusCode::FORBIDDEN,
&format!("当前密钥不允许访问模型 {model}"),
&format!("{ACCESS_POLICY_SUBJECT}不允许访问模型 {model}"),
),
}
}

View File

@@ -498,6 +498,95 @@ pub(super) fn classify_admin_operations_family_route(
"admin:wallets",
false,
))
} else if method == http::Method::GET
&& matches!(
normalized_path,
"/api/admin/user-groups" | "/api/admin/user-groups/"
)
{
Some(classified(
"admin_proxy",
"users_manage",
"list_user_groups",
"admin:users",
false,
))
} else if method == http::Method::POST
&& matches!(
normalized_path,
"/api/admin/user-groups" | "/api/admin/user-groups/"
)
{
Some(classified(
"admin_proxy",
"users_manage",
"create_user_group",
"admin:users",
false,
))
} else if method == http::Method::PUT
&& matches!(
normalized_path,
"/api/admin/user-groups/default" | "/api/admin/user-groups/default/"
)
{
Some(classified(
"admin_proxy",
"users_manage",
"set_default_user_group",
"admin:users",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/admin/user-groups/")
&& normalized_path.ends_with("/members")
&& normalized_path.matches('/').count() == 5
{
Some(classified(
"admin_proxy",
"users_manage",
"list_user_group_members",
"admin:users",
false,
))
} else if method == http::Method::PUT
&& normalized_path.starts_with("/api/admin/user-groups/")
&& normalized_path.ends_with("/members")
&& normalized_path.matches('/').count() == 5
{
Some(classified(
"admin_proxy",
"users_manage",
"replace_user_group_members",
"admin:users",
false,
))
} else if method == http::Method::PUT
&& normalized_path.starts_with("/api/admin/user-groups/")
&& normalized_path.matches('/').count() == 4
&& !normalized_path.ends_with("/default")
&& !normalized_path.ends_with("/members")
{
Some(classified(
"admin_proxy",
"users_manage",
"update_user_group",
"admin:users",
false,
))
} else if method == http::Method::DELETE
&& normalized_path.starts_with("/api/admin/user-groups/")
&& normalized_path.matches('/').count() == 4
&& !normalized_path.ends_with("/default")
&& !normalized_path.ends_with("/members")
{
Some(classified(
"admin_proxy",
"users_manage",
"delete_user_group",
"admin:users",
false,
))
} else if method == http::Method::GET
&& matches!(normalized_path, "/api/admin/users" | "/api/admin/users/")
{

View File

@@ -1,6 +1,8 @@
use http::Uri;
use super::{classify_control_route, headers};
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
use super::{classify_control_route, headers, GatewayPublicRequestContext};
#[test]
fn classifies_admin_users_list_as_admin_proxy_route() {
@@ -68,6 +70,86 @@ fn classifies_admin_user_batch_routes_as_admin_proxy_route() {
);
}
#[test]
fn classifies_admin_user_group_routes_as_admin_proxy_route() {
let headers = headers(&[]);
let list_uri: Uri = "/api/admin/user-groups".parse().expect("uri should parse");
let list = classify_control_route(&http::Method::GET, &list_uri, &headers)
.expect("route should classify");
assert_eq!(list.route_family.as_deref(), Some("users_manage"));
assert_eq!(list.route_kind.as_deref(), Some("list_user_groups"));
let create_uri: Uri = "/api/admin/user-groups".parse().expect("uri should parse");
let create = classify_control_route(&http::Method::POST, &create_uri, &headers)
.expect("route should classify");
assert_eq!(create.route_family.as_deref(), Some("users_manage"));
assert_eq!(create.route_kind.as_deref(), Some("create_user_group"));
let update_uri: Uri = "/api/admin/user-groups/group-1"
.parse()
.expect("uri should parse");
let update = classify_control_route(&http::Method::PUT, &update_uri, &headers)
.expect("route should classify");
assert_eq!(update.route_family.as_deref(), Some("users_manage"));
assert_eq!(update.route_kind.as_deref(), Some("update_user_group"));
let members_uri: Uri = "/api/admin/user-groups/group-1/members"
.parse()
.expect("uri should parse");
let members = classify_control_route(&http::Method::PUT, &members_uri, &headers)
.expect("route should classify");
assert_eq!(members.route_family.as_deref(), Some("users_manage"));
assert_eq!(
members.route_kind.as_deref(),
Some("replace_user_group_members")
);
let default_uri: Uri = "/api/admin/user-groups/default"
.parse()
.expect("uri should parse");
let default = classify_control_route(&http::Method::PUT, &default_uri, &headers)
.expect("route should classify");
assert_eq!(default.route_family.as_deref(), Some("users_manage"));
assert_eq!(
default.route_kind.as_deref(),
Some("set_default_user_group")
);
assert_eq!(
default.auth_endpoint_signature.as_deref(),
Some("admin:users")
);
}
#[test]
fn admin_user_group_write_routes_buffer_request_body() {
let headers = headers(&[]);
let routes = [
(http::Method::POST, "/api/admin/user-groups"),
(http::Method::PUT, "/api/admin/user-groups/group-1"),
(http::Method::PUT, "/api/admin/user-groups/group-1/members"),
(http::Method::PUT, "/api/admin/user-groups/default"),
];
for (method, path) in routes {
let uri: Uri = path.parse().expect("uri should parse");
let decision =
classify_control_route(&method, &uri, &headers).expect("route should classify");
let context = GatewayPublicRequestContext::from_request_parts(
"trace-user-group-write",
&method,
&uri,
&headers,
Some(decision),
);
assert!(
local_proxy_route_requires_buffered_body(&context),
"{method} {path} should buffer request body"
);
}
}
#[test]
fn classifies_admin_user_detail_routes_as_admin_proxy_route() {
let headers = headers(&[]);

View File

@@ -86,6 +86,139 @@ impl GatewayDataState {
}
}
pub(crate) async fn list_user_groups(
&self,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.list_user_groups().await,
None => Ok(Vec::new()),
}
}
pub(crate) async fn find_user_group_by_id(
&self,
group_id: &str,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.find_user_group_by_id(group_id).await,
None => Ok(None),
}
}
pub(crate) async fn list_user_groups_by_ids(
&self,
group_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.list_user_groups_by_ids(group_ids).await,
None => Ok(Vec::new()),
}
}
pub(crate) async fn create_user_group(
&self,
record: aether_data::repository::users::UpsertUserGroupRecord,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.create_user_group(record).await,
None => Ok(None),
}
}
pub(crate) async fn update_user_group(
&self,
group_id: &str,
record: aether_data::repository::users::UpsertUserGroupRecord,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.update_user_group(group_id, record).await,
None => Ok(None),
}
}
pub(crate) async fn delete_user_group(&self, group_id: &str) -> Result<bool, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.delete_user_group(group_id).await,
None => Ok(false),
}
}
pub(crate) async fn list_user_group_members(
&self,
group_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMember>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.list_user_group_members(group_id).await,
None => Ok(Vec::new()),
}
}
pub(crate) async fn replace_user_group_members(
&self,
group_id: &str,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMember>, DataLayerError> {
match &self.user_reader {
Some(repository) => {
repository
.replace_user_group_members(group_id, user_ids)
.await
}
None => Ok(Vec::new()),
}
}
pub(crate) async fn list_user_groups_for_user(
&self,
user_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.list_user_groups_for_user(user_id).await,
None => Ok(Vec::new()),
}
}
pub(crate) async fn list_user_group_memberships_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMembership>, DataLayerError>
{
match &self.user_reader {
Some(repository) => {
repository
.list_user_group_memberships_by_user_ids(user_ids)
.await
}
None => Ok(Vec::new()),
}
}
pub(crate) async fn replace_user_groups_for_user(
&self,
user_id: &str,
group_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, DataLayerError> {
match &self.user_reader {
Some(repository) => {
repository
.replace_user_groups_for_user(user_id, group_ids)
.await
}
None => Ok(Vec::new()),
}
}
pub(crate) async fn add_user_to_group(
&self,
group_id: &str,
user_id: &str,
) -> Result<bool, DataLayerError> {
match &self.user_reader {
Some(repository) => repository.add_user_to_group(group_id, user_id).await,
None => Ok(false),
}
}
pub(crate) async fn list_user_oauth_links(
&self,
user_id: &str,
@@ -424,6 +557,28 @@ impl GatewayDataState {
.await
}
pub(crate) async fn update_local_auth_user_policy_modes(
&self,
user_id: &str,
allowed_providers_mode: Option<String>,
allowed_api_formats_mode: Option<String>,
allowed_models_mode: Option<String>,
rate_limit_mode: Option<String>,
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
let Some(repository) = self.user_reader.as_ref() else {
return Ok(None);
};
repository
.update_local_auth_user_policy_modes(
user_id,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
)
.await
}
pub(crate) async fn touch_auth_user_last_login(
&self,
user_id: &str,
@@ -1470,13 +1625,14 @@ impl GatewayDataState {
api_key_id: &str,
now_unix_secs: u64,
) -> Result<Option<GatewayAuthApiKeySnapshot>, DataLayerError> {
read_resolved_auth_api_key_snapshot_by_user_api_key_ids(
let snapshot = read_resolved_auth_api_key_snapshot_by_user_api_key_ids(
self,
user_id,
api_key_id,
now_unix_secs,
)
.await
.await?;
self.apply_user_group_effective_policies(snapshot).await
}
pub(crate) async fn read_auth_api_key_snapshot_by_key_hash(
@@ -1484,7 +1640,148 @@ impl GatewayDataState {
key_hash: &str,
now_unix_secs: u64,
) -> Result<Option<GatewayAuthApiKeySnapshot>, DataLayerError> {
read_resolved_auth_api_key_snapshot_by_key_hash(self, key_hash, now_unix_secs).await
let snapshot =
read_resolved_auth_api_key_snapshot_by_key_hash(self, key_hash, now_unix_secs).await?;
self.apply_user_group_effective_policies(snapshot).await
}
async fn apply_user_group_effective_policies(
&self,
snapshot: Option<GatewayAuthApiKeySnapshot>,
) -> Result<Option<GatewayAuthApiKeySnapshot>, DataLayerError> {
let Some(mut snapshot) = snapshot else {
return Ok(None);
};
let Some(repository) = self.user_reader.as_ref() else {
return Ok(Some(snapshot));
};
let Some(user) = repository.find_user_auth_by_id(&snapshot.user_id).await? else {
return Ok(Some(snapshot));
};
let export_row = repository.find_export_user_by_id(&snapshot.user_id).await?;
let mut groups = repository
.list_user_groups_for_user(&snapshot.user_id)
.await?;
groups.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
let allowed_providers = resolve_effective_list_policy(
user.allowed_providers,
&user.allowed_providers_mode,
&groups,
|group| {
(
&group.allowed_providers_mode,
group.allowed_providers.clone(),
)
},
);
let allowed_api_formats = resolve_effective_list_policy(
user.allowed_api_formats,
&user.allowed_api_formats_mode,
&groups,
|group| {
(
&group.allowed_api_formats_mode,
group.allowed_api_formats.clone(),
)
},
);
let allowed_models = resolve_effective_list_policy(
user.allowed_models,
&user.allowed_models_mode,
&groups,
|group| (&group.allowed_models_mode, group.allowed_models.clone()),
);
let snapshot_user_rate_limit = snapshot.user_rate_limit;
let export_user_rate_limit = export_row.as_ref().and_then(|row| row.rate_limit);
let user_rate_limit_mode = match export_row.as_ref() {
Some(row)
if row.rate_limit.is_none()
&& row.rate_limit_mode == "system"
&& snapshot_user_rate_limit.is_some() =>
{
"custom"
}
Some(row) => row.rate_limit_mode.as_str(),
None if snapshot_user_rate_limit.is_some() => "custom",
None => "system",
};
let user_rate_limit = resolve_effective_rate_limit_policy(
export_user_rate_limit.or(snapshot_user_rate_limit),
user_rate_limit_mode,
&groups,
);
snapshot.apply_user_policy(
allowed_providers,
allowed_api_formats,
allowed_models,
user_rate_limit,
);
Ok(Some(snapshot))
}
}
fn resolve_effective_list_policy(
user_values: Option<Vec<String>>,
user_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup],
group_field: impl Fn(
&aether_data::repository::users::StoredUserGroup,
) -> (&str, Option<Vec<String>>),
) -> Option<Vec<String>> {
match user_mode {
"unrestricted" => None,
"specific" => Some(user_values.unwrap_or_default()),
"deny_all" => Some(Vec::new()),
"inherit" => groups
.iter()
.find_map(|group| {
let (mode, values) = group_field(group);
match mode {
"unrestricted" => Some(None),
"specific" => Some(Some(values.unwrap_or_default())),
"deny_all" => Some(Some(Vec::new())),
_ => None,
}
})
.flatten_or_unrestricted(),
_ => None,
}
}
trait FlattenPolicyOption<T> {
fn flatten_or_unrestricted(self) -> Option<T>;
}
impl<T> FlattenPolicyOption<T> for Option<Option<T>> {
fn flatten_or_unrestricted(self) -> Option<T> {
self.unwrap_or_default()
}
}
fn resolve_effective_rate_limit_policy(
user_rate_limit: Option<i32>,
user_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup],
) -> Option<i32> {
match user_mode {
"custom" => Some(user_rate_limit.unwrap_or(0)),
"system" => None,
"inherit" => groups
.iter()
.find_map(|group| match group.rate_limit_mode.as_str() {
"custom" => Some(Some(group.rate_limit.unwrap_or(0))),
"system" => Some(None),
_ => None,
})
.flatten_or_unrestricted(),
_ => None,
}
}

View File

@@ -63,6 +63,88 @@ impl<'a> AdminAppState<'a> {
self.app.find_user_auth_by_identifier(identifier).await
}
pub(crate) async fn list_user_groups(
&self,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app.list_user_groups().await
}
pub(crate) async fn find_user_group_by_id(
&self,
group_id: &str,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app.find_user_group_by_id(group_id).await
}
pub(crate) async fn list_user_groups_by_ids(
&self,
group_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app.list_user_groups_by_ids(group_ids).await
}
pub(crate) async fn create_user_group(
&self,
record: aether_data::repository::users::UpsertUserGroupRecord,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app.create_user_group(record).await
}
pub(crate) async fn update_user_group(
&self,
group_id: &str,
record: aether_data::repository::users::UpsertUserGroupRecord,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app.update_user_group(group_id, record).await
}
pub(crate) async fn delete_user_group(&self, group_id: &str) -> Result<bool, GatewayError> {
self.app.delete_user_group(group_id).await
}
pub(crate) async fn list_user_group_members(
&self,
group_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMember>, GatewayError> {
self.app.list_user_group_members(group_id).await
}
pub(crate) async fn replace_user_group_members(
&self,
group_id: &str,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMember>, GatewayError> {
self.app
.replace_user_group_members(group_id, user_ids)
.await
}
pub(crate) async fn list_user_groups_for_user(
&self,
user_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app.list_user_groups_for_user(user_id).await
}
pub(crate) async fn list_user_group_memberships_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMembership>, GatewayError> {
self.app
.list_user_group_memberships_by_user_ids(user_ids)
.await
}
pub(crate) async fn replace_user_groups_for_user(
&self,
user_id: &str,
group_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.app
.replace_user_groups_for_user(user_id, group_ids)
.await
}
pub(crate) async fn is_other_user_auth_email_taken(
&self,
email: &str,
@@ -187,6 +269,25 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn update_local_auth_user_policy_modes(
&self,
user_id: &str,
allowed_providers_mode: Option<String>,
allowed_api_formats_mode: Option<String>,
allowed_models_mode: Option<String>,
rate_limit_mode: Option<String>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
self.app
.update_local_auth_user_policy_modes(
user_id,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
)
.await
}
pub(crate) async fn update_auth_user_wallet_limit_mode(
&self,
user_id: &str,

View File

@@ -22,11 +22,14 @@ struct AdminUserSelectionFilters {
role: Option<String>,
#[serde(default)]
is_active: Option<bool>,
#[serde(default)]
group_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct AdminUserSelectionRequest {
user_ids: Vec<String>,
group_ids: Vec<String>,
filters: Option<AdminUserSelectionFilters>,
filters_scope_present: bool,
}
@@ -51,6 +54,7 @@ struct NormalizedAdminUserSelectionFilters {
search: Option<String>,
role: Option<String>,
is_active: Option<bool>,
group_id: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -60,12 +64,22 @@ struct AdminUserSelectionItem {
email: Option<String>,
role: String,
is_active: bool,
matched_by: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
struct AdminUserSelectionWarning {
#[serde(rename = "type")]
warning_type: String,
group_id: Option<String>,
message: String,
}
#[derive(Debug, Clone, Default)]
struct ResolvedAdminUserSelection {
items: Vec<AdminUserSelectionItem>,
missing_user_ids: Vec<String>,
warnings: Vec<AdminUserSelectionWarning>,
}
#[derive(Debug, Clone, Default)]
@@ -112,6 +126,7 @@ pub(in super::super) async fn build_admin_resolve_user_selection_response(
Ok(Json(json!({
"total": resolved.items.len(),
"items": resolved.items,
"warnings": resolved.warnings,
}))
.into_response())
}
@@ -229,6 +244,7 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
"success": success,
"failed": failed,
"failures": failures,
"warnings": resolved.warnings,
"action": request.action.trim().to_ascii_lowercase(),
"modified_fields": mutation.modified_fields,
}))
@@ -284,6 +300,11 @@ fn parse_selection_request_value(value: Value) -> Result<AdminUserSelectionReque
Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
.map_err(|_| "user_ids 必须是字符串数组".to_string())?,
};
let group_ids = match map.get("group_ids") {
None | Some(Value::Null) => Vec::new(),
Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
.map_err(|_| "group_ids 必须是字符串数组".to_string())?,
};
let (filters_scope_present, filters) = match map.get("filters") {
Some(Value::Object(_)) => {
@@ -299,6 +320,7 @@ fn parse_selection_request_value(value: Value) -> Result<AdminUserSelectionReque
Ok(AdminUserSelectionRequest {
user_ids,
group_ids,
filters,
filters_scope_present,
})
@@ -310,12 +332,36 @@ async fn resolve_admin_user_selection(
) -> 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 explicit_group_ids = normalize_user_ids(selection.group_ids);
if explicit_user_ids.is_empty()
&& explicit_group_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();
let mut warnings = Vec::new();
if !explicit_group_ids.is_empty() {
let groups = state
.list_user_groups_by_ids(&explicit_group_ids)
.await
.map_err(|_| "用户分组数据不可用".to_string())?;
let found_group_ids = groups
.iter()
.map(|group| group.id.clone())
.collect::<BTreeSet<_>>();
let missing_group_ids = explicit_group_ids
.iter()
.filter(|group_id| !found_group_ids.contains(*group_id))
.cloned()
.collect::<Vec<_>>();
if !missing_group_ids.is_empty() {
return Err(format!("用户分组不存在: {}", missing_group_ids.join(", ")));
}
}
if !explicit_user_ids.is_empty() {
let users = state
@@ -325,15 +371,14 @@ async fn resolve_admin_user_selection(
for user_id in explicit_user_ids {
match users.get(&user_id).filter(|user| !user.is_deleted) {
Some(user) => {
items_by_id.insert(
insert_or_update_selection_item(
&mut items_by_id,
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,
},
user.username.clone(),
user.email.clone(),
user.role.clone(),
user.is_active,
"direct".to_string(),
);
}
None => missing_user_ids.push(user_id),
@@ -341,24 +386,71 @@ async fn resolve_admin_user_selection(
}
}
if should_resolve_filters {
let users = state
.list_export_users()
for group_id in &explicit_group_ids {
let members = state
.list_user_group_members(group_id)
.await
.map_err(|_| "用户数据不可用".to_string())?;
.map_err(|_| "用户分组成员数据不可用".to_string())?;
let mut matched_count = 0usize;
for member in members.into_iter().filter(|member| !member.is_deleted) {
matched_count += 1;
insert_or_update_selection_item(
&mut items_by_id,
member.user_id,
member.username,
member.email,
member.role,
member.is_active,
format!("group:{group_id}"),
);
}
if matched_count == 0 {
warnings.push(AdminUserSelectionWarning {
warning_type: "empty_group".to_string(),
group_id: Some(group_id.clone()),
message: "分组内没有可操作用户".to_string(),
});
}
}
if should_resolve_filters {
let users = if filters.as_ref().is_some_and(|filters| {
filters.search.is_some()
|| filters.role.is_some()
|| filters.is_active.is_some()
|| filters.group_id.is_some()
}) {
state
.list_export_users_page(&aether_data::repository::users::UserExportListQuery {
skip: 0,
limit: 100_000,
role: filters.as_ref().and_then(|filters| filters.role.clone()),
is_active: filters.as_ref().and_then(|filters| filters.is_active),
search: filters.as_ref().and_then(|filters| filters.search.clone()),
group_id: filters
.as_ref()
.and_then(|filters| filters.group_id.clone()),
})
.await
.map_err(|_| "用户数据不可用".to_string())?
} else {
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,
},
insert_or_update_selection_item(
&mut items_by_id,
user.id,
user.username,
user.email,
user.role,
user.is_active,
"filter".to_string(),
);
}
}
@@ -374,9 +466,41 @@ async fn resolve_admin_user_selection(
Ok(ResolvedAdminUserSelection {
items,
missing_user_ids,
warnings,
})
}
fn insert_or_update_selection_item(
items_by_id: &mut BTreeMap<String, AdminUserSelectionItem>,
user_id: String,
username: String,
email: Option<String>,
role: String,
is_active: bool,
matched_by: String,
) {
match items_by_id.get_mut(&user_id) {
Some(item) => {
if !item.matched_by.iter().any(|value| value == &matched_by) {
item.matched_by.push(matched_by);
}
}
None => {
items_by_id.insert(
user_id.clone(),
AdminUserSelectionItem {
user_id,
username,
email,
role,
is_active,
matched_by: vec![matched_by],
},
);
}
}
}
fn normalize_selection_filters(
filters: Option<AdminUserSelectionFilters>,
) -> Result<Option<NormalizedAdminUserSelectionFilters>, String> {
@@ -401,6 +525,10 @@ fn normalize_selection_filters(
search,
role,
is_active: filters.is_active,
group_id: filters
.group_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
}))
}

View File

@@ -0,0 +1,457 @@
use super::{
build_admin_users_bad_request_response, build_admin_users_read_only_response,
format_optional_datetime_iso8601, normalize_admin_user_api_formats,
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,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
const DEFAULT_USER_GROUP_CONFIG_KEY: &str = "default_user_group_id";
#[derive(Debug, serde::Deserialize)]
struct AdminUserGroupPayload {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
priority: Option<i32>,
#[serde(default)]
allowed_providers: Option<Vec<String>>,
#[serde(default = "default_list_mode")]
allowed_providers_mode: String,
#[serde(default)]
allowed_api_formats: Option<Vec<String>>,
#[serde(default = "default_list_mode")]
allowed_api_formats_mode: String,
#[serde(default)]
allowed_models: Option<Vec<String>>,
#[serde(default = "default_list_mode")]
allowed_models_mode: String,
#[serde(default)]
rate_limit: Option<i32>,
#[serde(default = "default_rate_limit_mode")]
rate_limit_mode: String,
}
#[derive(Debug, serde::Deserialize)]
struct AdminUserGroupMembersPayload {
user_ids: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
struct AdminDefaultUserGroupPayload {
#[serde(default)]
group_id: Option<String>,
}
pub(in super::super) async fn build_admin_list_user_groups_response(
state: &AdminAppState<'_>,
) -> Result<Response<Body>, GatewayError> {
let default_group_id = read_default_user_group_id(state).await?;
let items = state
.list_user_groups()
.await?
.into_iter()
.map(|group| user_group_payload(group, default_group_id.as_deref()))
.collect::<Vec<_>>();
Ok(Json(json!({
"items": items,
"default_group_id": default_group_id,
}))
.into_response())
}
pub(in super::super) async fn build_admin_create_user_group_response(
state: &AdminAppState<'_>,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法创建用户分组",
));
}
let record = match parse_group_record(request_body) {
Ok(value) => value,
Err(detail) => return Ok(bad_request_owned(detail)),
};
let group = match state.create_user_group(record).await {
Ok(Some(group)) => group,
Ok(None) => {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法创建用户分组",
))
}
Err(err) if is_duplicate_group_name_error(&err) => {
return Ok(bad_request_owned("用户分组名称已存在".to_string()))
}
Err(err) => return Err(err),
};
let default_group_id = read_default_user_group_id(state).await?;
Ok(attach_admin_audit_response(
Json(user_group_payload(group, default_group_id.as_deref())).into_response(),
"admin_user_group_created",
"create_user_group",
"user_group",
"user_groups",
))
}
pub(in super::super) async fn build_admin_update_user_group_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法更新用户分组",
));
}
let Some(group_id) = user_group_id_from_path(request_context.path()) else {
return Ok(build_admin_users_bad_request_response("缺少 group_id"));
};
let record = match parse_group_record(request_body) {
Ok(value) => value,
Err(detail) => return Ok(bad_request_owned(detail)),
};
let group = match state.update_user_group(&group_id, record).await {
Ok(Some(group)) => group,
Ok(None) => return Ok(not_found("用户分组不存在")),
Err(err) if is_duplicate_group_name_error(&err) => {
return Ok(bad_request_owned("用户分组名称已存在".to_string()))
}
Err(err) => return Err(err),
};
let default_group_id = read_default_user_group_id(state).await?;
Ok(attach_admin_audit_response(
Json(user_group_payload(group, default_group_id.as_deref())).into_response(),
"admin_user_group_updated",
"update_user_group",
"user_group",
&group_id,
))
}
pub(in super::super) async fn build_admin_delete_user_group_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法删除用户分组",
));
}
let Some(group_id) = user_group_id_from_path(request_context.path()) else {
return Ok(build_admin_users_bad_request_response("缺少 group_id"));
};
if read_default_user_group_id(state).await?.as_deref() == Some(group_id.as_str()) {
state
.delete_system_config_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?;
}
if !state.delete_user_group(&group_id).await? {
return Ok(not_found("用户分组不存在"));
}
Ok(attach_admin_audit_response(
Json(json!({ "deleted": true })).into_response(),
"admin_user_group_deleted",
"delete_user_group",
"user_group",
&group_id,
))
}
pub(in super::super) async fn build_admin_list_user_group_members_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
) -> Result<Response<Body>, GatewayError> {
let Some(group_id) = user_group_member_group_id_from_path(request_context.path()) else {
return Ok(build_admin_users_bad_request_response("缺少 group_id"));
};
if state.find_user_group_by_id(&group_id).await?.is_none() {
return Ok(not_found("用户分组不存在"));
}
let items = state
.list_user_group_members(&group_id)
.await?
.into_iter()
.map(|member| {
json!({
"group_id": member.group_id,
"user_id": member.user_id,
"username": member.username,
"email": member.email,
"role": member.role,
"is_active": member.is_active,
"is_deleted": member.is_deleted,
"created_at": format_optional_datetime_iso8601(member.created_at),
})
})
.collect::<Vec<_>>();
Ok(Json(json!({ "items": items })).into_response())
}
pub(in super::super) async fn build_admin_replace_user_group_members_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法更新分组成员",
));
}
let Some(group_id) = user_group_member_group_id_from_path(request_context.path()) else {
return Ok(build_admin_users_bad_request_response("缺少 group_id"));
};
if state.find_user_group_by_id(&group_id).await?.is_none() {
return Ok(not_found("用户分组不存在"));
}
let payload = match parse_members_payload(request_body) {
Ok(value) => value,
Err(detail) => return Ok(bad_request_owned(detail)),
};
let user_ids = normalize_ids(payload.user_ids);
let known_users = state.resolve_auth_user_summaries_by_ids(&user_ids).await?;
if known_users.len() != user_ids.len() {
return Ok(bad_request_owned("成员包含不存在的用户".to_string()));
}
let items = state
.replace_user_group_members(&group_id, &user_ids)
.await?;
Ok(attach_admin_audit_response(
Json(json!({
"items": items.into_iter().map(|member| json!({
"group_id": member.group_id,
"user_id": member.user_id,
"username": member.username,
"email": member.email,
"role": member.role,
"is_active": member.is_active,
"is_deleted": member.is_deleted,
"created_at": format_optional_datetime_iso8601(member.created_at),
})).collect::<Vec<_>>()
}))
.into_response(),
"admin_user_group_members_updated",
"update_user_group_members",
"user_group",
&group_id,
))
}
pub(in super::super) async fn build_admin_set_default_user_group_response(
state: &AdminAppState<'_>,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法设置默认用户组",
));
}
let payload = match request_body {
Some(body) if !body.is_empty() => {
serde_json::from_slice::<AdminDefaultUserGroupPayload>(body)
.map_err(|_| "请求数据验证失败".to_string())
}
_ => Err("请求数据验证失败".to_string()),
};
let payload = match payload {
Ok(value) => value,
Err(detail) => return Ok(bad_request_owned(detail)),
};
let group_id = payload
.group_id
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if let Some(group_id) = group_id.as_deref() {
if state.find_user_group_by_id(group_id).await?.is_none() {
return Ok(bad_request_owned("默认用户组不存在".to_string()));
}
state
.upsert_system_config_json_value(
DEFAULT_USER_GROUP_CONFIG_KEY,
&json!(group_id),
Some("Default group for self-registered users"),
)
.await?;
} else {
state
.delete_system_config_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?;
}
Ok(Json(json!({ "default_group_id": group_id })).into_response())
}
pub(crate) async fn read_default_user_group_id(
state: &AdminAppState<'_>,
) -> Result<Option<String>, GatewayError> {
Ok(state
.read_system_config_json_value(DEFAULT_USER_GROUP_CONFIG_KEY)
.await?
.and_then(|value| value.as_str().map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()))
}
fn parse_group_record(
request_body: Option<&axum::body::Bytes>,
) -> Result<aether_data::repository::users::UpsertUserGroupRecord, String> {
let Some(body) = request_body.filter(|body| !body.is_empty()) else {
return Err("请求数据验证失败".to_string());
};
let payload = serde_json::from_slice::<AdminUserGroupPayload>(body)
.map_err(|_| "请求数据验证失败".to_string())?;
let name = aether_data::repository::users::normalize_user_group_name(&payload.name);
if name.is_empty() {
return Err("分组名称不能为空".to_string());
}
if payload.rate_limit.is_some_and(|value| value < 0) {
return Err("rate_limit 必须大于等于 0".to_string());
}
let allowed_providers =
normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers")?;
let allowed_api_formats = normalize_admin_user_api_formats(payload.allowed_api_formats)?;
let allowed_models =
normalize_admin_user_string_list(payload.allowed_models, "allowed_models")?;
Ok(aether_data::repository::users::UpsertUserGroupRecord {
name,
description: payload
.description
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
priority: payload.priority.unwrap_or_default(),
allowed_providers,
allowed_providers_mode: normalize_list_mode(&payload.allowed_providers_mode)?,
allowed_api_formats,
allowed_api_formats_mode: normalize_list_mode(&payload.allowed_api_formats_mode)?,
allowed_models,
allowed_models_mode: normalize_list_mode(&payload.allowed_models_mode)?,
rate_limit: payload.rate_limit,
rate_limit_mode: normalize_rate_mode(&payload.rate_limit_mode)?,
})
}
fn parse_members_payload(
request_body: Option<&axum::body::Bytes>,
) -> Result<AdminUserGroupMembersPayload, String> {
let Some(body) = request_body.filter(|body| !body.is_empty()) else {
return Err("请求数据验证失败".to_string());
};
serde_json::from_slice::<AdminUserGroupMembersPayload>(body)
.map_err(|_| "请求数据验证失败".to_string())
}
fn user_group_payload(
group: aether_data::repository::users::StoredUserGroup,
default_group_id: Option<&str>,
) -> serde_json::Value {
json!({
"id": group.id,
"name": group.name,
"normalized_name": group.normalized_name,
"description": group.description,
"priority": group.priority,
"allowed_providers": group.allowed_providers,
"allowed_providers_mode": group.allowed_providers_mode,
"allowed_api_formats": group.allowed_api_formats,
"allowed_api_formats_mode": group.allowed_api_formats_mode,
"allowed_models": group.allowed_models,
"allowed_models_mode": group.allowed_models_mode,
"rate_limit": group.rate_limit,
"rate_limit_mode": group.rate_limit_mode,
"is_default": default_group_id == Some(group.id.as_str()),
"created_at": format_optional_datetime_iso8601(group.created_at),
"updated_at": format_optional_datetime_iso8601(group.updated_at),
})
}
fn normalize_list_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unrestricted" | "specific" | "deny_all" => {
Ok(value.trim().to_ascii_lowercase())
}
_ => Err("权限列表模式不合法".to_string()),
}
}
fn normalize_rate_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "system" | "custom" => Ok(value.trim().to_ascii_lowercase()),
_ => Err("限速模式不合法".to_string()),
}
}
fn default_list_mode() -> String {
"inherit".to_string()
}
fn default_rate_limit_mode() -> String {
"inherit".to_string()
}
fn normalize_ids(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
}
fn user_group_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/user-groups/")?
.trim()
.trim_matches('/')
.to_string();
if value.is_empty() || value.contains('/') || value == "default" {
None
} else {
Some(value)
}
}
fn user_group_member_group_id_from_path(request_path: &str) -> Option<String> {
let value = request_path
.strip_prefix("/api/admin/user-groups/")?
.trim()
.trim_matches('/');
let group_id = value.strip_suffix("/members")?.trim_matches('/');
if group_id.is_empty() || group_id.contains('/') {
None
} else {
Some(group_id.to_string())
}
}
fn bad_request_owned(detail: String) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response()
}
fn not_found(detail: &'static str) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail })),
)
.into_response()
}
fn is_duplicate_group_name_error(err: &GatewayError) -> bool {
match err {
GatewayError::Internal(message) => message.contains("duplicate user group name"),
_ => false,
}
}

View File

@@ -1,10 +1,12 @@
use super::super::{
admin_default_user_initial_gift, build_admin_users_read_only_response,
normalize_admin_optional_user_email, normalize_admin_user_api_formats,
normalize_admin_user_role, normalize_admin_user_string_list, normalize_admin_username,
validate_admin_user_password, AdminCreateUserRequest,
legacy_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode,
normalize_admin_list_policy_mode, normalize_admin_optional_user_email,
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_user_string_list,
normalize_admin_username, validate_admin_user_password, AdminCreateUserRequest,
};
use super::support::{admin_user_password_policy, build_admin_user_payload};
use super::support::{admin_user_password_policy, build_admin_user_payload_with_groups};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::GatewayError;
@@ -136,6 +138,72 @@ pub(in super::super) async fn build_admin_create_user_response(
.into_response())
}
};
let allowed_providers_mode = match payload.allowed_providers_mode.as_deref() {
Some(value) => match normalize_admin_list_policy_mode(value) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => legacy_admin_list_policy_mode(&allowed_providers),
};
let allowed_api_formats_mode = match payload.allowed_api_formats_mode.as_deref() {
Some(value) => match normalize_admin_list_policy_mode(value) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => legacy_admin_list_policy_mode(&allowed_api_formats),
};
let allowed_models_mode = match payload.allowed_models_mode.as_deref() {
Some(value) => match normalize_admin_list_policy_mode(value) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => legacy_admin_list_policy_mode(&allowed_models),
};
let rate_limit_mode = match payload.rate_limit_mode.as_deref() {
Some(value) => match normalize_admin_rate_limit_policy_mode(value) {
Ok(value) => value,
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => legacy_admin_rate_limit_policy_mode(payload.rate_limit),
};
let group_ids = normalize_admin_user_group_ids(payload.group_ids);
let groups = if group_ids.is_empty() {
Vec::new()
} else {
let groups = state.list_user_groups_by_ids(&group_ids).await?;
if groups.len() != group_ids.len() {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "用户分组不存在" })),
)
.into_response());
}
groups
};
if let Some(email) = email.as_deref() {
if state.find_user_auth_by_identifier(email).await?.is_some() {
@@ -209,12 +277,33 @@ pub(in super::super) async fn build_admin_create_user_response(
"当前为只读模式,无法初始化用户钱包",
));
}
let Some(user) = state
.update_local_auth_user_policy_modes(
&user.id,
Some(allowed_providers_mode.clone()),
Some(allowed_api_formats_mode.clone()),
Some(allowed_models_mode.clone()),
Some(rate_limit_mode.clone()),
)
.await?
else {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法创建用户",
));
};
if !group_ids.is_empty() {
state
.replace_user_groups_for_user(&user.id, &group_ids)
.await?;
}
Ok(attach_admin_audit_response(
Json(build_admin_user_payload(
Json(build_admin_user_payload_with_groups(
&user,
payload.rate_limit,
Some(rate_limit_mode.as_str()),
payload.unlimited,
&groups,
))
.into_response(),
"admin_user_created",

View File

@@ -1,6 +1,7 @@
use super::super::{build_admin_users_bad_request_response, format_optional_datetime_iso8601};
use super::support::{
admin_user_id_from_detail_path, build_admin_user_payload, find_admin_export_user,
admin_user_id_from_detail_path, build_admin_user_export_payload,
build_admin_user_payload_with_groups, find_admin_export_user,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
@@ -32,6 +33,9 @@ pub(in super::super) async fn build_admin_list_users_response(
let search = query_param_value(request_context.query_string(), "search")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let group_id = query_param_value(request_context.query_string(), "group_id")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let paged_rows = state
.list_export_users_page(&aether_data::repository::users::UserExportListQuery {
@@ -40,16 +44,25 @@ pub(in super::super) async fn build_admin_list_users_response(
role: role.clone(),
is_active,
search,
group_id,
})
.await?;
let user_ids = paged_rows
.iter()
.map(|row| row.id.clone())
.collect::<Vec<_>>();
let (auth_rows_result, wallet_rows_result, usage_totals_result) = tokio::join!(
let (
auth_rows_result,
wallet_rows_result,
usage_totals_result,
memberships_result,
groups_result,
) = tokio::join!(
state.list_user_auth_by_ids(&user_ids),
state.list_wallet_snapshots_by_user_ids(&user_ids),
state.summarize_usage_totals_by_user_ids(&user_ids),
state.list_user_group_memberships_by_user_ids(&user_ids),
state.list_user_groups(),
);
let auth_by_user_id = auth_rows_result?
.into_iter()
@@ -63,6 +76,17 @@ pub(in super::super) async fn build_admin_list_users_response(
.into_iter()
.map(|item| (item.user_id.clone(), item))
.collect::<BTreeMap<_, _>>();
let groups_by_id = groups_result?
.into_iter()
.map(|group| (group.id.clone(), group))
.collect::<BTreeMap<_, _>>();
let mut group_ids_by_user_id = BTreeMap::<String, Vec<String>>::new();
for membership in memberships_result? {
group_ids_by_user_id
.entry(membership.user_id)
.or_default()
.push(membership.group_id);
}
let mut payload = Vec::with_capacity(paged_rows.len());
for row in paged_rows {
@@ -71,25 +95,25 @@ pub(in super::super) async fn build_admin_list_users_response(
.get(&row.id)
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
let usage_totals = usage_totals_by_user_id.get(&row.id);
payload.push(json!({
"id": row.id,
"email": row.email,
"username": row.username,
"role": row.role,
"allowed_providers": row.allowed_providers,
"allowed_api_formats": row.allowed_api_formats,
"allowed_models": row.allowed_models,
"rate_limit": row.rate_limit,
"unlimited": unlimited,
"is_active": row.is_active,
"created_at": format_optional_datetime_iso8601(auth.as_ref().and_then(|user| user.created_at)),
"updated_at": serde_json::Value::Null,
"last_login_at": format_optional_datetime_iso8601(
auth.as_ref().and_then(|user| user.last_login_at),
),
"request_count": usage_totals.map(|item| item.request_count).unwrap_or_default(),
"total_tokens": usage_totals.map(|item| item.total_tokens).unwrap_or_default(),
}));
let groups = group_ids_by_user_id
.get(&row.id)
.into_iter()
.flatten()
.filter_map(|group_id| groups_by_id.get(group_id).cloned())
.collect::<Vec<_>>();
payload.push(build_admin_user_export_payload(
&row,
unlimited,
auth.as_ref().and_then(|user| user.created_at),
auth.as_ref().and_then(|user| user.last_login_at),
usage_totals
.map(|item| item.request_count)
.unwrap_or_default(),
usage_totals
.map(|item| item.total_tokens)
.unwrap_or_default(),
&groups,
));
}
Ok(Json(payload).into_response())
@@ -116,13 +140,16 @@ pub(in super::super) async fn build_admin_get_user_response(
))
.await?;
let export_row = find_admin_export_user(state, &user_id).await?;
let groups = state.list_user_groups_for_user(&user_id).await?;
let unlimited = wallet
.as_ref()
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
Ok(Json(build_admin_user_payload(
Ok(Json(build_admin_user_payload_with_groups(
&user,
export_row.as_ref().and_then(|row| row.rate_limit),
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
unlimited,
&groups,
))
.into_response())
}

View File

@@ -36,6 +36,16 @@ pub(super) fn build_admin_user_payload(
user: &aether_data::repository::users::StoredUserAuthRecord,
rate_limit: Option<i32>,
unlimited: bool,
) -> serde_json::Value {
build_admin_user_payload_with_groups(user, rate_limit, None, unlimited, &[])
}
pub(super) fn build_admin_user_payload_with_groups(
user: &aether_data::repository::users::StoredUserAuthRecord,
rate_limit: Option<i32>,
rate_limit_mode: Option<&str>,
unlimited: bool,
groups: &[aether_data::repository::users::StoredUserGroup],
) -> serde_json::Value {
json!({
"id": user.id,
@@ -43,14 +53,233 @@ pub(super) fn build_admin_user_payload(
"username": user.username,
"role": user.role,
"allowed_providers": user.allowed_providers,
"allowed_providers_mode": user.allowed_providers_mode,
"allowed_api_formats": user.allowed_api_formats,
"allowed_api_formats_mode": user.allowed_api_formats_mode,
"allowed_models": user.allowed_models,
"allowed_models_mode": user.allowed_models_mode,
"rate_limit": rate_limit,
"rate_limit_mode": rate_limit_mode.unwrap_or("system"),
"unlimited": unlimited,
"is_active": user.is_active,
"created_at": format_optional_datetime_iso8601(user.created_at),
"updated_at": serde_json::Value::Null,
"last_login_at": format_optional_datetime_iso8601(user.last_login_at),
"groups": groups.iter().map(user_group_badge_payload).collect::<Vec<_>>(),
"effective_policy": effective_policy_payload(
user.allowed_providers.as_ref(),
&user.allowed_providers_mode,
user.allowed_api_formats.as_ref(),
&user.allowed_api_formats_mode,
user.allowed_models.as_ref(),
&user.allowed_models_mode,
rate_limit,
rate_limit_mode.unwrap_or("system"),
groups,
),
})
}
#[allow(clippy::too_many_arguments)]
pub(super) fn build_admin_user_export_payload(
row: &aether_data::repository::users::StoredUserExportRow,
unlimited: bool,
created_at: Option<chrono::DateTime<chrono::Utc>>,
last_login_at: Option<chrono::DateTime<chrono::Utc>>,
request_count: u64,
total_tokens: u64,
groups: &[aether_data::repository::users::StoredUserGroup],
) -> serde_json::Value {
json!({
"id": row.id,
"email": row.email,
"username": row.username,
"role": row.role,
"allowed_providers": row.allowed_providers,
"allowed_providers_mode": row.allowed_providers_mode,
"allowed_api_formats": row.allowed_api_formats,
"allowed_api_formats_mode": row.allowed_api_formats_mode,
"allowed_models": row.allowed_models,
"allowed_models_mode": row.allowed_models_mode,
"rate_limit": row.rate_limit,
"rate_limit_mode": row.rate_limit_mode,
"unlimited": unlimited,
"is_active": row.is_active,
"created_at": format_optional_datetime_iso8601(created_at),
"updated_at": serde_json::Value::Null,
"last_login_at": format_optional_datetime_iso8601(last_login_at),
"request_count": request_count,
"total_tokens": total_tokens,
"groups": groups.iter().map(user_group_badge_payload).collect::<Vec<_>>(),
"effective_policy": effective_policy_payload(
row.allowed_providers.as_ref(),
&row.allowed_providers_mode,
row.allowed_api_formats.as_ref(),
&row.allowed_api_formats_mode,
row.allowed_models.as_ref(),
&row.allowed_models_mode,
row.rate_limit,
&row.rate_limit_mode,
groups,
),
})
}
pub(super) fn user_group_badge_payload(
group: &aether_data::repository::users::StoredUserGroup,
) -> serde_json::Value {
json!({
"id": group.id,
"name": group.name,
"priority": group.priority,
})
}
#[allow(clippy::too_many_arguments)]
fn effective_policy_payload(
allowed_providers: Option<&Vec<String>>,
allowed_providers_mode: &str,
allowed_api_formats: Option<&Vec<String>>,
allowed_api_formats_mode: &str,
allowed_models: Option<&Vec<String>>,
allowed_models_mode: &str,
rate_limit: Option<i32>,
rate_limit_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup],
) -> serde_json::Value {
let mut sorted_groups = groups.to_vec();
sorted_groups.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
json!({
"allowed_providers": effective_list_policy_payload(
allowed_providers,
allowed_providers_mode,
&sorted_groups,
|group| (&group.allowed_providers_mode, group.allowed_providers.as_ref()),
),
"allowed_api_formats": effective_list_policy_payload(
allowed_api_formats,
allowed_api_formats_mode,
&sorted_groups,
|group| (&group.allowed_api_formats_mode, group.allowed_api_formats.as_ref()),
),
"allowed_models": effective_list_policy_payload(
allowed_models,
allowed_models_mode,
&sorted_groups,
|group| (&group.allowed_models_mode, group.allowed_models.as_ref()),
),
"rate_limit": effective_rate_limit_policy_payload(rate_limit, rate_limit_mode, &sorted_groups),
})
}
fn effective_list_policy_payload(
user_values: Option<&Vec<String>>,
user_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup],
group_field: impl Fn(
&aether_data::repository::users::StoredUserGroup,
) -> (&String, Option<&Vec<String>>),
) -> serde_json::Value {
match user_mode {
"unrestricted" => policy_payload("unrestricted", serde_json::Value::Null, "user", None),
"specific" => policy_payload(
"specific",
json!(user_values.cloned().unwrap_or_default()),
"user",
None,
),
"deny_all" => policy_payload("deny_all", json!(Vec::<String>::new()), "user", None),
"inherit" => {
for group in groups {
let (mode, values) = group_field(group);
match mode.as_str() {
"unrestricted" => {
return policy_payload(
"unrestricted",
serde_json::Value::Null,
"group",
Some(group),
)
}
"specific" => {
return policy_payload(
"specific",
json!(values.cloned().unwrap_or_default()),
"group",
Some(group),
)
}
"deny_all" => {
return policy_payload(
"deny_all",
json!(Vec::<String>::new()),
"group",
Some(group),
)
}
_ => {}
}
}
policy_payload("unrestricted", serde_json::Value::Null, "fallback", None)
}
_ => policy_payload("unrestricted", serde_json::Value::Null, "fallback", None),
}
}
fn effective_rate_limit_policy_payload(
user_rate_limit: Option<i32>,
user_mode: &str,
groups: &[aether_data::repository::users::StoredUserGroup],
) -> serde_json::Value {
match user_mode {
"custom" => policy_payload("custom", json!(user_rate_limit.unwrap_or(0)), "user", None),
"system" => policy_payload("system", serde_json::Value::Null, "user", None),
"inherit" => {
for group in groups {
match group.rate_limit_mode.as_str() {
"custom" => {
return policy_payload(
"custom",
json!(group.rate_limit.unwrap_or(0)),
"group",
Some(group),
)
}
"system" => {
return policy_payload(
"system",
serde_json::Value::Null,
"group",
Some(group),
)
}
_ => {}
}
}
policy_payload("system", serde_json::Value::Null, "fallback", None)
}
_ => policy_payload("system", serde_json::Value::Null, "fallback", None),
}
}
fn policy_payload(
mode: &str,
value: serde_json::Value,
source: &str,
group: Option<&aether_data::repository::users::StoredUserGroup>,
) -> serde_json::Value {
json!({
"mode": mode,
"value": value,
"source": source,
"group_id": group.map(|group| group.id.as_str()),
"group_name": group.map(|group| group.name.as_str()),
})
}

View File

@@ -1,12 +1,14 @@
use super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, normalize_admin_optional_user_email,
normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list,
normalize_admin_username, validate_admin_user_password, AdminUpdateUserPatch,
build_admin_users_read_only_response, normalize_admin_list_policy_mode,
normalize_admin_optional_user_email, normalize_admin_rate_limit_policy_mode,
normalize_admin_user_api_formats, normalize_admin_user_group_ids, normalize_admin_user_role,
normalize_admin_user_string_list, normalize_admin_username, validate_admin_user_password,
AdminUpdateUserPatch,
};
use super::support::{
admin_user_id_from_detail_path, admin_user_password_policy, build_admin_user_payload,
find_admin_export_user,
admin_user_id_from_detail_path, admin_user_password_policy,
build_admin_user_payload_with_groups, find_admin_export_user,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
@@ -177,15 +179,105 @@ pub(in super::super) async fn build_admin_update_user_response(
} else {
None
};
let allowed_providers_mode = if field_presence.contains("allowed_providers_mode") {
match payload.allowed_providers_mode.as_deref() {
Some(value) => match normalize_admin_list_policy_mode(value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => None,
}
} else {
None
};
let allowed_api_formats_mode = if field_presence.contains("allowed_api_formats_mode") {
match payload.allowed_api_formats_mode.as_deref() {
Some(value) => match normalize_admin_list_policy_mode(value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => None,
}
} else {
None
};
let allowed_models_mode = if field_presence.contains("allowed_models_mode") {
match payload.allowed_models_mode.as_deref() {
Some(value) => match normalize_admin_list_policy_mode(value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => None,
}
} else {
None
};
let rate_limit_mode = if field_presence.contains("rate_limit_mode") {
match payload.rate_limit_mode.as_deref() {
Some(value) => match normalize_admin_rate_limit_policy_mode(value) {
Ok(value) => Some(value),
Err(detail) => {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response())
}
},
None => None,
}
} else {
None
};
let group_ids = if field_presence.contains("group_ids") {
Some(normalize_admin_user_group_ids(payload.group_ids))
} else {
None
};
if let Some(group_ids) = group_ids.as_ref() {
if !group_ids.is_empty() {
let groups = state.list_user_groups_by_ids(group_ids).await?;
if groups.len() != group_ids.len() {
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "用户分组不存在" })),
)
.into_response());
}
}
}
let needs_auth_user_write = email.is_some()
|| username.is_some()
|| payload.password.is_some()
|| role.is_some()
|| field_presence.contains("allowed_providers")
|| allowed_providers_mode.is_some()
|| field_presence.contains("allowed_api_formats")
|| allowed_api_formats_mode.is_some()
|| field_presence.contains("allowed_models")
|| allowed_models_mode.is_some()
|| field_presence.contains("rate_limit")
|| payload.is_active.is_some();
|| rate_limit_mode.is_some()
|| payload.is_active.is_some()
|| group_ids.is_some();
if needs_auth_user_write && !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法更新用户",
@@ -210,6 +302,34 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response());
}
}
if allowed_providers_mode.is_some()
|| allowed_api_formats_mode.is_some()
|| allowed_models_mode.is_some()
|| rate_limit_mode.is_some()
{
if state
.update_local_auth_user_policy_modes(
&user_id,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
)
.await?
.is_none()
{
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "用户不存在" })),
)
.into_response());
}
}
if let Some(group_ids) = group_ids.as_ref() {
state
.replace_user_groups_for_user(&user_id, group_ids)
.await?;
}
if let Some(password) = payload.password.as_deref() {
let password_policy = admin_user_password_policy(state).await?;
@@ -322,13 +442,21 @@ pub(in super::super) async fn build_admin_update_user_response(
.as_ref()
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
let export_row = find_admin_export_user(state, &user_id).await?;
let groups = state.list_user_groups_for_user(&user_id).await?;
let rate_limit = export_row
.as_ref()
.and_then(|row| row.rate_limit)
.or(payload.rate_limit);
Ok(attach_admin_audit_response(
Json(build_admin_user_payload(&user, rate_limit, unlimited)).into_response(),
Json(build_admin_user_payload_with_groups(
&user,
rate_limit,
export_row.as_ref().map(|row| row.rate_limit_mode.as_str()),
unlimited,
&groups,
))
.into_response(),
"admin_user_updated",
"update_user",
"user",

View File

@@ -4,6 +4,7 @@ const ADMIN_USERS_DATA_UNAVAILABLE_DETAIL: &str = "Admin user management data un
mod api_keys;
mod batch;
mod groups;
mod lifecycle;
mod route_seam;
mod routes;
@@ -23,6 +24,12 @@ pub(crate) use self::api_keys::{
use self::batch::{
build_admin_resolve_user_selection_response, build_admin_user_batch_action_response,
};
use self::groups::{
build_admin_create_user_group_response, build_admin_delete_user_group_response,
build_admin_list_user_group_members_response, build_admin_list_user_groups_response,
build_admin_replace_user_group_members_response, build_admin_set_default_user_group_response,
build_admin_update_user_group_response,
};
use self::lifecycle::{
build_admin_create_user_response, build_admin_delete_user_response,
build_admin_get_user_response, build_admin_list_users_response,
@@ -36,10 +43,12 @@ use self::shared::AdminUpdateUserPatch;
use self::shared::{
admin_default_user_initial_gift, build_admin_users_bad_request_response,
build_admin_users_data_unavailable_response, build_admin_users_read_only_response,
format_optional_datetime_iso8601, normalize_admin_optional_user_email,
normalize_admin_user_role, normalize_admin_username, validate_admin_user_password,
AdminCreateUserApiKeyRequest, AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest,
AdminUpdateUserApiKeyRequest,
format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
legacy_admin_rate_limit_policy_mode, normalize_admin_list_policy_mode,
normalize_admin_optional_user_email, normalize_admin_rate_limit_policy_mode,
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
validate_admin_user_password, AdminCreateUserApiKeyRequest, AdminCreateUserRequest,
AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
};
pub(crate) use self::shared::{normalize_admin_user_api_formats, normalize_admin_user_string_list};

View File

@@ -1,13 +1,16 @@
use super::{
build_admin_create_user_api_key_response, build_admin_create_user_response,
build_admin_delete_user_api_key_response, build_admin_delete_user_response,
build_admin_create_user_api_key_response, build_admin_create_user_group_response,
build_admin_create_user_response, build_admin_delete_user_api_key_response,
build_admin_delete_user_group_response, build_admin_delete_user_response,
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_group_members_response, build_admin_list_user_groups_response,
build_admin_list_user_sessions_response, build_admin_list_users_response,
build_admin_resolve_user_selection_response, build_admin_reveal_user_api_key_response,
build_admin_replace_user_group_members_response, build_admin_resolve_user_selection_response,
build_admin_reveal_user_api_key_response, build_admin_set_default_user_group_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,
build_admin_update_user_group_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};
use crate::GatewayError;
@@ -15,8 +18,27 @@ use axum::{body::Body, http, response::Response};
fn is_admin_users_route(request_context: &AdminRequestContext<'_>) -> bool {
let path = request_context.path();
(request_context.method() == http::Method::GET
&& matches!(path, "/api/admin/users" | "/api/admin/users/"))
((request_context.method() == http::Method::GET
|| request_context.method() == http::Method::POST)
&& matches!(path, "/api/admin/user-groups" | "/api/admin/user-groups/"))
|| (request_context.method() == http::Method::PUT
&& matches!(
path,
"/api/admin/user-groups/default" | "/api/admin/user-groups/default/"
))
|| ((request_context.method() == http::Method::PUT
|| request_context.method() == http::Method::DELETE)
&& path.starts_with("/api/admin/user-groups/")
&& path.matches('/').count() == 4
&& !path.ends_with("/members")
&& !path.ends_with("/default"))
|| ((request_context.method() == http::Method::GET
|| request_context.method() == http::Method::PUT)
&& path.starts_with("/api/admin/user-groups/")
&& path.ends_with("/members")
&& path.matches('/').count() == 5)
|| (request_context.method() == http::Method::GET
&& 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
@@ -84,6 +106,26 @@ pub(super) async fn maybe_build_local_admin_users_routes_response(
}
match decision.route_kind.as_deref() {
Some("list_user_groups") => Ok(Some(build_admin_list_user_groups_response(state).await?)),
Some("create_user_group") => Ok(Some(
build_admin_create_user_group_response(state, request_body).await?,
)),
Some("update_user_group") => Ok(Some(
build_admin_update_user_group_response(state, request_context, request_body).await?,
)),
Some("delete_user_group") => Ok(Some(
build_admin_delete_user_group_response(state, request_context).await?,
)),
Some("list_user_group_members") => Ok(Some(
build_admin_list_user_group_members_response(state, request_context).await?,
)),
Some("replace_user_group_members") => Ok(Some(
build_admin_replace_user_group_members_response(state, request_context, request_body)
.await?,
)),
Some("set_default_user_group") => Ok(Some(
build_admin_set_default_user_group_response(state, request_body).await?,
)),
Some("create_user") => Ok(Some(
build_admin_create_user_response(state, request_context, request_body).await?,
)),

View File

@@ -68,11 +68,21 @@ pub(super) struct AdminCreateUserRequest {
#[serde(default)]
pub(super) allowed_providers: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_providers_mode: Option<String>,
#[serde(default)]
pub(super) allowed_api_formats: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_api_formats_mode: Option<String>,
#[serde(default)]
pub(super) allowed_models: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_models_mode: Option<String>,
#[serde(default)]
pub(super) rate_limit: Option<i32>,
#[serde(default)]
pub(super) rate_limit_mode: Option<String>,
#[serde(default)]
pub(super) group_ids: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
@@ -90,12 +100,22 @@ pub(super) struct AdminUpdateUserRequest {
#[serde(default)]
pub(super) allowed_providers: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_providers_mode: Option<String>,
#[serde(default)]
pub(super) allowed_api_formats: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_api_formats_mode: Option<String>,
#[serde(default)]
pub(super) allowed_models: Option<Vec<String>>,
#[serde(default)]
pub(super) allowed_models_mode: Option<String>,
#[serde(default)]
pub(super) rate_limit: Option<i32>,
#[serde(default)]
pub(super) rate_limit_mode: Option<String>,
#[serde(default)]
pub(super) group_ids: Vec<String>,
#[serde(default)]
pub(super) is_active: Option<bool>,
}
@@ -263,6 +283,48 @@ pub(crate) fn normalize_admin_user_api_formats(
Ok(Some(normalized))
}
pub(super) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unrestricted" | "specific" | "deny_all" => {
Ok(value.trim().to_ascii_lowercase())
}
_ => Err("权限列表模式不合法".to_string()),
}
}
pub(super) fn normalize_admin_rate_limit_policy_mode(value: &str) -> Result<String, String> {
match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "system" | "custom" => Ok(value.trim().to_ascii_lowercase()),
_ => Err("限速模式不合法".to_string()),
}
}
pub(super) fn legacy_admin_list_policy_mode(values: &Option<Vec<String>>) -> String {
if values.is_some() {
"specific".to_string()
} else {
"unrestricted".to_string()
}
}
pub(super) fn legacy_admin_rate_limit_policy_mode(value: Option<i32>) -> String {
if value.is_some() {
"custom".to_string()
} else {
"system".to_string()
}
}
pub(super) fn normalize_admin_user_group_ids(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
}
pub(super) fn admin_default_user_initial_gift(value: Option<&serde_json::Value>) -> f64 {
match value {
Some(serde_json::Value::Number(number)) => number.as_f64().unwrap_or(10.0),

View File

@@ -513,6 +513,17 @@ pub(super) async fn handle_auth_register(
false,
);
};
if let Err(err) = state
.assign_default_group_to_self_registered_user(&user.id)
.await
{
let _ = state.delete_local_auth_user(&user.id).await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("auth default user group assignment failed: {err:?}"),
false,
);
}
if require_verification {
if let Some(email) = email.as_deref() {

View File

@@ -325,6 +325,13 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (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_group"))
| (Some("users_manage"), http::Method::PUT, Some("update_user_group"))
| (
Some("users_manage"),
http::Method::PUT,
Some("replace_user_group_members" | "set_default_user_group"),
)
| (Some("users_manage"), http::Method::POST, Some("create_user_api_key"))
| (Some("users_manage"), http::Method::PUT, Some("update_user_api_key"))
| (Some("users_manage"), http::Method::PATCH, Some("lock_user_api_key"))

View File

@@ -211,6 +211,13 @@ pub(crate) async fn resolve_identity_oauth_login_user(
return Err(IdentityOAuthAccountError::Storage(format!("{err:?}")));
}
}
if let Err(err) = state
.assign_default_group_to_self_registered_user(&user.id)
.await
{
let _ = state.delete_local_auth_user(&user.id).await;
return Err(IdentityOAuthAccountError::Storage(format!("{err:?}")));
}
if let Err(err) = upsert_oauth_link(state, &user.id, claims, now).await {
let _ = state.delete_local_auth_user(&user.id).await;
return Err(err);

View File

@@ -3,6 +3,32 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::{AppState, GatewayError};
impl AppState {
pub(crate) async fn assign_default_group_to_self_registered_user(
&self,
user_id: &str,
) -> Result<(), GatewayError> {
let group_id = self
.read_system_config_json_value("default_user_group_id")
.await?
.and_then(|value| value.as_str().map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let Some(group_id) = group_id else {
return Ok(());
};
if self.find_user_group_by_id(&group_id).await?.is_none() {
return Err(GatewayError::Internal(format!(
"default_user_group_id points to missing group: {group_id}"
)));
}
if !self.add_user_to_group(&group_id, user_id).await? {
return Err(GatewayError::Internal(format!(
"failed to add user {user_id} to default group {group_id}"
)));
}
Ok(())
}
pub(crate) async fn resolve_auth_user_summaries_by_ids(
&self,
user_ids: &[String],
@@ -132,6 +158,126 @@ impl AppState {
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_groups(
&self,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.list_user_groups()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn find_user_group_by_id(
&self,
group_id: &str,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.find_user_group_by_id(group_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_groups_by_ids(
&self,
group_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.list_user_groups_by_ids(group_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn create_user_group(
&self,
record: aether_data::repository::users::UpsertUserGroupRecord,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.create_user_group(record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_user_group(
&self,
group_id: &str,
record: aether_data::repository::users::UpsertUserGroupRecord,
) -> Result<Option<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.update_user_group(group_id, record)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn delete_user_group(&self, group_id: &str) -> Result<bool, GatewayError> {
self.data
.delete_user_group(group_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_group_members(
&self,
group_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMember>, GatewayError> {
self.data
.list_user_group_members(group_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn replace_user_group_members(
&self,
group_id: &str,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMember>, GatewayError> {
self.data
.replace_user_group_members(group_id, user_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_groups_for_user(
&self,
user_id: &str,
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.list_user_groups_for_user(user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn list_user_group_memberships_by_user_ids(
&self,
user_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroupMembership>, GatewayError> {
self.data
.list_user_group_memberships_by_user_ids(user_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn replace_user_groups_for_user(
&self,
user_id: &str,
group_ids: &[String],
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
self.data
.replace_user_groups_for_user(user_id, group_ids)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn add_user_to_group(
&self,
group_id: &str,
user_id: &str,
) -> Result<bool, GatewayError> {
self.data
.add_user_to_group(group_id, user_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn is_other_user_auth_email_taken(
&self,
email: &str,
@@ -289,6 +435,12 @@ impl AppState {
Some(now),
None,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?
.with_policy_modes(
"inherit".to_string(),
"inherit".to_string(),
"inherit".to_string(),
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
store
.lock()
@@ -418,6 +570,45 @@ impl AppState {
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn update_local_auth_user_policy_modes(
&self,
user_id: &str,
allowed_providers_mode: Option<String>,
allowed_api_formats_mode: Option<String>,
allowed_models_mode: Option<String>,
rate_limit_mode: Option<String>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_user_store.as_ref() {
let mut guard = store.lock().expect("auth user store should lock");
let Some(user) = guard.get_mut(user_id) else {
return Ok(None);
};
if let Some(mode) = allowed_providers_mode {
user.allowed_providers_mode = mode;
}
if let Some(mode) = allowed_api_formats_mode {
user.allowed_api_formats_mode = mode;
}
if let Some(mode) = allowed_models_mode {
user.allowed_models_mode = mode;
}
let _ = rate_limit_mode;
return Ok(Some(user.clone()));
}
self.data
.update_local_auth_user_policy_modes(
user_id,
allowed_providers_mode,
allowed_api_formats_mode,
allowed_models_mode,
rate_limit_mode,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn touch_auth_user_last_login(
&self,
user_id: &str,
@@ -508,6 +699,12 @@ impl AppState {
Some(now),
None,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?
.with_policy_modes(
"inherit".to_string(),
"inherit".to_string(),
"inherit".to_string(),
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let gift_balance = if unlimited {
0.0

View File

@@ -2927,8 +2927,11 @@ async fn gateway_handles_admin_usage_cache_affinity_interval_timeline_with_legac
role: "user".to_string(),
auth_source: "local".to_string(),
allowed_providers: None,
allowed_providers_mode: "unrestricted".to_string(),
allowed_api_formats: None,
allowed_api_formats_mode: "unrestricted".to_string(),
allowed_models: None,
allowed_models_mode: "unrestricted".to_string(),
is_active: true,
is_deleted: false,
created_at: None,

View File

@@ -377,7 +377,10 @@ async fn embeddings_route_rejects_chat_only_model() {
Some(EXECUTION_PATH_LOCAL_AUTH_DENIED)
);
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(payload["error"]["message"], "当前密钥不允许访问模型 gpt-5");
assert_eq!(
payload["error"]["message"],
"当前用户、用户组或密钥的访问控制策略不允许访问模型 gpt-5"
);
gateway_handle.abort();
}
@@ -416,7 +419,7 @@ async fn embeddings_route_rejects_chat_only_api_format() {
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(
payload["error"]["message"],
"当前密钥不允许访问 openai:embedding 格式"
"当前用户、用户组或密钥的访问控制策略不允许访问 openai:embedding 格式"
);
gateway_handle.abort();

View File

@@ -501,7 +501,7 @@ async fn gateway_locally_denies_disallowed_claude_api_format_without_hitting_con
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(
payload["error"]["message"],
"当前密钥不允许访问 claude:messages 格式"
"当前用户、用户组或密钥的访问控制策略不允许访问 claude:messages 格式"
);
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
@@ -584,7 +584,7 @@ async fn gateway_locally_denies_disallowed_provider_without_hitting_control_or_u
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(
payload["error"]["message"],
"当前密钥不允许访问 claude 提供商"
"当前用户、用户组或密钥的访问控制策略不允许访问 claude 提供商"
);
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
@@ -656,7 +656,7 @@ async fn gateway_locally_denies_disallowed_gemini_model_without_hitting_control_
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(
payload["error"]["message"],
"当前密钥不允许访问模型 gemini-2.5-pro"
"当前用户、用户组或密钥的访问控制策略不允许访问模型 gemini-2.5-pro"
);
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
@@ -806,7 +806,10 @@ async fn gateway_locally_denies_disallowed_openai_model_without_hitting_control_
);
let payload: serde_json::Value = response.json().await.expect("response json should parse");
assert_eq!(payload["error"]["type"], "http_error");
assert_eq!(payload["error"]["message"], "当前密钥不允许访问模型 gpt-5");
assert_eq!(
payload["error"]["message"],
"当前用户、用户组或密钥的访问控制策略不允许访问模型 gpt-5"
);
assert_eq!(*auth_context_hits.lock().expect("mutex should lock"), 0);
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);

View File

@@ -319,7 +319,7 @@ async fn rerank_route_rejects_chat_only_api_format() {
let payload: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(
payload["error"]["message"],
"当前密钥不允许访问 openai:rerank 格式"
"当前用户、用户组或密钥的访问控制策略不允许访问 openai:rerank 格式"
);
gateway_handle.abort();