mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
1902 lines
67 KiB
Rust
1902 lines
67 KiB
Rust
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: "routing_profiles",
|
|
label: "调度分组",
|
|
assignable: true,
|
|
},
|
|
PermissionGroup {
|
|
scope: "security",
|
|
label: "安全",
|
|
assignable: true,
|
|
},
|
|
PermissionGroup {
|
|
scope: "stats",
|
|
label: "统计",
|
|
assignable: true,
|
|
},
|
|
PermissionGroup {
|
|
scope: "system",
|
|
label: "系统",
|
|
assignable: true,
|
|
},
|
|
PermissionGroup {
|
|
scope: "tasks",
|
|
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", "管理")];
|
|
|
|
// Freeze the implicit permissions granted before per-token permissions were
|
|
// introduced. New scopes must never expand legacy NULL-permission tokens. The
|
|
// management_tokens scope is deliberately withheld so a legacy token cannot
|
|
// mint an unrestricted replacement that outlives its own IP or expiry bounds.
|
|
const LEGACY_FULL_PERMISSION_SCOPES: &[&str] = &[
|
|
"adaptive",
|
|
"announcements",
|
|
"api_keys",
|
|
"billing",
|
|
"endpoints_health",
|
|
"endpoints_manage",
|
|
"endpoints_rpm",
|
|
"gemini_files",
|
|
"ldap",
|
|
"models",
|
|
"modules",
|
|
"monitoring",
|
|
"oauth",
|
|
"payments",
|
|
"pool",
|
|
"provider_ops",
|
|
"provider_oauth",
|
|
"provider_query",
|
|
"provider_strategy",
|
|
"providers",
|
|
"proxy_nodes",
|
|
"security",
|
|
"stats",
|
|
"system",
|
|
"usage",
|
|
"users",
|
|
"video_tasks",
|
|
"wallets",
|
|
];
|
|
|
|
pub(crate) fn legacy_full_management_token_permissions() -> Vec<String> {
|
|
LEGACY_FULL_PERMISSION_SCOPES
|
|
.iter()
|
|
.flat_map(|scope| {
|
|
ACCESS_LEVELS
|
|
.iter()
|
|
.map(move |(access, _)| permission_key(scope, access).to_string())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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 audit_admin_read_only_management_token_permissions() -> Vec<String> {
|
|
let mut permissions = read_only_management_token_permissions()
|
|
.into_iter()
|
|
.collect::<BTreeSet<_>>();
|
|
permissions.extend(
|
|
PERMISSION_GROUPS
|
|
.iter()
|
|
.filter(|group| !group.assignable)
|
|
.map(|group| permission_key(group.scope, "read").to_string()),
|
|
);
|
|
permissions.into_iter().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 Err("permissions 必须是非空字符串数组;省略该字段可使用默认权限".to_string());
|
|
}
|
|
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 Err("management token permissions JSON null is invalid".to_string());
|
|
}
|
|
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_route(method, decision)
|
|
))
|
|
}
|
|
|
|
pub(crate) fn validate_management_token_admin_route_permission(
|
|
method: &http::Method,
|
|
decision: &GatewayControlDecision,
|
|
token_permissions: Option<&[String]>,
|
|
) -> Result<(), ManagementTokenPermissionDenied> {
|
|
let Some(token_permissions) = token_permissions else {
|
|
return Ok(());
|
|
};
|
|
let Some(required_permission) = management_token_required_permission(method, decision) else {
|
|
return if decision.route_class.as_deref() == Some("admin_proxy") {
|
|
Err(ManagementTokenPermissionDenied {
|
|
required_permission: "admin:unknown:admin".to_string(),
|
|
})
|
|
} else {
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn management_token_principal_has_permission(
|
|
decision: &GatewayControlDecision,
|
|
required_permission: &str,
|
|
) -> bool {
|
|
let Some(principal) = decision.admin_principal.as_ref() else {
|
|
return false;
|
|
};
|
|
if principal.management_token_id.is_none() {
|
|
return true;
|
|
}
|
|
|
|
let legacy_permissions;
|
|
let permissions = match principal.management_token_permissions.as_deref() {
|
|
Some(permissions) => permissions,
|
|
None => {
|
|
legacy_permissions = legacy_full_management_token_permissions();
|
|
legacy_permissions.as_slice()
|
|
}
|
|
};
|
|
permissions
|
|
.iter()
|
|
.any(|permission| permission == 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 access_for_route(method: &http::Method, decision: &GatewayControlDecision) -> &'static str {
|
|
let signature = decision.auth_endpoint_signature.as_deref();
|
|
let route_kind = decision.route_kind.as_deref();
|
|
let requires_admin_permission = matches!(
|
|
(signature, route_kind),
|
|
(
|
|
Some("admin:system"),
|
|
Some(
|
|
"prepare_update"
|
|
| "apply_update"
|
|
| "rollback"
|
|
| "config_export"
|
|
| "users_export"
|
|
| "data_export"
|
|
| "s3_backup_run"
|
|
| "config_import"
|
|
| "users_import"
|
|
| "data_import"
|
|
| "smtp_test"
|
|
| "important_notification_test"
|
|
| "cleanup"
|
|
| "cleanup_usage_manual"
|
|
| "purge_config"
|
|
| "purge_users"
|
|
| "purge_usage"
|
|
| "purge_audit_logs"
|
|
| "purge_request_bodies"
|
|
| "purge_request_bodies_task"
|
|
| "purge_stats"
|
|
| "settings_set"
|
|
| "config_set"
|
|
| "config_delete"
|
|
)
|
|
) | (
|
|
Some("admin:endpoints_manage"),
|
|
Some(
|
|
"reveal_key"
|
|
| "export_key"
|
|
| "create_provider_key"
|
|
| "update_key"
|
|
| "create_endpoint"
|
|
| "update_endpoint"
|
|
| "refresh_quota"
|
|
| "codex_reset_credit_consume"
|
|
)
|
|
) | (Some("admin:providers"), Some("update_provider"))
|
|
| (
|
|
Some("admin:provider_query"),
|
|
Some("query_models" | "test_model" | "test_model_failover")
|
|
)
|
|
| (
|
|
Some("admin:provider_oauth"),
|
|
Some(
|
|
"complete_key_oauth"
|
|
| "refresh_key_oauth"
|
|
| "complete_provider_oauth"
|
|
| "import_refresh_token"
|
|
| "cookie_authorize"
|
|
| "start_cookie_authorize_task"
|
|
| "start_agent_identity_import_task"
|
|
| "batch_import_oauth"
|
|
| "start_batch_import_oauth_task"
|
|
| "device_poll"
|
|
)
|
|
)
|
|
| (
|
|
Some("admin:management_tokens"),
|
|
Some("create_token" | "regenerate_token")
|
|
)
|
|
| (
|
|
Some("admin:provider_ops"),
|
|
Some(
|
|
"connect_provider"
|
|
| "verify_provider"
|
|
| "get_provider_balance"
|
|
| "refresh_provider_balance"
|
|
| "provider_checkin"
|
|
| "execute_provider_action"
|
|
| "batch_balance"
|
|
)
|
|
)
|
|
| (
|
|
Some("admin:security"),
|
|
Some("blacklist_add" | "blacklist_remove" | "whitelist_add" | "whitelist_remove")
|
|
)
|
|
| (Some("admin:ldap"), Some("set_config" | "test_connection"))
|
|
| (Some("admin:modules"), Some("set_enabled"))
|
|
| (
|
|
Some("admin:users"),
|
|
Some("reveal_user_api_key" | "create_user_api_key")
|
|
)
|
|
| (
|
|
Some("admin:oauth"),
|
|
Some("upsert_provider" | "delete_provider")
|
|
)
|
|
| (
|
|
Some("admin:payments"),
|
|
Some("update_epay_gateway" | "update_payment_gateway")
|
|
)
|
|
| (
|
|
Some("admin:api_keys"),
|
|
Some("create_api_key" | "create_api_key_install_session")
|
|
)
|
|
| (
|
|
Some("admin:proxy_nodes"),
|
|
Some("create_proxy_node_install_session")
|
|
)
|
|
| (Some("admin:usage"), Some("detail" | "curl" | "replay"))
|
|
| (Some("admin:monitoring"), Some("trace_request"))
|
|
| (Some("admin:tasks"), Some("detail" | "events"))
|
|
| (
|
|
Some("admin:pool"),
|
|
Some("batch_action_keys" | "batch_update_keys")
|
|
)
|
|
) || (*method == http::Method::GET
|
|
&& signature == Some("admin:api_keys")
|
|
&& route_kind == Some("api_key_detail")
|
|
&& query_param_enabled(decision.public_query_string.as_deref(), "include_key"));
|
|
|
|
if requires_admin_permission {
|
|
// These routes reveal usable credentials or replace security-critical
|
|
// identity and system configuration. They are not delegated writes.
|
|
"admin"
|
|
} else {
|
|
access_for_method(method)
|
|
}
|
|
}
|
|
|
|
fn query_param_enabled(query: Option<&str>, key: &str) -> bool {
|
|
query
|
|
.into_iter()
|
|
.flat_map(|query| url::form_urlencoded::parse(query.as_bytes()))
|
|
.find(|(entry_key, _)| entry_key == key)
|
|
.is_some_and(|(_, value)| {
|
|
matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"1" | "true" | "yes" | "on"
|
|
)
|
|
})
|
|
}
|
|
|
|
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",
|
|
("routing_profiles", "read") => "admin:routing_profiles:read",
|
|
("routing_profiles", "write") => "admin:routing_profiles:write",
|
|
("routing_profiles", "admin") => "admin:routing_profiles: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",
|
|
("tasks", "read") => "admin:tasks:read",
|
|
("tasks", "write") => "admin:tasks:write",
|
|
("tasks", "admin") => "admin:tasks: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",
|
|
"tasks",
|
|
"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}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn catalog_covers_admin_route_signatures_from_route_sources() {
|
|
let route_sources = [
|
|
("route/admin.rs", include_str!("route/admin.rs")),
|
|
("route/oauth.rs", include_str!("route/oauth.rs")),
|
|
(
|
|
"route/public_support.rs",
|
|
include_str!("route/public_support.rs"),
|
|
),
|
|
(
|
|
"route/admin/basic_families.rs",
|
|
include_str!("route/admin/basic_families.rs"),
|
|
),
|
|
(
|
|
"route/admin/endpoints_families.rs",
|
|
include_str!("route/admin/endpoints_families.rs"),
|
|
),
|
|
(
|
|
"route/admin/model_provider_families.rs",
|
|
include_str!("route/admin/model_provider_families.rs"),
|
|
),
|
|
(
|
|
"route/admin/observability_families.rs",
|
|
include_str!("route/admin/observability_families.rs"),
|
|
),
|
|
(
|
|
"route/admin/operations_families.rs",
|
|
include_str!("route/admin/operations_families.rs"),
|
|
),
|
|
(
|
|
"route/admin/provider_ops_routes.rs",
|
|
include_str!("route/admin/provider_ops_routes.rs"),
|
|
),
|
|
(
|
|
"route/admin/system_families.rs",
|
|
include_str!("route/admin/system_families.rs"),
|
|
),
|
|
];
|
|
let mut route_scopes = BTreeSet::new();
|
|
|
|
for (file, source) in route_sources {
|
|
for scope in extract_admin_route_scopes(source) {
|
|
assert!(
|
|
is_known_management_token_permission_scope(scope),
|
|
"missing management token permission scope {scope} referenced by {file}"
|
|
);
|
|
route_scopes.insert(scope);
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
!route_scopes.is_empty(),
|
|
"admin route scope scanner did not find any route signatures"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn full_assignable_token_permissions_cannot_cover_management_tokens_scope() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/management-tokens".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("management_tokens_manage".to_string()),
|
|
Some("list_tokens".to_string()),
|
|
Some("admin:management_tokens".to_string()),
|
|
);
|
|
let permissions = all_assignable_management_token_permissions();
|
|
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.expect_err("management-token administration is not assignable")
|
|
.required_permission,
|
|
"admin:management_tokens:read"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_tokens_cannot_administer_management_tokens() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/management-tokens".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("management_tokens_manage".to_string()),
|
|
Some("create_token".to_string()),
|
|
Some("admin:management_tokens".to_string()),
|
|
);
|
|
let permissions = legacy_full_management_token_permissions();
|
|
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.expect_err("legacy tokens must not mint replacement credentials")
|
|
.required_permission,
|
|
"admin:management_tokens:admin"
|
|
);
|
|
assert!(!permissions
|
|
.iter()
|
|
.any(|permission| permission.starts_with("admin:management_tokens:")));
|
|
}
|
|
|
|
#[test]
|
|
fn read_only_permissions_allow_reads_and_reject_writes() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/providers".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("providers_manage".to_string()),
|
|
Some("create_provider".to_string()),
|
|
Some("admin:providers".to_string()),
|
|
);
|
|
let permissions = read_only_management_token_permissions();
|
|
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.is_ok());
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.expect_err("read-only permissions should reject writes")
|
|
.required_permission,
|
|
"admin:providers:write"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scoped_tokens_fail_closed_for_unclassified_admin_permissions() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/unclassified".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("unclassified_manage".to_string()),
|
|
Some("unclassified".to_string()),
|
|
None,
|
|
);
|
|
let permissions = vec!["admin:system:admin".to_string()];
|
|
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.expect_err("scoped tokens must not bypass an unclassified admin route")
|
|
.required_permission,
|
|
"admin:unknown:admin"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
None,
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_full_permissions_are_frozen_to_the_original_scope_catalog() {
|
|
let permissions = legacy_full_management_token_permissions();
|
|
|
|
for permission in [
|
|
"admin:system:admin",
|
|
"admin:oauth:admin",
|
|
"admin:proxy_nodes:admin",
|
|
] {
|
|
assert!(permissions.iter().any(|item| item == permission));
|
|
}
|
|
for permission in [
|
|
"admin:routing_profiles:read",
|
|
"admin:routing_profiles:admin",
|
|
"admin:tasks:read",
|
|
"admin:tasks:admin",
|
|
] {
|
|
assert!(
|
|
!permissions.iter().any(|item| item == permission),
|
|
"legacy token unexpectedly gained {permission}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_null_permissions_never_inherit_legacy_full_access() {
|
|
assert!(normalize_assignable_management_token_permissions(Some(&Value::Null)).is_err());
|
|
assert!(management_token_permission_keys_from_value(Some(&Value::Null)).is_err());
|
|
|
|
assert_eq!(
|
|
management_token_permission_keys_from_value(None)
|
|
.expect("SQL NULL remains the explicit legacy representation"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sensitive_action_permission_distinguishes_sessions_and_scoped_tokens() {
|
|
let mut decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/users/user-1".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("users_manage".to_string()),
|
|
Some("update_user".to_string()),
|
|
Some("admin:users".to_string()),
|
|
);
|
|
|
|
assert!(!management_token_principal_has_permission(
|
|
&decision,
|
|
"admin:users:admin"
|
|
));
|
|
|
|
decision.admin_principal = Some(crate::control::GatewayAdminPrincipalContext {
|
|
user_id: "admin-1".to_string(),
|
|
user_role: "admin".to_string(),
|
|
session_id: Some("session-1".to_string()),
|
|
management_token_id: None,
|
|
management_token_permissions: None,
|
|
});
|
|
assert!(management_token_principal_has_permission(
|
|
&decision,
|
|
"admin:users:admin"
|
|
));
|
|
|
|
let principal = decision
|
|
.admin_principal
|
|
.as_mut()
|
|
.expect("principal should exist");
|
|
principal.session_id = None;
|
|
principal.management_token_id = Some("management-token-1".to_string());
|
|
principal.management_token_permissions = Some(vec!["admin:users:write".to_string()]);
|
|
assert!(!management_token_principal_has_permission(
|
|
&decision,
|
|
"admin:users:admin"
|
|
));
|
|
|
|
decision
|
|
.admin_principal
|
|
.as_mut()
|
|
.expect("principal should exist")
|
|
.management_token_permissions = Some(vec!["admin:users:admin".to_string()]);
|
|
assert!(management_token_principal_has_permission(
|
|
&decision,
|
|
"admin:users:admin"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sensitive_system_data_transfers_require_admin_permission() {
|
|
let delegated_permissions = vec![
|
|
"admin:system:read".to_string(),
|
|
"admin:system:write".to_string(),
|
|
];
|
|
let admin_permissions = vec!["admin:system:admin".to_string()];
|
|
|
|
for (method, route_kind) in [
|
|
(http::Method::GET, "config_export"),
|
|
(http::Method::GET, "users_export"),
|
|
(http::Method::GET, "data_export"),
|
|
(http::Method::POST, "s3_backup_run"),
|
|
(http::Method::POST, "config_import"),
|
|
(http::Method::POST, "users_import"),
|
|
(http::Method::POST, "data_import"),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/system/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some("system_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:system".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some("admin:system:admin")
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&delegated_permissions),
|
|
)
|
|
.expect_err("delegated system access must not transfer sensitive data")
|
|
.required_permission,
|
|
"admin:system:admin"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_provider_upsert_requires_oauth_admin_permission() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/oauth/providers/custom".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("oauth_manage".to_string()),
|
|
Some("upsert_provider".to_string()),
|
|
Some("admin:oauth".to_string()),
|
|
);
|
|
let write_permissions = vec!["admin:oauth:write".to_string()];
|
|
let admin_permissions = vec!["admin:oauth:admin".to_string()];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::PUT, &decision).as_deref(),
|
|
Some("admin:oauth:admin")
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::PUT,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.expect_err("oauth write must not replace security-sensitive provider configuration")
|
|
.required_permission,
|
|
"admin:oauth:admin"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::PUT,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn oauth_provider_delete_requires_oauth_admin_permission() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/oauth/providers/custom".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("oauth_manage".to_string()),
|
|
Some("delete_provider".to_string()),
|
|
Some("admin:oauth".to_string()),
|
|
);
|
|
let write_permissions = vec!["admin:oauth:write".to_string()];
|
|
let admin_permissions = vec!["admin:oauth:admin".to_string()];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::DELETE, &decision).as_deref(),
|
|
Some("admin:oauth:admin")
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::DELETE,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.expect_err("oauth write must not delete an authentication provider")
|
|
.required_permission,
|
|
"admin:oauth:admin"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::DELETE,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn destructive_system_actions_require_system_admin_permission() {
|
|
let write_permissions = vec!["admin:system:write".to_string()];
|
|
let admin_permissions = vec!["admin:system:admin".to_string()];
|
|
|
|
for (method, route_kind) in [
|
|
(http::Method::POST, "prepare_update"),
|
|
(http::Method::POST, "apply_update"),
|
|
(http::Method::POST, "rollback"),
|
|
(http::Method::POST, "cleanup"),
|
|
(http::Method::POST, "cleanup_usage_manual"),
|
|
(http::Method::POST, "purge_config"),
|
|
(http::Method::POST, "purge_users"),
|
|
(http::Method::POST, "purge_usage"),
|
|
(http::Method::POST, "purge_audit_logs"),
|
|
(http::Method::POST, "purge_request_bodies"),
|
|
(http::Method::POST, "purge_request_bodies_task"),
|
|
(http::Method::POST, "purge_stats"),
|
|
(http::Method::PUT, "settings_set"),
|
|
(http::Method::PUT, "config_set"),
|
|
(http::Method::DELETE, "config_delete"),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/system/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some("system_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:system".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some("admin:system:admin"),
|
|
"unexpected permission for {method} {route_kind}"
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.expect_err("system write must not perform destructive or security-critical action")
|
|
.required_permission,
|
|
"admin:system:admin"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_operations_that_use_stored_credentials_require_provider_ops_admin_permission() {
|
|
let read_permissions = vec!["admin:provider_ops:read".to_string()];
|
|
let write_permissions = vec!["admin:provider_ops:write".to_string()];
|
|
let admin_permissions = vec!["admin:provider_ops:admin".to_string()];
|
|
|
|
for (method, route_kind) in [
|
|
(http::Method::POST, "connect_provider"),
|
|
(http::Method::POST, "verify_provider"),
|
|
(http::Method::GET, "get_provider_balance"),
|
|
(http::Method::POST, "refresh_provider_balance"),
|
|
(http::Method::POST, "provider_checkin"),
|
|
(http::Method::POST, "execute_provider_action"),
|
|
(http::Method::POST, "batch_balance"),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/provider-ops/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some("provider_ops_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:provider_ops".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some("admin:provider_ops:admin")
|
|
);
|
|
for delegated_permissions in [&read_permissions, &write_permissions] {
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(delegated_permissions),
|
|
)
|
|
.expect_err("delegated access must not execute stored provider credentials")
|
|
.required_permission,
|
|
"admin:provider_ops:admin"
|
|
);
|
|
}
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_queries_that_execute_stored_credentials_require_admin_permission() {
|
|
let write_permissions = vec!["admin:provider_query:write".to_string()];
|
|
let admin_permissions = vec!["admin:provider_query:admin".to_string()];
|
|
|
|
for route_kind in ["query_models", "test_model", "test_model_failover"] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/provider-query/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some("provider_query_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:provider_query".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::POST, &decision).as_deref(),
|
|
Some("admin:provider_query:admin")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.is_err());
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn endpoint_actions_that_execute_stored_credentials_require_admin_permission() {
|
|
let write_permissions = vec!["admin:endpoints_manage:write".to_string()];
|
|
let admin_permissions = vec!["admin:endpoints_manage:admin".to_string()];
|
|
|
|
for (method, route_kind) in [
|
|
(http::Method::PUT, "update_key"),
|
|
(http::Method::POST, "create_endpoint"),
|
|
(http::Method::PUT, "update_endpoint"),
|
|
(http::Method::POST, "refresh_quota"),
|
|
(http::Method::POST, "codex_reset_credit_consume"),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/endpoints/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some("endpoints_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:endpoints_manage".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some("admin:endpoints_manage:admin")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.is_err());
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stored_credential_egress_configuration_requires_admin_permission() {
|
|
for (method, signature, route_kind) in [
|
|
(http::Method::PATCH, "admin:providers", "update_provider"),
|
|
(http::Method::POST, "admin:pool", "batch_action_keys"),
|
|
(http::Method::PATCH, "admin:pool", "batch_update_keys"),
|
|
] {
|
|
let scope = signature.trim_start_matches("admin:");
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/{scope}/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some(format!("{scope}_manage")),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
let write_permissions = vec![format!("{signature}:write")];
|
|
let admin_permissions = vec![format!("{signature}:admin")];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some(format!("{signature}:admin").as_str())
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.expect_err("delegated writes must not redirect stored provider credentials")
|
|
.required_permission,
|
|
format!("{signature}:admin")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn connection_tests_that_transmit_stored_secrets_require_admin_permission() {
|
|
for (signature, route_kind, read_permission, write_permission, admin_permission) in [
|
|
(
|
|
"admin:system",
|
|
"smtp_test",
|
|
"admin:system:read",
|
|
"admin:system:write",
|
|
"admin:system:admin",
|
|
),
|
|
(
|
|
"admin:system",
|
|
"important_notification_test",
|
|
"admin:system:read",
|
|
"admin:system:write",
|
|
"admin:system:admin",
|
|
),
|
|
(
|
|
"admin:ldap",
|
|
"test_connection",
|
|
"admin:ldap:read",
|
|
"admin:ldap:write",
|
|
"admin:ldap:admin",
|
|
),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/security-sensitive-test".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("security_test".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
let read_permissions = vec![read_permission.to_string()];
|
|
let write_permissions = vec![write_permission.to_string()];
|
|
let admin_permissions = vec![admin_permission.to_string()];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::POST, &decision).as_deref(),
|
|
Some(admin_permission)
|
|
);
|
|
for delegated_permissions in [&read_permissions, &write_permissions] {
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(delegated_permissions),
|
|
)
|
|
.expect_err("delegated access must not transmit stored service secrets")
|
|
.required_permission,
|
|
admin_permission
|
|
);
|
|
}
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn security_control_plane_mutations_require_admin_permission() {
|
|
for (method, signature, route_kind) in [
|
|
(http::Method::POST, "admin:security", "blacklist_add"),
|
|
(http::Method::DELETE, "admin:security", "blacklist_remove"),
|
|
(http::Method::POST, "admin:security", "whitelist_add"),
|
|
(http::Method::DELETE, "admin:security", "whitelist_remove"),
|
|
(http::Method::PUT, "admin:ldap", "set_config"),
|
|
(http::Method::PUT, "admin:modules", "set_enabled"),
|
|
] {
|
|
let scope = signature.trim_start_matches("admin:");
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/{scope}/{route_kind}"),
|
|
Some("admin_proxy".to_string()),
|
|
Some(format!("{scope}_manage")),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
let read_permissions = vec![format!("{signature}:read")];
|
|
let write_permissions = vec![format!("{signature}:write")];
|
|
let admin_permissions = vec![format!("{signature}:admin")];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some(format!("{signature}:admin").as_str())
|
|
);
|
|
for delegated_permissions in [&read_permissions, &write_permissions] {
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(delegated_permissions),
|
|
)
|
|
.expect_err("delegated tokens must not change security controls")
|
|
.required_permission,
|
|
format!("{signature}:admin")
|
|
);
|
|
}
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn payment_gateway_updates_require_payments_admin_permission() {
|
|
for route_kind in ["update_epay_gateway", "update_payment_gateway"] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/payments/gateways/epay".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("payments_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:payments".to_string()),
|
|
);
|
|
let write_permissions = vec!["admin:payments:write".to_string()];
|
|
let admin_permissions = vec!["admin:payments:admin".to_string()];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::PUT, &decision).as_deref(),
|
|
Some("admin:payments:admin")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::PUT,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.is_err());
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::PUT,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plaintext_credential_reads_require_admin_permission() {
|
|
let read_only_permissions = read_only_management_token_permissions();
|
|
let cases = [
|
|
(
|
|
"admin:endpoints_manage",
|
|
"reveal_key",
|
|
None,
|
|
"admin:endpoints_manage:admin",
|
|
),
|
|
(
|
|
"admin:endpoints_manage",
|
|
"export_key",
|
|
None,
|
|
"admin:endpoints_manage:admin",
|
|
),
|
|
(
|
|
"admin:users",
|
|
"reveal_user_api_key",
|
|
None,
|
|
"admin:users:admin",
|
|
),
|
|
(
|
|
"admin:api_keys",
|
|
"api_key_detail",
|
|
Some("include_key=true"),
|
|
"admin:api_keys:admin",
|
|
),
|
|
(
|
|
"admin:api_keys",
|
|
"create_api_key_install_session",
|
|
None,
|
|
"admin:api_keys:admin",
|
|
),
|
|
(
|
|
"admin:proxy_nodes",
|
|
"create_proxy_node_install_session",
|
|
None,
|
|
"admin:proxy_nodes:admin",
|
|
),
|
|
];
|
|
|
|
for (signature, route_kind, query, expected) in cases {
|
|
let mut decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/sensitive-read".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("security_test".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
decision.public_query_string = query.map(str::to_string);
|
|
|
|
let method = if route_kind.ends_with("install_session") {
|
|
http::Method::POST
|
|
} else {
|
|
http::Method::GET
|
|
};
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some(expected)
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&read_only_permissions),
|
|
)
|
|
.expect_err("read-only access must not reveal plaintext credentials")
|
|
.required_permission,
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn raw_usage_and_trace_reads_require_admin_permission() {
|
|
let cases = [
|
|
("admin:usage", "detail", http::Method::GET),
|
|
("admin:usage", "curl", http::Method::GET),
|
|
("admin:usage", "replay", http::Method::POST),
|
|
("admin:monitoring", "trace_request", http::Method::GET),
|
|
];
|
|
|
|
for (signature, route_kind, method) in cases {
|
|
let scope = signature.trim_start_matches("admin:");
|
|
let read_permissions = vec![format!("{signature}:read")];
|
|
let admin_permissions = vec![format!("{signature}:admin")];
|
|
let decision = GatewayControlDecision::synthetic(
|
|
format!("/api/admin/{scope}/sensitive-record"),
|
|
Some("admin_proxy".to_string()),
|
|
Some(format!("{scope}_manage")),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some(format!("{signature}:admin").as_str())
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&read_permissions),
|
|
)
|
|
.expect_err("delegated read access must not expose raw request diagnostics")
|
|
.required_permission,
|
|
format!("{signature}:admin")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
for (signature, route_kind) in [
|
|
("admin:usage", "records"),
|
|
("admin:monitoring", "trace_provider_stats"),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/summary".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("summary".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::GET, &decision).as_deref(),
|
|
Some(format!("{signature}:read").as_str())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn task_read_permission_excludes_raw_detail_and_events() {
|
|
let read_permissions = vec!["admin:tasks:read".to_string()];
|
|
let admin_permissions = vec!["admin:tasks:admin".to_string()];
|
|
|
|
for route_kind in ["list_tasks", "stats"] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/tasks".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("tasks_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:tasks".to_string()),
|
|
);
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::GET, &decision).as_deref(),
|
|
Some("admin:tasks:read")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&read_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
|
|
for route_kind in ["detail", "events"] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/tasks/run-1".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("tasks_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:tasks".to_string()),
|
|
);
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::GET, &decision).as_deref(),
|
|
Some("admin:tasks:admin")
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&read_permissions),
|
|
)
|
|
.expect_err("task read access must not expose raw task diagnostics")
|
|
.required_permission,
|
|
"admin:tasks:admin"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
None,
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn user_lifecycle_routes_preserve_write_permission_for_field_level_checks() {
|
|
let write_permissions = vec!["admin:users:write".to_string()];
|
|
let cases = [
|
|
(http::Method::POST, "/api/admin/users", "create_user"),
|
|
(http::Method::PUT, "/api/admin/users/user-1", "update_user"),
|
|
(
|
|
http::Method::POST,
|
|
"/api/admin/users/batch-action",
|
|
"batch_action_users",
|
|
),
|
|
];
|
|
|
|
for (method, path, route_kind) in cases {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
path.to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("users_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:users".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some("admin:users:write"),
|
|
"unexpected permission for {method} {path}"
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_user_resource_mutations_still_require_users_write_permission() {
|
|
let write_permissions = vec!["admin:users:write".to_string()];
|
|
|
|
for (method, route_kind) in [
|
|
(http::Method::PUT, "update_user_api_key"),
|
|
(http::Method::DELETE, "delete_user_session"),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/users/user-1/resource".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("users_manage".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some("admin:users".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&method, &decision).as_deref(),
|
|
Some("admin:users:write")
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&method,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn usable_credential_creation_requires_admin_permission() {
|
|
for (signature, route_kind, write_permission, admin_permission) in [
|
|
(
|
|
"admin:management_tokens",
|
|
"create_token",
|
|
"admin:management_tokens:write",
|
|
"admin:management_tokens:admin",
|
|
),
|
|
(
|
|
"admin:management_tokens",
|
|
"regenerate_token",
|
|
"admin:management_tokens:write",
|
|
"admin:management_tokens:admin",
|
|
),
|
|
(
|
|
"admin:endpoints_manage",
|
|
"create_provider_key",
|
|
"admin:endpoints_manage:write",
|
|
"admin:endpoints_manage:admin",
|
|
),
|
|
(
|
|
"admin:users",
|
|
"create_user_api_key",
|
|
"admin:users:write",
|
|
"admin:users:admin",
|
|
),
|
|
(
|
|
"admin:api_keys",
|
|
"create_api_key",
|
|
"admin:api_keys:write",
|
|
"admin:api_keys:admin",
|
|
),
|
|
] {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/credential".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("credential_security_test".to_string()),
|
|
Some(route_kind.to_string()),
|
|
Some(signature.to_string()),
|
|
);
|
|
let write_permissions = vec![write_permission.to_string()];
|
|
let admin_permissions = vec![admin_permission.to_string()];
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::POST, &decision).as_deref(),
|
|
Some(admin_permission)
|
|
);
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&write_permissions),
|
|
)
|
|
.expect_err("delegated write access must not issue usable credentials")
|
|
.required_permission,
|
|
admin_permission
|
|
);
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&admin_permissions),
|
|
)
|
|
.is_ok());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ordinary_system_reads_still_require_read_permission() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/system/settings".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("system_manage".to_string()),
|
|
Some("settings_get".to_string()),
|
|
Some("admin:system".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::GET, &decision).as_deref(),
|
|
Some("admin:system:read")
|
|
);
|
|
|
|
let mut api_key_detail = GatewayControlDecision::synthetic(
|
|
"/api/admin/api-keys/key-1".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("api_keys_manage".to_string()),
|
|
Some("api_key_detail".to_string()),
|
|
Some("admin:api_keys".to_string()),
|
|
);
|
|
for query in [None, Some("include_key=false"), Some("include_key=invalid")] {
|
|
api_key_detail.public_query_string = query.map(str::to_string);
|
|
assert_eq!(
|
|
management_token_required_permission(&http::Method::GET, &api_key_detail)
|
|
.as_deref(),
|
|
Some("admin:api_keys:read")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn audit_admin_read_only_permissions_allow_management_tokens_reads_and_reject_writes() {
|
|
let decision = GatewayControlDecision::synthetic(
|
|
"/api/admin/management-tokens".to_string(),
|
|
Some("admin_proxy".to_string()),
|
|
Some("management_tokens_manage".to_string()),
|
|
Some("list_tokens".to_string()),
|
|
Some("admin:management_tokens".to_string()),
|
|
);
|
|
let permissions = audit_admin_read_only_management_token_permissions();
|
|
|
|
assert!(validate_management_token_admin_route_permission(
|
|
&http::Method::GET,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.is_ok());
|
|
assert_eq!(
|
|
validate_management_token_admin_route_permission(
|
|
&http::Method::POST,
|
|
&decision,
|
|
Some(&permissions),
|
|
)
|
|
.expect_err("read-only permissions should reject management token writes")
|
|
.required_permission,
|
|
"admin:management_tokens:write"
|
|
);
|
|
}
|
|
|
|
fn extract_admin_route_scopes(source: &'static str) -> BTreeSet<&'static str> {
|
|
let mut scopes = BTreeSet::new();
|
|
let mut remaining = source;
|
|
|
|
while let Some(start) = remaining.find("\"admin:") {
|
|
let signature_start = start + 1;
|
|
let after_start = &remaining[signature_start..];
|
|
let Some(end) = after_start.find('"') else {
|
|
break;
|
|
};
|
|
let signature = &after_start[..end];
|
|
let mut parts = signature.split(':');
|
|
if parts.next() == Some("admin") {
|
|
if let (Some(scope), None) = (parts.next(), parts.next()) {
|
|
scopes.insert(scope);
|
|
}
|
|
}
|
|
remaining = &after_start[end + 1..];
|
|
}
|
|
|
|
scopes
|
|
}
|
|
}
|