mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge pull request #449 from RWDai/feat/audit-admin-readonly
Add read-only audit administrator role
This commit is contained in:
@@ -153,7 +153,7 @@ pub(super) fn extract_trusted_admin_headers(
|
||||
let user_role = header_value_str(headers, crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER)?
|
||||
.trim()
|
||||
.to_string();
|
||||
if !user_role.eq_ignore_ascii_case("admin") {
|
||||
if !crate::roles::can_access_admin_console(&user_role) {
|
||||
return None;
|
||||
}
|
||||
let session_id = header_value_str(headers, crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER)
|
||||
@@ -171,7 +171,7 @@ pub(super) fn extract_trusted_admin_headers(
|
||||
|
||||
Some(GatewayTrustedAdminHeaders {
|
||||
user_id,
|
||||
user_role: "admin".to_string(),
|
||||
user_role,
|
||||
session_id,
|
||||
management_token_id,
|
||||
})
|
||||
@@ -563,6 +563,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_trusted_audit_admin_headers() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(
|
||||
crate::constants::GATEWAY_HEADER,
|
||||
"rust-phase3b".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
crate::constants::TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
"audit-admin-1".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||
"audit_admin".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER,
|
||||
"sess-audit-1".parse().unwrap(),
|
||||
);
|
||||
|
||||
let extracted = extract_request_credentials(
|
||||
&headers,
|
||||
&uri("/api/admin/endpoints/health/api-formats"),
|
||||
"admin:endpoints_health",
|
||||
);
|
||||
assert_eq!(
|
||||
extracted.trusted_admin_headers,
|
||||
Some(GatewayTrustedAdminHeaders {
|
||||
user_id: "audit-admin-1".to_string(),
|
||||
user_role: "audit_admin".to_string(),
|
||||
session_id: Some("sess-audit-1".to_string()),
|
||||
management_token_id: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_trusted_admin_headers_without_gateway_marker() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
|
||||
@@ -273,7 +273,7 @@ async fn resolve_local_admin_principal(
|
||||
if claims
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|role| !role.eq_ignore_ascii_case("admin"))
|
||||
.is_some_and(|role| !crate::roles::can_access_admin_console(role))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -300,7 +300,7 @@ async fn resolve_local_admin_principal_from_claims(
|
||||
let Some(user) = state.find_user_auth_by_id(user_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
|
||||
if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ async fn resolve_local_admin_principal_from_claims(
|
||||
|
||||
Ok(Some(GatewayAdminPrincipalContext {
|
||||
user_id: user.id,
|
||||
user_role: "admin".to_string(),
|
||||
user_role: user.role,
|
||||
session_id: Some(session.id),
|
||||
management_token_id: None,
|
||||
management_token_permissions: None,
|
||||
|
||||
@@ -224,6 +224,19 @@ pub(crate) fn read_only_management_token_permissions() -> Vec<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> {
|
||||
@@ -615,6 +628,64 @@ mod tests {
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[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 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;
|
||||
|
||||
@@ -14,11 +14,13 @@ pub(crate) use auth::{
|
||||
};
|
||||
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,
|
||||
all_assignable_management_token_permissions,
|
||||
audit_admin_read_only_management_token_permissions,
|
||||
management_token_permission_catalog_payload, management_token_permission_keys_from_value,
|
||||
management_token_permission_mode_and_summary,
|
||||
management_token_permissions_cover_all_assignable_permissions,
|
||||
management_token_required_permission, normalize_assignable_management_token_permissions,
|
||||
validate_management_token_admin_route_permission,
|
||||
read_only_management_token_permissions, validate_management_token_admin_route_permission,
|
||||
};
|
||||
pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -503,7 +503,7 @@ fn normalize_selection_filters(
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty() && value != "all")
|
||||
{
|
||||
Some(role) if matches!(role.as_str(), "user" | "admin") => Some(role),
|
||||
Some(role) if crate::roles::normalize_assignable_user_role(&role).is_some() => Some(role),
|
||||
Some(_) => return Err("role 参数不合法".to_string()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -225,16 +225,13 @@ pub(super) fn validate_admin_user_password(password: &str, policy: &str) -> Resu
|
||||
}
|
||||
|
||||
pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result<String, String> {
|
||||
match value
|
||||
let role = value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("user")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"user" => Ok("user".to_string()),
|
||||
"admin" => Ok("admin".to_string()),
|
||||
_ => Err("角色参数不合法".to_string()),
|
||||
.unwrap_or("user");
|
||||
match crate::roles::normalize_assignable_user_role(role) {
|
||||
Some(role) => Ok(role.to_string()),
|
||||
None => Err("角色参数不合法".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::super::internal;
|
||||
use crate::admin_api;
|
||||
use crate::audit::attach_admin_audit_event;
|
||||
use crate::control::{
|
||||
audit_admin_read_only_management_token_permissions,
|
||||
validate_management_token_admin_route_permission, GatewayPublicRequestContext,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
@@ -61,21 +62,32 @@ fn maybe_build_management_token_permission_denied_response(
|
||||
) -> 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 audit_admin_read_only_permissions;
|
||||
let token_permissions = if crate::roles::can_write_admin_console(&admin_principal.user_role) {
|
||||
admin_principal.management_token_permissions.as_deref()
|
||||
} else {
|
||||
audit_admin_read_only_permissions = audit_admin_read_only_management_token_permissions();
|
||||
Some(audit_admin_read_only_permissions.as_slice())
|
||||
};
|
||||
let denied = validate_management_token_admin_route_permission(
|
||||
&request_context.request_method,
|
||||
decision,
|
||||
admin_principal.management_token_permissions.as_deref(),
|
||||
token_permissions,
|
||||
)
|
||||
.err()?;
|
||||
let actor_id = admin_principal
|
||||
.management_token_id
|
||||
.as_deref()
|
||||
.unwrap_or(admin_principal.user_id.as_str());
|
||||
|
||||
warn!(
|
||||
trace_id = %request_context.trace_id,
|
||||
admin_management_token_id = %token_id,
|
||||
admin_actor_id = %actor_id,
|
||||
admin_user_role = %admin_principal.user_role,
|
||||
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"
|
||||
"admin route permission denied"
|
||||
);
|
||||
|
||||
let mut response = (
|
||||
@@ -91,10 +103,10 @@ fn maybe_build_management_token_permission_denied_response(
|
||||
.into_response();
|
||||
attach_admin_audit_event(
|
||||
&mut response,
|
||||
"admin_management_token_permission_denied",
|
||||
"admin_route_permission_denied",
|
||||
"permission_denied",
|
||||
"management_token_permission",
|
||||
token_id,
|
||||
"admin_route_permission",
|
||||
actor_id,
|
||||
);
|
||||
Some(response)
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ async fn maybe_promote_management_token_admin_principal(
|
||||
let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
|
||||
if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
|
||||
return Ok(());
|
||||
}
|
||||
let management_token_permissions = match management_token_permission_keys_from_value(
|
||||
|
||||
@@ -56,6 +56,7 @@ mod provider_key_auth;
|
||||
pub(crate) use aether_provider_transport as provider_transport;
|
||||
mod rate_limit;
|
||||
mod request_candidate_runtime;
|
||||
mod roles;
|
||||
mod router;
|
||||
mod scheduler;
|
||||
mod state;
|
||||
|
||||
52
apps/aether-gateway/src/roles.rs
Normal file
52
apps/aether-gateway/src/roles.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
pub(crate) const ROLE_USER: &str = "user";
|
||||
pub(crate) const ROLE_ADMIN: &str = "admin";
|
||||
pub(crate) const ROLE_AUDIT_ADMIN: &str = "audit_admin";
|
||||
|
||||
pub(crate) fn is_full_admin_role(role: &str) -> bool {
|
||||
role.trim().eq_ignore_ascii_case(ROLE_ADMIN)
|
||||
}
|
||||
|
||||
pub(crate) fn is_audit_admin_role(role: &str) -> bool {
|
||||
role.trim().eq_ignore_ascii_case(ROLE_AUDIT_ADMIN)
|
||||
}
|
||||
|
||||
pub(crate) fn can_access_admin_console(role: &str) -> bool {
|
||||
is_full_admin_role(role) || is_audit_admin_role(role)
|
||||
}
|
||||
|
||||
pub(crate) fn can_write_admin_console(role: &str) -> bool {
|
||||
is_full_admin_role(role)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_assignable_user_role(role: &str) -> Option<&'static str> {
|
||||
match role.trim().to_ascii_lowercase().as_str() {
|
||||
ROLE_USER => Some(ROLE_USER),
|
||||
ROLE_ADMIN => Some(ROLE_ADMIN),
|
||||
ROLE_AUDIT_ADMIN => Some(ROLE_AUDIT_ADMIN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
can_access_admin_console, can_write_admin_console, normalize_assignable_user_role,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn audit_admin_can_access_but_not_write_admin_console() {
|
||||
assert!(can_access_admin_console("audit_admin"));
|
||||
assert!(!can_write_admin_console("audit_admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignable_roles_include_audit_admin() {
|
||||
assert_eq!(
|
||||
normalize_assignable_user_role(" audit_admin "),
|
||||
Some("audit_admin")
|
||||
);
|
||||
assert_eq!(normalize_assignable_user_role("admin"), Some("admin"));
|
||||
assert_eq!(normalize_assignable_user_role("user"), Some("user"));
|
||||
assert_eq!(normalize_assignable_user_role("owner"), None);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ impl AppState {
|
||||
role: &str,
|
||||
) -> Result<Vec<String>, GatewayError> {
|
||||
let mut group_ids = normalized_user_group_ids(group_ids);
|
||||
if role.trim().eq_ignore_ascii_case("admin") {
|
||||
if crate::roles::can_access_admin_console(role) {
|
||||
if let Some(default_group_id) = self.configured_default_user_group_id().await? {
|
||||
group_ids.remove(&default_group_id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user