use std::sync::Arc;
use async_trait::async_trait;
use rskit_authz::{AuthzDecision, AuthzRequest, Decider};
use serde_json::json;
use crate::authz::{ToolAuthorizationDecision, ToolAuthorizationRequest, ToolAuthorizer};
pub struct DeciderToolAuthorizer {
decider: Arc<dyn Decider>,
action: String,
principal: String,
}
impl DeciderToolAuthorizer {
#[must_use]
pub fn new(decider: Arc<dyn Decider>) -> Self {
Self {
decider,
action: "tools/call".to_owned(),
principal: String::new(),
}
}
#[must_use]
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = action.into();
self
}
#[must_use]
pub fn with_principal(mut self, principal: impl Into<String>) -> Self {
self.principal = principal.into();
self
}
fn build_request(&self, request: &ToolAuthorizationRequest) -> AuthzRequest {
AuthzRequest {
principal: if self.principal.is_empty() {
"anonymous".to_owned()
} else {
self.principal.clone()
},
action: self.action.clone(),
resource: format!("mcp:tool:{}", request.tool_name),
scopes: Vec::new(),
attributes: json!({
"mcp_name": request.mcp_name,
"arguments": request.arguments,
}),
}
}
}
#[async_trait]
impl ToolAuthorizer for DeciderToolAuthorizer {
async fn authorize_tool(
&self,
request: &ToolAuthorizationRequest,
) -> Result<ToolAuthorizationDecision, String> {
let canonical = self.build_request(request);
let decision = self.decider.decide(&canonical);
Ok(match decision {
AuthzDecision::Allow => ToolAuthorizationDecision {
allowed: true,
reason: "allow".to_owned(),
},
AuthzDecision::Deny(reason) => ToolAuthorizationDecision {
allowed: false,
reason,
},
AuthzDecision::RequiresHumanApproval(reason) => ToolAuthorizationDecision {
allowed: false,
reason: format!("requires human approval: {reason}"),
},
other => ToolAuthorizationDecision {
allowed: false,
reason: format!("unsupported authz decision: {other:?}"),
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
struct StaticDecider {
decision: AuthzDecision,
}
impl Decider for StaticDecider {
fn decide(&self, _req: &AuthzRequest) -> AuthzDecision {
self.decision.clone()
}
}
fn req(name: &str) -> ToolAuthorizationRequest {
ToolAuthorizationRequest {
tool_name: name.to_owned(),
mcp_name: name.to_owned(),
arguments: rskit_tool::ToolInput::empty(),
}
}
#[tokio::test]
async fn allow_passes_through() {
let auth = DeciderToolAuthorizer::new(Arc::new(StaticDecider {
decision: AuthzDecision::Allow,
}));
let d = auth.authorize_tool(&req("ping")).await.unwrap();
assert!(d.allowed);
}
#[tokio::test]
async fn deny_returns_reason() {
let auth = DeciderToolAuthorizer::new(Arc::new(StaticDecider {
decision: AuthzDecision::Deny("nope".into()),
}));
let d = auth.authorize_tool(&req("ping")).await.unwrap();
assert!(!d.allowed);
assert_eq!(d.reason, "nope");
}
#[tokio::test]
async fn requires_human_approval_is_treated_as_deny_at_authz_stage() {
let auth = DeciderToolAuthorizer::new(Arc::new(StaticDecider {
decision: AuthzDecision::RequiresHumanApproval("policy".into()),
}));
let d = auth.authorize_tool(&req("ping")).await.unwrap();
assert!(!d.allowed);
assert!(d.reason.contains("policy"));
}
#[tokio::test]
async fn maps_resource_with_mcp_prefix() {
struct Capture;
impl Decider for Capture {
fn decide(&self, req: &AuthzRequest) -> AuthzDecision {
if req.resource == "mcp:tool:ping" && req.action == "tools/call" {
AuthzDecision::Allow
} else {
AuthzDecision::Deny(format!(
"unexpected: action={} resource={}",
req.action, req.resource
))
}
}
}
let auth = DeciderToolAuthorizer::new(Arc::new(Capture));
let d = auth.authorize_tool(&req("ping")).await.unwrap();
assert!(d.allowed, "{}", d.reason);
}
}