#![allow(clippy::borrowed_box)]
use std::{
collections::{BTreeMap, HashMap},
env,
};
use async_trait::async_trait;
use serde_derive::{Deserialize, Serialize};
use vaultrs::{
client::{VaultClient, VaultClientSettingsBuilder},
error::ClientError,
kv1, kv2,
};
use super::ProviderKind;
use crate::{
config::{PathMap, ProviderInfo, KV},
Error, Provider, Result,
};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HashivaultOptions {
pub address: Option<String>,
pub token: Option<String>,
}
pub struct Hashivault {
pub client: VaultClient,
pub name: String,
}
impl Hashivault {
pub fn new(name: &str, opts: Option<HashivaultOptions>) -> Result<Self> {
let settings = if let Some(opts) = opts {
let mut settings = VaultClientSettingsBuilder::default();
if let Some(address) = opts.address {
settings.address(address);
}
if let Some(token) = opts.token {
settings.token(token);
}
settings.build().map_err(Box::from)?
} else {
VaultClientSettingsBuilder::default()
.address(env::var("VAULT_ADDR")?)
.token(env::var("VAULT_TOKEN")?)
.build()
.map_err(Box::from)?
};
let client = VaultClient::new(settings).map_err(Box::from)?;
Ok(Self {
client,
name: name.to_string(),
})
}
}
fn parse_path(pm: &PathMap) -> Result<(&str, &str, &str)> {
let (engine, full_path) = (pm.protocol.as_deref().unwrap_or("kv2"), pm.path.as_str());
let (mount, path) = full_path.split_once('/').ok_or_else(|| {
Error::Message(
"path must have initial mount seperated by '/', e.g. `secret/foo`".to_string(),
)
})?;
Ok((engine, mount, path))
}
fn xerr(pm: &PathMap, e: ClientError) -> Error {
match e {
ClientError::RestClientError { source } => match source {
rustify::errors::ClientError::ServerResponseError { code, content } => {
match (code, content.clone()) {
(404, Some(content))
if content.contains("Invalid path for a versioned K/V secrets") =>
{
Error::PathError(
pm.path.clone(),
"missing or incompatible protocol version".to_string(),
)
}
(404, _) => Error::NotFound {
path: pm.path.clone(),
msg: "not found".to_string(),
},
_ => Error::Message(format!("code: {code}, {content:?}")),
}
}
_ => Error::Any(Box::from(source)),
},
ClientError::APIError {
code: 404,
errors: _,
} => Error::NotFound {
path: pm.path.clone(),
msg: "not found".to_string(),
},
_ => Error::Any(Box::from(e)),
}
}
async fn get_data(client: &VaultClient, pm: &PathMap) -> Result<BTreeMap<String, String>> {
let (engine, mount, path) = parse_path(pm)?;
let data = if engine == "kv2" {
kv2::read(client, mount, path).await
} else {
kv1::get(client, mount, path).await
}
.map_err(|e| xerr(pm, e))?;
Ok(data)
}
async fn get_data_or_empty(client: &VaultClient, pm: &PathMap) -> Result<BTreeMap<String, String>> {
let data = match get_data(client, pm).await {
Ok(data) => data,
Err(Error::NotFound { path: _, msg: _ }) => BTreeMap::new(),
Err(e) => return Err(e),
};
Ok(data)
}
async fn put_data(
client: &VaultClient,
pm: &PathMap,
data: &BTreeMap<String, String>,
) -> Result<()> {
let (engine, mount, path) = parse_path(pm)?;
if engine == "kv2" {
kv2::set(client, mount, path, data)
.await
.map_err(|e| xerr(pm, e))?;
} else {
kv1::set(
client,
mount,
path,
&data
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect::<HashMap<_, _>>(),
)
.await
.map_err(|e| xerr(pm, e))?;
};
Ok(())
}
#[async_trait]
impl Provider for Hashivault {
fn kind(&self) -> ProviderInfo {
ProviderInfo {
kind: ProviderKind::Hashicorp,
name: self.name.clone(),
}
}
async fn get(&self, pm: &PathMap) -> Result<Vec<KV>> {
Ok(KV::from_data(
&get_data(&self.client, pm).await.map_err(|e| match e {
Error::NotFound { path, msg } => Error::NotFound { path, msg },
_ => Error::GetError {
path: pm.path.to_string(),
msg: e.to_string(),
},
})?,
pm,
&self.kind(),
))
}
async fn put(&self, pm: &PathMap, kvs: &[KV]) -> Result<()> {
let mut data = get_data_or_empty(&self.client, pm)
.await
.map_err(|e| Error::PutError {
path: pm.path.to_string(),
msg: e.to_string(),
})?;
for kv in kvs {
data.insert(kv.key.clone(), kv.value.clone());
}
put_data(&self.client, pm, &data)
.await
.map_err(|e| Error::PutError {
path: pm.path.to_string(),
msg: e.to_string(),
})?;
Ok(())
}
async fn del(&self, pm: &PathMap) -> Result<()> {
if !pm.keys.is_empty() {
let mut data =
get_data_or_empty(&self.client, pm)
.await
.map_err(|e| Error::DeleteError {
path: pm.path.to_string(),
msg: e.to_string(),
})?;
for key in pm.keys.keys() {
data.remove(key);
}
put_data(&self.client, pm, &data)
.await
.map_err(|e| Error::DeleteError {
path: pm.path.to_string(),
msg: e.to_string(),
})?;
return Ok(());
}
let (engine, mount, path) = parse_path(pm)?;
if engine == "kv2" {
kv2::delete_latest(&self.client, mount, path)
.await
.map_err(|e| xerr(pm, e))
.map_err(|e| Error::DeleteError {
path: pm.path.to_string(),
msg: e.to_string(),
})?;
} else {
kv1::delete(&self.client, mount, path)
.await
.map_err(|e| xerr(pm, e))
.map_err(|e| Error::DeleteError {
path: pm.path.to_string(),
msg: e.to_string(),
})?;
};
Ok(())
}
}
#[cfg(test)]
mod tests {
use dockertest_server::servers::hashi::{VaultServer, VaultServerConfig};
use dockertest_server::Test;
use super::*;
use crate::providers::test_utils;
#[test]
#[cfg(not(windows))]
fn sanity_test() {
use std::time::Duration;
if env::var("RUNNER_OS").unwrap_or_default() == "macOS" {
return;
}
let config = VaultServerConfig::builder()
.version("1.8.2".into())
.build()
.unwrap();
let mut test = Test::new();
test.register(config);
test.run(|instance| async move {
let server: VaultServer = instance.server();
let data = serde_json::json!({
"address": server.external_url(),
"token": server.token
});
tokio::time::sleep(Duration::from_secs(2)).await;
let p = Box::new(
super::Hashivault::new(
"hashicorp_vault",
Some(serde_json::from_value(data).unwrap()),
)
.unwrap(),
) as Box<dyn Provider + Send + Sync>;
test_utils::ProviderTest::new(p).run().await;
});
}
}