xand_secrets_vault/
lib.rs

1#![forbid(unsafe_code)]
2
3mod concurrency_util;
4mod models;
5mod serde_util;
6
7use async_trait::async_trait;
8
9use secrecy::ExposeSecret;
10use secrecy::Secret;
11use xand_secrets::{CheckHealthError, ReadSecretError, SecretKeyValueStore};
12
13use reqwest::{Certificate, Client as HttpClient, Response as HttpResponse, StatusCode};
14
15use serde::{Deserialize, Serialize};
16use std::fs::File;
17use std::io::Read;
18
19use concurrency_util::concurrent_and;
20use thiserror::Error;
21
22const TOKEN_HEADER: &str = "X-Vault-Token";
23
24#[derive(Debug, Error)]
25pub enum VaultSecretStoreCreationError {
26    #[error("creating the internal client failed. {internal_error}")]
27    ClientCreation {
28        #[source]
29        internal_error: reqwest::Error,
30    },
31    #[error("could not parse the given certificate file. {internal_error}")]
32    CertificateFormat {
33        #[source]
34        internal_error: reqwest::Error,
35    },
36    #[error("failed to open the provided certificate pem file. {internal_error}")]
37    CertificateFileLoad {
38        #[source]
39        internal_error: std::io::Error,
40    },
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45#[serde(deny_unknown_fields)]
46pub struct VaultConfiguration {
47    pub http_endpoint: String,
48    #[serde(serialize_with = "serde_util::serialize_secret")]
49    pub token: Secret<String>,
50    pub additional_https_root_certificate_files: Option<Vec<String>>,
51}
52
53#[derive(Debug)]
54pub struct VaultSecretKeyValueStore {
55    config: VaultConfiguration,
56    client: reqwest::Client,
57}
58
59#[derive(Debug)]
60pub struct VaultKey {
61    pub path: String,
62    pub key: String,
63}
64
65impl VaultKey {
66    fn parse_from_slash_separated_key(key: &str) -> Result<Self, ReadSecretError> {
67        key.rfind('/')
68            .map(|index| VaultKey {
69                path: String::from(&key[..index]),
70                key: String::from(&key[index + 1..]),
71            })
72            .ok_or(ReadSecretError::KeyNotFound {
73                key: key.to_string(),
74            })
75    }
76}
77
78impl VaultSecretKeyValueStore {
79    pub fn create_from_config(
80        config: VaultConfiguration,
81    ) -> Result<VaultSecretKeyValueStore, VaultSecretStoreCreationError> {
82        let client = Self::build_client(&config)?;
83        Ok(VaultSecretKeyValueStore { config, client })
84    }
85
86    fn build_client(
87        config: &VaultConfiguration,
88    ) -> Result<HttpClient, VaultSecretStoreCreationError> {
89        let mut builder = HttpClient::builder();
90
91        for cert_path in config
92            .additional_https_root_certificate_files
93            .iter()
94            .flatten()
95        {
96            builder = builder.add_root_certificate(Self::load_certificate_pem(cert_path.as_str())?);
97        }
98
99        builder.build().map_err(
100            |internal_error| VaultSecretStoreCreationError::ClientCreation { internal_error },
101        )
102    }
103
104    fn load_certificate_pem(cert_path: &str) -> Result<Certificate, VaultSecretStoreCreationError> {
105        let mut buf = Vec::new();
106        File::open(cert_path)
107            .and_then(|mut f| f.read_to_end(&mut buf))
108            .map_err(
109                |internal_error| VaultSecretStoreCreationError::CertificateFileLoad {
110                    internal_error,
111                },
112            )?;
113
114        reqwest::Certificate::from_pem(&buf).map_err(|internal_error| {
115            VaultSecretStoreCreationError::CertificateFormat { internal_error }
116        })
117    }
118
119    fn remove_trailing_slash(path: &str) -> &str {
120        let len = path.len();
121        if path.ends_with('/') {
122            &path[..len - 1]
123        } else {
124            path
125        }
126    }
127
128    fn get_url_for_path(&self, path: &str) -> String {
129        format!(
130            "{}/v1/{}",
131            Self::remove_trailing_slash(&self.config.http_endpoint[..]),
132            path
133        )
134    }
135
136    fn map_reqwest_error_to_read_error(error: reqwest::Error) -> ReadSecretError {
137        ReadSecretError::Request {
138            internal_error: Box::new(error),
139        }
140    }
141
142    async fn parse_read_response(
143        response: HttpResponse,
144    ) -> Result<models::KeyValueReadResponse, ReadSecretError> {
145        response
146            .json::<models::KeyValueReadResponse>()
147            .await
148            .map_err(Self::map_reqwest_error_to_read_error)
149    }
150
151    async fn get_error_list(response: HttpResponse) -> Option<Vec<String>> {
152        response
153            .json::<models::ErrorResponse>()
154            .await
155            .map(|error_response| error_response.errors)
156            .ok()
157    }
158
159    async fn map_status_code_to_read_error(
160        response: HttpResponse,
161        full_key: &str,
162    ) -> ReadSecretError {
163        let status_code = response.status();
164
165        if let StatusCode::NOT_FOUND = status_code {
166            return ReadSecretError::KeyNotFound {
167                key: full_key.to_owned(),
168            };
169        }
170
171        let internal_error = Box::new(VaultHttpError::new(
172            response.url().path().to_owned(),
173            status_code,
174            Self::get_error_list(response).await,
175        ));
176        match status_code {
177            StatusCode::FORBIDDEN => ReadSecretError::Authentication { internal_error },
178            _ => ReadSecretError::Request { internal_error },
179        }
180    }
181
182    async fn probe_health_at_path(&self, path: &str) -> Result<HttpResponse, reqwest::Error> {
183        let http_url = self.get_url_for_path(path);
184
185        let health_response = self
186            .client
187            // We use GET rather than HEAD because most Vault endpoints don't support HEAD requests.
188            .get(&http_url[..])
189            .header(TOKEN_HEADER, self.config.token.expose_secret())
190            .send()
191            .await?;
192
193        Ok(health_response)
194    }
195
196    async fn map_status_code_to_check_health_result(
197        response: HttpResponse,
198    ) -> Result<(), CheckHealthError> {
199        match response.status() {
200            StatusCode::OK => Ok(()),
201            StatusCode::FORBIDDEN => Err(CheckHealthError::Authentication {
202                internal_error: Box::new(VaultHttpError::new(
203                    String::from(response.url().path()),
204                    response.status(),
205                    None,
206                )),
207            }),
208            _ => Err(CheckHealthError::RemoteInternal {
209                internal_error: Box::new(VaultHttpError::new(
210                    String::from(response.url().path()),
211                    response.status(),
212                    None,
213                )),
214            }),
215        }
216    }
217
218    async fn check_vault_system_health(&self) -> Result<(), CheckHealthError> {
219        // The health endpoint returns OK status iff the vault is initialized
220        // and unsealed -- i.e., useable. However, it does NOT check
221        // authentication. This endpoint is public by default.
222
223        // Status codes documented at: https://www.vaultproject.io/api-docs/system/health
224
225        let health_path = "sys/health";
226        match self.probe_health_at_path(health_path).await {
227            // 429 indicates "unsealed and standby"; in this mode, the Vault is able to handle read
228            // requests, but is only serving as a proxy to a dedicated master. We consider this "healthy".
229            Ok(response) if response.status().as_u16() == 429 => Ok(()),
230            // 473 indicates "performance standby". Similar to 429, the Vault is forwarding requests
231            // to a separate Vault; this code differs from above only in that the cluster is
232            // configured in "high-availability" mode rather than with a user-chosen master. We
233            // consider this "healthy".
234            Ok(response) if response.status().as_u16() == 473 => Ok(()),
235            Ok(response) => Self::map_status_code_to_check_health_result(response).await,
236            Err(e) => Err(CheckHealthError::Unreachable {
237                internal_error: Box::new(e),
238            }),
239        }
240    }
241
242    async fn check_vault_credential_health(&self) -> Result<(), CheckHealthError> {
243        // We use the auth API to validate our credentials. This endpoint is
244        // chosen because it requires authentication, but the actual content
245        // of the response is entirely ignored.
246        let auth_path = "sys/auth";
247        match self.probe_health_at_path(auth_path).await {
248            Ok(response) => Self::map_status_code_to_check_health_result(response).await,
249            Err(e) => Err(CheckHealthError::Unreachable {
250                internal_error: Box::new(e),
251            }),
252        }
253    }
254}
255
256#[async_trait]
257impl SecretKeyValueStore for VaultSecretKeyValueStore {
258    async fn read(&self, key: &str) -> Result<Secret<String>, ReadSecretError> {
259        let VaultKey {
260            path: split_path,
261            key: split_key,
262        } = VaultKey::parse_from_slash_separated_key(key)?;
263
264        let http_url = self.get_url_for_path(split_path.as_str());
265
266        let response = self
267            .client
268            .get(&http_url[..])
269            .header(TOKEN_HEADER, self.config.token.expose_secret())
270            .send()
271            .await
272            .map_err(Self::map_reqwest_error_to_read_error)?;
273
274        if !response.status().is_success() {
275            return Err(Self::map_status_code_to_read_error(response, key).await);
276        }
277
278        let response_body = VaultSecretKeyValueStore::parse_read_response(response).await?;
279
280        let secret =
281            response_body
282                .data
283                .get(split_key.as_str())
284                .ok_or(ReadSecretError::KeyNotFound {
285                    key: key.to_owned(),
286                })?;
287
288        Ok(secret.to_owned())
289    }
290
291    async fn check_health(&self) -> Result<(), CheckHealthError> {
292        concurrent_and(
293            self.check_vault_system_health(),
294            self.check_vault_credential_health(),
295        )
296        .await
297    }
298}
299
300#[derive(Debug)]
301struct VaultHttpError {
302    pub path: String,
303    pub status_code: StatusCode,
304    pub errors: Option<Vec<String>>,
305}
306
307impl VaultHttpError {
308    pub fn new(
309        path: String,
310        status_code: StatusCode,
311        errors: Option<Vec<String>>,
312    ) -> VaultHttpError {
313        VaultHttpError {
314            path,
315            status_code,
316            errors,
317        }
318    }
319}
320
321impl std::error::Error for VaultHttpError {}
322impl std::fmt::Display for VaultHttpError {
323    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
324        write!(
325            f,
326            "An error code was returned while making an HTTP request to path \"{}\". Status code: {}.{}",
327            self.path,
328            self.status_code.as_str(),
329            self.errors.as_ref().map_or(String::default(), |e| format!(" Vault returned errors: {:?}", e))
330        )
331    }
332}