vault_audit_tools/
vault_api.rs

1//! Vault API client for entity enrichment.
2//!
3//! This module provides a client for interacting with the `HashiCorp` Vault API
4//! to enrich audit log data with additional entity information.
5//!
6//! # Environment Variables
7//!
8//! The client respects standard Vault environment variables:
9//!
10//! - `VAULT_ADDR` - Vault server address (e.g., `https://vault.example.com:8200`)
11//! - `VAULT_TOKEN` - Authentication token for API access
12//! - `VAULT_NAMESPACE` - Vault namespace for API requests (e.g., `tenant1`, `admin/team-a`)
13//! - `VAULT_SKIP_VERIFY` - Skip TLS certificate verification (set to `1`, `true`, or `yes`)
14//! - `VAULT_CACERT` - Path to CA certificate for TLS verification
15//!
16//! # Examples
17//!
18//! ```no_run
19//! use vault_audit_tools::vault_api::VaultClient;
20//!
21//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
22//! let addr = "https://vault.example.com:8200";
23//! let token = "hvs.secret".to_string();
24//! let client = VaultClient::new(addr, token)?;
25//! # Ok(())
26//! # }
27//! ```
28
29use anyhow::{anyhow, Context, Result};
30use reqwest::Client;
31use serde::de::DeserializeOwned;
32use serde_json::Value;
33use std::env;
34use std::fs;
35
36/// Check if TLS verification should be skipped based on environment or flag.
37///
38/// Returns `true` if either the `insecure_flag` parameter is true, or if
39/// the `VAULT_SKIP_VERIFY` environment variable is set to a truthy value
40/// (`1`, `true`, `yes`, case-insensitive).
41pub fn should_skip_verify(insecure_flag: bool) -> bool {
42    if insecure_flag {
43        return true;
44    }
45
46    // Check VAULT_SKIP_VERIFY environment variable
47    env::var("VAULT_SKIP_VERIFY")
48        .ok()
49        .and_then(|v| {
50            v.parse::<bool>().ok().or_else(|| {
51                // Also accept "1", "true", "yes" (case-insensitive)
52                match v.to_lowercase().as_str() {
53                    "1" | "true" | "yes" => Some(true),
54                    _ => Some(false),
55                }
56            })
57        })
58        .unwrap_or(false)
59}
60
61/// Vault API client configuration.
62///
63/// Provides methods to interact with Vault's identity and secrets engines.
64#[derive(Debug, Clone)]
65pub struct VaultClient {
66    addr: String,
67    token: String,
68    namespace: Option<String>,
69    client: Client,
70}
71
72impl VaultClient {
73    /// Create a new Vault client from address and token
74    #[allow(dead_code)]
75    pub fn new(addr: &str, token: String) -> Result<Self> {
76        Self::new_with_skip_verify(addr, token, false)
77    }
78
79    /// Create a new Vault client with option to skip TLS verification
80    #[allow(dead_code)]
81    pub fn new_with_skip_verify(addr: &str, token: String, skip_verify: bool) -> Result<Self> {
82        Self::new_with_options(addr, token, None, skip_verify)
83    }
84
85    /// Create a new Vault client with all options
86    pub fn new_with_options(
87        addr: &str,
88        token: String,
89        namespace: Option<String>,
90        skip_verify: bool,
91    ) -> Result<Self> {
92        let client = Client::builder()
93            .danger_accept_invalid_certs(skip_verify)
94            .build()
95            .context("Failed to create HTTP client")?;
96
97        Ok(Self {
98            addr: addr.trim_end_matches('/').to_string(),
99            token,
100            namespace,
101            client,
102        })
103    }
104
105    /// Create a client from environment variables
106    #[allow(dead_code)]
107    pub fn from_env() -> Result<Self> {
108        let addr = env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string());
109        let namespace = env::var("VAULT_NAMESPACE").ok();
110
111        let token = if let Ok(token) = env::var("VAULT_TOKEN") {
112            token
113        } else if let Ok(token_file) = env::var("VAULT_TOKEN_FILE") {
114            fs::read_to_string(&token_file)
115                .with_context(|| format!("Failed to read token from file: {}", token_file))?
116                .trim()
117                .to_string()
118        } else {
119            return Err(anyhow!(
120                "VAULT_TOKEN or VAULT_TOKEN_FILE must be set. Provide a token via:\n\
121                 - Environment variable: export VAULT_TOKEN=hvs.xxxxx\n\
122                 - Token file: export VAULT_TOKEN_FILE=/path/to/token"
123            ));
124        };
125
126        Self::new_with_options(&addr, token, namespace, false)
127    }
128
129    /// Create a client with optional parameters (for CLI)
130    pub fn from_options(
131        vault_addr: Option<&str>,
132        vault_token: Option<&str>,
133        vault_namespace: Option<&str>,
134        skip_verify: bool,
135    ) -> Result<Self> {
136        let addr = vault_addr
137            .map(std::string::ToString::to_string)
138            .or_else(|| env::var("VAULT_ADDR").ok())
139            .unwrap_or_else(|| "http://127.0.0.1:8200".to_string());
140
141        let namespace = vault_namespace
142            .map(std::string::ToString::to_string)
143            .or_else(|| env::var("VAULT_NAMESPACE").ok());
144
145        let token = if let Some(t) = vault_token {
146            t.to_string()
147        } else if let Ok(t) = env::var("VAULT_TOKEN") {
148            t
149        } else if let Ok(token_file) = env::var("VAULT_TOKEN_FILE") {
150            fs::read_to_string(&token_file)
151                .with_context(|| format!("Failed to read token from file: {}", token_file))?
152                .trim()
153                .to_string()
154        } else {
155            return Err(anyhow!(
156                "VAULT_TOKEN or VAULT_TOKEN_FILE must be set. Provide a token via:\n\
157                 - Command-line: --vault-token hvs.xxxxx\n\
158                 - Environment variable: export VAULT_TOKEN=hvs.xxxxx\n\
159                 - Token file: export VAULT_TOKEN_FILE=/path/to/token"
160            ));
161        };
162
163        Self::new_with_options(&addr, token, namespace, skip_verify)
164    }
165
166    /// Make a GET request to a Vault API endpoint
167    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
168        let url = format!("{}{}", self.addr, path);
169
170        let mut request = self.client.get(&url).header("X-Vault-Token", &self.token);
171
172        if let Some(namespace) = &self.namespace {
173            request = request.header("X-Vault-Namespace", namespace);
174        }
175
176        let response = request
177            .send()
178            .await
179            .context("Failed to send request to Vault")?;
180
181        let status = response.status();
182        let body = response
183            .text()
184            .await
185            .context("Failed to read response body")?;
186
187        if !status.is_success() {
188            return Err(anyhow!(
189                "Vault API request failed with status {}: {}",
190                status,
191                body
192            ));
193        }
194
195        serde_json::from_str(&body)
196            .with_context(|| format!("Failed to parse JSON response from {}", path))
197    }
198
199    /// Make a GET request and return raw JSON Value
200    pub async fn get_json(&self, path: &str) -> Result<Value> {
201        self.get(path).await
202    }
203
204    /// Make a LIST request and return raw JSON Value (for KV v2 metadata listing)
205    pub async fn list_json(&self, path: &str) -> Result<Value> {
206        let url = format!("{}{}", self.addr, path);
207
208        let mut request = self
209            .client
210            .request(reqwest::Method::from_bytes(b"LIST")?, &url)
211            .header("X-Vault-Token", &self.token);
212
213        if let Some(namespace) = &self.namespace {
214            request = request.header("X-Vault-Namespace", namespace);
215        }
216
217        let response = request
218            .send()
219            .await
220            .context("Failed to send LIST request to Vault")?;
221
222        let status = response.status();
223        let body = response
224            .text()
225            .await
226            .context("Failed to read response body")?;
227
228        if !status.is_success() {
229            return Err(anyhow!(
230                "Vault API LIST request failed with status {}: {}",
231                status,
232                body
233            ));
234        }
235
236        serde_json::from_str(&body)
237            .with_context(|| format!("Failed to parse JSON response from {}", path))
238    }
239
240    /// Make a GET request and return raw text (useful for NDJSON)
241    pub async fn get_text(&self, path: &str) -> Result<String> {
242        let url = format!("{}{}", self.addr, path);
243
244        let mut request = self.client.get(&url).header("X-Vault-Token", &self.token);
245
246        if let Some(namespace) = &self.namespace {
247            request = request.header("X-Vault-Namespace", namespace);
248        }
249
250        let response = request
251            .send()
252            .await
253            .context("Failed to send request to Vault")?;
254
255        let status = response.status();
256        let body = response
257            .text()
258            .await
259            .context("Failed to read response body")?;
260
261        if !status.is_success() {
262            return Err(anyhow!(
263                "Vault API request failed with status {}: {}",
264                status,
265                body
266            ));
267        }
268
269        Ok(body)
270    }
271
272    /// Get the Vault address
273    pub fn addr(&self) -> &str {
274        &self.addr
275    }
276}
277
278/// Helper to extract data from Vault response wrapper
279pub fn extract_data<T: DeserializeOwned>(value: Value) -> Result<T> {
280    if let Some(data) = value.get("data") {
281        serde_json::from_value(data.clone())
282            .context("Failed to deserialize data from Vault response")
283    } else {
284        serde_json::from_value(value).context("Failed to deserialize Vault response")
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_client_creation() {
294        let client = VaultClient::new("http://127.0.0.1:8200", "test-token".to_string());
295        assert!(client.is_ok());
296    }
297
298    #[test]
299    fn test_addr_trimming() {
300        let client = VaultClient::new("http://127.0.0.1:8200/", "test-token".to_string()).unwrap();
301        assert_eq!(client.addr(), "http://127.0.0.1:8200");
302    }
303}