use std::sync::Arc;
use astrid_audit::AuditAction;
use astrid_core::dirs::AstridHome;
use astrid_core::principal::PrincipalId;
use astrid_core::profile::PrincipalProfile;
use astrid_events::ipc::{IpcMessage, IpcPayload};
use astrid_events::kernel_api::{AdminKernelRequest, AdminRequestKind};
use tempfile::TempDir;
use crate::Kernel;
async fn fixture() -> (TempDir, Arc<Kernel>) {
let dir = tempfile::tempdir().expect("tempdir");
let home = AstridHome::from_path(dir.path());
let kernel = crate::test_kernel_with_home(home).await;
(dir, kernel)
}
fn pid(name: &str) -> PrincipalId {
PrincipalId::new(name).unwrap()
}
fn seed_profile(kernel: &Arc<Kernel>, principal: &PrincipalId, profile: &PrincipalProfile) {
let path = PrincipalProfile::path_for(&kernel.astrid_home, principal);
profile.save_to_path(&path).expect("seed profile");
kernel.profile_cache.invalidate(principal);
}
async fn send_admin(
kernel: &Arc<Kernel>,
caller: &PrincipalId,
suffix: &str,
req: AdminKernelRequest,
) -> serde_json::Value {
let topic = format!("astrid.v1.admin.{suffix}");
let response_topic = format!("astrid.v1.admin.response.{suffix}");
let mut rx = kernel.event_bus.subscribe_topic(&response_topic);
let payload = serde_json::to_value(&req).expect("serialize admin request");
let mut msg = IpcMessage::new(topic, IpcPayload::RawJson(payload), kernel.session_id.0);
msg.principal = Some(caller.as_str().to_string());
let _ = kernel.event_bus.publish(astrid_events::AstridEvent::Ipc {
metadata: astrid_events::EventMetadata::new("test"),
message: msg,
});
let response = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let event = rx.recv().await.expect("response event");
if let astrid_events::AstridEvent::Ipc { message, .. } = &*event
&& let IpcPayload::RawJson(val) = &message.payload
{
return val.clone();
}
}
})
.await
.expect("admin response within 2s");
response
}
#[tokio::test(flavor = "multi_thread")]
async fn disabled_principal_denied_on_admin_topic() {
let (_dir, kernel) = fixture().await;
let mut profile = PrincipalProfile::default();
profile.groups = vec!["admin".to_string()];
profile.enabled = false;
let caller = pid("locked_out_admin");
seed_profile(&kernel, &caller, &profile);
let mut target_profile = PrincipalProfile::default();
target_profile.groups = vec!["restricted".to_string()];
seed_profile(&kernel, &pid("target_user"), &target_profile);
let resp = send_admin(
&kernel,
&caller,
"caps.grant",
AdminRequestKind::CapsGrant {
principal: pid("target_user"),
capabilities: vec!["self:capsule:install".into()],
unsafe_admin: false,
}
.into(),
)
.await;
assert_eq!(resp["status"], "Error");
let err_msg = resp["data"].as_str().unwrap_or_default();
assert!(
err_msg.contains("agent is disabled") || err_msg.contains("disabled"),
"expected disabled-principal error, got: {err_msg}"
);
let after = kernel.profile_cache.resolve(&pid("target_user")).unwrap();
assert!(
after.grants.is_empty(),
"disabled-principal request must not mutate target: {:?}",
after.grants
);
}
#[tokio::test(flavor = "multi_thread")]
async fn enabled_principal_proceeds_through_admin_topic() {
let (_dir, kernel) = fixture().await;
let mut profile = PrincipalProfile::default();
profile.groups = vec!["admin".to_string()];
profile.enabled = true;
let caller = pid("active_admin");
seed_profile(&kernel, &caller, &profile);
let mut target_profile = PrincipalProfile::default();
target_profile.groups = vec!["restricted".to_string()];
seed_profile(&kernel, &pid("target_user"), &target_profile);
let resp = send_admin(
&kernel,
&caller,
"caps.grant",
AdminRequestKind::CapsGrant {
principal: pid("target_user"),
capabilities: vec!["self:capsule:install".into()],
unsafe_admin: false,
}
.into(),
)
.await;
assert_eq!(resp["status"], "Success", "got: {resp}");
let after = kernel.profile_cache.resolve(&pid("target_user")).unwrap();
assert_eq!(after.grants, vec!["self:capsule:install".to_string()]);
}
#[tokio::test(flavor = "multi_thread")]
async fn admin_request_audit_includes_params_payload() {
let (_dir, kernel) = fixture().await;
let mut admin = PrincipalProfile::default();
admin.groups = vec!["admin".to_string()];
seed_profile(&kernel, &PrincipalId::default(), &admin);
let mut target = PrincipalProfile::default();
target.groups = vec!["restricted".to_string()];
seed_profile(&kernel, &pid("target_user"), &target);
let resp = send_admin(
&kernel,
&PrincipalId::default(),
"caps.grant",
AdminRequestKind::CapsGrant {
principal: pid("target_user"),
capabilities: vec!["self:capsule:install".into(), "self:capsule:list".into()],
unsafe_admin: false,
}
.into(),
)
.await;
assert_eq!(resp["status"], "Success", "got: {resp}");
let entries = kernel
.audit_log
.get_session_entries(&kernel.session_id)
.expect("read audit chain");
let found = entries
.iter()
.rev()
.find_map(|e| match &e.action {
AuditAction::AdminRequest { method, params, .. } if method == "admin.caps.grant" => {
Some(params.clone())
},
_ => None,
})
.expect("admin.caps.grant audit entry");
let params = found.expect("audit entry must carry params");
assert_eq!(params["method"], "CapsGrant");
let caps = ¶ms["params"]["capabilities"];
assert_eq!(caps[0], "self:capsule:install");
assert_eq!(caps[1], "self:capsule:list");
}
#[tokio::test(flavor = "multi_thread")]
async fn admin_request_id_is_echoed_back_on_response() {
let (_dir, kernel) = fixture().await;
let mut admin = PrincipalProfile::default();
admin.groups = vec!["admin".to_string()];
seed_profile(&kernel, &PrincipalId::default(), &admin);
let resp = send_admin(
&kernel,
&PrincipalId::default(),
"agent.list",
AdminKernelRequest::with_request_id("req-correlate-42", AdminRequestKind::AgentList),
)
.await;
assert_eq!(resp["request_id"], "req-correlate-42");
assert_eq!(resp["status"], "AgentList");
}
#[tokio::test(flavor = "multi_thread")]
async fn admin_request_id_echoed_on_deny_path_too() {
let (_dir, kernel) = fixture().await;
let mut admin = PrincipalProfile::default();
admin.groups = vec!["admin".to_string()];
admin.enabled = false;
let caller = pid("disabled_admin");
seed_profile(&kernel, &caller, &admin);
let resp = send_admin(
&kernel,
&caller,
"agent.list",
AdminKernelRequest::with_request_id("req-deny-correlate", AdminRequestKind::AgentList),
)
.await;
assert_eq!(resp["request_id"], "req-deny-correlate");
assert_eq!(resp["status"], "Error");
}