Skip to main content

agentkernel/policy/
client.rs

1//! HTTP client for fetching policy bundles from the enterprise policy server.
2//!
3//! Supports authenticated requests via Bearer token or API key header,
4//! and background polling for policy updates.
5
6#![cfg(feature = "enterprise")]
7
8use anyhow::{Context as _, Result};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::watch;
12
13use super::signing::PolicyBundle;
14
15/// HTTP client for the enterprise policy server.
16pub struct PolicyClient {
17    /// Base URL of the policy server (e.g., "https://policy.acme-corp.com")
18    server_url: String,
19    /// API key for authentication
20    api_key: Option<String>,
21    /// HTTP client
22    http_client: reqwest::Client,
23}
24
25impl PolicyClient {
26    /// Create a new PolicyClient.
27    ///
28    /// # Arguments
29    /// * `server_url` - Base URL of the policy server
30    /// * `api_key` - Optional API key for authentication
31    pub fn new(server_url: &str, api_key: Option<String>) -> Result<Self> {
32        let http_client = reqwest::Client::builder()
33            .timeout(Duration::from_secs(30))
34            .connect_timeout(Duration::from_secs(10))
35            .user_agent(format!("agentkernel/{}", env!("CARGO_PKG_VERSION")))
36            .build()
37            .context("Failed to build HTTP client")?;
38
39        Ok(Self {
40            server_url: server_url.trim_end_matches('/').to_string(),
41            api_key,
42            http_client,
43        })
44    }
45
46    /// Fetch the current policy bundle from the server.
47    ///
48    /// Makes a GET request to `/v1/policies` and deserializes the response
49    /// as a PolicyBundle.
50    pub async fn fetch_bundle(&self) -> Result<PolicyBundle> {
51        let url = format!("{}/v1/policies", self.server_url);
52
53        let mut request = self.http_client.get(&url);
54
55        // Add authentication
56        if let Some(ref key) = self.api_key {
57            request = request.header("Authorization", format!("Bearer {}", key));
58        }
59
60        let response = request
61            .send()
62            .await
63            .context("Failed to connect to policy server")?;
64
65        if !response.status().is_success() {
66            let status = response.status();
67            let body = response
68                .text()
69                .await
70                .unwrap_or_else(|_| "<no body>".to_string());
71            anyhow::bail!("Policy server returned {}: {}", status, body);
72        }
73
74        let bundle: PolicyBundle = response
75            .json()
76            .await
77            .context("Failed to parse policy bundle response")?;
78
79        Ok(bundle)
80    }
81
82    /// Start background polling for policy updates.
83    ///
84    /// Fetches the policy bundle at the given interval and sends updates
85    /// through the returned watch channel. Stops when the shutdown signal
86    /// is received.
87    ///
88    /// # Arguments
89    /// * `interval` - How often to poll for updates
90    /// * `shutdown` - Receiver that signals when to stop polling
91    ///
92    /// # Returns
93    /// A watch receiver that receives new PolicyBundle values on updates.
94    pub fn poll(
95        self: Arc<Self>,
96        interval: Duration,
97        mut shutdown: watch::Receiver<bool>,
98    ) -> watch::Receiver<Option<PolicyBundle>> {
99        let (tx, rx) = watch::channel(None);
100
101        tokio::spawn(async move {
102            let mut ticker = tokio::time::interval(interval);
103            // First tick is immediate
104            ticker.tick().await;
105
106            loop {
107                tokio::select! {
108                    _ = ticker.tick() => {
109                        match self.fetch_bundle().await {
110                            Ok(bundle) => {
111                                let _ = tx.send(Some(bundle));
112                            }
113                            Err(e) => {
114                                eprintln!("[enterprise] Failed to fetch policy bundle: {}", e);
115                            }
116                        }
117                    }
118                    _ = shutdown.changed() => {
119                        if *shutdown.borrow() {
120                            break;
121                        }
122                    }
123                }
124            }
125        });
126
127        rx
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_client_creation() {
137        let client = PolicyClient::new("https://policy.example.com", None);
138        assert!(client.is_ok());
139    }
140
141    #[test]
142    fn test_client_creation_with_key() {
143        let client = PolicyClient::new(
144            "https://policy.example.com",
145            Some("test-api-key".to_string()),
146        );
147        assert!(client.is_ok());
148    }
149
150    #[test]
151    fn test_server_url_normalization() {
152        let client = PolicyClient::new("https://policy.example.com/", None).unwrap();
153        assert_eq!(client.server_url, "https://policy.example.com");
154    }
155}