mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge pull request #448 from RWDai/fix/disable-hidden-user-policies
Disable hidden per-user policy fields
This commit is contained in:
@@ -1667,7 +1667,6 @@ impl GatewayDataState {
|
||||
apply_admin_unrestricted_auth_snapshot(&mut snapshot);
|
||||
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?;
|
||||
@@ -1677,53 +1676,25 @@ impl GatewayDataState {
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
|
||||
let mut allowed_providers = resolve_effective_list_policy(
|
||||
user.allowed_providers,
|
||||
&user.allowed_providers_mode,
|
||||
&groups,
|
||||
|group| {
|
||||
let mut allowed_providers =
|
||||
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
||||
(
|
||||
&group.allowed_providers_mode,
|
||||
group.allowed_providers.clone(),
|
||||
)
|
||||
},
|
||||
);
|
||||
let mut allowed_api_formats = resolve_effective_list_policy(
|
||||
user.allowed_api_formats,
|
||||
&user.allowed_api_formats_mode,
|
||||
&groups,
|
||||
|group| {
|
||||
});
|
||||
let mut allowed_api_formats =
|
||||
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
||||
(
|
||||
&group.allowed_api_formats_mode,
|
||||
group.allowed_api_formats.clone(),
|
||||
)
|
||||
},
|
||||
);
|
||||
let mut 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,
|
||||
);
|
||||
});
|
||||
let mut allowed_models =
|
||||
resolve_effective_list_policy(None, "unrestricted", &groups, |group| {
|
||||
(&group.allowed_models_mode, group.allowed_models.clone())
|
||||
});
|
||||
let user_rate_limit = resolve_effective_rate_limit_policy(None, "system", &groups);
|
||||
if !snapshot.api_key_is_standalone {
|
||||
constrain_api_key_list_policy_to_user_policy(
|
||||
&mut allowed_providers,
|
||||
@@ -2160,6 +2131,65 @@ mod tests {
|
||||
assert_eq!(resolved.api_key_concurrent_limit, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_personal_policy_fields_are_ignored_when_groups_are_applied() {
|
||||
let mut snapshot = sample_snapshot("key-user", "user-1").with_user_rate_limit(Some(200));
|
||||
snapshot.api_key_allowed_providers = None;
|
||||
snapshot.api_key_allowed_api_formats = None;
|
||||
snapshot.api_key_allowed_models = None;
|
||||
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some("hash-user".to_string()),
|
||||
snapshot,
|
||||
)]));
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_auth_user("user-1", "user"),
|
||||
]));
|
||||
let group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "Group Policy".to_string(),
|
||||
description: None,
|
||||
priority: 10,
|
||||
allowed_providers: Some(vec!["anthropic".to_string()]),
|
||||
allowed_providers_mode: "specific".to_string(),
|
||||
allowed_api_formats: Some(vec!["claude:messages".to_string()]),
|
||||
allowed_api_formats_mode: "specific".to_string(),
|
||||
allowed_models: Some(vec!["claude-sonnet-4-5".to_string()]),
|
||||
allowed_models_mode: "specific".to_string(),
|
||||
rate_limit: Some(30),
|
||||
rate_limit_mode: "custom".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("group should create")
|
||||
.expect("group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&group.id, "user-1")
|
||||
.await
|
||||
.expect("group membership should create");
|
||||
|
||||
let state = GatewayDataState::with_auth_api_key_reader_for_tests(auth_repository)
|
||||
.with_user_reader(user_repository);
|
||||
let resolved = state
|
||||
.read_auth_api_key_snapshot_by_key_hash("hash-user", 100)
|
||||
.await
|
||||
.expect("snapshot should resolve")
|
||||
.expect("snapshot should exist");
|
||||
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_providers(),
|
||||
Some(&["anthropic".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_api_formats(),
|
||||
Some(&["claude:messages".to_string()][..])
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.effective_allowed_models(),
|
||||
Some(&["claude-sonnet-4-5".to_string()][..])
|
||||
);
|
||||
assert_eq!(resolved.user_rate_limit, Some(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_state_lists_auth_api_key_export_records() {
|
||||
let repository = Arc::new(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_read_only_response,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_role, normalize_admin_user_string_list,
|
||||
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_user_role,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
@@ -85,14 +85,6 @@ struct ResolvedAdminUserSelection {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct AdminUserBatchMutation {
|
||||
role: Option<String>,
|
||||
allowed_providers_present: bool,
|
||||
allowed_providers: Option<Vec<String>>,
|
||||
allowed_api_formats_present: bool,
|
||||
allowed_api_formats: Option<Vec<String>>,
|
||||
allowed_models_present: bool,
|
||||
allowed_models: Option<Vec<String>>,
|
||||
rate_limit_present: bool,
|
||||
rate_limit: Option<i32>,
|
||||
is_active: Option<bool>,
|
||||
unlimited: Option<bool>,
|
||||
modified_fields: Vec<&'static str>,
|
||||
@@ -100,12 +92,7 @@ struct AdminUserBatchMutation {
|
||||
|
||||
impl AdminUserBatchMutation {
|
||||
fn has_auth_user_fields(&self) -> bool {
|
||||
self.role.is_some()
|
||||
|| self.allowed_providers_present
|
||||
|| self.allowed_api_formats_present
|
||||
|| self.allowed_models_present
|
||||
|| self.rate_limit_present
|
||||
|| self.is_active.is_some()
|
||||
self.role.is_some() || self.is_active.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,14 +201,14 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
.update_local_auth_user_admin_fields(
|
||||
&item.user_id,
|
||||
mutation.role.clone(),
|
||||
mutation.allowed_providers_present,
|
||||
mutation.allowed_providers.clone(),
|
||||
mutation.allowed_api_formats_present,
|
||||
mutation.allowed_api_formats.clone(),
|
||||
mutation.allowed_models_present,
|
||||
mutation.allowed_models.clone(),
|
||||
mutation.rate_limit_present,
|
||||
mutation.rate_limit,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
mutation.is_active,
|
||||
)
|
||||
.await?
|
||||
@@ -632,25 +619,8 @@ fn parse_access_control_mutation(payload: Option<Value>) -> Result<AdminUserBatc
|
||||
};
|
||||
let mut mutation = AdminUserBatchMutation::default();
|
||||
|
||||
if let Some(value) = payload.get("allowed_providers") {
|
||||
mutation.allowed_providers_present = true;
|
||||
mutation.allowed_providers = parse_optional_string_list(value, "allowed_providers")?;
|
||||
mutation.modified_fields.push("allowed_providers");
|
||||
}
|
||||
if let Some(value) = payload.get("allowed_api_formats") {
|
||||
mutation.allowed_api_formats_present = true;
|
||||
mutation.allowed_api_formats = parse_optional_api_formats(value)?;
|
||||
mutation.modified_fields.push("allowed_api_formats");
|
||||
}
|
||||
if let Some(value) = payload.get("allowed_models") {
|
||||
mutation.allowed_models_present = true;
|
||||
mutation.allowed_models = parse_optional_string_list(value, "allowed_models")?;
|
||||
mutation.modified_fields.push("allowed_models");
|
||||
}
|
||||
if let Some(value) = payload.get("rate_limit") {
|
||||
mutation.rate_limit_present = true;
|
||||
mutation.rate_limit = parse_optional_rate_limit(value)?;
|
||||
mutation.modified_fields.push("rate_limit");
|
||||
if let Some(field) = disabled_user_policy_field(&payload) {
|
||||
return Err(disabled_user_policy_detail(field));
|
||||
}
|
||||
if let Some(value) = payload.get("unlimited") {
|
||||
mutation.unlimited = Some(parse_unlimited(value)?);
|
||||
@@ -664,39 +634,6 @@ fn parse_access_control_mutation(payload: Option<Value>) -> Result<AdminUserBatc
|
||||
Ok(mutation)
|
||||
}
|
||||
|
||||
fn parse_optional_string_list(
|
||||
value: &Value,
|
||||
field_name: &str,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let values = serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| format!("{field_name} 必须是字符串数组或 null"))?;
|
||||
normalize_admin_user_string_list(Some(values), field_name)
|
||||
}
|
||||
|
||||
fn parse_optional_api_formats(value: &Value) -> Result<Option<Vec<String>>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let values = serde_json::from_value::<Vec<String>>(value.clone())
|
||||
.map_err(|_| "allowed_api_formats 必须是字符串数组或 null".to_string())?;
|
||||
normalize_admin_user_api_formats(Some(values))
|
||||
}
|
||||
|
||||
fn parse_optional_rate_limit(value: &Value) -> Result<Option<i32>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rate_limit = serde_json::from_value::<i32>(value.clone())
|
||||
.map_err(|_| "rate_limit 必须是整数或 null".to_string())?;
|
||||
if rate_limit < 0 {
|
||||
return Err("rate_limit 必须大于等于 0".to_string());
|
||||
}
|
||||
Ok(Some(rate_limit))
|
||||
}
|
||||
|
||||
fn parse_unlimited(value: &Value) -> Result<bool, String> {
|
||||
serde_json::from_value::<bool>(value.clone()).map_err(|_| "unlimited 必须是布尔值".to_string())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use super::super::{
|
||||
admin_default_user_initial_gift, build_admin_users_read_only_response,
|
||||
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,
|
||||
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_optional_user_email,
|
||||
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
|
||||
validate_admin_user_password, AdminCreateUserRequest,
|
||||
};
|
||||
use super::support::{admin_user_password_policy, build_admin_user_payload_with_groups};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
@@ -16,7 +14,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(in super::super) async fn build_admin_create_user_response(
|
||||
state: &AdminAppState<'_>,
|
||||
@@ -40,7 +38,25 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
let payload = match serde_json::from_slice::<AdminCreateUserRequest>(request_body) {
|
||||
let raw_payload = match serde_json::from_slice::<Value>(request_body) {
|
||||
Ok(Value::Object(map)) => map,
|
||||
_ => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "请求数据验证失败" })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
};
|
||||
if let Some(field) = disabled_user_policy_field(&raw_payload) {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": disabled_user_policy_detail(field) })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let payload = match serde_json::from_value::<AdminCreateUserRequest>(Value::Object(raw_payload))
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok((
|
||||
@@ -89,13 +105,6 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
if payload.rate_limit.is_some_and(|value| value < 0) {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "rate_limit 必须大于等于 0" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
if payload
|
||||
.initial_gift_usd
|
||||
.is_some_and(|value| !value.is_finite() || !(0.0..=10000.0).contains(&value))
|
||||
@@ -106,90 +115,6 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let allowed_providers =
|
||||
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
};
|
||||
let allowed_api_formats = match normalize_admin_user_api_formats(payload.allowed_api_formats) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
};
|
||||
let allowed_models =
|
||||
match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.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 requested_group_ids = normalize_admin_user_group_ids(payload.group_ids);
|
||||
let group_ids = state
|
||||
.include_default_user_group_ids_for_role(&requested_group_ids, &role)
|
||||
@@ -259,10 +184,10 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
payload.rate_limit,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
@@ -280,20 +205,6 @@ 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)
|
||||
@@ -303,8 +214,8 @@ pub(in super::super) async fn build_admin_create_user_response(
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(build_admin_user_payload_with_groups(
|
||||
&user,
|
||||
payload.rate_limit,
|
||||
Some(rate_limit_mode.as_str()),
|
||||
None,
|
||||
None,
|
||||
payload.unlimited,
|
||||
&groups,
|
||||
))
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use super::super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
|
||||
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,
|
||||
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
|
||||
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
|
||||
normalize_admin_username, validate_admin_user_password, AdminUpdateUserPatch,
|
||||
};
|
||||
use super::support::{
|
||||
admin_user_id_from_detail_path, admin_user_password_policy,
|
||||
@@ -53,6 +51,13 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
.into_response())
|
||||
}
|
||||
};
|
||||
if let Some(field) = disabled_user_policy_field(&raw_payload) {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": disabled_user_policy_detail(field) })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let patch = match AdminUpdateUserPatch::from_object(raw_payload.clone()) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
@@ -131,123 +136,6 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
None => None,
|
||||
};
|
||||
let effective_role = role.as_deref().unwrap_or(existing_user.role.as_str());
|
||||
if payload.rate_limit.is_some_and(|value| value < 0) {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "rate_limit 必须大于等于 0" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
let allowed_providers = if field_presence.contains("allowed_providers") {
|
||||
match normalize_admin_user_string_list(payload.allowed_providers, "allowed_providers") {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let allowed_api_formats = if field_presence.contains("allowed_api_formats") {
|
||||
match normalize_admin_user_api_formats(payload.allowed_api_formats) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let allowed_models = if field_presence.contains("allowed_models") {
|
||||
match normalize_admin_user_string_list(payload.allowed_models, "allowed_models") {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": detail })),
|
||||
)
|
||||
.into_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") {
|
||||
let requested_group_ids = normalize_admin_user_group_ids(payload.group_ids);
|
||||
Some(
|
||||
@@ -286,14 +174,6 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
|| 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")
|
||||
|| 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() {
|
||||
@@ -358,25 +238,19 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
}
|
||||
}
|
||||
|
||||
if role.is_some()
|
||||
|| field_presence.contains("allowed_providers")
|
||||
|| field_presence.contains("allowed_api_formats")
|
||||
|| field_presence.contains("allowed_models")
|
||||
|| field_presence.contains("rate_limit")
|
||||
|| payload.is_active.is_some()
|
||||
{
|
||||
if role.is_some() || payload.is_active.is_some() {
|
||||
if state
|
||||
.update_local_auth_user_admin_fields(
|
||||
&user_id,
|
||||
role,
|
||||
field_presence.contains("allowed_providers"),
|
||||
allowed_providers,
|
||||
field_presence.contains("allowed_api_formats"),
|
||||
allowed_api_formats,
|
||||
field_presence.contains("allowed_models"),
|
||||
allowed_models,
|
||||
field_presence.contains("rate_limit"),
|
||||
payload.rate_limit,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
payload.is_active,
|
||||
)
|
||||
.await?
|
||||
@@ -389,30 +263,6 @@ 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(unlimited) = payload.unlimited {
|
||||
match state
|
||||
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
|
||||
@@ -461,10 +311,7 @@ pub(in super::super) async fn build_admin_update_user_response(
|
||||
.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);
|
||||
let rate_limit = export_row.as_ref().and_then(|row| row.rate_limit);
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(build_admin_user_payload_with_groups(
|
||||
|
||||
@@ -43,11 +43,11 @@ 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, legacy_admin_list_policy_mode,
|
||||
legacy_admin_rate_limit_policy_mode, normalize_admin_optional_user_email,
|
||||
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
|
||||
validate_admin_user_password, AdminCreateUserApiKeyRequest, AdminCreateUserRequest,
|
||||
AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
|
||||
disabled_user_policy_detail, disabled_user_policy_field, format_optional_datetime_iso8601,
|
||||
legacy_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode,
|
||||
normalize_admin_optional_user_email, 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_list_policy_mode, normalize_admin_rate_limit_policy_mode,
|
||||
|
||||
@@ -66,22 +66,6 @@ pub(super) struct AdminCreateUserRequest {
|
||||
#[serde(default)]
|
||||
pub(super) unlimited: bool,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
@@ -98,22 +82,6 @@ pub(super) struct AdminUpdateUserRequest {
|
||||
#[serde(default)]
|
||||
pub(super) unlimited: Option<bool>,
|
||||
#[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>,
|
||||
@@ -121,6 +89,30 @@ pub(super) struct AdminUpdateUserRequest {
|
||||
|
||||
pub(super) type AdminUpdateUserPatch = AdminTypedObjectPatch<AdminUpdateUserRequest>;
|
||||
|
||||
const DISABLED_USER_POLICY_FIELDS: &[&str] = &[
|
||||
"allowed_providers",
|
||||
"allowed_providers_mode",
|
||||
"allowed_api_formats",
|
||||
"allowed_api_formats_mode",
|
||||
"allowed_models",
|
||||
"allowed_models_mode",
|
||||
"rate_limit",
|
||||
"rate_limit_mode",
|
||||
];
|
||||
|
||||
pub(super) fn disabled_user_policy_field(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&'static str> {
|
||||
DISABLED_USER_POLICY_FIELDS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|field| object.contains_key(*field))
|
||||
}
|
||||
|
||||
pub(super) fn disabled_user_policy_detail(field: &str) -> String {
|
||||
format!("{field} 已停用,请通过用户分组管理访问权限")
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_users_data_unavailable_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -407,11 +407,7 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
|
||||
"password": "NewUser123!",
|
||||
"role": "user",
|
||||
"unlimited": false,
|
||||
"initial_gift_usd": 6.5,
|
||||
"allowed_providers": ["openai"],
|
||||
"allowed_api_formats": ["openai:chat"],
|
||||
"allowed_models": ["gpt-4.1"],
|
||||
"rate_limit": 80
|
||||
"initial_gift_usd": 6.5
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -424,9 +420,34 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
|
||||
assert_eq!(create_payload["email"], "new-user@example.com");
|
||||
assert_eq!(create_payload["username"], "new_user");
|
||||
assert_eq!(create_payload["role"], "user");
|
||||
assert_eq!(create_payload["rate_limit"], 80);
|
||||
assert_eq!(create_payload["rate_limit"], serde_json::Value::Null);
|
||||
assert_eq!(create_payload["unlimited"], false);
|
||||
|
||||
let hidden_policy_response = reqwest::Client::new()
|
||||
.post(format!("{create_gateway_url}/api/admin/users"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"email": "hidden-policy@example.com",
|
||||
"username": "hidden_policy",
|
||||
"password": "Hidden123!",
|
||||
"allowed_models": ["gpt-4.1"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(hidden_policy_response.status(), StatusCode::BAD_REQUEST);
|
||||
let hidden_policy_payload: serde_json::Value = hidden_policy_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(
|
||||
hidden_policy_payload["detail"],
|
||||
"allowed_models 已停用,请通过用户分组管理访问权限"
|
||||
);
|
||||
|
||||
create_gateway_handle.abort();
|
||||
}
|
||||
|
||||
@@ -717,10 +738,6 @@ async fn gateway_handles_admin_user_batch_actions_locally() {
|
||||
},
|
||||
"action": "update_access_control",
|
||||
"payload": {
|
||||
"allowed_providers": null,
|
||||
"allowed_api_formats": ["OPENAI:RESPONSES"],
|
||||
"allowed_models": [],
|
||||
"rate_limit": 0,
|
||||
"unlimited": false
|
||||
}
|
||||
}))
|
||||
@@ -735,15 +752,34 @@ async fn gateway_handles_admin_user_batch_actions_locally() {
|
||||
assert_eq!(access_payload["total"], 1);
|
||||
assert_eq!(access_payload["success"], 1);
|
||||
assert_eq!(access_payload["failed"], 0);
|
||||
assert_eq!(access_payload["modified_fields"], json!(["unlimited"]));
|
||||
|
||||
let hidden_access_response = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"selection": {
|
||||
"user_ids": ["user-1"]
|
||||
},
|
||||
"action": "update_access_control",
|
||||
"payload": {
|
||||
"rate_limit": 0
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(hidden_access_response.status(), StatusCode::BAD_REQUEST);
|
||||
let hidden_access_payload: serde_json::Value = hidden_access_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(
|
||||
access_payload["modified_fields"],
|
||||
json!([
|
||||
"allowed_providers",
|
||||
"allowed_api_formats",
|
||||
"allowed_models",
|
||||
"rate_limit",
|
||||
"unlimited"
|
||||
])
|
||||
hidden_access_payload["detail"],
|
||||
"rate_limit 已停用,请通过用户分组管理访问权限"
|
||||
);
|
||||
|
||||
let role_response = client
|
||||
@@ -855,12 +891,6 @@ async fn gateway_handles_admin_user_batch_actions_locally() {
|
||||
assert_eq!(detail_payload["role"], "admin");
|
||||
assert_eq!(detail_payload["is_active"], true);
|
||||
assert_eq!(detail_payload["unlimited"], false);
|
||||
assert_eq!(detail_payload["allowed_providers"], serde_json::Value::Null);
|
||||
assert_eq!(
|
||||
detail_payload["allowed_api_formats"],
|
||||
json!(["openai:responses"])
|
||||
);
|
||||
assert_eq!(detail_payload["allowed_models"], json!([]));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -967,10 +997,6 @@ async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_pri
|
||||
"email": "alice-updated@example.com",
|
||||
"username": "alice_updated",
|
||||
"role": "admin",
|
||||
"allowed_providers": ["openai", "anthropic"],
|
||||
"allowed_api_formats": ["openai:chat", "gemini:generate_content"],
|
||||
"allowed_models": ["gpt-4.1", "gemini-2.5-pro"],
|
||||
"rate_limit": 120,
|
||||
"is_active": false,
|
||||
"unlimited": true
|
||||
}))
|
||||
@@ -985,10 +1011,32 @@ async fn gateway_handles_admin_user_detail_routes_locally_with_trusted_admin_pri
|
||||
assert_eq!(update_payload["email"], "alice-updated@example.com");
|
||||
assert_eq!(update_payload["username"], "alice_updated");
|
||||
assert_eq!(update_payload["role"], "admin");
|
||||
assert_eq!(update_payload["rate_limit"], 120);
|
||||
assert_eq!(update_payload["rate_limit"], serde_json::Value::Null);
|
||||
assert_eq!(update_payload["unlimited"], true);
|
||||
assert_eq!(update_payload["is_active"], false);
|
||||
|
||||
let hidden_update_response = client
|
||||
.put(format!("{gateway_url}/api/admin/users/user-1"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"allowed_providers": ["openai"]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(hidden_update_response.status(), StatusCode::BAD_REQUEST);
|
||||
let hidden_update_payload: serde_json::Value = hidden_update_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(
|
||||
hidden_update_payload["detail"],
|
||||
"allowed_providers 已停用,请通过用户分组管理访问权限"
|
||||
);
|
||||
|
||||
let delete_response = client
|
||||
.delete(format!("{gateway_url}/api/admin/users/user-1"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
|
||||
@@ -30,7 +30,8 @@ use aether_data::repository::management_tokens::{
|
||||
};
|
||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||
use aether_data::repository::users::{
|
||||
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow,
|
||||
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow, UpsertUserGroupRecord,
|
||||
UserReadRepository,
|
||||
};
|
||||
use aether_data::repository::wallet::{
|
||||
InMemoryWalletRepository, StoredWalletSnapshot, WalletWriteRepository,
|
||||
@@ -2701,15 +2702,38 @@ async fn gateway_handles_user_monitoring_rate_limit_status_locally_without_proxy
|
||||
.expect("auth api key snapshot should build")
|
||||
.with_user_rate_limit(Some(80)),
|
||||
)]));
|
||||
let user_repository: Arc<dyn UserReadRepository> =
|
||||
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_auth_user(now),
|
||||
]));
|
||||
let group = user_repository
|
||||
.create_user_group(UpsertUserGroupRecord {
|
||||
name: "Monitoring Limits".to_string(),
|
||||
description: None,
|
||||
priority: 0,
|
||||
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(),
|
||||
rate_limit: Some(80),
|
||||
rate_limit_mode: "custom".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("group should create")
|
||||
.expect("group should exist");
|
||||
user_repository
|
||||
.add_user_to_group(&group.id, "user-auth-1")
|
||||
.await
|
||||
.expect("group membership should create");
|
||||
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_builder(|| {
|
||||
let data_state = crate::data::GatewayDataState::with_auth_api_key_repository_for_tests(
|
||||
auth_repository,
|
||||
)
|
||||
.with_user_reader(Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
sample_auth_user(now),
|
||||
])));
|
||||
.with_user_reader(Arc::clone(&user_repository));
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state)
|
||||
|
||||
Reference in New Issue
Block a user