Skip to main content

kvlar_proxy/
approval.rs

1//! Approval backend trait and implementations.
2//!
3//! When a policy evaluates to `RequireApproval`, the proxy uses an
4//! `ApprovalBackend` to request human approval and await the decision.
5
6use kvlar_core::{ApprovalRequest, ApprovalResponse};
7use std::future::Future;
8use std::pin::Pin;
9use std::time::Duration;
10
11/// Errors that can occur during the approval process.
12#[derive(Debug, thiserror::Error)]
13pub enum ApprovalError {
14    /// The approval request timed out.
15    #[error("approval request timed out after {0:?}")]
16    Timeout(Duration),
17
18    /// The approval backend encountered an error.
19    #[error("approval backend error: {0}")]
20    Backend(String),
21}
22
23/// Trait for backends that handle human approval requests.
24///
25/// Implementations send approval requests to humans (via webhook, Slack,
26/// email, CLI prompt, etc.) and await their decisions.
27///
28/// Uses boxed futures for object safety (`dyn ApprovalBackend`).
29pub trait ApprovalBackend: Send + Sync {
30    /// Sends an approval request and waits for the human decision.
31    ///
32    /// Returns the human's decision, or an error if the request
33    /// times out or the backend fails.
34    fn request_approval(
35        &self,
36        request: &ApprovalRequest,
37    ) -> Pin<Box<dyn Future<Output = Result<ApprovalResponse, ApprovalError>> + Send + '_>>;
38}
39
40/// A simple approval backend that always denies. Used as the default
41/// when no approval backend is configured — maintains fail-closed behavior.
42pub 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
57/// HTTP webhook approval backend.
58///
59/// Sends a POST request with the `ApprovalRequest` JSON body to a configured
60/// URL and expects the webhook to respond synchronously with an `ApprovalResponse`.
61///
62/// For asynchronous workflows (Slack, email), the webhook can hold the connection
63/// open until a decision is made, or return a denial with instructions.
64pub struct WebhookApprovalBackend {
65    url: String,
66    client: reqwest::Client,
67    timeout: Duration,
68}
69
70impl WebhookApprovalBackend {
71    /// Creates a new webhook backend with the given URL and timeout.
72    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}