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 GET request and return raw text (useful for NDJSON)
205    pub async fn get_text(&self, path: &str) -> Result<String> {
206        let url = format!("{}{}", self.addr, path);
207
208        let mut request = self.client.get(&url).header("X-Vault-Token", &self.token);
209
210        if let Some(namespace) = &self.namespace {
211            request = request.header("X-Vault-Namespace", namespace);
212        }
213
214        let response = request
215            .send()
216            .await
217            .context("Failed to send request to Vault")?;
218
219        let status = response.status();
220        let body = response
221            .text()
222            .await
223            .context("Failed to read response body")?;
224
225        if !status.is_success() {
226            return Err(anyhow!(
227                "Vault API request failed with status {}: {}",
228                status,
229                body
230            ));
231        }
232
233        Ok(body)
234    }
235
236    /// Get the Vault address
237    pub fn addr(&self) -> &str {
238        &self.addr
239    }
240}
241
242/// Helper to extract data from Vault response wrapper
243pub fn extract_data<T: DeserializeOwned>(value: Value) -> Result<T> {
244    if let Some(data) = value.get("data") {
245        serde_json::from_value(data.clone())
246            .context("Failed to deserialize data from Vault response")
247    } else {
248        serde_json::from_value(value).context("Failed to deserialize Vault response")
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_client_creation() {
258        let client = VaultClient::new("http://127.0.0.1:8200", "test-token".to_string());
259        assert!(client.is_ok());
260    }
261
262    #[test]
263    fn test_addr_trimming() {
264        let client = VaultClient::new("http://127.0.0.1:8200/", "test-token".to_string()).unwrap();
265        assert_eq!(client.addr(), "http://127.0.0.1:8200");
266    }
267}