mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge remote-tracking branch 'origin/pr/399' into aether-rust-pioneer
# Conflicts: # crates/aether-data/src/lifecycle/bootstrap/postgres.rs # crates/aether-data/src/lifecycle/migrate/tests.rs
This commit is contained in:
@@ -54,6 +54,7 @@ pub(crate) struct GatewayAdminPrincipalContext {
|
|||||||
pub(crate) user_role: String,
|
pub(crate) user_role: String,
|
||||||
pub(crate) session_id: Option<String>,
|
pub(crate) session_id: Option<String>,
|
||||||
pub(crate) management_token_id: Option<String>,
|
pub(crate) management_token_id: Option<String>,
|
||||||
|
pub(crate) management_token_permissions: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(in super::super) enum ControlDecisionAuthResolution {
|
pub(in super::super) enum ControlDecisionAuthResolution {
|
||||||
@@ -243,6 +244,7 @@ fn resolve_trusted_admin_principal(
|
|||||||
user_role: trusted_headers.user_role,
|
user_role: trusted_headers.user_role,
|
||||||
session_id: trusted_headers.session_id,
|
session_id: trusted_headers.session_id,
|
||||||
management_token_id: trusted_headers.management_token_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(),
|
user_role: "admin".to_string(),
|
||||||
session_id: Some(session.id),
|
session_id: Some(session.id),
|
||||||
management_token_id: None,
|
management_token_id: None,
|
||||||
|
management_token_permissions: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
518
apps/aether-gateway/src/control/management_token_permissions.rs
Normal file
518
apps/aether-gateway/src/control/management_token_permissions.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use axum::http::Uri;
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod execute;
|
mod execute;
|
||||||
|
mod management_token_permissions;
|
||||||
mod public;
|
mod public;
|
||||||
mod route;
|
mod route;
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ pub(crate) use auth::{
|
|||||||
GatewayAdminPrincipalContext, GatewayControlAuthContext, GatewayLocalAuthRejection,
|
GatewayAdminPrincipalContext, GatewayControlAuthContext, GatewayLocalAuthRejection,
|
||||||
};
|
};
|
||||||
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
|
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};
|
pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use route::classify_control_route;
|
pub(crate) use route::classify_control_route;
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ pub(super) fn classify_admin_basic_family_route(
|
|||||||
normalized_path_no_trailing: &str,
|
normalized_path_no_trailing: &str,
|
||||||
) -> Option<ClassifiedRoute> {
|
) -> Option<ClassifiedRoute> {
|
||||||
if method == http::Method::GET
|
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!(
|
&& matches!(
|
||||||
normalized_path,
|
normalized_path,
|
||||||
"/api/admin/management-tokens" | "/api/admin/management-tokens/"
|
"/api/admin/management-tokens" | "/api/admin/management-tokens/"
|
||||||
@@ -20,6 +34,19 @@ pub(super) fn classify_admin_basic_family_route(
|
|||||||
"admin:management_tokens",
|
"admin:management_tokens",
|
||||||
false,
|
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
|
} else if method == http::Method::GET
|
||||||
&& normalized_path.starts_with("/api/admin/management-tokens/")
|
&& normalized_path.starts_with("/api/admin/management-tokens/")
|
||||||
{
|
{
|
||||||
@@ -30,6 +57,16 @@ pub(super) fn classify_admin_basic_family_route(
|
|||||||
"admin:management_tokens",
|
"admin:management_tokens",
|
||||||
false,
|
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
|
} else if method == http::Method::DELETE
|
||||||
&& normalized_path.starts_with("/api/admin/management-tokens/")
|
&& normalized_path.starts_with("/api/admin/management-tokens/")
|
||||||
{
|
{
|
||||||
@@ -40,6 +77,17 @@ pub(super) fn classify_admin_basic_family_route(
|
|||||||
"admin:management_tokens",
|
"admin:management_tokens",
|
||||||
false,
|
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
|
} else if method == http::Method::PATCH
|
||||||
&& normalized_path.starts_with("/api/admin/management-tokens/")
|
&& normalized_path.starts_with("/api/admin/management-tokens/")
|
||||||
&& normalized_path.ends_with("/status")
|
&& normalized_path.ends_with("/status")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use http::Uri;
|
use http::Uri;
|
||||||
|
|
||||||
|
use crate::control::management_token_required_permission;
|
||||||
|
|
||||||
use super::{classify_control_route, headers};
|
use super::{classify_control_route, headers};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -497,6 +499,58 @@ fn classifies_admin_list_management_tokens_as_admin_proxy_route() {
|
|||||||
assert!(!decision.is_execution_runtime_candidate());
|
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]
|
#[test]
|
||||||
fn classifies_admin_ldap_config_get_as_admin_proxy_route() {
|
fn classifies_admin_ldap_config_get_as_admin_proxy_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
|
|||||||
@@ -72,6 +72,30 @@ impl<'a> AdminAppState<'a> {
|
|||||||
self.app.delete_management_token(token_id).await
|
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(
|
pub(crate) async fn list_management_tokens(
|
||||||
&self,
|
&self,
|
||||||
query: &aether_data::repository::management_tokens::ManagementTokenListQuery,
|
query: &aether_data::repository::management_tokens::ManagementTokenListQuery,
|
||||||
@@ -95,6 +119,18 @@ impl<'a> AdminAppState<'a> {
|
|||||||
.await
|
.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(
|
pub(crate) async fn remove_admin_security_blacklist(
|
||||||
&self,
|
&self,
|
||||||
ip_address: &str,
|
ip_address: &str,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub(crate) async fn maybe_build_local_admin_core_response(
|
|||||||
super::management_tokens::maybe_build_local_admin_management_tokens_response(
|
super::management_tokens::maybe_build_local_admin_management_tokens_response(
|
||||||
state,
|
state,
|
||||||
request_context,
|
request_context,
|
||||||
|
request_body,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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::request::{AdminAppState, AdminRequestContext};
|
||||||
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
|
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
|
||||||
use crate::handlers::admin::system::shared::paths::{
|
use crate::handlers::admin::system::shared::paths::{
|
||||||
admin_management_token_id_from_path, admin_management_token_status_id_from_path,
|
admin_management_token_id_from_path, admin_management_token_regenerate_id_from_path,
|
||||||
is_admin_management_tokens_root,
|
admin_management_token_status_id_from_path, is_admin_management_tokens_root,
|
||||||
};
|
};
|
||||||
use crate::handlers::internal::build_management_token_payload;
|
use crate::handlers::internal::build_management_token_payload;
|
||||||
use crate::GatewayError;
|
use crate::handlers::shared::generate_gateway_secret_plaintext;
|
||||||
use aether_data::repository::management_tokens::ManagementTokenListQuery;
|
use crate::{GatewayError, LocalMutationOutcome};
|
||||||
|
use aether_data::repository::management_tokens::{
|
||||||
|
CreateManagementTokenRecord, ManagementTokenListQuery, RegenerateManagementTokenSecret,
|
||||||
|
StoredManagementTokenUserSummary, UpdateManagementTokenRecord,
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::{Body, Bytes},
|
||||||
http,
|
http,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use chrono::Utc;
|
||||||
use serde_json::json;
|
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(
|
pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
request_context: &AdminRequestContext<'_>,
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
request_body: Option<&Bytes>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
let Some(decision) = request_context.decision() else {
|
let Some(decision) = request_context.decision() else {
|
||||||
return Ok(None);
|
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")
|
if decision.route_kind.as_deref() == Some("list_tokens")
|
||||||
&& request_context.method() == http::Method::GET
|
&& request_context.method() == http::Method::GET
|
||||||
&& is_admin_management_tokens_root(request_context.path())
|
&& 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")
|
if decision.route_kind.as_deref() == Some("get_token")
|
||||||
&& request_context.method() == http::Method::GET
|
&& request_context.method() == http::Method::GET
|
||||||
{
|
{
|
||||||
@@ -105,15 +461,60 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
|||||||
Some(&token.user),
|
Some(&token.user),
|
||||||
))
|
))
|
||||||
.into_response(),
|
.into_response(),
|
||||||
None => (
|
None => admin_management_token_not_found_response(),
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_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")
|
if decision.route_kind.as_deref() == Some("delete_token")
|
||||||
&& request_context.method() == http::Method::DELETE
|
&& request_context.method() == http::Method::DELETE
|
||||||
{
|
{
|
||||||
@@ -121,35 +522,17 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let Some(token_id) = admin_management_token_id_from_path(request_context.path()) else {
|
let Some(token_id) = admin_management_token_id_from_path(request_context.path()) else {
|
||||||
return Ok(Some(
|
return Ok(Some(admin_management_token_not_found_response()));
|
||||||
(
|
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
));
|
|
||||||
};
|
};
|
||||||
let existing = match state.get_management_token_with_user(&token_id).await? {
|
let existing = match state.get_management_token_with_user(&token_id).await? {
|
||||||
Some(token) => token,
|
Some(token) => token,
|
||||||
None => {
|
None => return Ok(Some(admin_management_token_not_found_response())),
|
||||||
return Ok(Some(
|
|
||||||
(
|
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let deleted = state.delete_management_token(&existing.token.id).await?;
|
let deleted = state.delete_management_token(&existing.token.id).await?;
|
||||||
return Ok(Some(if deleted {
|
return Ok(Some(if deleted {
|
||||||
Json(json!({ "message": "删除成功" })).into_response()
|
Json(json!({ "message": "删除成功" })).into_response()
|
||||||
} else {
|
} else {
|
||||||
(
|
admin_management_token_not_found_response()
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_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())
|
let Some(token_id) = admin_management_token_status_id_from_path(request_context.path())
|
||||||
else {
|
else {
|
||||||
return Ok(Some(
|
return Ok(Some(admin_management_token_not_found_response()));
|
||||||
(
|
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
));
|
|
||||||
};
|
};
|
||||||
let existing = match state.get_management_token_with_user(&token_id).await? {
|
let existing = match state.get_management_token_with_user(&token_id).await? {
|
||||||
Some(token) => token,
|
Some(token) => token,
|
||||||
None => {
|
None => return Ok(Some(admin_management_token_not_found_response())),
|
||||||
return Ok(Some(
|
|
||||||
(
|
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let Some(updated) = state
|
let Some(updated) = state
|
||||||
.set_management_token_active(&existing.token.id, !existing.token.is_active)
|
.set_management_token_active(&existing.token.id, !existing.token.is_active)
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
return Ok(Some(
|
return Ok(Some(admin_management_token_not_found_response()));
|
||||||
(
|
|
||||||
http::StatusCode::NOT_FOUND,
|
|
||||||
Json(json!({ "detail": "Management Token 不存在" })),
|
|
||||||
)
|
|
||||||
.into_response(),
|
|
||||||
));
|
|
||||||
};
|
};
|
||||||
return Ok(Some(
|
return Ok(Some(
|
||||||
Json(json!({
|
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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::constants::{
|
|||||||
EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AUTH_DENIED, EXECUTION_PATH_LOCAL_OVERLOADED,
|
EXECUTION_PATH_HEADER, EXECUTION_PATH_LOCAL_AUTH_DENIED, EXECUTION_PATH_LOCAL_OVERLOADED,
|
||||||
EXECUTION_PATH_LOCAL_RATE_LIMITED, EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH,
|
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::{
|
use crate::execution_runtime::{
|
||||||
maybe_build_local_sync_finalize_response, maybe_build_local_video_error_response,
|
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,
|
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,
|
token: &StoredManagementToken,
|
||||||
user: Option<&StoredManagementTokenUserSummary>,
|
user: Option<&StoredManagementTokenUserSummary>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
|
let (permission_mode, permission_summary) =
|
||||||
|
management_token_permission_mode_and_summary(token.permissions.as_ref());
|
||||||
let mut payload = json!({
|
let mut payload = json!({
|
||||||
"id": token.id,
|
"id": token.id,
|
||||||
"user_id": token.user_id,
|
"user_id": token.user_id,
|
||||||
@@ -472,6 +474,9 @@ pub(crate) fn build_management_token_payload(
|
|||||||
"description": token.description,
|
"description": token.description,
|
||||||
"token_display": token.token_display(),
|
"token_display": token.token_display(),
|
||||||
"allowed_ips": token.allowed_ips,
|
"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),
|
"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_at": token.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339),
|
||||||
"last_used_ip": token.last_used_ip,
|
"last_used_ip": token.last_used_ip,
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
use super::super::internal;
|
use super::super::internal;
|
||||||
use crate::admin_api;
|
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 crate::{AppState, GatewayError};
|
||||||
use axum::body::{Body, Bytes};
|
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(
|
pub(super) async fn maybe_build_local_internal_proxy_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -34,6 +41,10 @@ pub(super) async fn maybe_build_local_admin_proxy_response(
|
|||||||
if decision.admin_principal.is_none() {
|
if decision.admin_principal.is_none() {
|
||||||
return Ok(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(
|
admin_api::maybe_build_local_admin_response(admin_api::AdminRouteRequest::new(
|
||||||
state,
|
state,
|
||||||
@@ -42,3 +53,46 @@ pub(super) async fn maybe_build_local_admin_proxy_response(
|
|||||||
))
|
))
|
||||||
.await
|
.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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ use crate::constants::{
|
|||||||
TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER,
|
TUNNEL_AFFINITY_OWNER_INSTANCE_HEADER,
|
||||||
};
|
};
|
||||||
use crate::control::{
|
use crate::control::{
|
||||||
allows_control_execute_emergency, maybe_execute_via_control, request_model_local_rejection,
|
allows_control_execute_emergency, management_token_permission_keys_from_value,
|
||||||
should_buffer_request_for_local_auth, trusted_auth_local_rejection, GatewayControlDecision,
|
maybe_execute_via_control, request_model_local_rejection, should_buffer_request_for_local_auth,
|
||||||
GatewayPublicRequestContext,
|
trusted_auth_local_rejection, GatewayControlDecision, GatewayPublicRequestContext,
|
||||||
};
|
};
|
||||||
use crate::executor::{
|
use crate::executor::{
|
||||||
beautify_local_execution_client_error_message, build_local_execution_runtime_miss_context,
|
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") {
|
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
|
||||||
return Ok(());
|
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 {
|
decision.admin_principal = Some(crate::control::GatewayAdminPrincipalContext {
|
||||||
user_id: user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
user_role: user.role.clone(),
|
user_role: user.role.clone(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
management_token_id: Some(token_with_user.token.id.clone()),
|
management_token_id: Some(token_with_user.token.id.clone()),
|
||||||
|
management_token_permissions,
|
||||||
});
|
});
|
||||||
|
|
||||||
let remote_ip = remote_addr.ip().to_string();
|
let remote_ip = remote_addr.ip().to_string();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use super::{
|
|||||||
query_param_value, resolve_authenticated_local_user, AppState, AuthenticatedLocalUserContext,
|
query_param_value, resolve_authenticated_local_user, AppState, AuthenticatedLocalUserContext,
|
||||||
GatewayPublicRequestContext,
|
GatewayPublicRequestContext,
|
||||||
};
|
};
|
||||||
|
use crate::control::normalize_assignable_management_token_permissions;
|
||||||
use crate::handlers::shared::generate_gateway_secret_plaintext;
|
use crate::handlers::shared::generate_gateway_secret_plaintext;
|
||||||
use crate::LocalMutationOutcome;
|
use crate::LocalMutationOutcome;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ struct UsersMeManagementTokenCreateInput {
|
|||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
allowed_ips: Option<serde_json::Value>,
|
allowed_ips: Option<serde_json::Value>,
|
||||||
|
permissions: serde_json::Value,
|
||||||
expires_at_unix_secs: Option<u64>,
|
expires_at_unix_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ struct UsersMeManagementTokenUpdateInput {
|
|||||||
clear_description: bool,
|
clear_description: bool,
|
||||||
allowed_ips: Option<serde_json::Value>,
|
allowed_ips: Option<serde_json::Value>,
|
||||||
clear_allowed_ips: bool,
|
clear_allowed_ips: bool,
|
||||||
|
permissions: Option<serde_json::Value>,
|
||||||
expires_at_unix_secs: Option<u64>,
|
expires_at_unix_secs: Option<u64>,
|
||||||
clear_expires_at: bool,
|
clear_expires_at: bool,
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ impl UsersMeManagementTokenUpdateInput {
|
|||||||
&& !self.clear_description
|
&& !self.clear_description
|
||||||
&& self.allowed_ips.is_none()
|
&& self.allowed_ips.is_none()
|
||||||
&& !self.clear_allowed_ips
|
&& !self.clear_allowed_ips
|
||||||
|
&& self.permissions.is_none()
|
||||||
&& self.expires_at_unix_secs.is_none()
|
&& self.expires_at_unix_secs.is_none()
|
||||||
&& !self.clear_expires_at
|
&& !self.clear_expires_at
|
||||||
}
|
}
|
||||||
@@ -282,6 +286,8 @@ fn users_me_parse_management_token_create_input(
|
|||||||
_ => return Err("输入验证失败".to_string()),
|
_ => return Err("输入验证失败".to_string()),
|
||||||
};
|
};
|
||||||
let allowed_ips = users_me_parse_management_token_allowed_ips(payload.get("allowed_ips"))?;
|
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") {
|
let expires_at_unix_secs = match payload.get("expires_at") {
|
||||||
Some(value) => users_me_parse_management_token_expires_at(value, false)?,
|
Some(value) => users_me_parse_management_token_expires_at(value, false)?,
|
||||||
None => None,
|
None => None,
|
||||||
@@ -291,6 +297,7 @@ fn users_me_parse_management_token_create_input(
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
expires_at_unix_secs,
|
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 let Some(value) = payload.get("expires_at") {
|
||||||
if value.is_null() || value.as_str().is_some_and(|value| value.trim().is_empty()) {
|
if value.is_null() || value.as_str().is_some_and(|value| value.trim().is_empty()) {
|
||||||
input.clear_expires_at = true;
|
input.clear_expires_at = true;
|
||||||
@@ -482,6 +495,13 @@ pub(super) async fn handle_users_me_management_token_create(
|
|||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(response) => return response,
|
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 {
|
let Some(request_body) = request_body else {
|
||||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "缺少请求体", false);
|
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(),
|
name: input.name.clone(),
|
||||||
description: input.description,
|
description: input.description,
|
||||||
allowed_ips: input.allowed_ips,
|
allowed_ips: input.allowed_ips,
|
||||||
|
permissions: Some(input.permissions),
|
||||||
expires_at_unix_secs: input.expires_at_unix_secs,
|
expires_at_unix_secs: input.expires_at_unix_secs,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
};
|
};
|
||||||
@@ -668,6 +689,7 @@ pub(super) async fn handle_users_me_management_token_update(
|
|||||||
clear_description: input.clear_description,
|
clear_description: input.clear_description,
|
||||||
allowed_ips: input.allowed_ips,
|
allowed_ips: input.allowed_ips,
|
||||||
clear_allowed_ips: input.clear_allowed_ips,
|
clear_allowed_ips: input.clear_allowed_ips,
|
||||||
|
permissions: input.permissions,
|
||||||
expires_at_unix_secs: input.expires_at_unix_secs,
|
expires_at_unix_secs: input.expires_at_unix_secs,
|
||||||
clear_expires_at: input.clear_expires_at,
|
clear_expires_at: input.clear_expires_at,
|
||||||
is_active: None,
|
is_active: None,
|
||||||
|
|||||||
@@ -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_key"))
|
||||||
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
|
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
|
||||||
| (Some("modules_manage"), http::Method::PUT, Some("set_enabled"))
|
| (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::PUT, Some("upsert_provider"))
|
||||||
| (Some("oauth_manage"), http::Method::POST, Some("test_provider"))
|
| (Some("oauth_manage"), http::Method::POST, Some("test_provider"))
|
||||||
| (Some("provider_oauth_manage"), http::Method::POST, Some("complete_key_oauth"))
|
| (Some("provider_oauth_manage"), http::Method::POST, Some("complete_key_oauth"))
|
||||||
|
|||||||
@@ -7055,6 +7055,152 @@ async fn gateway_handles_admin_management_token_detail_locally_with_trusted_admi
|
|||||||
upstream_handle.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_deletes_admin_management_token_locally_with_trusted_admin_principal() {
|
async fn gateway_deletes_admin_management_token_locally_with_trusted_admin_principal() {
|
||||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||||
|
|||||||
@@ -763,6 +763,173 @@ async fn gateway_registers_and_unregisters_proxy_nodes_locally_with_management_t
|
|||||||
upstream_handle.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_registers_proxy_node_with_management_token_when_allowed_ips_is_json_null() {
|
async fn gateway_registers_proxy_node_with_management_token_when_allowed_ips_is_json_null() {
|
||||||
let raw_token = "ae_proxy_register_json_null";
|
let raw_token = "ae_proxy_register_json_null";
|
||||||
|
|||||||
@@ -6858,7 +6858,8 @@ async fn gateway_rejects_users_me_management_token_nested_get_path_as_local_not_
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_users_me_management_token_writes_locally_without_proxying_upstream() {
|
async fn gateway_handles_users_me_management_token_writes_locally_without_proxying_upstream() {
|
||||||
let now = Utc::now();
|
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(
|
let access_token = build_test_auth_token(
|
||||||
"access",
|
"access",
|
||||||
serde_json::Map::from_iter([
|
serde_json::Map::from_iter([
|
||||||
@@ -6913,6 +6914,7 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
|
|||||||
"name": "writer-token",
|
"name": "writer-token",
|
||||||
"description": "writer token",
|
"description": "writer token",
|
||||||
"allowed_ips": ["127.0.0.1"],
|
"allowed_ips": ["127.0.0.1"],
|
||||||
|
"permissions": ["admin:usage:read"],
|
||||||
"expires_at": (now + chrono::Duration::days(1)).to_rfc3339(),
|
"expires_at": (now + chrono::Duration::days(1)).to_rfc3339(),
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
@@ -6930,6 +6932,10 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
|
|||||||
.to_string();
|
.to_string();
|
||||||
assert_eq!(create_payload["message"], "Management Token 创建成功");
|
assert_eq!(create_payload["message"], "Management Token 创建成功");
|
||||||
assert_eq!(create_payload["data"]["name"], "writer-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 = create_payload["token"].as_str().unwrap_or_default();
|
||||||
let created_token_random = created_token.strip_prefix("ae-").unwrap_or_default();
|
let created_token_random = created_token.strip_prefix("ae-").unwrap_or_default();
|
||||||
assert_eq!(created_token_random.len(), 32);
|
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",
|
"name": "writer-token-renamed",
|
||||||
"description": null,
|
"description": null,
|
||||||
"allowed_ips": ["10.0.0.1"],
|
"allowed_ips": ["10.0.0.1"],
|
||||||
|
"permissions": ["admin:usage:read", "admin:pool:write"],
|
||||||
"expires_at": (now + chrono::Duration::days(2)).to_rfc3339(),
|
"expires_at": (now + chrono::Duration::days(2)).to_rfc3339(),
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
@@ -6967,6 +6974,10 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
|
|||||||
update_payload["data"]["description"],
|
update_payload["data"]["description"],
|
||||||
serde_json::Value::Null
|
serde_json::Value::Null
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
update_payload["data"]["permissions"],
|
||||||
|
json!(["admin:pool:write", "admin:usage:read"])
|
||||||
|
);
|
||||||
|
|
||||||
let toggle_response = client
|
let toggle_response = client
|
||||||
.patch(format!(
|
.patch(format!(
|
||||||
@@ -7055,6 +7066,81 @@ async fn gateway_handles_users_me_management_token_writes_locally_without_proxyi
|
|||||||
upstream_handle.abort();
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_returns_service_unavailable_for_users_me_management_token_reads_without_reader() {
|
async fn gateway_returns_service_unavailable_for_users_me_management_token_reads_without_reader() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
@@ -1518,6 +1518,10 @@ pub fn admin_management_token_status_id_from_path(request_path: &str) -> Option<
|
|||||||
suffixed_path_identifier_from_path(request_path, "/api/admin/management-tokens/", "/status")
|
suffixed_path_identifier_from_path(request_path, "/api/admin/management-tokens/", "/status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn admin_management_token_regenerate_id_from_path(request_path: &str) -> Option<String> {
|
||||||
|
suffixed_path_identifier_from_path(request_path, "/api/admin/management-tokens/", "/regenerate")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn admin_adaptive_effective_limit(key: &StoredProviderCatalogKey) -> Option<u32> {
|
pub fn admin_adaptive_effective_limit(key: &StoredProviderCatalogKey) -> Option<u32> {
|
||||||
if key.rpm_limit.is_none() {
|
if key.rpm_limit.is_none() {
|
||||||
key.learned_rpm_limit
|
key.learned_rpm_limit
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE management_tokens
|
||||||
|
ADD COLUMN permissions TEXT NULL AFTER allowed_ips;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE public.management_tokens
|
||||||
|
ADD COLUMN IF NOT EXISTS permissions json;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE management_tokens
|
||||||
|
ADD COLUMN permissions TEXT;
|
||||||
|
|
||||||
@@ -117,6 +117,7 @@ CREATE TABLE IF NOT EXISTS management_tokens (
|
|||||||
`token_hash` VARCHAR(255) NOT NULL,
|
`token_hash` VARCHAR(255) NOT NULL,
|
||||||
`token_prefix` VARCHAR(64),
|
`token_prefix` VARCHAR(64),
|
||||||
`allowed_ips` JSON,
|
`allowed_ips` JSON,
|
||||||
|
`permissions` JSON,
|
||||||
`expires_at` BIGINT,
|
`expires_at` BIGINT,
|
||||||
`last_used_at` BIGINT,
|
`last_used_at` BIGINT,
|
||||||
`last_used_ip` VARCHAR(255),
|
`last_used_ip` VARCHAR(255),
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ CREATE TABLE IF NOT EXISTS public.management_tokens (
|
|||||||
token_hash character varying(255) NOT NULL,
|
token_hash character varying(255) NOT NULL,
|
||||||
token_prefix character varying(64),
|
token_prefix character varying(64),
|
||||||
allowed_ips jsonb,
|
allowed_ips jsonb,
|
||||||
|
permissions jsonb,
|
||||||
expires_at bigint,
|
expires_at bigint,
|
||||||
last_used_at bigint,
|
last_used_at bigint,
|
||||||
last_used_ip character varying(255),
|
last_used_ip character varying(255),
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS management_tokens (
|
|||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
token_prefix TEXT,
|
token_prefix TEXT,
|
||||||
allowed_ips TEXT,
|
allowed_ips TEXT,
|
||||||
|
permissions TEXT,
|
||||||
expires_at INTEGER,
|
expires_at INTEGER,
|
||||||
last_used_at INTEGER,
|
last_used_at INTEGER,
|
||||||
last_used_ip TEXT,
|
last_used_ip TEXT,
|
||||||
|
|||||||
@@ -499,6 +499,11 @@ name = "allowed_ips"
|
|||||||
type = "json"
|
type = "json"
|
||||||
nullable = true
|
nullable = true
|
||||||
|
|
||||||
|
[[table.management_tokens.columns]]
|
||||||
|
name = "permissions"
|
||||||
|
type = "json"
|
||||||
|
nullable = true
|
||||||
|
|
||||||
[[table.management_tokens.columns]]
|
[[table.management_tokens.columns]]
|
||||||
name = "expires_at"
|
name = "expires_at"
|
||||||
type = "unix_seconds"
|
type = "unix_seconds"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tracing::info;
|
|||||||
// Generated by build.rs from schema/bootstrap/postgres.
|
// Generated by build.rs from schema/bootstrap/postgres.
|
||||||
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
||||||
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
||||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260507000000;
|
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260507120000;
|
||||||
|
|
||||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||||
SELECT COUNT(*)::BIGINT
|
SELECT COUNT(*)::BIGINT
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
|
|||||||
20260505000000,
|
20260505000000,
|
||||||
20260505130000,
|
20260505130000,
|
||||||
20260507000000,
|
20260507000000,
|
||||||
|
20260507120000,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -497,7 +498,7 @@ fn mysql_and_sqlite_migrations_do_not_use_postgres_jsonb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mysql_and_sqlite_migrations_are_baseline_only_until_enabled() {
|
fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||||
let mysql_versions = super::mysql::MIGRATOR
|
let mysql_versions = super::mysql::MIGRATOR
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|migration| migration.migration_type.is_up_migration())
|
.filter(|migration| migration.migration_type.is_up_migration())
|
||||||
@@ -509,8 +510,8 @@ fn mysql_and_sqlite_migrations_are_baseline_only_until_enabled() {
|
|||||||
.map(|migration| migration.version)
|
.map(|migration| migration.version)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
assert_eq!(mysql_versions, vec![20260403000000]);
|
assert_eq!(mysql_versions, vec![20260403000000, 20260507120000]);
|
||||||
assert_eq!(sqlite_versions, vec![20260403000000]);
|
assert_eq!(sqlite_versions, vec![20260403000000, 20260507120000]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1012,6 +1013,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
|||||||
20260505000000,
|
20260505000000,
|
||||||
20260505130000,
|
20260505130000,
|
||||||
20260507000000,
|
20260507000000,
|
||||||
|
20260507120000,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ impl ManagementTokenWriteRepository for InMemoryManagementTokenRepository {
|
|||||||
record.token_prefix.clone(),
|
record.token_prefix.clone(),
|
||||||
record.allowed_ips.clone(),
|
record.allowed_ips.clone(),
|
||||||
)
|
)
|
||||||
|
.with_permissions(record.permissions.clone())
|
||||||
.with_runtime_fields(record.expires_at_unix_secs, None, None, 0, record.is_active)
|
.with_runtime_fields(record.expires_at_unix_secs, None, None, 0, record.is_active)
|
||||||
.with_timestamps(now, now);
|
.with_timestamps(now, now);
|
||||||
items.push(StoredManagementTokenWithUser::new(
|
items.push(StoredManagementTokenWithUser::new(
|
||||||
@@ -205,6 +206,10 @@ impl ManagementTokenWriteRepository for InMemoryManagementTokenRepository {
|
|||||||
items[index].token.allowed_ips = Some(allowed_ips.clone());
|
items[index].token.allowed_ips = Some(allowed_ips.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(permissions) = &record.permissions {
|
||||||
|
items[index].token.permissions = Some(permissions.clone());
|
||||||
|
}
|
||||||
|
|
||||||
if record.clear_expires_at {
|
if record.clear_expires_at {
|
||||||
items[index].token.expires_at_unix_secs = None;
|
items[index].token.expires_at_unix_secs = None;
|
||||||
} else if let Some(expires_at_unix_secs) = record.expires_at_unix_secs {
|
} else if let Some(expires_at_unix_secs) = record.expires_at_unix_secs {
|
||||||
@@ -371,12 +376,17 @@ mod tests {
|
|||||||
name: "created".to_string(),
|
name: "created".to_string(),
|
||||||
description: Some("created token".to_string()),
|
description: Some("created token".to_string()),
|
||||||
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
|
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
|
||||||
|
permissions: Some(serde_json::json!(["admin:usage:read"])),
|
||||||
expires_at_unix_secs: Some(1_800_000_000),
|
expires_at_unix_secs: Some(1_800_000_000),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create should succeed");
|
.expect("create should succeed");
|
||||||
assert_eq!(created.name, "created");
|
assert_eq!(created.name, "created");
|
||||||
|
assert_eq!(
|
||||||
|
created.permissions,
|
||||||
|
Some(serde_json::json!(["admin:usage:read"]))
|
||||||
|
);
|
||||||
|
|
||||||
let updated = repository
|
let updated = repository
|
||||||
.update_management_token(&UpdateManagementTokenRecord {
|
.update_management_token(&UpdateManagementTokenRecord {
|
||||||
@@ -386,6 +396,7 @@ mod tests {
|
|||||||
clear_description: true,
|
clear_description: true,
|
||||||
allowed_ips: Some(serde_json::json!(["10.0.0.1"])),
|
allowed_ips: Some(serde_json::json!(["10.0.0.1"])),
|
||||||
clear_allowed_ips: false,
|
clear_allowed_ips: false,
|
||||||
|
permissions: Some(serde_json::json!(["admin:usage:read", "admin:usage:write"])),
|
||||||
expires_at_unix_secs: None,
|
expires_at_unix_secs: None,
|
||||||
clear_expires_at: true,
|
clear_expires_at: true,
|
||||||
is_active: Some(false),
|
is_active: Some(false),
|
||||||
@@ -396,6 +407,10 @@ mod tests {
|
|||||||
assert_eq!(updated.name, "renamed");
|
assert_eq!(updated.name, "renamed");
|
||||||
assert_eq!(updated.description, None);
|
assert_eq!(updated.description, None);
|
||||||
assert_eq!(updated.allowed_ips, Some(serde_json::json!(["10.0.0.1"])));
|
assert_eq!(updated.allowed_ips, Some(serde_json::json!(["10.0.0.1"])));
|
||||||
|
assert_eq!(
|
||||||
|
updated.permissions,
|
||||||
|
Some(serde_json::json!(["admin:usage:read", "admin:usage:write"]))
|
||||||
|
);
|
||||||
assert_eq!(updated.expires_at_unix_secs, None);
|
assert_eq!(updated.expires_at_unix_secs, None);
|
||||||
assert!(!updated.is_active);
|
assert!(!updated.is_active);
|
||||||
|
|
||||||
@@ -416,6 +431,10 @@ mod tests {
|
|||||||
.expect("lookup by hash should succeed")
|
.expect("lookup by hash should succeed")
|
||||||
.expect("token should exist");
|
.expect("token should exist");
|
||||||
assert_eq!(by_hash.token.id, "token-3");
|
assert_eq!(by_hash.token.id, "token-3");
|
||||||
|
assert_eq!(
|
||||||
|
by_hash.token.permissions,
|
||||||
|
Some(serde_json::json!(["admin:usage:read", "admin:usage:write"]))
|
||||||
|
);
|
||||||
|
|
||||||
let used = repository
|
let used = repository
|
||||||
.record_management_token_usage("token-3", Some("127.0.0.1"))
|
.record_management_token_usage("token-3", Some("127.0.0.1"))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ SELECT
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
expires_at AS expires_at_unix_secs,
|
expires_at AS expires_at_unix_secs,
|
||||||
last_used_at AS last_used_at_unix_secs,
|
last_used_at AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -62,6 +63,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
mt.expires_at AS expires_at_unix_secs,
|
mt.expires_at AS expires_at_unix_secs,
|
||||||
mt.last_used_at AS last_used_at_unix_secs,
|
mt.last_used_at AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -96,6 +98,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
mt.expires_at AS expires_at_unix_secs,
|
mt.expires_at AS expires_at_unix_secs,
|
||||||
mt.last_used_at AS last_used_at_unix_secs,
|
mt.last_used_at AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -121,6 +124,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
mt.expires_at AS expires_at_unix_secs,
|
mt.expires_at AS expires_at_unix_secs,
|
||||||
mt.last_used_at AS last_used_at_unix_secs,
|
mt.last_used_at AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -211,8 +215,8 @@ impl ManagementTokenWriteRepository for MysqlManagementTokenRepository {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO management_tokens (
|
INSERT INTO management_tokens (
|
||||||
id, user_id, token_hash, token_prefix, name, description, allowed_ips,
|
id, user_id, token_hash, token_prefix, name, description, allowed_ips,
|
||||||
expires_at, is_active, created_at, updated_at
|
permissions, expires_at, is_active, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&record.id)
|
.bind(&record.id)
|
||||||
@@ -222,6 +226,7 @@ INSERT INTO management_tokens (
|
|||||||
.bind(&record.name)
|
.bind(&record.name)
|
||||||
.bind(record.description.as_deref())
|
.bind(record.description.as_deref())
|
||||||
.bind(json_to_string(record.allowed_ips.as_ref())?)
|
.bind(json_to_string(record.allowed_ips.as_ref())?)
|
||||||
|
.bind(json_to_string(record.permissions.as_ref())?)
|
||||||
.bind(
|
.bind(
|
||||||
record
|
record
|
||||||
.expires_at_unix_secs
|
.expires_at_unix_secs
|
||||||
@@ -262,6 +267,7 @@ INSERT INTO management_tokens (
|
|||||||
} else {
|
} else {
|
||||||
record.allowed_ips.as_ref().or(current.allowed_ips.as_ref())
|
record.allowed_ips.as_ref().or(current.allowed_ips.as_ref())
|
||||||
};
|
};
|
||||||
|
let permissions = record.permissions.as_ref().or(current.permissions.as_ref());
|
||||||
let expires_at = if record.clear_expires_at {
|
let expires_at = if record.clear_expires_at {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -276,6 +282,7 @@ UPDATE management_tokens
|
|||||||
SET name = ?,
|
SET name = ?,
|
||||||
description = ?,
|
description = ?,
|
||||||
allowed_ips = ?,
|
allowed_ips = ?,
|
||||||
|
permissions = ?,
|
||||||
expires_at = ?,
|
expires_at = ?,
|
||||||
is_active = ?,
|
is_active = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
@@ -285,6 +292,7 @@ WHERE id = ?
|
|||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
.bind(json_to_string(allowed_ips)?)
|
.bind(json_to_string(allowed_ips)?)
|
||||||
|
.bind(json_to_string(permissions)?)
|
||||||
.bind(expires_at.and_then(|value| i64::try_from(value).ok()))
|
.bind(expires_at.and_then(|value| i64::try_from(value).ok()))
|
||||||
.bind(is_active)
|
.bind(is_active)
|
||||||
.bind(now as i64)
|
.bind(now as i64)
|
||||||
@@ -438,6 +446,7 @@ fn map_token_row(row: &MySqlRow) -> Result<StoredManagementToken, DataLayerError
|
|||||||
row.try_get("token_prefix").map_sql_err()?,
|
row.try_get("token_prefix").map_sql_err()?,
|
||||||
json_from_string(row.try_get("allowed_ips").map_sql_err()?)?,
|
json_from_string(row.try_get("allowed_ips").map_sql_err()?)?,
|
||||||
)
|
)
|
||||||
|
.with_permissions(json_from_string(row.try_get("permissions").map_sql_err()?)?)
|
||||||
.with_runtime_fields(
|
.with_runtime_fields(
|
||||||
optional_unix_secs(row.try_get("expires_at_unix_secs").map_sql_err()?),
|
optional_unix_secs(row.try_get("expires_at_unix_secs").map_sql_err()?),
|
||||||
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_sql_err()?),
|
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_sql_err()?),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -53,6 +54,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -78,6 +80,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM mt.expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM mt.last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -109,6 +112,7 @@ INSERT INTO management_tokens (
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
expires_at,
|
expires_at,
|
||||||
is_active
|
is_active
|
||||||
)
|
)
|
||||||
@@ -120,11 +124,12 @@ VALUES (
|
|||||||
$5,
|
$5,
|
||||||
$6,
|
$6,
|
||||||
$7,
|
$7,
|
||||||
|
$8,
|
||||||
CASE
|
CASE
|
||||||
WHEN $8::bigint IS NULL THEN NULL
|
WHEN $9::bigint IS NULL THEN NULL
|
||||||
ELSE to_timestamp($8::double precision)
|
ELSE to_timestamp($9::double precision)
|
||||||
END,
|
END,
|
||||||
$9
|
$10
|
||||||
)
|
)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
@@ -133,6 +138,7 @@ RETURNING
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -155,12 +161,13 @@ SET name = COALESCE($2, name),
|
|||||||
WHEN $6::json IS NULL THEN allowed_ips
|
WHEN $6::json IS NULL THEN allowed_ips
|
||||||
ELSE $6
|
ELSE $6
|
||||||
END,
|
END,
|
||||||
|
permissions = COALESCE($7::json, permissions),
|
||||||
expires_at = CASE
|
expires_at = CASE
|
||||||
WHEN $7 THEN NULL
|
WHEN $8 THEN NULL
|
||||||
WHEN $8::bigint IS NULL THEN expires_at
|
WHEN $9::bigint IS NULL THEN expires_at
|
||||||
ELSE to_timestamp($8::double precision)
|
ELSE to_timestamp($9::double precision)
|
||||||
END,
|
END,
|
||||||
is_active = COALESCE($9, is_active),
|
is_active = COALESCE($10, is_active),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
@@ -170,6 +177,7 @@ RETURNING
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -191,6 +199,7 @@ RETURNING
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -213,6 +222,7 @@ RETURNING
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -236,6 +246,7 @@ RETURNING
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
EXTRACT(EPOCH FROM expires_at)::bigint AS expires_at_unix_secs,
|
||||||
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
EXTRACT(EPOCH FROM last_used_at)::bigint AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -327,6 +338,7 @@ impl ManagementTokenWriteRepository for SqlxManagementTokenRepository {
|
|||||||
.bind(&record.name)
|
.bind(&record.name)
|
||||||
.bind(record.description.as_deref())
|
.bind(record.description.as_deref())
|
||||||
.bind(record.allowed_ips.as_ref())
|
.bind(record.allowed_ips.as_ref())
|
||||||
|
.bind(record.permissions.as_ref())
|
||||||
.bind(
|
.bind(
|
||||||
record
|
record
|
||||||
.expires_at_unix_secs
|
.expires_at_unix_secs
|
||||||
@@ -351,6 +363,7 @@ impl ManagementTokenWriteRepository for SqlxManagementTokenRepository {
|
|||||||
.bind(record.description.as_deref())
|
.bind(record.description.as_deref())
|
||||||
.bind(record.clear_allowed_ips)
|
.bind(record.clear_allowed_ips)
|
||||||
.bind(record.allowed_ips.as_ref())
|
.bind(record.allowed_ips.as_ref())
|
||||||
|
.bind(record.permissions.as_ref())
|
||||||
.bind(record.clear_expires_at)
|
.bind(record.clear_expires_at)
|
||||||
.bind(
|
.bind(
|
||||||
record
|
record
|
||||||
@@ -458,6 +471,7 @@ fn map_token_row(row: &PgRow) -> Result<StoredManagementToken, DataLayerError> {
|
|||||||
row.try_get("token_prefix").map_postgres_err()?,
|
row.try_get("token_prefix").map_postgres_err()?,
|
||||||
row.try_get("allowed_ips").map_postgres_err()?,
|
row.try_get("allowed_ips").map_postgres_err()?,
|
||||||
)
|
)
|
||||||
|
.with_permissions(row.try_get("permissions").map_postgres_err()?)
|
||||||
.with_runtime_fields(
|
.with_runtime_fields(
|
||||||
optional_unix_secs(row.try_get("expires_at_unix_secs").map_postgres_err()?),
|
optional_unix_secs(row.try_get("expires_at_unix_secs").map_postgres_err()?),
|
||||||
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_postgres_err()?),
|
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_postgres_err()?),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ SELECT
|
|||||||
description,
|
description,
|
||||||
token_prefix,
|
token_prefix,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
|
permissions,
|
||||||
expires_at AS expires_at_unix_secs,
|
expires_at AS expires_at_unix_secs,
|
||||||
last_used_at AS last_used_at_unix_secs,
|
last_used_at AS last_used_at_unix_secs,
|
||||||
last_used_ip,
|
last_used_ip,
|
||||||
@@ -61,6 +62,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
mt.expires_at AS expires_at_unix_secs,
|
mt.expires_at AS expires_at_unix_secs,
|
||||||
mt.last_used_at AS last_used_at_unix_secs,
|
mt.last_used_at AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -95,6 +97,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
mt.expires_at AS expires_at_unix_secs,
|
mt.expires_at AS expires_at_unix_secs,
|
||||||
mt.last_used_at AS last_used_at_unix_secs,
|
mt.last_used_at AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -120,6 +123,7 @@ SELECT
|
|||||||
mt.description,
|
mt.description,
|
||||||
mt.token_prefix,
|
mt.token_prefix,
|
||||||
mt.allowed_ips,
|
mt.allowed_ips,
|
||||||
|
mt.permissions,
|
||||||
mt.expires_at AS expires_at_unix_secs,
|
mt.expires_at AS expires_at_unix_secs,
|
||||||
mt.last_used_at AS last_used_at_unix_secs,
|
mt.last_used_at AS last_used_at_unix_secs,
|
||||||
mt.last_used_ip,
|
mt.last_used_ip,
|
||||||
@@ -210,8 +214,8 @@ impl ManagementTokenWriteRepository for SqliteManagementTokenRepository {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO management_tokens (
|
INSERT INTO management_tokens (
|
||||||
id, user_id, token_hash, token_prefix, name, description, allowed_ips,
|
id, user_id, token_hash, token_prefix, name, description, allowed_ips,
|
||||||
expires_at, is_active, created_at, updated_at
|
permissions, expires_at, is_active, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&record.id)
|
.bind(&record.id)
|
||||||
@@ -221,6 +225,7 @@ INSERT INTO management_tokens (
|
|||||||
.bind(&record.name)
|
.bind(&record.name)
|
||||||
.bind(record.description.as_deref())
|
.bind(record.description.as_deref())
|
||||||
.bind(json_to_string(record.allowed_ips.as_ref())?)
|
.bind(json_to_string(record.allowed_ips.as_ref())?)
|
||||||
|
.bind(json_to_string(record.permissions.as_ref())?)
|
||||||
.bind(
|
.bind(
|
||||||
record
|
record
|
||||||
.expires_at_unix_secs
|
.expires_at_unix_secs
|
||||||
@@ -261,6 +266,7 @@ INSERT INTO management_tokens (
|
|||||||
} else {
|
} else {
|
||||||
record.allowed_ips.as_ref().or(current.allowed_ips.as_ref())
|
record.allowed_ips.as_ref().or(current.allowed_ips.as_ref())
|
||||||
};
|
};
|
||||||
|
let permissions = record.permissions.as_ref().or(current.permissions.as_ref());
|
||||||
let expires_at = if record.clear_expires_at {
|
let expires_at = if record.clear_expires_at {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -275,6 +281,7 @@ UPDATE management_tokens
|
|||||||
SET name = ?,
|
SET name = ?,
|
||||||
description = ?,
|
description = ?,
|
||||||
allowed_ips = ?,
|
allowed_ips = ?,
|
||||||
|
permissions = ?,
|
||||||
expires_at = ?,
|
expires_at = ?,
|
||||||
is_active = ?,
|
is_active = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
@@ -284,6 +291,7 @@ WHERE id = ?
|
|||||||
.bind(name)
|
.bind(name)
|
||||||
.bind(description)
|
.bind(description)
|
||||||
.bind(json_to_string(allowed_ips)?)
|
.bind(json_to_string(allowed_ips)?)
|
||||||
|
.bind(json_to_string(permissions)?)
|
||||||
.bind(expires_at.and_then(|value| i64::try_from(value).ok()))
|
.bind(expires_at.and_then(|value| i64::try_from(value).ok()))
|
||||||
.bind(is_active)
|
.bind(is_active)
|
||||||
.bind(now as i64)
|
.bind(now as i64)
|
||||||
@@ -435,6 +443,7 @@ fn map_token_row(row: &SqliteRow) -> Result<StoredManagementToken, DataLayerErro
|
|||||||
row.try_get("token_prefix").map_sql_err()?,
|
row.try_get("token_prefix").map_sql_err()?,
|
||||||
json_from_string(row.try_get("allowed_ips").map_sql_err()?)?,
|
json_from_string(row.try_get("allowed_ips").map_sql_err()?)?,
|
||||||
)
|
)
|
||||||
|
.with_permissions(json_from_string(row.try_get("permissions").map_sql_err()?)?)
|
||||||
.with_runtime_fields(
|
.with_runtime_fields(
|
||||||
optional_unix_secs(row.try_get("expires_at_unix_secs").map_sql_err()?),
|
optional_unix_secs(row.try_get("expires_at_unix_secs").map_sql_err()?),
|
||||||
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_sql_err()?),
|
optional_unix_secs(row.try_get("last_used_at_unix_secs").map_sql_err()?),
|
||||||
@@ -516,12 +525,17 @@ VALUES ('user-1', 'user-1@example.com', 'user-1', 'admin', 1, 1, 1)
|
|||||||
name: "primary".to_string(),
|
name: "primary".to_string(),
|
||||||
description: Some("primary token".to_string()),
|
description: Some("primary token".to_string()),
|
||||||
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
|
allowed_ips: Some(serde_json::json!(["127.0.0.1"])),
|
||||||
|
permissions: Some(serde_json::json!(["admin:usage:read"])),
|
||||||
expires_at_unix_secs: Some(1_800_000_000),
|
expires_at_unix_secs: Some(1_800_000_000),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("token should create");
|
.expect("token should create");
|
||||||
assert_eq!(created.name, "primary");
|
assert_eq!(created.name, "primary");
|
||||||
|
assert_eq!(
|
||||||
|
created.permissions,
|
||||||
|
Some(serde_json::json!(["admin:usage:read"]))
|
||||||
|
);
|
||||||
|
|
||||||
let page = repository
|
let page = repository
|
||||||
.list_management_tokens(&ManagementTokenListQuery {
|
.list_management_tokens(&ManagementTokenListQuery {
|
||||||
@@ -550,6 +564,7 @@ VALUES ('user-1', 'user-1@example.com', 'user-1', 'admin', 1, 1, 1)
|
|||||||
clear_description: true,
|
clear_description: true,
|
||||||
allowed_ips: Some(serde_json::json!(["10.0.0.1"])),
|
allowed_ips: Some(serde_json::json!(["10.0.0.1"])),
|
||||||
clear_allowed_ips: false,
|
clear_allowed_ips: false,
|
||||||
|
permissions: Some(serde_json::json!(["admin:usage:read", "admin:usage:write"])),
|
||||||
expires_at_unix_secs: None,
|
expires_at_unix_secs: None,
|
||||||
clear_expires_at: true,
|
clear_expires_at: true,
|
||||||
is_active: Some(false),
|
is_active: Some(false),
|
||||||
@@ -560,6 +575,10 @@ VALUES ('user-1', 'user-1@example.com', 'user-1', 'admin', 1, 1, 1)
|
|||||||
assert_eq!(updated.name, "renamed");
|
assert_eq!(updated.name, "renamed");
|
||||||
assert!(!updated.is_active);
|
assert!(!updated.is_active);
|
||||||
assert_eq!(updated.description, None);
|
assert_eq!(updated.description, None);
|
||||||
|
assert_eq!(
|
||||||
|
updated.permissions,
|
||||||
|
Some(serde_json::json!(["admin:usage:read", "admin:usage:write"]))
|
||||||
|
);
|
||||||
assert_eq!(updated.expires_at_unix_secs, None);
|
assert_eq!(updated.expires_at_unix_secs, None);
|
||||||
|
|
||||||
let toggled = repository
|
let toggled = repository
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub struct StoredManagementToken {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub token_prefix: Option<String>,
|
pub token_prefix: Option<String>,
|
||||||
pub allowed_ips: Option<serde_json::Value>,
|
pub allowed_ips: Option<serde_json::Value>,
|
||||||
|
pub permissions: Option<serde_json::Value>,
|
||||||
pub expires_at_unix_secs: Option<u64>,
|
pub expires_at_unix_secs: Option<u64>,
|
||||||
pub last_used_at_unix_secs: Option<u64>,
|
pub last_used_at_unix_secs: Option<u64>,
|
||||||
pub last_used_ip: Option<String>,
|
pub last_used_ip: Option<String>,
|
||||||
@@ -80,6 +81,7 @@ impl StoredManagementToken {
|
|||||||
description: None,
|
description: None,
|
||||||
token_prefix: None,
|
token_prefix: None,
|
||||||
allowed_ips: None,
|
allowed_ips: None,
|
||||||
|
permissions: None,
|
||||||
expires_at_unix_secs: None,
|
expires_at_unix_secs: None,
|
||||||
last_used_at_unix_secs: None,
|
last_used_at_unix_secs: None,
|
||||||
last_used_ip: None,
|
last_used_ip: None,
|
||||||
@@ -102,6 +104,11 @@ impl StoredManagementToken {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_permissions(mut self, permissions: Option<serde_json::Value>) -> Self {
|
||||||
|
self.permissions = permissions;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_runtime_fields(
|
pub fn with_runtime_fields(
|
||||||
mut self,
|
mut self,
|
||||||
expires_at_unix_secs: Option<u64>,
|
expires_at_unix_secs: Option<u64>,
|
||||||
@@ -166,6 +173,7 @@ pub struct CreateManagementTokenRecord {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub allowed_ips: Option<serde_json::Value>,
|
pub allowed_ips: Option<serde_json::Value>,
|
||||||
|
pub permissions: Option<serde_json::Value>,
|
||||||
pub expires_at_unix_secs: Option<u64>,
|
pub expires_at_unix_secs: Option<u64>,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
}
|
}
|
||||||
@@ -214,6 +222,7 @@ impl CreateManagementTokenRecord {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
validate_management_token_permissions(self.permissions.as_ref())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +235,7 @@ pub struct UpdateManagementTokenRecord {
|
|||||||
pub clear_description: bool,
|
pub clear_description: bool,
|
||||||
pub allowed_ips: Option<serde_json::Value>,
|
pub allowed_ips: Option<serde_json::Value>,
|
||||||
pub clear_allowed_ips: bool,
|
pub clear_allowed_ips: bool,
|
||||||
|
pub permissions: Option<serde_json::Value>,
|
||||||
pub expires_at_unix_secs: Option<u64>,
|
pub expires_at_unix_secs: Option<u64>,
|
||||||
pub clear_expires_at: bool,
|
pub clear_expires_at: bool,
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
@@ -262,10 +272,35 @@ impl UpdateManagementTokenRecord {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
validate_management_token_permissions(self.permissions.as_ref())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_management_token_permissions(
|
||||||
|
permissions: Option<&serde_json::Value>,
|
||||||
|
) -> Result<(), crate::DataLayerError> {
|
||||||
|
let Some(permissions) = permissions else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(items) = permissions.as_array() else {
|
||||||
|
return Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"permissions must be an array".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"permissions must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if items.iter().any(|value| value.as_str().is_none()) {
|
||||||
|
return Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"permissions must contain only strings".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct RegenerateManagementTokenSecret {
|
pub struct RegenerateManagementTokenSecret {
|
||||||
pub token_id: String,
|
pub token_id: String,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface ManagementToken {
|
|||||||
description?: string
|
description?: string
|
||||||
token_display: string
|
token_display: string
|
||||||
allowed_ips?: string[] | null
|
allowed_ips?: string[] | null
|
||||||
|
permissions?: string[] | null
|
||||||
|
permission_mode?: 'legacy_full' | 'full' | 'read_only' | 'custom'
|
||||||
|
permission_summary?: string
|
||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
last_used_at?: string | null
|
last_used_at?: string | null
|
||||||
last_used_ip?: string | null
|
last_used_ip?: string | null
|
||||||
@@ -32,6 +35,7 @@ export interface CreateManagementTokenRequest {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
allowed_ips?: string[]
|
allowed_ips?: string[]
|
||||||
|
permissions?: string[] | null
|
||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +49,25 @@ export interface UpdateManagementTokenRequest {
|
|||||||
name?: string
|
name?: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
allowed_ips?: string[] | null
|
allowed_ips?: string[] | null
|
||||||
|
permissions?: string[] | null
|
||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagementTokenPermissionCatalogItem {
|
||||||
|
key: string
|
||||||
|
scope: string
|
||||||
|
scope_label: string
|
||||||
|
access: 'read' | 'write' | 'admin'
|
||||||
|
access_label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagementTokenPermissionCatalogResponse {
|
||||||
|
items: ManagementTokenPermissionCatalogItem[]
|
||||||
|
all_permissions: string[]
|
||||||
|
read_only_permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ManagementTokenListResponse {
|
export interface ManagementTokenListResponse {
|
||||||
items: ManagementToken[]
|
items: ManagementToken[]
|
||||||
total: number
|
total: number
|
||||||
@@ -179,6 +198,43 @@ export const adminManagementTokenApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可分配权限目录(管理员)
|
||||||
|
*/
|
||||||
|
async getPermissionCatalog(): Promise<ManagementTokenPermissionCatalogResponse> {
|
||||||
|
const response = await apiClient.get<ManagementTokenPermissionCatalogResponse>(
|
||||||
|
'/api/admin/management-tokens/permissions/catalog'
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Management Token(管理员)
|
||||||
|
*/
|
||||||
|
async createToken(
|
||||||
|
data: CreateManagementTokenRequest
|
||||||
|
): Promise<CreateManagementTokenResponse> {
|
||||||
|
const response = await apiClient.post<CreateManagementTokenResponse>(
|
||||||
|
'/api/admin/management-tokens',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 Management Token(管理员)
|
||||||
|
*/
|
||||||
|
async updateToken(
|
||||||
|
tokenId: string,
|
||||||
|
data: UpdateManagementTokenRequest
|
||||||
|
): Promise<{ message: string; data: ManagementToken }> {
|
||||||
|
const response = await apiClient.put<{ message: string; data: ManagementToken }>(
|
||||||
|
`/api/admin/management-tokens/${tokenId}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除任意 Token(管理员)
|
* 删除任意 Token(管理员)
|
||||||
*/
|
*/
|
||||||
@@ -199,5 +255,17 @@ export const adminManagementTokenApi = {
|
|||||||
`/api/admin/management-tokens/${tokenId}/status`
|
`/api/admin/management-tokens/${tokenId}/status`
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新生成任意 Token(管理员)
|
||||||
|
*/
|
||||||
|
async regenerateToken(
|
||||||
|
tokenId: string
|
||||||
|
): Promise<{ token: string; data: ManagementToken }> {
|
||||||
|
const response = await apiClient.post<{ token: string; data: ManagementToken }>(
|
||||||
|
`/api/admin/management-tokens/${tokenId}/regenerate`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm sm:text-base font-semibold">
|
<h3 class="text-sm sm:text-base font-semibold">
|
||||||
访问令牌
|
{{ isAdminPage ? '管理令牌' : '访问令牌' }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-muted-foreground mt-0.5">
|
<p class="text-xs text-muted-foreground mt-0.5">
|
||||||
<template v-if="quota">
|
<template v-if="quota">
|
||||||
@@ -20,9 +20,12 @@
|
|||||||
class="text-destructive font-medium"
|
class="text-destructive font-medium"
|
||||||
>(已达上限)</span>
|
>(已达上限)</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="canManageTokens">
|
||||||
用于程序化访问管理 API 的令牌
|
用于程序化访问管理 API 的令牌
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
仅管理员可以创建和管理此类令牌
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,12 +33,13 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- 新增按钮 -->
|
<!-- 新增按钮 -->
|
||||||
<Button
|
<Button
|
||||||
|
v-if="canManageTokens"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
title="创建新令牌"
|
title="创建新令牌"
|
||||||
:disabled="quota ? quota.used >= quota.max : false"
|
:disabled="quota ? quota.used >= quota.max : false"
|
||||||
@click="showCreateDialog = true"
|
@click="openCreateDialog"
|
||||||
>
|
>
|
||||||
<Plus class="w-3.5 h-3.5" />
|
<Plus class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -69,9 +73,10 @@
|
|||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="canManageTokens"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="shadow-lg shadow-primary/20"
|
class="shadow-lg shadow-primary/20"
|
||||||
@click="showCreateDialog = true"
|
@click="openCreateDialog"
|
||||||
>
|
>
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
创建访问令牌
|
创建访问令牌
|
||||||
@@ -94,6 +99,18 @@
|
|||||||
<TableHead class="min-w-[160px] h-12 font-semibold">
|
<TableHead class="min-w-[160px] h-12 font-semibold">
|
||||||
令牌
|
令牌
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
v-if="isAdminPage"
|
||||||
|
class="min-w-[160px] h-12 font-semibold"
|
||||||
|
>
|
||||||
|
所属用户
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[150px] h-12 font-semibold">
|
||||||
|
权限
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="min-w-[150px] h-12 font-semibold">
|
||||||
|
IP 限制
|
||||||
|
</TableHead>
|
||||||
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
|
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
|
||||||
使用次数
|
使用次数
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -103,7 +120,10 @@
|
|||||||
<TableHead class="min-w-[100px] h-12 font-semibold">
|
<TableHead class="min-w-[100px] h-12 font-semibold">
|
||||||
时间
|
时间
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="min-w-[100px] h-12 font-semibold text-center">
|
<TableHead
|
||||||
|
v-if="canManageTokens"
|
||||||
|
class="min-w-[100px] h-12 font-semibold text-center"
|
||||||
|
>
|
||||||
操作
|
操作
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -140,6 +160,7 @@
|
|||||||
{{ token.token_display }}
|
{{ token.token_display }}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="canManageTokens"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
@@ -151,6 +172,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
v-if="isAdminPage"
|
||||||
|
class="py-4"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium truncate">
|
||||||
|
{{ token.user?.username || token.user?.email || token.user_id }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="token.user?.email"
|
||||||
|
class="text-xs text-muted-foreground truncate"
|
||||||
|
>
|
||||||
|
{{ token.user.email }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="font-medium px-2 py-1"
|
||||||
|
>
|
||||||
|
{{ token.permission_summary || permissionModeText(token.permission_mode) }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell class="py-4 text-xs text-muted-foreground">
|
||||||
|
<div class="truncate">
|
||||||
|
{{ token.allowed_ips?.length ? token.allowed_ips.join(', ') : '不限制' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
{{ token.last_used_ip ? `最后 IP ${token.last_used_ip}` : '暂无最后 IP' }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<!-- 使用次数 -->
|
<!-- 使用次数 -->
|
||||||
<TableCell class="py-4 text-center">
|
<TableCell class="py-4 text-center">
|
||||||
<span class="text-sm font-medium">
|
<span class="text-sm font-medium">
|
||||||
@@ -179,7 +233,10 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<TableCell class="py-4">
|
<TableCell
|
||||||
|
v-if="canManageTokens"
|
||||||
|
class="py-4"
|
||||||
|
>
|
||||||
<div class="flex justify-center gap-1">
|
<div class="flex justify-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -240,7 +297,10 @@
|
|||||||
{{ getStatusText(token) }}
|
{{ getStatusText(token) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
<div
|
||||||
|
v-if="canManageTokens"
|
||||||
|
class="flex items-center gap-0.5 flex-shrink-0"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -275,6 +335,7 @@
|
|||||||
<div class="flex items-center gap-2 text-xs mb-2">
|
<div class="flex items-center gap-2 text-xs mb-2">
|
||||||
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
|
<code class="font-mono text-muted-foreground">{{ token.token_display }}</code>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="canManageTokens"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
@@ -289,6 +350,8 @@
|
|||||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
|
<span>{{ formatNumber(token.usage_count || 0) }} 次使用</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
|
<span>{{ token.permission_summary || permissionModeText(token.permission_mode) }}</span>
|
||||||
|
<span>·</span>
|
||||||
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
|
<span>{{ token.last_used_at ? formatRelativeTime(token.last_used_at) : '从未使用' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,6 +460,104 @@
|
|||||||
留空表示永不过期
|
留空表示永不过期
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 权限 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Label class="text-sm font-semibold">权限</Label>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:variant="permissionMode === 'full' ? 'default' : 'outline'"
|
||||||
|
class="h-9"
|
||||||
|
@click="setPermissionMode('full')"
|
||||||
|
>
|
||||||
|
全权
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:variant="permissionMode === 'read_only' ? 'default' : 'outline'"
|
||||||
|
class="h-9"
|
||||||
|
@click="setPermissionMode('read_only')"
|
||||||
|
>
|
||||||
|
只读
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:variant="permissionMode === 'custom' ? 'default' : 'outline'"
|
||||||
|
class="h-9"
|
||||||
|
@click="setPermissionMode('custom')"
|
||||||
|
>
|
||||||
|
自定义
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="permissionMode === 'custom'"
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 px-3 text-xs"
|
||||||
|
@click="setCustomPermissions('none')"
|
||||||
|
>
|
||||||
|
全部禁用
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 px-3 text-xs"
|
||||||
|
@click="setCustomPermissions('read_only')"
|
||||||
|
>
|
||||||
|
全部只读
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="h-8 px-3 text-xs"
|
||||||
|
@click="setCustomPermissions('full')"
|
||||||
|
>
|
||||||
|
全部全权
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="permissionMode === 'custom'"
|
||||||
|
class="max-h-72 overflow-y-auto rounded-md border border-border/60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="group in permissionGroups"
|
||||||
|
:key="group.scope"
|
||||||
|
class="border-b border-border/50 last:border-b-0 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ group.label }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Checkbox
|
||||||
|
:checked="isPermissionGroupDenied(group)"
|
||||||
|
@update:checked="togglePermissionGroupDenied(group, $event)"
|
||||||
|
/>
|
||||||
|
<span>禁止</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.key"
|
||||||
|
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:checked="selectedPermissions.includes(item.key)"
|
||||||
|
@update:checked="togglePermission(item.key, $event)"
|
||||||
|
/>
|
||||||
|
<span>{{ item.access_label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -512,15 +673,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
|
adminManagementTokenApi,
|
||||||
managementTokenApi,
|
managementTokenApi,
|
||||||
type ManagementToken
|
type ManagementToken,
|
||||||
|
type ManagementTokenPermissionCatalogItem
|
||||||
} from '@/api/management-tokens'
|
} from '@/api/management-tokens'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
|
import Checkbox from '@/components/ui/checkbox.vue'
|
||||||
import { Dialog, Pagination } from '@/components/ui'
|
import { Dialog, Pagination } from '@/components/ui'
|
||||||
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
||||||
import {
|
import {
|
||||||
@@ -548,6 +714,11 @@ import { log } from '@/utils/logger'
|
|||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||||
|
const canManageTokens = computed(() => authStore.isAdmin)
|
||||||
|
|
||||||
// 数据
|
// 数据
|
||||||
const tokens = ref<ManagementToken[]>([])
|
const tokens = ref<ManagementToken[]>([])
|
||||||
@@ -566,6 +737,29 @@ const pageSize = ref(10)
|
|||||||
|
|
||||||
const paginatedTokens = computed(() => tokens.value)
|
const paginatedTokens = computed(() => tokens.value)
|
||||||
|
|
||||||
|
const permissionCatalog = ref<ManagementTokenPermissionCatalogItem[]>([])
|
||||||
|
const allPermissionKeys = ref<string[]>([])
|
||||||
|
const readOnlyPermissionKeys = ref<string[]>([])
|
||||||
|
const permissionMode = ref<'full' | 'read_only' | 'custom'>('full')
|
||||||
|
const selectedPermissions = ref<string[]>([])
|
||||||
|
|
||||||
|
const permissionGroups = computed(() => {
|
||||||
|
const groups = new Map<string, { scope: string; label: string; items: ManagementTokenPermissionCatalogItem[] }>()
|
||||||
|
for (const item of permissionCatalog.value) {
|
||||||
|
const group = groups.get(item.scope)
|
||||||
|
if (group) {
|
||||||
|
group.items.push(item)
|
||||||
|
} else {
|
||||||
|
groups.set(item.scope, {
|
||||||
|
scope: item.scope,
|
||||||
|
label: item.scope_label,
|
||||||
|
items: [item]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(groups.values())
|
||||||
|
})
|
||||||
|
|
||||||
// 监听分页变化
|
// 监听分页变化
|
||||||
watch([currentPage, pageSize], () => {
|
watch([currentPage, pageSize], () => {
|
||||||
loadTokens()
|
loadTokens()
|
||||||
@@ -598,7 +792,11 @@ const tokenToRegenerate = ref<ManagementToken | null>(null)
|
|||||||
|
|
||||||
// 表单验证
|
// 表单验证
|
||||||
const isFormValid = computed(() => {
|
const isFormValid = computed(() => {
|
||||||
return formData.name.trim().length > 0
|
if (formData.name.trim().length === 0) return false
|
||||||
|
if (permissionMode.value === 'custom' && selectedPermissions.value.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
|
function getStatusVariant(token: ManagementToken): 'success' | 'secondary' | 'destructive' {
|
||||||
@@ -624,18 +822,28 @@ onMounted(() => {
|
|||||||
loadTokens()
|
loadTokens()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
canManageTokens,
|
||||||
|
(allowed) => {
|
||||||
|
if (allowed) {
|
||||||
|
void loadPermissionCatalog()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
async function loadTokens() {
|
async function loadTokens() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const skip = (currentPage.value - 1) * pageSize.value
|
const skip = (currentPage.value - 1) * pageSize.value
|
||||||
const response = await managementTokenApi.listTokens({ skip, limit: pageSize.value })
|
const response = isAdminPage.value
|
||||||
|
? await adminManagementTokenApi.listAllTokens({ skip, limit: pageSize.value })
|
||||||
|
: await managementTokenApi.listTokens({ skip, limit: pageSize.value })
|
||||||
|
|
||||||
tokens.value = response.items
|
tokens.value = response.items
|
||||||
totalTokens.value = response.total
|
totalTokens.value = response.total
|
||||||
|
|
||||||
if (response.quota) {
|
quota.value = response.quota ?? null
|
||||||
quota.value = response.quota
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果当前页超出范围,重置到第一页
|
// 如果当前页超出范围,重置到第一页
|
||||||
if (tokens.value.length === 0 && currentPage.value > 1) {
|
if (tokens.value.length === 0 && currentPage.value > 1) {
|
||||||
@@ -649,8 +857,34 @@ async function loadTokens() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPermissionCatalog() {
|
||||||
|
if (permissionCatalog.value.length > 0) return
|
||||||
|
try {
|
||||||
|
const response = await adminManagementTokenApi.getPermissionCatalog()
|
||||||
|
permissionCatalog.value = response.items
|
||||||
|
allPermissionKeys.value = response.all_permissions
|
||||||
|
readOnlyPermissionKeys.value = response.read_only_permissions
|
||||||
|
if (selectedPermissions.value.length === 0) {
|
||||||
|
selectedPermissions.value = [...response.all_permissions]
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
log.error('加载 Management Token 权限目录失败:', err)
|
||||||
|
showError(parseApiError(err, '权限目录加载失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
if (!canManageTokens.value) return
|
||||||
|
resetForm()
|
||||||
|
permissionMode.value = 'full'
|
||||||
|
selectedPermissions.value = [...allPermissionKeys.value]
|
||||||
|
void loadPermissionCatalog()
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 打开编辑对话框
|
// 打开编辑对话框
|
||||||
function openEditDialog(token: ManagementToken) {
|
function openEditDialog(token: ManagementToken) {
|
||||||
|
if (!canManageTokens.value) return
|
||||||
editingToken.value = token
|
editingToken.value = token
|
||||||
formData.name = token.name
|
formData.name = token.name
|
||||||
formData.description = token.description || ''
|
formData.description = token.description || ''
|
||||||
@@ -660,17 +894,114 @@ function openEditDialog(token: ManagementToken) {
|
|||||||
formData.expiresAt = token.expires_at
|
formData.expiresAt = token.expires_at
|
||||||
? toLocalDatetimeString(new Date(token.expires_at))
|
? toLocalDatetimeString(new Date(token.expires_at))
|
||||||
: ''
|
: ''
|
||||||
|
const mode = token.permission_mode === 'read_only' || token.permission_mode === 'custom'
|
||||||
|
? token.permission_mode
|
||||||
|
: 'full'
|
||||||
|
permissionMode.value = mode
|
||||||
|
selectedPermissions.value = token.permissions?.length
|
||||||
|
? [...token.permissions]
|
||||||
|
: (mode === 'read_only' ? [...readOnlyPermissionKeys.value] : [...allPermissionKeys.value])
|
||||||
|
void loadPermissionCatalog()
|
||||||
showCreateDialog.value = true
|
showCreateDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭对话框
|
// 关闭对话框
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
showCreateDialog.value = false
|
showCreateDialog.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
editingToken.value = null
|
editingToken.value = null
|
||||||
formData.name = ''
|
formData.name = ''
|
||||||
formData.description = ''
|
formData.description = ''
|
||||||
formData.allowedIpsText = ''
|
formData.allowedIpsText = ''
|
||||||
formData.expiresAt = ''
|
formData.expiresAt = ''
|
||||||
|
permissionMode.value = 'full'
|
||||||
|
selectedPermissions.value = [...allPermissionKeys.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPermissionMode(mode: 'full' | 'read_only' | 'custom') {
|
||||||
|
permissionMode.value = mode
|
||||||
|
if (mode === 'full') {
|
||||||
|
selectedPermissions.value = [...allPermissionKeys.value]
|
||||||
|
} else if (mode === 'read_only') {
|
||||||
|
selectedPermissions.value = [...readOnlyPermissionKeys.value]
|
||||||
|
} else if (selectedPermissions.value.length === 0) {
|
||||||
|
selectedPermissions.value = [...readOnlyPermissionKeys.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCustomPermissions(mode: 'none' | 'read_only' | 'full') {
|
||||||
|
if (mode === 'none') {
|
||||||
|
selectedPermissions.value = []
|
||||||
|
} else if (mode === 'read_only') {
|
||||||
|
selectedPermissions.value = [...readOnlyPermissionKeys.value]
|
||||||
|
} else {
|
||||||
|
selectedPermissions.value = [...allPermissionKeys.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePermission(key: string, checked: boolean) {
|
||||||
|
const next = new Set(selectedPermissions.value)
|
||||||
|
if (checked) {
|
||||||
|
next.add(key)
|
||||||
|
} else {
|
||||||
|
next.delete(key)
|
||||||
|
}
|
||||||
|
selectedPermissions.value = Array.from(next).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionGroupDenied(group: { items: ManagementTokenPermissionCatalogItem[] }): boolean {
|
||||||
|
return group.items.every(item => !selectedPermissions.value.includes(item.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePermissionGroupDenied(
|
||||||
|
group: { items: ManagementTokenPermissionCatalogItem[] },
|
||||||
|
checked: boolean
|
||||||
|
) {
|
||||||
|
const next = new Set(selectedPermissions.value)
|
||||||
|
for (const item of group.items) {
|
||||||
|
next.delete(item.key)
|
||||||
|
}
|
||||||
|
if (!checked) {
|
||||||
|
const readPermission = group.items.find(item => item.access === 'read')
|
||||||
|
if (readPermission) {
|
||||||
|
next.add(readPermission.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedPermissions.value = Array.from(next).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFormPermissions(): Promise<string[]> {
|
||||||
|
if (permissionCatalog.value.length === 0) {
|
||||||
|
await loadPermissionCatalog()
|
||||||
|
}
|
||||||
|
if (allPermissionKeys.value.length === 0) {
|
||||||
|
throw new Error('权限目录不可用')
|
||||||
|
}
|
||||||
|
if (permissionMode.value === 'full') {
|
||||||
|
return [...allPermissionKeys.value]
|
||||||
|
}
|
||||||
|
if (permissionMode.value === 'read_only') {
|
||||||
|
return [...readOnlyPermissionKeys.value]
|
||||||
|
}
|
||||||
|
return [...selectedPermissions.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionModeText(mode?: ManagementToken['permission_mode']): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'legacy_full':
|
||||||
|
return '旧版全权限'
|
||||||
|
case 'full':
|
||||||
|
return '全权限'
|
||||||
|
case 'read_only':
|
||||||
|
return '只读'
|
||||||
|
case 'custom':
|
||||||
|
return '自定义'
|
||||||
|
default:
|
||||||
|
return '未配置'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存 Token
|
// 保存 Token
|
||||||
@@ -688,16 +1019,26 @@ async function saveToken() {
|
|||||||
const expiresAtUtc = formData.expiresAt
|
const expiresAtUtc = formData.expiresAt
|
||||||
? new Date(formData.expiresAt).toISOString()
|
? new Date(formData.expiresAt).toISOString()
|
||||||
: null
|
: null
|
||||||
|
const permissions = await resolveFormPermissions()
|
||||||
|
|
||||||
if (editingToken.value) {
|
if (editingToken.value) {
|
||||||
// 更新
|
// 更新
|
||||||
const tokenId = editingToken.value.id
|
const tokenId = editingToken.value.id
|
||||||
const result = await managementTokenApi.updateToken(tokenId, {
|
const result = isAdminPage.value
|
||||||
name: formData.name,
|
? await adminManagementTokenApi.updateToken(tokenId, {
|
||||||
description: formData.description.trim() || null,
|
name: formData.name,
|
||||||
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
|
description: formData.description.trim() || null,
|
||||||
expires_at: expiresAtUtc
|
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
|
||||||
})
|
permissions,
|
||||||
|
expires_at: expiresAtUtc
|
||||||
|
})
|
||||||
|
: await managementTokenApi.updateToken(tokenId, {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description.trim() || null,
|
||||||
|
allowed_ips: allowedIps.length > 0 ? allowedIps : null,
|
||||||
|
permissions,
|
||||||
|
expires_at: expiresAtUtc
|
||||||
|
})
|
||||||
// 局部更新:直接替换列表中对应的记录
|
// 局部更新:直接替换列表中对应的记录
|
||||||
const index = tokens.value.findIndex(t => t.id === tokenId)
|
const index = tokens.value.findIndex(t => t.id === tokenId)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -706,12 +1047,16 @@ async function saveToken() {
|
|||||||
success('令牌更新成功')
|
success('令牌更新成功')
|
||||||
} else {
|
} else {
|
||||||
// 创建
|
// 创建
|
||||||
const result = await managementTokenApi.createToken({
|
const payload = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
|
allowed_ips: allowedIps.length > 0 ? allowedIps : undefined,
|
||||||
|
permissions,
|
||||||
expires_at: expiresAtUtc
|
expires_at: expiresAtUtc
|
||||||
})
|
}
|
||||||
|
const result = isAdminPage.value
|
||||||
|
? await adminManagementTokenApi.createToken(payload)
|
||||||
|
: await managementTokenApi.createToken(payload)
|
||||||
newTokenValue.value = result.token
|
newTokenValue.value = result.token
|
||||||
isRegenerating.value = false
|
isRegenerating.value = false
|
||||||
showTokenDialog.value = true
|
showTokenDialog.value = true
|
||||||
@@ -730,8 +1075,11 @@ async function saveToken() {
|
|||||||
|
|
||||||
// 切换状态
|
// 切换状态
|
||||||
async function toggleToken(token: ManagementToken) {
|
async function toggleToken(token: ManagementToken) {
|
||||||
|
if (!canManageTokens.value) return
|
||||||
try {
|
try {
|
||||||
const result = await managementTokenApi.toggleToken(token.id)
|
const result = isAdminPage.value
|
||||||
|
? await adminManagementTokenApi.toggleToken(token.id)
|
||||||
|
: await managementTokenApi.toggleToken(token.id)
|
||||||
|
|
||||||
const index = tokens.value.findIndex(t => t.id === token.id)
|
const index = tokens.value.findIndex(t => t.id === token.id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -746,6 +1094,7 @@ async function toggleToken(token: ManagementToken) {
|
|||||||
|
|
||||||
// 删除
|
// 删除
|
||||||
function confirmDelete(token: ManagementToken) {
|
function confirmDelete(token: ManagementToken) {
|
||||||
|
if (!canManageTokens.value) return
|
||||||
tokenToDelete.value = token
|
tokenToDelete.value = token
|
||||||
showDeleteDialog.value = true
|
showDeleteDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -755,7 +1104,11 @@ async function deleteToken() {
|
|||||||
|
|
||||||
deleting.value = true
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
await managementTokenApi.deleteToken(tokenToDelete.value.id)
|
if (isAdminPage.value) {
|
||||||
|
await adminManagementTokenApi.deleteToken(tokenToDelete.value.id)
|
||||||
|
} else {
|
||||||
|
await managementTokenApi.deleteToken(tokenToDelete.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
success('令牌已删除')
|
success('令牌已删除')
|
||||||
@@ -771,6 +1124,7 @@ async function deleteToken() {
|
|||||||
|
|
||||||
// 重新生成
|
// 重新生成
|
||||||
function confirmRegenerate(token: ManagementToken) {
|
function confirmRegenerate(token: ManagementToken) {
|
||||||
|
if (!canManageTokens.value) return
|
||||||
tokenToRegenerate.value = token
|
tokenToRegenerate.value = token
|
||||||
showRegenerateDialog.value = true
|
showRegenerateDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -780,7 +1134,9 @@ async function regenerateToken() {
|
|||||||
|
|
||||||
regenerating.value = true
|
regenerating.value = true
|
||||||
try {
|
try {
|
||||||
const result = await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
|
const result = isAdminPage.value
|
||||||
|
? await adminManagementTokenApi.regenerateToken(tokenToRegenerate.value.id)
|
||||||
|
: await managementTokenApi.regenerateToken(tokenToRegenerate.value.id)
|
||||||
newTokenValue.value = result.token
|
newTokenValue.value = result.token
|
||||||
isRegenerating.value = true
|
isRegenerating.value = true
|
||||||
showRegenerateDialog.value = false
|
showRegenerateDialog.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user