#[cfg(test)]
mod enforcement_tests;
mod handlers;
mod invite_handlers;
mod pair_device_handlers;
mod quota;
#[cfg(test)]
mod state_tests;
#[cfg(test)]
mod state_tests_agent_modify;
#[cfg(test)]
mod state_tests_caps;
#[cfg(test)]
mod state_tests_usage;
#[cfg(test)]
mod tests;
use std::sync::Arc;
use astrid_audit::{AuditOutcome, AuthorizationProof};
use astrid_core::principal::PrincipalId;
use astrid_events::ipc::IpcPayload;
use astrid_events::kernel_api::{
AdminKernelRequest, AdminKernelResponse, AdminRequestKind, AdminResponseBody,
};
use tracing::warn;
use super::{
AdminAuditEntry, AuthorityScope, authorize_request, publish_response, record_admin_audit,
resolve_caller,
};
const ADMIN_TOPIC_PREFIX: &str = "astrid.v1.admin.";
const ADMIN_RESPONSE_PREFIX: &str = "astrid.v1.admin.response.";
pub(crate) fn spawn_admin_router(kernel: Arc<crate::Kernel>) -> tokio::task::JoinHandle<()> {
let mut receiver = kernel
.event_bus
.subscribe_topic_as("astrid.v1.admin.*", "admin_router");
tokio::spawn(async move {
while let Some(event) = receiver.recv().await {
let astrid_events::AstridEvent::Ipc { message, .. } = &*event else {
continue;
};
if message.topic.starts_with(ADMIN_RESPONSE_PREFIX) {
continue;
}
let IpcPayload::RawJson(val) = &message.payload else {
continue;
};
match serde_json::from_value::<AdminKernelRequest>(val.clone()) {
Ok(req) => {
let kernel = Arc::clone(&kernel);
let topic = message.topic.clone();
let caller = resolve_caller(message);
tokio::spawn(async move {
handle_admin_request(&kernel, topic, caller, req).await;
});
},
Err(e) => {
warn!(
error = %e,
topic = %message.topic,
"Failed to parse AdminKernelRequest from IPC"
);
},
}
}
})
}
fn admin_response_topic(input_topic: &str) -> String {
input_topic.strip_prefix(ADMIN_TOPIC_PREFIX).map_or_else(
|| input_topic.to_string(),
|suffix| format!("{ADMIN_RESPONSE_PREFIX}{suffix}"),
)
}
#[must_use]
pub fn resolve_admin_scope(req: &AdminRequestKind, caller: &PrincipalId) -> AuthorityScope {
match req {
AdminRequestKind::QuotaGet { principal }
| AdminRequestKind::QuotaSet { principal, .. }
| AdminRequestKind::UsageGet { principal } => {
if principal == caller {
AuthorityScope::Self_
} else {
AuthorityScope::Global
}
},
AdminRequestKind::AgentList
| AdminRequestKind::GroupList
| AdminRequestKind::PairDeviceIssue { .. } => AuthorityScope::Self_,
AdminRequestKind::AgentCreate { .. }
| AdminRequestKind::AgentDelete { .. }
| AdminRequestKind::AgentEnable { .. }
| AdminRequestKind::AgentDisable { .. }
| AdminRequestKind::AgentModify { .. }
| AdminRequestKind::GroupCreate { .. }
| AdminRequestKind::GroupDelete { .. }
| AdminRequestKind::GroupModify { .. }
| AdminRequestKind::CapsGrant { .. }
| AdminRequestKind::CapsRevoke { .. }
| AdminRequestKind::InviteIssue { .. }
| AdminRequestKind::InviteRedeem { .. }
| AdminRequestKind::InviteList
| AdminRequestKind::InviteRevoke { .. }
| AdminRequestKind::PairDeviceRedeem { .. } => AuthorityScope::Global,
}
}
#[must_use]
pub fn required_capability_for_admin_request(
req: &AdminRequestKind,
scope: AuthorityScope,
) -> &'static str {
match (req, scope) {
(AdminRequestKind::AgentCreate { .. }, _) => "agent:create",
(AdminRequestKind::AgentDelete { .. }, _) => "agent:delete",
(AdminRequestKind::AgentEnable { .. }, _) => "agent:enable",
(AdminRequestKind::AgentDisable { .. }, _) => "agent:disable",
(AdminRequestKind::AgentModify { .. }, _) => "agent:modify",
(AdminRequestKind::AgentList, AuthorityScope::Self_) => "self:agent:list",
(AdminRequestKind::AgentList, AuthorityScope::Global) => "agent:list",
(AdminRequestKind::QuotaSet { .. }, AuthorityScope::Self_) => "self:quota:set",
(AdminRequestKind::QuotaSet { .. }, AuthorityScope::Global) => "quota:set",
(
AdminRequestKind::QuotaGet { .. } | AdminRequestKind::UsageGet { .. },
AuthorityScope::Self_,
) => "self:quota:get",
(
AdminRequestKind::QuotaGet { .. } | AdminRequestKind::UsageGet { .. },
AuthorityScope::Global,
) => "quota:get",
(AdminRequestKind::GroupCreate { .. }, _) => "group:create",
(AdminRequestKind::GroupDelete { .. }, _) => "group:delete",
(AdminRequestKind::GroupModify { .. }, _) => "group:modify",
(AdminRequestKind::GroupList, AuthorityScope::Self_) => "self:group:list",
(AdminRequestKind::GroupList, AuthorityScope::Global) => "group:list",
(AdminRequestKind::CapsGrant { .. }, _) => "caps:grant",
(AdminRequestKind::CapsRevoke { .. }, _) => "caps:revoke",
(AdminRequestKind::InviteIssue { .. }, _) => "invite:issue",
(AdminRequestKind::InviteRedeem { .. }, _) => "invite:redeem",
(AdminRequestKind::InviteList, _) => "invite:list",
(AdminRequestKind::InviteRevoke { .. }, _) => "invite:revoke",
(AdminRequestKind::PairDeviceIssue { .. }, _) => "self:auth:pair",
(AdminRequestKind::PairDeviceRedeem { .. }, _) => "auth:pair:redeem",
}
}
#[must_use]
pub fn admin_request_method(req: &AdminRequestKind) -> &'static str {
match req {
AdminRequestKind::AgentCreate { .. } => "admin.agent.create",
AdminRequestKind::AgentDelete { .. } => "admin.agent.delete",
AdminRequestKind::AgentEnable { .. } => "admin.agent.enable",
AdminRequestKind::AgentDisable { .. } => "admin.agent.disable",
AdminRequestKind::AgentModify { .. } => "admin.agent.modify",
AdminRequestKind::AgentList => "admin.agent.list",
AdminRequestKind::QuotaSet { .. } => "admin.quota.set",
AdminRequestKind::QuotaGet { .. } => "admin.quota.get",
AdminRequestKind::UsageGet { .. } => "admin.usage.get",
AdminRequestKind::GroupCreate { .. } => "admin.group.create",
AdminRequestKind::GroupDelete { .. } => "admin.group.delete",
AdminRequestKind::GroupModify { .. } => "admin.group.modify",
AdminRequestKind::GroupList => "admin.group.list",
AdminRequestKind::CapsGrant { .. } => "admin.caps.grant",
AdminRequestKind::CapsRevoke { .. } => "admin.caps.revoke",
AdminRequestKind::InviteIssue { .. } => "admin.invite.issue",
AdminRequestKind::InviteRedeem { .. } => "admin.invite.redeem",
AdminRequestKind::InviteList => "admin.invite.list",
AdminRequestKind::InviteRevoke { .. } => "admin.invite.revoke",
AdminRequestKind::PairDeviceIssue { .. } => "admin.auth.pair.issue",
AdminRequestKind::PairDeviceRedeem { .. } => "admin.auth.pair.redeem",
}
}
fn sanitize_admin_audit_params(req: &AdminRequestKind) -> Option<serde_json::Value> {
let mut val = serde_json::to_value(req).ok()?;
let params = val
.as_object_mut()
.and_then(|m| m.get_mut("params"))
.and_then(|p| p.as_object_mut())?;
match req {
AdminRequestKind::InviteRedeem {
public_key, token, ..
} => {
let fp = invite_handlers::fingerprint_public_key(public_key);
params.remove("public_key");
params.insert(
"public_key_fingerprint".to_string(),
serde_json::Value::String(fp),
);
params.remove("token");
params.insert(
"token_fingerprint".to_string(),
serde_json::Value::String(crate::invite::hash_token(token)),
);
},
AdminRequestKind::InviteRevoke { token } => {
params.remove("token");
params.insert(
"token_fingerprint".to_string(),
serde_json::Value::String(fingerprint_revoke_input(token)),
);
},
AdminRequestKind::PairDeviceRedeem { token, public_key } => {
let fp = invite_handlers::fingerprint_public_key(public_key);
params.remove("public_key");
params.insert(
"public_key_fingerprint".to_string(),
serde_json::Value::String(fp),
);
params.remove("token");
params.insert(
"token_fingerprint".to_string(),
serde_json::Value::String(crate::pair_token::hash_token(token)),
);
},
_ => {},
}
Some(val)
}
fn fingerprint_revoke_input(token: &str) -> String {
if token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) {
token.to_ascii_lowercase()
} else {
crate::invite::hash_token(token)
}
}
#[must_use]
pub fn admin_target_principal(req: &AdminRequestKind) -> Option<&PrincipalId> {
match req {
AdminRequestKind::AgentDelete { principal }
| AdminRequestKind::AgentEnable { principal }
| AdminRequestKind::AgentDisable { principal }
| AdminRequestKind::AgentModify { principal, .. }
| AdminRequestKind::QuotaSet { principal, .. }
| AdminRequestKind::QuotaGet { principal }
| AdminRequestKind::UsageGet { principal }
| AdminRequestKind::CapsGrant { principal, .. }
| AdminRequestKind::CapsRevoke { principal, .. } => Some(principal),
AdminRequestKind::AgentCreate { .. }
| AdminRequestKind::AgentList
| AdminRequestKind::GroupCreate { .. }
| AdminRequestKind::GroupDelete { .. }
| AdminRequestKind::GroupModify { .. }
| AdminRequestKind::GroupList
| AdminRequestKind::InviteIssue { .. }
| AdminRequestKind::InviteRedeem { .. }
| AdminRequestKind::InviteList
| AdminRequestKind::InviteRevoke { .. }
| AdminRequestKind::PairDeviceIssue { .. }
| AdminRequestKind::PairDeviceRedeem { .. } => None,
}
}
fn redeem_audit_proof(body: &AdminResponseBody) -> (AuthorizationProof, AuditOutcome) {
match body {
AdminResponseBody::Error(reason) => (
AuthorizationProof::Denied {
reason: reason.clone(),
},
AuditOutcome::failure(reason.clone()),
),
_ => (
AuthorizationProof::System {
reason: "redeem (invite or pair-device): token is the auth".to_string(),
},
AuditOutcome::success(),
),
}
}
async fn handle_admin_request(
kernel: &Arc<crate::Kernel>,
topic: String,
caller: PrincipalId,
req: AdminKernelRequest,
) {
let response_topic = admin_response_topic(&topic);
let request_id = req.request_id.clone();
let method = admin_request_method(&req.kind);
let scope = resolve_admin_scope(&req.kind, &caller);
let required_cap = required_capability_for_admin_request(&req.kind, scope);
let target = admin_target_principal(&req.kind).cloned();
let audit_params = sanitize_admin_audit_params(&req.kind);
if matches!(
req.kind,
AdminRequestKind::InviteRedeem { .. } | AdminRequestKind::PairDeviceRedeem { .. }
) {
let body = handlers::dispatch(kernel, &caller, req.kind).await;
let (authorization, outcome) = redeem_audit_proof(&body);
record_admin_audit(
kernel,
AdminAuditEntry {
caller: &caller,
method,
required_cap,
target_principal: None,
params: audit_params,
authorization,
outcome,
},
);
publish_response(
kernel,
response_topic,
AdminKernelResponse::for_request(request_id, body),
);
return;
}
match authorize_request(kernel, &caller, required_cap) {
Ok(()) => {
record_admin_audit(
kernel,
AdminAuditEntry {
caller: &caller,
method,
required_cap,
target_principal: target.clone(),
params: audit_params.clone(),
authorization: AuthorizationProof::System {
reason: format!("policy allow: {caller} holds {required_cap}"),
},
outcome: AuditOutcome::success(),
},
);
},
Err(e) => {
warn!(
security_event = true,
method = method,
principal = %caller,
required = required_cap,
error = %e,
"Permission check denied admin request"
);
record_admin_audit(
kernel,
AdminAuditEntry {
caller: &caller,
method,
required_cap,
target_principal: target,
params: audit_params,
authorization: AuthorizationProof::Denied {
reason: e.to_string(),
},
outcome: AuditOutcome::failure(e.to_string()),
},
);
publish_response(
kernel,
response_topic,
AdminKernelResponse::for_request(
request_id,
AdminResponseBody::Error(e.to_string()),
),
);
return;
},
}
let body = handlers::dispatch(kernel, &caller, req.kind).await;
publish_response(
kernel,
response_topic,
AdminKernelResponse::for_request(request_id, body),
);
}