mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: add user groups and inherited access policies
This commit is contained in:
@@ -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}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
{
|
||||
|
||||
@@ -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(&[]);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
457
apps/aether-gateway/src/handlers/admin/users/groups.rs
Normal file
457
apps/aether-gateway/src/handlers/admin/users/groups.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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?,
|
||||
)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user