feat: add management token permissions

This commit is contained in:
Entropy.Xu
2026-05-07 23:48:29 +08:00
parent 6f620d92be
commit cc5cb3475e
34 changed files with 2212 additions and 100 deletions

View File

@@ -54,6 +54,7 @@ pub(crate) struct GatewayAdminPrincipalContext {
pub(crate) user_role: String,
pub(crate) session_id: Option<String>,
pub(crate) management_token_id: Option<String>,
pub(crate) management_token_permissions: Option<Vec<String>>,
}
pub(in super::super) enum ControlDecisionAuthResolution {
@@ -243,6 +244,7 @@ fn resolve_trusted_admin_principal(
user_role: trusted_headers.user_role,
session_id: trusted_headers.session_id,
management_token_id: trusted_headers.management_token_id,
management_token_permissions: None,
})
}
@@ -328,6 +330,7 @@ async fn resolve_local_admin_principal_from_claims(
user_role: "admin".to_string(),
session_id: Some(session.id),
management_token_id: None,
management_token_permissions: None,
}))
}

View File

@@ -0,0 +1,518 @@
use axum::http;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::BTreeSet;
use super::GatewayControlDecision;
#[derive(Debug, Clone, Copy)]
struct PermissionGroup {
scope: &'static str,
label: &'static str,
assignable: bool,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub(crate) struct ManagementTokenPermissionCatalogItem {
pub(crate) key: &'static str,
pub(crate) scope: &'static str,
pub(crate) scope_label: &'static str,
pub(crate) access: &'static str,
pub(crate) access_label: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManagementTokenPermissionDenied {
pub(crate) required_permission: String,
}
const PERMISSION_GROUPS: &[PermissionGroup] = &[
PermissionGroup {
scope: "adaptive",
label: "自适应调度",
assignable: true,
},
PermissionGroup {
scope: "announcements",
label: "公告",
assignable: true,
},
PermissionGroup {
scope: "api_keys",
label: "API 密钥",
assignable: true,
},
PermissionGroup {
scope: "billing",
label: "账单",
assignable: true,
},
PermissionGroup {
scope: "endpoints_health",
label: "端点健康",
assignable: true,
},
PermissionGroup {
scope: "endpoints_manage",
label: "端点配置",
assignable: true,
},
PermissionGroup {
scope: "endpoints_rpm",
label: "端点 RPM",
assignable: true,
},
PermissionGroup {
scope: "gemini_files",
label: "Gemini 文件",
assignable: true,
},
PermissionGroup {
scope: "ldap",
label: "LDAP",
assignable: true,
},
PermissionGroup {
scope: "management_tokens",
label: "访问令牌",
assignable: false,
},
PermissionGroup {
scope: "models",
label: "模型",
assignable: true,
},
PermissionGroup {
scope: "modules",
label: "模块管理",
assignable: true,
},
PermissionGroup {
scope: "monitoring",
label: "监控",
assignable: true,
},
PermissionGroup {
scope: "oauth",
label: "OAuth 配置",
assignable: true,
},
PermissionGroup {
scope: "payments",
label: "支付",
assignable: true,
},
PermissionGroup {
scope: "pool",
label: "号池",
assignable: true,
},
PermissionGroup {
scope: "provider_ops",
label: "Provider 运维",
assignable: true,
},
PermissionGroup {
scope: "provider_oauth",
label: "Provider OAuth",
assignable: true,
},
PermissionGroup {
scope: "provider_query",
label: "Provider 查询",
assignable: true,
},
PermissionGroup {
scope: "provider_strategy",
label: "Provider 策略",
assignable: true,
},
PermissionGroup {
scope: "providers",
label: "供应商与模型",
assignable: true,
},
PermissionGroup {
scope: "proxy_nodes",
label: "代理节点",
assignable: true,
},
PermissionGroup {
scope: "security",
label: "安全",
assignable: true,
},
PermissionGroup {
scope: "stats",
label: "统计",
assignable: true,
},
PermissionGroup {
scope: "system",
label: "系统",
assignable: true,
},
PermissionGroup {
scope: "usage",
label: "用量",
assignable: true,
},
PermissionGroup {
scope: "users",
label: "用户",
assignable: true,
},
PermissionGroup {
scope: "video_tasks",
label: "视频任务",
assignable: true,
},
PermissionGroup {
scope: "wallets",
label: "钱包",
assignable: true,
},
];
const ACCESS_LEVELS: &[(&str, &str)] = &[("read", "读取"), ("write", "写入"), ("admin", "管理")];
pub(crate) fn management_token_permission_catalog_items(
) -> Vec<ManagementTokenPermissionCatalogItem> {
PERMISSION_GROUPS
.iter()
.filter(|group| group.assignable)
.flat_map(|group| {
ACCESS_LEVELS.iter().map(move |(access, access_label)| {
ManagementTokenPermissionCatalogItem {
key: permission_key(group.scope, access),
scope: group.scope,
scope_label: group.label,
access,
access_label,
}
})
})
.collect()
}
pub(crate) fn management_token_permission_catalog_payload() -> Value {
let items = management_token_permission_catalog_items();
json!({
"items": items,
"all_permissions": all_assignable_management_token_permissions(),
"read_only_permissions": read_only_management_token_permissions(),
})
}
pub(crate) fn all_assignable_management_token_permissions() -> Vec<String> {
management_token_permission_catalog_items()
.into_iter()
.map(|item| item.key.to_string())
.collect()
}
pub(crate) fn read_only_management_token_permissions() -> Vec<String> {
PERMISSION_GROUPS
.iter()
.filter(|group| group.assignable)
.map(|group| permission_key(group.scope, "read").to_string())
.collect()
}
pub(crate) fn normalize_assignable_management_token_permissions(
value: Option<&Value>,
) -> Result<Value, String> {
let Some(value) = value else {
return Ok(json!(all_assignable_management_token_permissions()));
};
if value.is_null() {
return Ok(json!(all_assignable_management_token_permissions()));
}
let Some(items) = value.as_array() else {
return Err("permissions 必须是字符串数组".to_string());
};
if items.is_empty() {
return Err("permissions 不能为空".to_string());
}
let mut normalized = BTreeSet::new();
for item in items {
let Some(raw) = item.as_str() else {
return Err("permissions 必须是字符串数组".to_string());
};
let key = raw.trim();
if key.is_empty() {
return Err("permissions 不能包含空字符串".to_string());
}
if !is_assignable_management_token_permission(key) {
return Err(format!("无效的管理令牌权限: {key}"));
}
normalized.insert(key.to_string());
}
Ok(json!(normalized.into_iter().collect::<Vec<_>>()))
}
pub(crate) fn management_token_permission_keys_from_value(
value: Option<&Value>,
) -> Result<Option<Vec<String>>, String> {
let Some(value) = value else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(items) = value.as_array() else {
return Err("management token permissions must be an array".to_string());
};
if items.is_empty() {
return Err("management token permissions must not be empty".to_string());
}
let mut keys = Vec::with_capacity(items.len());
for item in items {
let Some(key) = item.as_str() else {
return Err("management token permissions must contain strings".to_string());
};
if !is_assignable_management_token_permission(key) {
return Err(format!("unknown management token permission: {key}"));
}
keys.push(key.to_string());
}
Ok(Some(keys))
}
pub(crate) fn management_token_permission_mode_and_summary(
permissions: Option<&Value>,
) -> (&'static str, String) {
let keys = match management_token_permission_keys_from_value(permissions) {
Ok(Some(keys)) => keys,
Ok(None) => return ("legacy_full", "旧版全权限".to_string()),
Err(_) => return ("custom", "权限配置异常".to_string()),
};
let key_set = keys.iter().map(String::as_str).collect::<BTreeSet<_>>();
let all = all_assignable_management_token_permissions();
let read_only = read_only_management_token_permissions();
if all.iter().all(|key| key_set.contains(key.as_str())) {
return ("full", "全权限".to_string());
}
if !keys.is_empty() && keys.iter().all(|key| key.ends_with(":read")) {
return ("read_only", "只读".to_string());
}
if read_only.iter().all(|key| key_set.contains(key.as_str()))
&& keys.iter().any(|key| !key.ends_with(":read"))
{
return ("custom", format!("自定义 {} 项(含全部读取)", keys.len()));
}
("custom", format!("自定义 {}", keys.len()))
}
pub(crate) fn management_token_required_permission(
method: &http::Method,
decision: &GatewayControlDecision,
) -> Option<String> {
let signature = decision.auth_endpoint_signature.as_deref()?.trim();
let scope = signature.strip_prefix("admin:")?.trim();
if scope.is_empty() {
return None;
}
Some(format!("admin:{scope}:{}", access_for_method(method)))
}
pub(crate) fn validate_management_token_admin_route_permission(
method: &http::Method,
decision: &GatewayControlDecision,
token_permissions: Option<&[String]>,
) -> Result<(), ManagementTokenPermissionDenied> {
let Some(required_permission) = management_token_required_permission(method, decision) else {
return Ok(());
};
let Some(token_permissions) = token_permissions else {
return Ok(());
};
let scope = required_permission
.strip_prefix("admin:")
.and_then(|value| value.rsplit_once(':').map(|(scope, _)| scope))
.unwrap_or_default();
let admin_permission = format!("admin:{scope}:admin");
if token_permissions
.iter()
.any(|permission| permission == &required_permission || permission == &admin_permission)
{
Ok(())
} else {
Err(ManagementTokenPermissionDenied {
required_permission,
})
}
}
fn access_for_method(method: &http::Method) -> &'static str {
if matches!(
*method,
http::Method::GET | http::Method::HEAD | http::Method::OPTIONS
) {
"read"
} else {
"write"
}
}
fn permission_key(scope: &str, access: &str) -> &'static str {
match (scope, access) {
("adaptive", "read") => "admin:adaptive:read",
("adaptive", "write") => "admin:adaptive:write",
("adaptive", "admin") => "admin:adaptive:admin",
("announcements", "read") => "admin:announcements:read",
("announcements", "write") => "admin:announcements:write",
("announcements", "admin") => "admin:announcements:admin",
("api_keys", "read") => "admin:api_keys:read",
("api_keys", "write") => "admin:api_keys:write",
("api_keys", "admin") => "admin:api_keys:admin",
("billing", "read") => "admin:billing:read",
("billing", "write") => "admin:billing:write",
("billing", "admin") => "admin:billing:admin",
("endpoints_health", "read") => "admin:endpoints_health:read",
("endpoints_health", "write") => "admin:endpoints_health:write",
("endpoints_health", "admin") => "admin:endpoints_health:admin",
("endpoints_manage", "read") => "admin:endpoints_manage:read",
("endpoints_manage", "write") => "admin:endpoints_manage:write",
("endpoints_manage", "admin") => "admin:endpoints_manage:admin",
("endpoints_rpm", "read") => "admin:endpoints_rpm:read",
("endpoints_rpm", "write") => "admin:endpoints_rpm:write",
("endpoints_rpm", "admin") => "admin:endpoints_rpm:admin",
("gemini_files", "read") => "admin:gemini_files:read",
("gemini_files", "write") => "admin:gemini_files:write",
("gemini_files", "admin") => "admin:gemini_files:admin",
("ldap", "read") => "admin:ldap:read",
("ldap", "write") => "admin:ldap:write",
("ldap", "admin") => "admin:ldap:admin",
("management_tokens", "read") => "admin:management_tokens:read",
("management_tokens", "write") => "admin:management_tokens:write",
("management_tokens", "admin") => "admin:management_tokens:admin",
("models", "read") => "admin:models:read",
("models", "write") => "admin:models:write",
("models", "admin") => "admin:models:admin",
("modules", "read") => "admin:modules:read",
("modules", "write") => "admin:modules:write",
("modules", "admin") => "admin:modules:admin",
("monitoring", "read") => "admin:monitoring:read",
("monitoring", "write") => "admin:monitoring:write",
("monitoring", "admin") => "admin:monitoring:admin",
("oauth", "read") => "admin:oauth:read",
("oauth", "write") => "admin:oauth:write",
("oauth", "admin") => "admin:oauth:admin",
("payments", "read") => "admin:payments:read",
("payments", "write") => "admin:payments:write",
("payments", "admin") => "admin:payments:admin",
("pool", "read") => "admin:pool:read",
("pool", "write") => "admin:pool:write",
("pool", "admin") => "admin:pool:admin",
("provider_ops", "read") => "admin:provider_ops:read",
("provider_ops", "write") => "admin:provider_ops:write",
("provider_ops", "admin") => "admin:provider_ops:admin",
("provider_oauth", "read") => "admin:provider_oauth:read",
("provider_oauth", "write") => "admin:provider_oauth:write",
("provider_oauth", "admin") => "admin:provider_oauth:admin",
("provider_query", "read") => "admin:provider_query:read",
("provider_query", "write") => "admin:provider_query:write",
("provider_query", "admin") => "admin:provider_query:admin",
("provider_strategy", "read") => "admin:provider_strategy:read",
("provider_strategy", "write") => "admin:provider_strategy:write",
("provider_strategy", "admin") => "admin:provider_strategy:admin",
("providers", "read") => "admin:providers:read",
("providers", "write") => "admin:providers:write",
("providers", "admin") => "admin:providers:admin",
("proxy_nodes", "read") => "admin:proxy_nodes:read",
("proxy_nodes", "write") => "admin:proxy_nodes:write",
("proxy_nodes", "admin") => "admin:proxy_nodes:admin",
("security", "read") => "admin:security:read",
("security", "write") => "admin:security:write",
("security", "admin") => "admin:security:admin",
("stats", "read") => "admin:stats:read",
("stats", "write") => "admin:stats:write",
("stats", "admin") => "admin:stats:admin",
("system", "read") => "admin:system:read",
("system", "write") => "admin:system:write",
("system", "admin") => "admin:system:admin",
("usage", "read") => "admin:usage:read",
("usage", "write") => "admin:usage:write",
("usage", "admin") => "admin:usage:admin",
("users", "read") => "admin:users:read",
("users", "write") => "admin:users:write",
("users", "admin") => "admin:users:admin",
("video_tasks", "read") => "admin:video_tasks:read",
("video_tasks", "write") => "admin:video_tasks:write",
("video_tasks", "admin") => "admin:video_tasks:admin",
("wallets", "read") => "admin:wallets:read",
("wallets", "write") => "admin:wallets:write",
("wallets", "admin") => "admin:wallets:admin",
_ => "admin:unknown:read",
}
}
fn is_known_management_token_permission_scope(scope: &str) -> bool {
PERMISSION_GROUPS.iter().any(|group| group.scope == scope)
}
fn is_assignable_management_token_permission(key: &str) -> bool {
management_token_permission_catalog_items()
.iter()
.any(|item| item.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_covers_known_admin_auth_scopes() {
let scopes = [
"adaptive",
"announcements",
"api_keys",
"billing",
"endpoints_health",
"endpoints_manage",
"endpoints_rpm",
"gemini_files",
"ldap",
"management_tokens",
"models",
"modules",
"monitoring",
"oauth",
"payments",
"pool",
"provider_oauth",
"provider_ops",
"provider_query",
"provider_strategy",
"providers",
"proxy_nodes",
"security",
"stats",
"system",
"usage",
"users",
"video_tasks",
"wallets",
];
for scope in scopes {
assert!(
is_known_management_token_permission_scope(scope),
"missing scope {scope}"
);
if scope == "management_tokens" {
continue;
}
for access in ["read", "write", "admin"] {
let key = format!("admin:{scope}:{access}");
assert!(
is_assignable_management_token_permission(&key),
"missing permission key {key}"
);
}
}
}
}

View File

@@ -3,6 +3,7 @@ use axum::http::Uri;
mod auth;
mod execute;
mod management_token_permissions;
mod public;
mod route;
@@ -12,6 +13,12 @@ pub(crate) use auth::{
GatewayAdminPrincipalContext, GatewayControlAuthContext, GatewayLocalAuthRejection,
};
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
pub(crate) use management_token_permissions::{
all_assignable_management_token_permissions, management_token_permission_catalog_payload,
management_token_permission_keys_from_value, management_token_permission_mode_and_summary,
management_token_required_permission, normalize_assignable_management_token_permissions,
validate_management_token_admin_route_permission,
};
pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext};
#[cfg(test)]
pub(crate) use route::classify_control_route;

View File

@@ -8,6 +8,20 @@ pub(super) fn classify_admin_basic_family_route(
normalized_path_no_trailing: &str,
) -> Option<ClassifiedRoute> {
if method == http::Method::GET
&& matches!(
normalized_path,
"/api/admin/management-tokens/permissions/catalog"
| "/api/admin/management-tokens/permissions/catalog/"
)
{
Some(classified(
"admin_proxy",
"management_tokens_manage",
"permissions_catalog",
"admin:management_tokens",
false,
))
} else if method == http::Method::GET
&& matches!(
normalized_path,
"/api/admin/management-tokens" | "/api/admin/management-tokens/"
@@ -20,6 +34,19 @@ pub(super) fn classify_admin_basic_family_route(
"admin:management_tokens",
false,
))
} else if method == http::Method::POST
&& matches!(
normalized_path,
"/api/admin/management-tokens" | "/api/admin/management-tokens/"
)
{
Some(classified(
"admin_proxy",
"management_tokens_manage",
"create_token",
"admin:management_tokens",
false,
))
} else if method == http::Method::GET
&& normalized_path.starts_with("/api/admin/management-tokens/")
{
@@ -30,6 +57,16 @@ pub(super) fn classify_admin_basic_family_route(
"admin:management_tokens",
false,
))
} else if method == http::Method::PUT
&& normalized_path.starts_with("/api/admin/management-tokens/")
{
Some(classified(
"admin_proxy",
"management_tokens_manage",
"update_token",
"admin:management_tokens",
false,
))
} else if method == http::Method::DELETE
&& normalized_path.starts_with("/api/admin/management-tokens/")
{
@@ -40,6 +77,17 @@ pub(super) fn classify_admin_basic_family_route(
"admin:management_tokens",
false,
))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/management-tokens/")
&& normalized_path.ends_with("/regenerate")
{
Some(classified(
"admin_proxy",
"management_tokens_manage",
"regenerate_token",
"admin:management_tokens",
false,
))
} else if method == http::Method::PATCH
&& normalized_path.starts_with("/api/admin/management-tokens/")
&& normalized_path.ends_with("/status")

View File

@@ -1,5 +1,7 @@
use http::Uri;
use crate::control::management_token_required_permission;
use super::{classify_control_route, headers};
#[test]
@@ -497,6 +499,58 @@ fn classifies_admin_list_management_tokens_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_management_token_write_routes_and_permission_catalog() {
let headers = headers(&[]);
let cases = [
(
http::Method::GET,
"/api/admin/management-tokens/permissions/catalog",
"permissions_catalog",
"admin:management_tokens:read",
),
(
http::Method::POST,
"/api/admin/management-tokens",
"create_token",
"admin:management_tokens:write",
),
(
http::Method::PUT,
"/api/admin/management-tokens/token-123",
"update_token",
"admin:management_tokens:write",
),
(
http::Method::POST,
"/api/admin/management-tokens/token-123/regenerate",
"regenerate_token",
"admin:management_tokens:write",
),
];
for (method, path, expected_kind, expected_permission) in cases {
let uri: Uri = path.parse().expect("uri should parse");
let decision =
classify_control_route(&method, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(
decision.route_family.as_deref(),
Some("management_tokens_manage")
);
assert_eq!(decision.route_kind.as_deref(), Some(expected_kind));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("admin:management_tokens")
);
assert_eq!(
management_token_required_permission(&method, &decision).as_deref(),
Some(expected_permission)
);
}
}
#[test]
fn classifies_admin_ldap_config_get_as_admin_proxy_route() {
let headers = headers(&[]);

View File

@@ -72,6 +72,30 @@ impl<'a> AdminAppState<'a> {
self.app.delete_management_token(token_id).await
}
pub(crate) async fn create_management_token(
&self,
record: &aether_data::repository::management_tokens::CreateManagementTokenRecord,
) -> Result<
crate::LocalMutationOutcome<
aether_data::repository::management_tokens::StoredManagementToken,
>,
GatewayError,
> {
self.app.create_management_token(record).await
}
pub(crate) async fn update_management_token(
&self,
record: &aether_data::repository::management_tokens::UpdateManagementTokenRecord,
) -> Result<
crate::LocalMutationOutcome<
aether_data::repository::management_tokens::StoredManagementToken,
>,
GatewayError,
> {
self.app.update_management_token(record).await
}
pub(crate) async fn list_management_tokens(
&self,
query: &aether_data::repository::management_tokens::ManagementTokenListQuery,
@@ -95,6 +119,18 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn regenerate_management_token_secret(
&self,
mutation: &aether_data::repository::management_tokens::RegenerateManagementTokenSecret,
) -> Result<
crate::LocalMutationOutcome<
aether_data::repository::management_tokens::StoredManagementToken,
>,
GatewayError,
> {
self.app.regenerate_management_token_secret(mutation).await
}
pub(crate) async fn remove_admin_security_blacklist(
&self,
ip_address: &str,

View File

@@ -47,6 +47,7 @@ pub(crate) async fn maybe_build_local_admin_core_response(
super::management_tokens::maybe_build_local_admin_management_tokens_response(
state,
request_context,
request_body,
)
.await?
{

View File

@@ -1,23 +1,314 @@
use crate::control::{
management_token_permission_catalog_payload, normalize_assignable_management_token_permissions,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
use crate::handlers::admin::system::shared::paths::{
admin_management_token_id_from_path, admin_management_token_status_id_from_path,
is_admin_management_tokens_root,
admin_management_token_id_from_path, admin_management_token_regenerate_id_from_path,
admin_management_token_status_id_from_path, is_admin_management_tokens_root,
};
use crate::handlers::internal::build_management_token_payload;
use crate::GatewayError;
use aether_data::repository::management_tokens::ManagementTokenListQuery;
use crate::handlers::shared::generate_gateway_secret_plaintext;
use crate::{GatewayError, LocalMutationOutcome};
use aether_data::repository::management_tokens::{
CreateManagementTokenRecord, ManagementTokenListQuery, RegenerateManagementTokenSecret,
StoredManagementTokenUserSummary, UpdateManagementTokenRecord,
};
use axum::{
body::Body,
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use chrono::Utc;
use serde_json::json;
use sha2::{Digest, Sha256};
use uuid::Uuid;
const ADMIN_MANAGEMENT_TOKEN_PREFIX: &str = "ae";
const ADMIN_MANAGEMENT_TOKEN_SEPARATOR: &str = "-";
const ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN: usize = 10;
fn admin_management_token_bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn admin_management_token_not_found_response() -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response()
}
fn admin_management_token_read_only_response() -> Response<Body> {
(
http::StatusCode::CONFLICT,
Json(json!({
"detail": "Management Token 本地写入仓库不可用",
"error_code": "read_only_mode",
})),
)
.into_response()
}
#[derive(Debug, Clone)]
struct AdminManagementTokenCreateInput {
name: String,
description: Option<String>,
allowed_ips: Option<serde_json::Value>,
permissions: serde_json::Value,
expires_at_unix_secs: Option<u64>,
}
#[derive(Debug, Clone, Default)]
struct AdminManagementTokenUpdateInput {
name: Option<String>,
description: Option<String>,
clear_description: bool,
allowed_ips: Option<serde_json::Value>,
clear_allowed_ips: bool,
permissions: Option<serde_json::Value>,
expires_at_unix_secs: Option<u64>,
clear_expires_at: bool,
}
fn generate_admin_management_token_plaintext() -> String {
generate_gateway_secret_plaintext(
ADMIN_MANAGEMENT_TOKEN_PREFIX,
ADMIN_MANAGEMENT_TOKEN_SEPARATOR,
)
}
fn hash_admin_management_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn admin_management_token_prefix(value: &str) -> Option<String> {
(!value.is_empty())
.then(|| value[..value.len().min(ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN)].to_string())
}
fn admin_validate_ip_or_cidr(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
if value.parse::<std::net::IpAddr>().is_ok() {
return true;
}
let Some((host, prefix)) = value.split_once('/') else {
return false;
};
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
return false;
};
let Ok(prefix) = prefix.trim().parse::<u8>() else {
return false;
};
match ip {
std::net::IpAddr::V4(_) => prefix <= 32,
std::net::IpAddr::V6(_) => prefix <= 128,
}
}
fn admin_parse_management_token_allowed_ips(
value: Option<&serde_json::Value>,
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = value else {
return Ok(None);
};
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::Array(items) => {
if items.is_empty() {
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
}
let mut normalized = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
let Some(raw) = item.as_str() else {
return Err("IP 白名单必须是字符串数组".to_string());
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("IP 白名单第 {} 项为空", index + 1));
}
if !admin_validate_ip_or_cidr(trimmed) {
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
}
normalized.push(trimmed.to_string());
}
Ok(Some(json!(normalized)))
}
_ => Err("IP 白名单必须是字符串数组".to_string()),
}
}
fn admin_parse_management_token_expires_at(
value: &serde_json::Value,
) -> Result<Option<u64>, String> {
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::String(raw) => {
let raw = raw.trim();
if raw.is_empty() {
return Ok(None);
}
let parsed = chrono::DateTime::parse_from_rfc3339(raw)
.map(|value| value.with_timezone(&Utc))
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f")
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
})
.or_else(|_| chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
.map(|value| value.and_utc())
})
.map_err(|_| format!("无效的时间格式: {raw}"))?;
let unix_secs = parsed.timestamp();
if unix_secs <= Utc::now().timestamp() {
return Err("过期时间必须在未来".to_string());
}
u64::try_from(unix_secs)
.map(Some)
.map_err(|_| format!("无效的时间格式: {raw}"))
}
_ => Err("expires_at 必须是字符串或 null".to_string()),
}
}
fn admin_parse_management_token_create_input(
request_body: &[u8],
) -> Result<AdminManagementTokenCreateInput, String> {
let payload =
serde_json::from_slice::<serde_json::Map<String, serde_json::Value>>(request_body)
.map_err(|_| "输入验证失败".to_string())?;
let name = match payload.get("name") {
Some(serde_json::Value::String(value)) if (1..=100).contains(&value.chars().count()) => {
value.clone()
}
_ => return Err("输入验证失败".to_string()),
};
let description = match payload.get("description") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(value)) if value.chars().count() <= 500 => {
Some(value.clone())
}
_ => return Err("输入验证失败".to_string()),
};
let allowed_ips = admin_parse_management_token_allowed_ips(payload.get("allowed_ips"))?;
let permissions =
normalize_assignable_management_token_permissions(payload.get("permissions"))?;
let expires_at_unix_secs = match payload.get("expires_at") {
Some(value) => admin_parse_management_token_expires_at(value)?,
None => None,
};
Ok(AdminManagementTokenCreateInput {
name,
description,
allowed_ips,
permissions,
expires_at_unix_secs,
})
}
fn admin_parse_management_token_update_input(
request_body: &[u8],
) -> Result<AdminManagementTokenUpdateInput, String> {
let payload =
serde_json::from_slice::<serde_json::Map<String, serde_json::Value>>(request_body)
.map_err(|_| "输入验证失败".to_string())?;
let mut input = AdminManagementTokenUpdateInput::default();
if let Some(value) = payload.get("name") {
match value {
serde_json::Value::Null => {}
serde_json::Value::String(value) if (1..=100).contains(&value.chars().count()) => {
input.name = Some(value.clone());
}
_ => return Err("输入验证失败".to_string()),
}
}
if let Some(value) = payload.get("description") {
match value {
serde_json::Value::Null => input.clear_description = true,
serde_json::Value::String(value) if value.chars().count() <= 500 => {
if value.is_empty() {
input.clear_description = true;
} else {
input.description = Some(value.clone());
}
}
_ => return Err("输入验证失败".to_string()),
}
}
if let Some(value) = payload.get("allowed_ips") {
if value.is_null() {
input.clear_allowed_ips = true;
} else {
input.allowed_ips = admin_parse_management_token_allowed_ips(Some(value))?;
}
}
if let Some(value) = payload.get("permissions") {
input.permissions = Some(normalize_assignable_management_token_permissions(Some(
value,
))?);
}
if let Some(value) = payload.get("expires_at") {
if value.is_null() || value.as_str().is_some_and(|value| value.trim().is_empty()) {
input.clear_expires_at = true;
} else {
input.expires_at_unix_secs = admin_parse_management_token_expires_at(value)?;
}
}
Ok(input)
}
async fn admin_management_token_user_summary(
state: &AdminAppState<'_>,
user_id: &str,
) -> Result<StoredManagementTokenUserSummary, Response<Body>> {
let Some(user) = state.find_user_auth_by_id(user_id).await.map_err(|err| {
(
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": format!("management token user lookup failed: {err:?}") })),
)
.into_response()
})?
else {
return Err((
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "管理员用户不存在" })),
)
.into_response());
};
StoredManagementTokenUserSummary::new(
user.id,
user.email,
user.username,
user.role,
)
.map_err(|err| {
(
http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "detail": format!("management token user summary build failed: {err:?}") })),
)
.into_response()
})
}
pub(crate) async fn maybe_build_local_admin_management_tokens_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
return Ok(None);
@@ -43,6 +334,14 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
));
}
if decision.route_kind.as_deref() == Some("permissions_catalog")
&& request_context.method() == http::Method::GET
{
return Ok(Some(
Json(management_token_permission_catalog_payload()).into_response(),
));
}
if decision.route_kind.as_deref() == Some("list_tokens")
&& request_context.method() == http::Method::GET
&& is_admin_management_tokens_root(request_context.path())
@@ -83,6 +382,63 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
));
}
if decision.route_kind.as_deref() == Some("create_token")
&& request_context.method() == http::Method::POST
&& is_admin_management_tokens_root(request_context.path())
{
if !state.has_management_token_writer() {
return Ok(None);
}
let Some(request_body) = request_body else {
return Ok(Some(admin_management_token_bad_request_response(
"缺少请求体",
)));
};
let input = match admin_parse_management_token_create_input(request_body) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_management_token_bad_request_response(detail))),
};
let Some(admin_principal) = decision.admin_principal.as_ref() else {
return Ok(None);
};
let user = match admin_management_token_user_summary(state, &admin_principal.user_id).await
{
Ok(value) => value,
Err(response) => return Ok(Some(response)),
};
let raw_token = generate_admin_management_token_plaintext();
let record = CreateManagementTokenRecord {
id: Uuid::new_v4().to_string(),
user_id: user.id.clone(),
user,
token_hash: hash_admin_management_token(&raw_token),
token_prefix: admin_management_token_prefix(&raw_token),
name: input.name.clone(),
description: input.description,
allowed_ips: input.allowed_ips,
permissions: Some(input.permissions),
expires_at_unix_secs: input.expires_at_unix_secs,
is_active: true,
};
return Ok(Some(match state.create_management_token(&record).await? {
LocalMutationOutcome::Applied(token) => (
http::StatusCode::CREATED,
Json(json!({
"message": "Management Token 创建成功",
"token": raw_token,
"data": build_management_token_payload(&token, Some(&record.user)),
})),
)
.into_response(),
LocalMutationOutcome::Invalid(detail) => {
admin_management_token_bad_request_response(detail)
}
LocalMutationOutcome::Unavailable => admin_management_token_read_only_response(),
LocalMutationOutcome::NotFound => admin_management_token_not_found_response(),
}));
}
if decision.route_kind.as_deref() == Some("get_token")
&& request_context.method() == http::Method::GET
{
@@ -105,15 +461,60 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
Some(&token.user),
))
.into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response(),
None => admin_management_token_not_found_response(),
},
));
}
if decision.route_kind.as_deref() == Some("update_token")
&& request_context.method() == http::Method::PUT
{
if !state.has_management_token_writer() {
return Ok(None);
}
let Some(token_id) = admin_management_token_id_from_path(request_context.path()) else {
return Ok(Some(admin_management_token_not_found_response()));
};
let existing = match state.get_management_token_with_user(&token_id).await? {
Some(token) => token,
None => return Ok(Some(admin_management_token_not_found_response())),
};
let Some(request_body) = request_body else {
return Ok(Some(admin_management_token_bad_request_response(
"缺少请求体",
)));
};
let input = match admin_parse_management_token_update_input(request_body) {
Ok(value) => value,
Err(detail) => return Ok(Some(admin_management_token_bad_request_response(detail))),
};
let record = UpdateManagementTokenRecord {
token_id: existing.token.id.clone(),
name: input.name,
description: input.description,
clear_description: input.clear_description,
allowed_ips: input.allowed_ips,
clear_allowed_ips: input.clear_allowed_ips,
permissions: input.permissions,
expires_at_unix_secs: input.expires_at_unix_secs,
clear_expires_at: input.clear_expires_at,
is_active: None,
};
return Ok(Some(match state.update_management_token(&record).await? {
LocalMutationOutcome::Applied(token) => Json(json!({
"message": "更新成功",
"data": build_management_token_payload(&token, Some(&existing.user)),
}))
.into_response(),
LocalMutationOutcome::NotFound => admin_management_token_not_found_response(),
LocalMutationOutcome::Invalid(detail) => {
admin_management_token_bad_request_response(detail)
}
LocalMutationOutcome::Unavailable => admin_management_token_read_only_response(),
}));
}
if decision.route_kind.as_deref() == Some("delete_token")
&& request_context.method() == http::Method::DELETE
{
@@ -121,35 +522,17 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
return Ok(None);
}
let Some(token_id) = admin_management_token_id_from_path(request_context.path()) else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response(),
));
return Ok(Some(admin_management_token_not_found_response()));
};
let existing = match state.get_management_token_with_user(&token_id).await? {
Some(token) => token,
None => {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response(),
));
}
None => return Ok(Some(admin_management_token_not_found_response())),
};
let deleted = state.delete_management_token(&existing.token.id).await?;
return Ok(Some(if deleted {
Json(json!({ "message": "删除成功" })).into_response()
} else {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response()
admin_management_token_not_found_response()
}));
}
@@ -161,37 +544,17 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
}
let Some(token_id) = admin_management_token_status_id_from_path(request_context.path())
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response(),
));
return Ok(Some(admin_management_token_not_found_response()));
};
let existing = match state.get_management_token_with_user(&token_id).await? {
Some(token) => token,
None => {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response(),
));
}
None => return Ok(Some(admin_management_token_not_found_response())),
};
let Some(updated) = state
.set_management_token_active(&existing.token.id, !existing.token.is_active)
.await?
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Management Token 不存在" })),
)
.into_response(),
));
return Ok(Some(admin_management_token_not_found_response()));
};
return Ok(Some(
Json(json!({
@@ -202,5 +565,43 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
));
}
if decision.route_kind.as_deref() == Some("regenerate_token")
&& request_context.method() == http::Method::POST
{
if !state.has_management_token_writer() {
return Ok(None);
}
let Some(token_id) = admin_management_token_regenerate_id_from_path(request_context.path())
else {
return Ok(Some(admin_management_token_not_found_response()));
};
let existing = match state.get_management_token_with_user(&token_id).await? {
Some(token) => token,
None => return Ok(Some(admin_management_token_not_found_response())),
};
let raw_token = generate_admin_management_token_plaintext();
let mutation = RegenerateManagementTokenSecret {
token_id: existing.token.id.clone(),
token_hash: hash_admin_management_token(&raw_token),
token_prefix: admin_management_token_prefix(&raw_token),
};
return Ok(Some(
match state.regenerate_management_token_secret(&mutation).await? {
LocalMutationOutcome::Applied(token) => Json(json!({
"message": "Token 已重新生成",
"token": raw_token,
"data": build_management_token_payload(&token, Some(&existing.user)),
}))
.into_response(),
LocalMutationOutcome::NotFound => admin_management_token_not_found_response(),
LocalMutationOutcome::Invalid(detail) => {
admin_management_token_bad_request_response(detail)
}
LocalMutationOutcome::Unavailable => admin_management_token_read_only_response(),
},
));
}
Ok(None)
}

View File

@@ -37,3 +37,7 @@ pub(crate) fn admin_management_token_id_from_path(request_path: &str) -> Option<
pub(crate) fn admin_management_token_status_id_from_path(request_path: &str) -> Option<String> {
aether_admin::system::admin_management_token_status_id_from_path(request_path)
}
pub(crate) fn admin_management_token_regenerate_id_from_path(request_path: &str) -> Option<String> {
aether_admin::system::admin_management_token_regenerate_id_from_path(request_path)
}

View File

@@ -7,7 +7,7 @@ use crate::constants::{
EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AUTH_DENIED, EXECUTION_PATH_LOCAL_OVERLOADED,
EXECUTION_PATH_LOCAL_RATE_LIMITED, EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH,
};
use crate::control::GatewayControlDecision;
use crate::control::{management_token_permission_mode_and_summary, GatewayControlDecision};
use crate::execution_runtime::{
maybe_build_local_sync_finalize_response, maybe_build_local_video_error_response,
maybe_build_local_video_success_outcome, resolve_local_sync_error_background_report_kind,
@@ -465,6 +465,8 @@ pub(crate) fn build_management_token_payload(
token: &StoredManagementToken,
user: Option<&StoredManagementTokenUserSummary>,
) -> serde_json::Value {
let (permission_mode, permission_summary) =
management_token_permission_mode_and_summary(token.permissions.as_ref());
let mut payload = json!({
"id": token.id,
"user_id": token.user_id,
@@ -472,6 +474,9 @@ pub(crate) fn build_management_token_payload(
"description": token.description,
"token_display": token.token_display(),
"allowed_ips": token.allowed_ips,
"permissions": token.permissions,
"permission_mode": permission_mode,
"permission_summary": permission_summary,
"expires_at": token.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
"last_used_at": token.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339),
"last_used_ip": token.last_used_ip,

View File

@@ -1,9 +1,16 @@
use super::super::internal;
use crate::admin_api;
use crate::control::GatewayPublicRequestContext;
use crate::audit::attach_admin_audit_event;
use crate::control::{
validate_management_token_admin_route_permission, GatewayPublicRequestContext,
};
use crate::{AppState, GatewayError};
use axum::body::{Body, Bytes};
use axum::http::Response;
use axum::http::{self, Response};
use axum::response::IntoResponse;
use axum::Json;
use serde_json::json;
use tracing::warn;
pub(super) async fn maybe_build_local_internal_proxy_response(
state: &AppState,
@@ -34,6 +41,10 @@ pub(super) async fn maybe_build_local_admin_proxy_response(
if decision.admin_principal.is_none() {
return Ok(None);
}
if let Some(response) = maybe_build_management_token_permission_denied_response(request_context)
{
return Ok(Some(response));
}
admin_api::maybe_build_local_admin_response(admin_api::AdminRouteRequest::new(
state,
@@ -42,3 +53,46 @@ pub(super) async fn maybe_build_local_admin_proxy_response(
))
.await
}
fn maybe_build_management_token_permission_denied_response(
request_context: &GatewayPublicRequestContext,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
let admin_principal = decision.admin_principal.as_ref()?;
let token_id = admin_principal.management_token_id.as_deref()?;
let denied = validate_management_token_admin_route_permission(
&request_context.request_method,
decision,
admin_principal.management_token_permissions.as_deref(),
)
.err()?;
warn!(
trace_id = %request_context.trace_id,
admin_management_token_id = %token_id,
route_family = decision.route_family.as_deref().unwrap_or("unknown"),
route_kind = decision.route_kind.as_deref().unwrap_or("unknown"),
required_permission = %denied.required_permission,
"management token permission denied"
);
let mut response = (
http::StatusCode::FORBIDDEN,
Json(json!({
"detail": "management token permission denied",
"required_permission": denied.required_permission,
"route_family": decision.route_family.as_deref(),
"route_kind": decision.route_kind.as_deref(),
"request_path": request_context.request_path,
})),
)
.into_response();
attach_admin_audit_event(
&mut response,
"admin_management_token_permission_denied",
"permission_denied",
"management_token_permission",
token_id,
);
Some(response)
}

View File

@@ -31,9 +31,9 @@ use crate::constants::{
TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER,
};
use crate::control::{
allows_control_execute_emergency, maybe_execute_via_control, request_model_local_rejection,
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayControlDecision,
GatewayPublicRequestContext,
allows_control_execute_emergency, management_token_permission_keys_from_value,
maybe_execute_via_control, request_model_local_rejection, should_buffer_request_for_local_auth,
trusted_auth_local_rejection, GatewayControlDecision, GatewayPublicRequestContext,
};
use crate::executor::{
beautify_local_execution_client_error_message, build_local_execution_runtime_miss_context,
@@ -229,12 +229,27 @@ async fn maybe_promote_management_token_admin_principal(
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
return Ok(());
}
let management_token_permissions = match management_token_permission_keys_from_value(
token_with_user.token.permissions.as_ref(),
) {
Ok(value) => value,
Err(err) => {
warn!(
trace_id = %trace_id,
token_id = %token_with_user.token.id,
error = %err,
"gateway rejected management token with invalid permissions"
);
return Ok(());
}
};
decision.admin_principal = Some(crate::control::GatewayAdminPrincipalContext {
user_id: user.id.clone(),
user_role: user.role.clone(),
session_id: None,
management_token_id: Some(token_with_user.token.id.clone()),
management_token_permissions,
});
let remote_ip = remote_addr.ip().to_string();

View File

@@ -19,6 +19,7 @@ use super::{
query_param_value, resolve_authenticated_local_user, AppState, AuthenticatedLocalUserContext,
GatewayPublicRequestContext,
};
use crate::control::normalize_assignable_management_token_permissions;
use crate::handlers::shared::generate_gateway_secret_plaintext;
use crate::LocalMutationOutcome;
@@ -38,6 +39,7 @@ struct UsersMeManagementTokenCreateInput {
name: String,
description: Option<String>,
allowed_ips: Option<serde_json::Value>,
permissions: serde_json::Value,
expires_at_unix_secs: Option<u64>,
}
@@ -48,6 +50,7 @@ struct UsersMeManagementTokenUpdateInput {
clear_description: bool,
allowed_ips: Option<serde_json::Value>,
clear_allowed_ips: bool,
permissions: Option<serde_json::Value>,
expires_at_unix_secs: Option<u64>,
clear_expires_at: bool,
}
@@ -59,6 +62,7 @@ impl UsersMeManagementTokenUpdateInput {
&& !self.clear_description
&& self.allowed_ips.is_none()
&& !self.clear_allowed_ips
&& self.permissions.is_none()
&& self.expires_at_unix_secs.is_none()
&& !self.clear_expires_at
}
@@ -282,6 +286,8 @@ fn users_me_parse_management_token_create_input(
_ => return Err("输入验证失败".to_string()),
};
let allowed_ips = users_me_parse_management_token_allowed_ips(payload.get("allowed_ips"))?;
let permissions =
normalize_assignable_management_token_permissions(payload.get("permissions"))?;
let expires_at_unix_secs = match payload.get("expires_at") {
Some(value) => users_me_parse_management_token_expires_at(value, false)?,
None => None,
@@ -291,6 +297,7 @@ fn users_me_parse_management_token_create_input(
name,
description,
allowed_ips,
permissions,
expires_at_unix_secs,
})
}
@@ -335,6 +342,12 @@ fn users_me_parse_management_token_update_input(
}
}
if let Some(value) = payload.get("permissions") {
input.permissions = Some(normalize_assignable_management_token_permissions(Some(
value,
))?);
}
if let Some(value) = payload.get("expires_at") {
if value.is_null() || value.as_str().is_some_and(|value| value.trim().is_empty()) {
input.clear_expires_at = true;
@@ -482,6 +495,13 @@ pub(super) async fn handle_users_me_management_token_create(
Ok(value) => value,
Err(response) => return response,
};
if !auth.user.role.eq_ignore_ascii_case("admin") {
return build_auth_error_response(
http::StatusCode::FORBIDDEN,
"仅管理员可以创建 Management Token",
false,
);
}
let Some(request_body) = request_body else {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少请求体", false);
};
@@ -536,6 +556,7 @@ pub(super) async fn handle_users_me_management_token_create(
name: input.name.clone(),
description: input.description,
allowed_ips: input.allowed_ips,
permissions: Some(input.permissions),
expires_at_unix_secs: input.expires_at_unix_secs,
is_active: true,
};
@@ -668,6 +689,7 @@ pub(super) async fn handle_users_me_management_token_update(
clear_description: input.clear_description,
allowed_ips: input.allowed_ips,
clear_allowed_ips: input.clear_allowed_ips,
permissions: input.permissions,
expires_at_unix_secs: input.expires_at_unix_secs,
clear_expires_at: input.clear_expires_at,
is_active: None,

View File

@@ -224,6 +224,8 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("endpoints_manage"), http::Method::PUT, Some("update_key"))
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
| (Some("modules_manage"), http::Method::PUT, Some("set_enabled"))
| (Some("management_tokens_manage"), http::Method::POST, Some("create_token"))
| (Some("management_tokens_manage"), http::Method::PUT, Some("update_token"))
| (Some("oauth_manage"), http::Method::PUT, Some("upsert_provider"))
| (Some("oauth_manage"), http::Method::POST, Some("test_provider"))
| (Some("provider_oauth_manage"), http::Method::POST, Some("complete_key_oauth"))

View File

@@ -6767,6 +6767,152 @@ async fn gateway_handles_admin_management_token_detail_locally_with_trusted_admi
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_creates_updates_and_regenerates_admin_management_token_locally_with_permissions() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().fallback(any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}));
let repository = Arc::new(InMemoryManagementTokenRepository::default());
let state = AppState::new().expect("gateway should build");
let admin_user = state
.create_local_auth_user_with_settings(
Some("management-admin@example.com".to_string()),
true,
"admin".to_string(),
"hash".to_string(),
"admin".to_string(),
None,
None,
None,
None,
)
.await
.expect("admin user should be created")
.expect("admin user should exist");
let (upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(state.with_data_state_for_tests(
GatewayDataState::with_management_token_repository_for_tests(repository.clone()),
));
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let create_response = client
.post(format!("{gateway_url}/api/admin/management-tokens"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"name": "admin-token",
"description": "admin token",
"allowed_ips": ["127.0.0.1"],
"permissions": ["admin:usage:read", "admin:pool:write"],
"expires_at": "2099-01-01T00:00:00Z",
}))
.send()
.await
.expect("request should succeed");
let create_status = create_response.status();
let create_body = create_response.text().await.expect("body should read");
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
let create_payload: serde_json::Value =
serde_json::from_str(&create_body).expect("json body should parse");
let token_id = create_payload["data"]["id"]
.as_str()
.expect("token id should exist")
.to_string();
assert_eq!(create_payload["message"], "Management Token 创建成功");
assert_eq!(create_payload["data"]["user"]["id"], json!(admin_user.id));
assert_eq!(
create_payload["data"]["permissions"],
json!(["admin:pool:write", "admin:usage:read"])
);
let created_secret = create_payload["token"].as_str().unwrap_or_default();
assert!(created_secret.starts_with("ae-"));
let update_response = client
.put(format!(
"{gateway_url}/api/admin/management-tokens/{token_id}"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"name": "admin-token-updated",
"description": null,
"allowed_ips": null,
"permissions": ["admin:usage:read"],
"expires_at": null,
}))
.send()
.await
.expect("request should succeed");
assert_eq!(update_response.status(), StatusCode::OK);
let update_payload: serde_json::Value = update_response
.json()
.await
.expect("json body should parse");
assert_eq!(update_payload["message"], "更新成功");
assert_eq!(update_payload["data"]["name"], "admin-token-updated");
assert_eq!(
update_payload["data"]["permissions"],
json!(["admin:usage:read"])
);
assert_eq!(
update_payload["data"]["allowed_ips"],
serde_json::Value::Null
);
let regenerate_response = client
.post(format!(
"{gateway_url}/api/admin/management-tokens/{token_id}/regenerate"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, admin_user.id.as_str())
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(regenerate_response.status(), StatusCode::OK);
let regenerate_payload: serde_json::Value = regenerate_response
.json()
.await
.expect("json body should parse");
assert_eq!(regenerate_payload["message"], "Token 已重新生成");
assert!(regenerate_payload["token"]
.as_str()
.unwrap_or_default()
.starts_with("ae-"));
assert_eq!(
repository
.get_management_token_with_user(&token_id)
.await
.expect("lookup should succeed")
.expect("token should remain")
.token
.permissions,
Some(json!(["admin:usage:read"]))
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
drop(upstream_url);
}
#[tokio::test]
async fn gateway_deletes_admin_management_token_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));

View File

@@ -763,6 +763,173 @@ async fn gateway_registers_and_unregisters_proxy_nodes_locally_with_management_t
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_allows_management_token_with_proxy_nodes_write_permission() {
let raw_token = "ae-proxy-register-write-permission";
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::default());
let state = AppState::new().expect("gateway should build");
let admin_user = state
.create_local_auth_user_with_settings(
Some("proxy-admin-write@example.com".to_string()),
true,
"admin".to_string(),
"hash".to_string(),
"admin".to_string(),
None,
None,
None,
None,
)
.await
.expect("admin user should be created")
.expect("admin user should exist");
let mut management_token = sample_management_token(
"token-proxy-register-write",
&admin_user.id,
"proxy-admin-write",
true,
);
management_token.token.allowed_ips = None;
management_token.token.permissions = Some(json!(["admin:proxy_nodes:write"]));
let management_token_repository =
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
vec![management_token],
vec![(
hash_management_token(raw_token),
"token-proxy-register-write".to_string(),
)],
));
let state = state.with_data_state_for_tests(
GatewayDataState::with_management_token_repository_for_tests(management_token_repository)
.attach_proxy_node_repository_for_tests(proxy_node_repository),
);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let register_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/proxy-nodes/register"))
.header(GATEWAY_HEADER, "rust-phase3b")
.bearer_auth(raw_token)
.json(&json!({
"name": "proxy-write",
"ip": "1.1.1.1",
"port": 0,
"heartbeat_interval": 30,
"tunnel_mode": true
}))
.send()
.await
.expect("request should succeed");
assert_eq!(register_response.status(), StatusCode::OK);
let register_payload: serde_json::Value = register_response
.json()
.await
.expect("json body should parse");
assert_eq!(register_payload["node"]["name"], "proxy-write");
assert_eq!(
register_payload["node"]["registered_by"],
json!(admin_user.id)
);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_management_token_without_required_admin_route_permission() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/proxy-nodes/register",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let raw_token = "ae-proxy-register-denied";
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::default());
let state = AppState::new().expect("gateway should build");
let admin_user = state
.create_local_auth_user_with_settings(
Some("proxy-admin-denied@example.com".to_string()),
true,
"admin".to_string(),
"hash".to_string(),
"admin".to_string(),
None,
None,
None,
None,
)
.await
.expect("admin user should be created")
.expect("admin user should exist");
let mut management_token = sample_management_token(
"token-proxy-register-denied",
&admin_user.id,
"proxy-admin-denied",
true,
);
management_token.token.allowed_ips = None;
management_token.token.permissions = Some(json!(["admin:usage:read"]));
let management_token_repository =
Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
vec![management_token],
vec![(
hash_management_token(raw_token),
"token-proxy-register-denied".to_string(),
)],
));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let state = state.with_data_state_for_tests(
GatewayDataState::with_management_token_repository_for_tests(management_token_repository)
.attach_proxy_node_repository_for_tests(proxy_node_repository),
);
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let register_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/proxy-nodes/register"))
.header(GATEWAY_HEADER, "rust-phase3b")
.bearer_auth(raw_token)
.json(&json!({
"name": "proxy-denied",
"ip": "1.1.1.1",
"port": 0,
"heartbeat_interval": 30,
"tunnel_mode": true
}))
.send()
.await
.expect("request should succeed");
assert_eq!(register_response.status(), StatusCode::FORBIDDEN);
let denied_payload: serde_json::Value = register_response
.json()
.await
.expect("json body should parse");
assert_eq!(
denied_payload["detail"],
json!("management token permission denied")
);
assert_eq!(
denied_payload["required_permission"],
json!("admin:proxy_nodes:write")
);
assert_eq!(denied_payload["route_family"], json!("proxy_nodes_manage"));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
drop(upstream_url);
}
#[tokio::test]
async fn gateway_registers_proxy_node_with_management_token_when_allowed_ips_is_json_null() {
let raw_token = "ae_proxy_register_json_null";

View File

@@ -6858,7 +6858,8 @@ async fn gateway_rejects_users_me_management_token_nested_get_path_as_local_not_
#[tokio::test]
async fn gateway_handles_users_me_management_token_writes_locally_without_proxying_upstream() {
let now = Utc::now();
let user = sample_auth_user(now);
let mut user = sample_auth_user(now);
user.role = "admin".to_string();
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
@@ -6913,6 +6914,7 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
"name": "writer-token",
"description": "writer token",
"allowed_ips": ["127.0.0.1"],
"permissions": ["admin:usage:read"],
"expires_at": (now + chrono::Duration::days(1)).to_rfc3339(),
}))
.send()
@@ -6930,6 +6932,10 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
.to_string();
assert_eq!(create_payload["message"], "Management Token 创建成功");
assert_eq!(create_payload["data"]["name"], "writer-token");
assert_eq!(
create_payload["data"]["permissions"],
json!(["admin:usage:read"])
);
let created_token = create_payload["token"].as_str().unwrap_or_default();
let created_token_random = created_token.strip_prefix("ae-").unwrap_or_default();
assert_eq!(created_token_random.len(), 32);
@@ -6951,6 +6957,7 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
"name": "writer-token-renamed",
"description": null,
"allowed_ips": ["10.0.0.1"],
"permissions": ["admin:usage:read", "admin:pool:write"],
"expires_at": (now + chrono::Duration::days(2)).to_rfc3339(),
}))
.send()
@@ -6967,6 +6974,10 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
update_payload["data"]["description"],
serde_json::Value::Null
);
assert_eq!(
update_payload["data"]["permissions"],
json!(["admin:pool:write", "admin:usage:read"])
);
let toggle_response = client
.patch(format!(
@@ -7055,6 +7066,81 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_rejects_users_me_management_token_create_for_non_admin_user_without_proxying_upstream(
) {
let now = Utc::now();
let user = sample_auth_user(now);
let access_token = build_test_auth_token(
"access",
serde_json::Map::from_iter([
("user_id".to_string(), json!(user.id)),
("role".to_string(), json!(user.role)),
(
"created_at".to_string(),
json!(user.created_at.map(|value| value.to_rfc3339())),
),
(
"session_id".to_string(),
json!("session-users-me-management-token-create-denied"),
),
]),
now + chrono::Duration::hours(1),
);
let repository = Arc::new(InMemoryManagementTokenRepository::default());
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
crate::data::GatewayDataState::with_management_token_repository_for_tests(
repository,
)
.with_user_reader(user_repository);
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state)
.with_auth_sessions_for_tests([sample_auth_session(
"user-auth-1",
"session-users-me-management-token-create-denied",
"device-users-me-management-token-create-denied",
"refresh-token-users-me-management-token-create-denied",
now,
)])
})
.await;
let create_response = reqwest::Client::new()
.post(format!("{gateway_url}/api/me/management-tokens"))
.header("authorization", format!("Bearer {access_token}"))
.header(
"x-client-device-id",
"device-users-me-management-token-create-denied",
)
.header("user-agent", "AetherTest/1.0")
.json(&json!({
"name": "ordinary-user-token",
"description": "ordinary user token",
}))
.send()
.await
.expect("request should succeed");
assert_eq!(create_response.status(), StatusCode::FORBIDDEN);
let create_payload: serde_json::Value = create_response
.json()
.await
.expect("json body should parse");
assert_eq!(
create_payload["detail"],
json!("仅管理员可以创建 Management Token")
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_returns_service_unavailable_for_users_me_management_token_reads_without_reader() {
let now = Utc::now();