1use kvlar_core::{ApprovalRequest, ApprovalResponse};
7use std::future::Future;
8use std::pin::Pin;
9use std::time::Duration;
10
11#[derive(Debug, thiserror::Error)]
13pub enum ApprovalError {
14 #[error("approval request timed out after {0:?}")]
16 Timeout(Duration),
17
18 #[error("approval backend error: {0}")]
20 Backend(String),
21}
22
23pub trait ApprovalBackend: Send + Sync {
30 fn request_approval(
35 &self,
36 request: &ApprovalRequest,
37 ) -> Pin<Box<dyn Future<Output = Result<ApprovalResponse, ApprovalError>> + Send + '_>>;
38}
39
40pub struct DenyAllApprovalBackend;
43
44impl ApprovalBackend for DenyAllApprovalBackend {
45 fn request_approval(
46 &self,
47 _request: &ApprovalRequest,
48 ) -> Pin<Box<dyn Future<Output = Result<ApprovalResponse, ApprovalError>> + Send + '_>> {
49 Box::pin(async {
50 Ok(ApprovalResponse::Denied {
51 reason: Some("No approval backend configured — denied by default".into()),
52 })
53 })
54 }
55}
56
57pub struct WebhookApprovalBackend {
65 url: String,
66 client: reqwest::Client,
67 timeout: Duration,
68}
69
70impl WebhookApprovalBackend {
71 pub fn new(url: impl Into<String>, timeout: Duration) -> Self {
73 let client = reqwest::Client::builder()
74 .timeout(timeout)
75 .build()
76 .expect("failed to build HTTP client");
77 Self {
78 url: url.into(),
79 client,
80 timeout,
81 }
82 }
83}
84
85impl ApprovalBackend for WebhookApprovalBackend {
86 fn request_approval(
87 &self,
88 request: &ApprovalRequest,
89 ) -> Pin<Box<dyn Future<Output = Result<ApprovalResponse, ApprovalError>> + Send + '_>> {
90 let url = self.url.clone();
91 let client = self.client.clone();
92 let timeout = self.timeout;
93 let request = request.clone();
94
95 Box::pin(async move {
96 let resp = client.post(&url).json(&request).send().await.map_err(|e| {
97 if e.is_timeout() {
98 ApprovalError::Timeout(timeout)
99 } else {
100 ApprovalError::Backend(format!("webhook request failed: {e}"))
101 }
102 })?;
103
104 if !resp.status().is_success() {
105 return Err(ApprovalError::Backend(format!(
106 "webhook returned status {}",
107 resp.status()
108 )));
109 }
110
111 let approval_response: ApprovalResponse = resp.json().await.map_err(|e| {
112 ApprovalError::Backend(format!("failed to parse webhook response: {e}"))
113 })?;
114
115 Ok(approval_response)
116 })
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[tokio::test]
125 async fn test_deny_all_backend() {
126 let backend = DenyAllApprovalBackend;
127 let request = ApprovalRequest::new(
128 "delete_file",
129 serde_json::json!({"path": "/tmp/test"}),
130 "approve-delete",
131 "Deletion requires approval",
132 "agent-1",
133 );
134 let response = backend.request_approval(&request).await.unwrap();
135 assert!(response.is_denied());
136 }
137
138 #[tokio::test]
139 async fn test_deny_all_backend_as_dyn() {
140 let backend: Box<dyn ApprovalBackend> = Box::new(DenyAllApprovalBackend);
141 let request = ApprovalRequest::new(
142 "send_email",
143 serde_json::json!({"to": "user@example.com"}),
144 "approve-email",
145 "Email requires approval",
146 "agent-1",
147 );
148 let response = backend.request_approval(&request).await.unwrap();
149 assert!(response.is_denied());
150 if let ApprovalResponse::Denied { reason } = response {
151 assert!(reason.unwrap().contains("No approval backend configured"));
152 }
153 }
154}