feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
@@ -1,9 +1,8 @@
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
attach_admin_audit_response, decrypt_catalog_secret_with_fallbacks,
};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::handlers::shared::{
api_key_placeholder_display, generate_gateway_api_key_plaintext, masked_gateway_api_key_display,
api_key_placeholder_display, generate_gateway_api_key_plaintext,
masked_gateway_api_key_display, open_auth_api_key_secret,
};
use axum::{body::Body, response::Response};
use serde_json::json;
@@ -17,16 +16,12 @@ pub(crate) fn format_optional_unix_secs_iso8601(value: Option<u64>) -> Option<St
pub(crate) fn masked_user_api_key_display(
state: &AdminAppState<'_>,
ciphertext: Option<&str>,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
) -> String {
let Some(ciphertext) = ciphertext.map(str::trim).filter(|value| !value.is_empty()) else {
let Ok(projection) = open_auth_api_key_secret(state.app(), record) else {
return api_key_placeholder_display();
};
let Some(full_key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
else {
return api_key_placeholder_display();
};
masked_gateway_api_key_display(Some(full_key.as_str()))
masked_gateway_api_key_display(Some(projection.plaintext.as_str()))
}
pub(super) fn build_admin_user_api_key_detail_payload(
@@ -37,7 +32,7 @@ pub(super) fn build_admin_user_api_key_detail_payload(
json!({
"id": record.api_key_id,
"name": record.name,
"key_display": masked_user_api_key_display(state, record.key_encrypted.as_deref()),
"key_display": masked_user_api_key_display(state, record),
"is_active": record.is_active,
"is_locked": is_locked,
"total_requests": record.total_requests,
@@ -11,7 +11,9 @@ use super::super::helpers::{
use super::super::paths::admin_user_id_from_api_keys_path;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::mark_sensitive_admin_response_no_store;
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
use crate::handlers::shared::seal_auth_api_key_secret;
use crate::GatewayError;
use axum::{
body::Body,
@@ -141,7 +143,16 @@ pub(crate) async fn build_admin_create_user_api_key_response(
};
let plaintext_key = generate_admin_user_api_key_plaintext();
let Some(key_encrypted) = state.encrypt_catalog_secret_with_fallbacks(&plaintext_key) else {
let api_key_id = uuid::Uuid::new_v4().to_string();
let key_hash = hash_admin_user_api_key(&plaintext_key);
let Ok(key_encrypted) = seal_auth_api_key_secret(
state.app(),
&target_user_id,
&api_key_id,
&key_hash,
false,
&plaintext_key,
) else {
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": "API密钥加密失败" })),
@@ -152,17 +163,18 @@ pub(crate) async fn build_admin_create_user_api_key_response(
let Some(created) = state
.create_user_api_key(aether_data::repository::auth::CreateUserApiKeyRecord {
user_id: target_user_id.clone(),
api_key_id: uuid::Uuid::new_v4().to_string(),
key_hash: hash_admin_user_api_key(&plaintext_key),
api_key_id,
key_hash,
key_encrypted: Some(key_encrypted),
name: Some(name.clone()),
allowed_providers: None,
allowed_providers,
allowed_api_formats: None,
allowed_models: None,
ip_rules,
rate_limit,
concurrent_limit,
force_capabilities: None,
feature_settings,
is_active: true,
expires_at_unix_secs: None,
auto_delete_on_expiry: false,
@@ -175,56 +187,27 @@ pub(crate) async fn build_admin_create_user_api_key_response(
return Ok(build_admin_users_data_unavailable_response());
};
let created = if allowed_providers.is_some() {
match state
.set_user_api_key_allowed_providers(
&target_user_id,
&created.api_key_id,
allowed_providers,
)
.await?
{
Some(updated) => updated,
None => created,
}
} else {
created
};
let created = if feature_settings.is_some() {
match state
.set_user_api_key_feature_settings(
&target_user_id,
&created.api_key_id,
feature_settings.clone(),
)
.await?
{
Some(updated) => updated,
None => created,
}
} else {
created
};
Ok(attach_audit_response(
Json(json!({
"id": created.api_key_id,
"key": plaintext_key,
"name": created.name,
"key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()),
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"ip_rules": created.ip_rules,
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
"feature_settings": created.feature_settings,
"message": "API Key创建成功,请妥善保存完整密钥",
}))
.into_response(),
"admin_user_api_key_created",
"create_user_api_key",
"user_api_key",
&created.api_key_id,
Ok(mark_sensitive_admin_response_no_store(
attach_audit_response(
Json(json!({
"id": created.api_key_id,
"key": plaintext_key,
"name": created.name,
"key_display": masked_user_api_key_display(state, &created),
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"ip_rules": created.ip_rules,
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
"feature_settings": created.feature_settings,
"message": "API Key创建成功,请妥善保存完整密钥",
}))
.into_response(),
"admin_user_api_key_created",
"create_user_api_key",
"user_api_key",
&created.api_key_id,
),
))
}
@@ -56,7 +56,7 @@ pub(crate) async fn build_admin_list_user_api_keys_response(
json!({
"id": record.api_key_id,
"name": record.name,
"key_display": masked_user_api_key_display(state, record.key_encrypted.as_deref()),
"key_display": masked_user_api_key_display(state, &record),
"is_active": record.is_active,
"is_locked": is_locked,
"total_requests": record.total_requests,
@@ -3,6 +3,8 @@ use super::super::helpers::attach_audit_response;
use super::super::paths::admin_user_api_key_full_key_parts;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::mark_sensitive_admin_response_no_store;
use crate::handlers::shared::decrypt_or_migrate_auth_api_key_secret;
use crate::GatewayError;
use axum::{
body::Body,
@@ -51,19 +53,24 @@ pub(crate) async fn build_admin_reveal_user_api_key_response(
.into_response());
}
let Some(full_key) = state.decrypt_catalog_secret_with_fallbacks(ciphertext) else {
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": "解密密钥失败" })),
)
.into_response());
let full_key = match decrypt_or_migrate_auth_api_key_secret(state.app(), &record).await {
Ok(value) => value,
Err(_) => {
return Ok((
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": "解密或校验密钥失败" })),
)
.into_response())
}
};
Ok(attach_audit_response(
Json(json!({ "key": full_key })).into_response(),
"admin_user_api_key_revealed",
"reveal_user_api_key",
"user_api_key",
&key_id,
Ok(mark_sensitive_admin_response_no_store(
attach_audit_response(
Json(json!({ "key": full_key })).into_response(),
"admin_user_api_key_revealed",
"reveal_user_api_key",
"user_api_key",
&key_id,
),
))
}
@@ -107,15 +107,24 @@ pub(crate) async fn build_admin_update_user_api_key_response(
},
None => None,
};
let name_present = name.is_some();
let rate_limit_present = payload.rate_limit.is_some();
let concurrent_limit_present = concurrent_limit.is_some();
let Some(updated) = state
.update_user_api_key_basic(aether_data::repository::auth::UpdateUserApiKeyBasicRecord {
user_id: user_id.clone(),
api_key_id: api_key_id.clone(),
key_encrypted: None,
key_encrypted_present: false,
name,
name_present,
rate_limit: payload.rate_limit,
rate_limit_present,
concurrent_limit,
concurrent_limit_present,
ip_rules,
feature_settings,
})
.await?
else {
@@ -125,14 +134,6 @@ pub(crate) async fn build_admin_update_user_api_key_response(
)
.into_response());
};
let updated = if let Some(feature_settings) = feature_settings {
state
.set_user_api_key_feature_settings(&user_id, &api_key_id, feature_settings)
.await?
.unwrap_or(updated)
} else {
updated
};
let is_locked = state
.list_auth_api_key_snapshots_by_ids(std::slice::from_ref(&api_key_id))
@@ -1,6 +1,7 @@
use super::{
build_admin_users_bad_request_response, build_admin_users_read_only_response,
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_user_role,
build_admin_users_bad_request_response, build_admin_users_permission_denied_response,
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
management_token_may_administer_user_accounts, normalize_admin_user_role,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
@@ -131,6 +132,24 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let resolved = match resolve_admin_user_selection(state, request.selection).await {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let mutates_admin_account = mutation
.role
.as_deref()
.is_some_and(crate::roles::can_access_admin_console)
|| (mutation.has_auth_user_fields()
&& resolved
.items
.iter()
.any(|item| crate::roles::can_access_admin_console(&item.role)));
if mutates_admin_account && !management_token_may_administer_user_accounts(request_context) {
return Ok(build_admin_users_permission_denied_response(
request_context,
));
}
if mutation.has_auth_user_fields() && !state.has_auth_user_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法批量更新用户",
@@ -141,10 +160,6 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
"当前为只读模式,无法批量更新用户钱包",
));
}
let resolved = match resolve_admin_user_selection(state, request.selection).await {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let active_admin_demotions = count_active_admin_demotions(&mutation, &resolved.items);
let active_admin_count = if active_admin_demotions > 0 {
state.count_active_admin_users().await?
@@ -1,4 +1,5 @@
use super::{build_admin_users_bad_request_response, build_admin_users_data_unavailable_response};
use crate::handlers::admin::billing::admin_payment_gateway_response_projection;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339};
use crate::handlers::shared::unix_ms_to_rfc3339;
@@ -118,7 +119,7 @@ fn plan_has_package_rights(record: &BillingPlanRecord) -> bool {
items.iter().any(|item| {
matches!(
item.get("type").and_then(|value| value.as_str()),
Some("daily_quota" | "membership_group")
Some("daily_quota" | "membership_group" | "usage_policy")
)
})
})
@@ -138,7 +139,8 @@ fn admin_payment_order_payload(record: &crate::AdminWalletPaymentOrderRecord) ->
"refundable_amount_usd": record.refundable_amount_usd,
"payment_method": record.payment_method,
"gateway_order_id": record.gateway_order_id,
"gateway_response": record.gateway_response,
"gateway_response": admin_payment_gateway_response_projection(record.gateway_response.as_ref()),
"has_gateway_response": record.gateway_response.is_some(),
"status": record.status,
"order_kind": "plan_purchase",
"created_at": unix_ms_to_rfc3339(record.created_at_unix_ms),
@@ -334,7 +336,10 @@ pub(in super::super) async fn build_admin_grant_user_billing_plan_response(
user_id: user_id.clone(),
amount_usd: 0.0,
pay_amount: 0.0,
pay_currency: plan.price_currency.clone(),
// Manual grants have no provider settlement. Keep the order
// currency stable and independent of the plan's display
// currency (the amount is always zero).
pay_currency: "USD".to_string(),
exchange_rate: 1.0,
payment_method: "admin_grant".to_string(),
payment_provider: Some("admin".to_string()),
@@ -374,7 +379,7 @@ pub(in super::super) async fn build_admin_grant_user_billing_plan_response(
&order.id,
Some(&order_no),
Some(0.0),
Some(&plan.price_currency),
Some("USD"),
Some(1.0),
Some(json!({ "admin_grant": true })),
operator_id.as_deref(),
@@ -1,6 +1,7 @@
use super::super::{
admin_default_user_initial_gift, build_admin_users_read_only_response,
disabled_user_policy_detail, disabled_user_policy_field, normalize_admin_feature_settings,
admin_default_user_initial_gift, build_admin_users_permission_denied_response,
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
management_token_may_administer_user_accounts, normalize_admin_feature_settings,
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
normalize_admin_username, validate_admin_user_password, AdminCreateUserRequest,
};
@@ -18,7 +19,7 @@ use serde_json::{json, Value};
pub(in super::super) async fn build_admin_create_user_response(
state: &AdminAppState<'_>,
_request_context: &AdminRequestContext<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&axum::body::Bytes>,
) -> Result<Response<Body>, GatewayError> {
if !state.has_auth_user_write_capability() {
@@ -107,6 +108,13 @@ pub(in super::super) async fn build_admin_create_user_response(
.into_response())
}
};
if crate::roles::can_access_admin_console(&role)
&& !management_token_may_administer_user_accounts(request_context)
{
return Ok(build_admin_users_permission_denied_response(
request_context,
));
}
let password_policy = admin_user_password_policy(state).await?;
if let Err(detail) = validate_admin_user_password(&payload.password, &password_policy) {
return Ok((
@@ -206,25 +214,152 @@ pub(in super::super) async fn build_admin_create_user_response(
));
};
if state
.initialize_auth_user_wallet(&user.id, initial_gift_usd, payload.unlimited)
.await?
.is_none()
let initialized = match state
.initialize_auth_user_wallet_with_outcome(&user.id, initial_gift_usd, payload.unlimited)
.await
{
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法初始化用户钱包",
Ok(Some(initialized)) => initialized,
Ok(None) => {
// The user row was created before wallet provisioning. Remove it
// when the backend reports that wallet initialization is unavailable;
// the guarded rollback refuses to delete a concurrently-created or
// funded wallet.
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(&user.id, None)
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
"admin user wallet-unavailable cleanup failed"
);
}
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法初始化用户钱包",
));
}
Err(err) => {
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(&user.id, None)
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
"admin user wallet initialization cleanup failed"
);
}
return Err(err);
}
};
let owned_wallet_id = initialized.created.then(|| initialized.wallet.id.clone());
let wallet_is_user_owned = initialized.wallet.user_id.as_deref() == Some(user.id.as_str())
&& initialized.wallet.api_key_id.is_none();
if !wallet_is_user_owned {
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(&user.id, owned_wallet_id.as_deref())
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
wallet_id = ?owned_wallet_id,
"admin user wallet owner validation cleanup failed"
);
}
return Err(GatewayError::Internal(
"admin user wallet owner does not match the provisioned user".to_string(),
));
}
if !group_ids.is_empty() {
state
let replaced_groups = match state
.replace_user_groups_for_user(&user.id, &group_ids)
.await?;
.await
{
Ok(groups) if groups.len() == group_ids.len() => groups,
Ok(_) => {
let error =
GatewayError::Internal("user groups could not be persisted".to_string());
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(
&user.id,
owned_wallet_id.as_deref(),
)
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
wallet_id = ?owned_wallet_id,
"admin user group provisioning cleanup failed"
);
}
return Err(error);
}
Err(error) => {
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(
&user.id,
owned_wallet_id.as_deref(),
)
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
wallet_id = ?owned_wallet_id,
"admin user group provisioning cleanup failed"
);
}
return Err(error);
}
};
let _ = replaced_groups;
}
let feature_settings = if feature_settings.is_some() {
state
.update_user_feature_settings(&user.id, feature_settings.clone())
.await?
.or(feature_settings)
let feature_settings = if let Some(requested_feature_settings) = feature_settings {
match state
.update_user_feature_settings(&user.id, Some(requested_feature_settings))
.await
{
Ok(Some(updated_feature_settings)) => Some(updated_feature_settings),
Ok(None) => {
let error = GatewayError::Internal(
"user feature settings could not be persisted".to_string(),
);
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(
&user.id,
owned_wallet_id.as_deref(),
)
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
wallet_id = ?owned_wallet_id,
"admin user feature settings cleanup failed"
);
}
return Err(error);
}
Err(error) => {
if let Err(cleanup_error) = state
.rollback_provisional_auth_user_with_wallet(
&user.id,
owned_wallet_id.as_deref(),
)
.await
{
tracing::error!(
error = ?cleanup_error,
user_id = %user.id,
wallet_id = ?owned_wallet_id,
"admin user feature settings cleanup failed"
);
}
return Err(error);
}
}
} else {
None
};
@@ -1,4 +1,7 @@
use super::super::build_admin_users_bad_request_response;
use super::super::{
build_admin_users_bad_request_response, build_admin_users_permission_denied_response,
management_token_may_administer_user_accounts,
};
use super::support::admin_user_id_from_detail_path;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
@@ -26,7 +29,19 @@ pub(in super::super) async fn build_admin_delete_user_response(
.into_response());
};
if user.role.eq_ignore_ascii_case("admin") && state.count_active_admin_users().await? <= 1 {
if crate::roles::can_access_admin_console(&user.role)
&& !management_token_may_administer_user_accounts(request_context)
{
return Ok(build_admin_users_permission_denied_response(
request_context,
));
}
if user.is_active
&& !user.is_deleted
&& user.role.eq_ignore_ascii_case("admin")
&& state.count_active_admin_users().await? <= 1
{
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "不能删除最后一个管理员账户" })),
@@ -1,9 +1,10 @@
use super::super::{
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
normalize_admin_feature_settings, normalize_admin_optional_user_email,
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
validate_admin_user_password, AdminUpdateUserPatch,
build_admin_users_permission_denied_response, build_admin_users_read_only_response,
disabled_user_policy_detail, disabled_user_policy_field,
management_token_may_administer_user_accounts, normalize_admin_feature_settings,
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,
@@ -70,6 +71,7 @@ pub(in super::super) async fn build_admin_update_user_response(
}
};
let (field_presence, payload) = patch.into_parts();
let email_present = field_presence.contains("email");
let feature_settings = if field_presence.contains("feature_settings") {
match normalize_admin_feature_settings(payload.feature_settings.flatten()) {
Ok(value) => Some(value),
@@ -150,16 +152,47 @@ pub(in super::super) async fn build_admin_update_user_response(
},
None => None,
};
let changes_admin_role = role.as_deref().is_some_and(|role| {
!role.eq_ignore_ascii_case(&existing_user.role)
&& (crate::roles::can_access_admin_console(&existing_user.role)
|| crate::roles::can_access_admin_console(role))
});
let resets_admin_password =
payload.password.is_some() && crate::roles::can_access_admin_console(&existing_user.role);
let changes_admin_active_state = payload
.is_active
.is_some_and(|is_active| is_active != existing_user.is_active)
&& crate::roles::can_access_admin_console(&existing_user.role);
let mutates_existing_admin = crate::roles::can_access_admin_console(&existing_user.role)
&& (email.is_some()
|| username.is_some()
|| payload.password.is_some()
|| role.is_some()
|| payload.is_active.is_some()
|| field_presence.contains("group_ids")
|| field_presence.contains("feature_settings")
|| payload.unlimited.is_some());
if (changes_admin_role
|| resets_admin_password
|| changes_admin_active_state
|| mutates_existing_admin)
&& !management_token_may_administer_user_accounts(request_context)
{
return Ok(build_admin_users_permission_denied_response(
request_context,
));
}
if existing_user.is_active
&& crate::roles::is_full_admin_role(&existing_user.role)
&& role
&& (role
.as_deref()
.is_some_and(|role| !crate::roles::is_full_admin_role(role))
|| payload.is_active == Some(false))
&& state.count_active_admin_users().await? <= 1
{
return Ok((
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "不能降级最后一个管理员账户" })),
Json(json!({ "detail": "不能降级或停用最后一个管理员账户" })),
)
.into_response());
}
@@ -193,7 +226,7 @@ pub(in super::super) async fn build_admin_update_user_response(
}
}
}
let needs_auth_user_write = email.is_some()
let needs_auth_user_write = email_present
|| username.is_some()
|| payload.password.is_some()
|| role.is_some()
@@ -211,9 +244,21 @@ pub(in super::super) async fn build_admin_update_user_response(
));
}
if email.is_some() || username.is_some() {
if email_present || username.is_some() {
let resets_email_verification = email.as_deref().is_some_and(|email| {
existing_user
.email
.as_deref()
.is_none_or(|existing| !existing.trim().eq_ignore_ascii_case(email.trim()))
});
if state
.update_local_auth_user_profile(&user_id, email.clone(), username.clone())
.update_local_auth_user_profile(
&user_id,
email_present,
email.clone(),
resets_email_verification.then_some(false),
username.clone(),
)
.await?
.is_none()
{
@@ -249,10 +294,13 @@ pub(in super::super) async fn build_admin_update_user_response(
.into_response())
}
};
if state
.update_local_auth_user_password_hash(&user_id, password_hash, chrono::Utc::now())
if !state
.reset_local_auth_user_password_and_revoke_sessions(
&user_id,
password_hash,
chrono::Utc::now(),
)
.await?
.is_none()
{
return Ok((
http::StatusCode::NOT_FOUND,
@@ -48,9 +48,10 @@ use self::sessions::{
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,
disabled_user_policy_detail, disabled_user_policy_field, format_optional_datetime_iso8601,
legacy_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode,
build_admin_users_data_unavailable_response, build_admin_users_permission_denied_response,
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
legacy_admin_rate_limit_policy_mode, management_token_may_administer_user_accounts,
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,
@@ -1,4 +1,7 @@
use super::{build_admin_users_bad_request_response, format_optional_datetime_iso8601};
use super::{
build_admin_users_bad_request_response, build_admin_users_permission_denied_response,
format_optional_datetime_iso8601, management_token_may_administer_user_accounts,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::{GatewayError, GatewayUserSessionView};
@@ -98,12 +101,19 @@ pub(super) async fn build_admin_delete_user_session_response(
));
};
if state.find_user_auth_by_id(&user_id).await?.is_none() {
let Some(user) = state.find_user_auth_by_id(&user_id).await? else {
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "用户不存在" })),
)
.into_response());
};
if crate::roles::can_access_admin_console(&user.role)
&& !management_token_may_administer_user_accounts(request_context)
{
return Ok(build_admin_users_permission_denied_response(
request_context,
));
}
if state
@@ -144,12 +154,19 @@ pub(super) async fn build_admin_delete_user_sessions_response(
return Ok(build_admin_users_bad_request_response("缺少 user_id"));
};
if state.find_user_auth_by_id(&user_id).await?.is_none() {
let Some(user) = state.find_user_auth_by_id(&user_id).await? else {
return Ok((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "用户不存在" })),
)
.into_response());
};
if crate::roles::can_access_admin_console(&user.role)
&& !management_token_may_administer_user_accounts(request_context)
{
return Ok(build_admin_users_permission_denied_response(
request_context,
));
}
let revoked_count = state
@@ -66,7 +66,7 @@ pub(super) struct AdminToggleUserApiKeyLockRequest {
pub(super) locked: Option<bool>,
}
#[derive(Debug, serde::Deserialize)]
#[derive(serde::Deserialize)]
pub(super) struct AdminCreateUserRequest {
pub(super) username: String,
pub(super) password: String,
@@ -84,7 +84,7 @@ pub(super) struct AdminCreateUserRequest {
pub(super) feature_settings: Option<Value>,
}
#[derive(Debug, serde::Deserialize)]
#[derive(serde::Deserialize)]
pub(super) struct AdminUpdateUserRequest {
#[serde(default)]
pub(super) email: Option<String>,
@@ -157,6 +157,41 @@ pub(super) fn build_admin_users_bad_request_response(detail: impl Into<String>)
.into_response()
}
pub(super) fn management_token_may_administer_user_accounts(
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
) -> bool {
request_context.decision().is_some_and(|decision| {
crate::control::management_token_principal_has_permission(decision, "admin:users:admin")
})
}
pub(super) fn build_admin_users_permission_denied_response(
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
) -> Response<Body> {
let actor_id = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
.and_then(|principal| principal.management_token_id.as_deref())
.unwrap_or("unknown");
crate::handlers::admin::shared::attach_admin_audit_response(
(
http::StatusCode::FORBIDDEN,
Json(json!({
"detail": "management token permission denied",
"required_permission": "admin:users:admin",
"route_family": request_context.route_family(),
"route_kind": request_context.route_kind(),
"request_path": request_context.path(),
})),
)
.into_response(),
"admin_user_account_permission_denied",
"permission_denied",
"admin_user_account",
actor_id,
)
}
pub(super) fn normalize_admin_optional_user_email(
value: Option<&str>,
) -> Result<Option<String>, String> {