use std::collections::HashMap;
use std::env;
use std::result::Result as StdResult;
use minijinja::{Error as MinijinjaError, ErrorKind as MinijinjaErrorKind, Value, value::Kwargs};
use reqwest;
use vaultrs::client::VaultClientSettingsBuilder;
pub fn function(secret: String, options: Kwargs) -> StdResult<Value, MinijinjaError> {
let (path, field) = parse_secret_path(&secret)?;
let url: Option<String> = options.get("url")?;
let token: Option<String> = options.get("token")?;
let mount: Option<String> = options.get("mount")?;
let auth_method: Option<String> = options.get("auth_method")?;
let username: Option<String> = options.get("username")?;
let password: Option<String> = options.get("password")?;
let role_id: Option<String> = options.get("role_id")?;
let secret_id: Option<String> = options.get("secret_id")?;
let jwt: Option<String> = options.get("jwt")?;
let namespace: Option<String> = options.get("namespace")?;
let return_format: Option<String> = options.get("return_format")?;
let vault_url = url.or_else(|| env::var("VAULT_ADDR").ok()).ok_or_else(|| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"Vault URL not provided. Set 'url' parameter or VAULT_ADDR environment variable.",
)
})?;
let mount_point = mount.unwrap_or_else(|| "secret".to_string());
let auth_method_str = auth_method.unwrap_or_else(|| "token".to_string());
let return_format_str = return_format.unwrap_or_else(|| "dict".to_string());
let rt = tokio::runtime::Runtime::new().map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to create async runtime: {e}"),
)
})?;
let result = rt.block_on(async {
let vault_token = match auth_method_str.as_str() {
"token" => {
token
.or_else(|| env::var("VAULT_TOKEN").ok())
.ok_or_else(|| MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"Vault token not provided. Set 'token' parameter or VAULT_TOKEN environment variable.",
))?
},
"userpass" => {
let user = username.ok_or_else(|| MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"Username required for userpass authentication",
))?;
let pass = password.ok_or_else(|| MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"Password required for userpass authentication",
))?;
authenticate_userpass(&vault_url, &user, &pass).await?
},
"approle" => {
let role = role_id.ok_or_else(|| MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"role_id required for approle authentication",
))?;
let secret = secret_id.ok_or_else(|| MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"secret_id required for approle authentication",
))?;
authenticate_approle(&vault_url, &role, &secret).await?
},
"jwt" => {
let jwt_token = jwt.ok_or_else(|| MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"jwt required for jwt authentication",
))?;
let role = role_id.unwrap_or_else(|| "default".to_string());
authenticate_jwt(&vault_url, &jwt_token, &role).await?
},
"none" => {
"".to_string()
},
_ => {
return Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Unsupported auth method: {auth_method_str}"),
));
}
};
fetch_secret(
&vault_url,
&vault_token,
&mount_point,
&path,
field.as_deref(),
&namespace,
&return_format_str,
)
.await
})?;
options.assert_all_used()?;
Ok(result)
}
fn parse_secret_path(secret: &str) -> StdResult<(String, Option<String>), MinijinjaError> {
if let Some(colon_pos) = secret.find(':') {
let path = secret[..colon_pos].to_string();
let field = secret[colon_pos + 1..].to_string();
Ok((path, Some(field)))
} else {
Ok((secret.to_string(), None))
}
}
async fn authenticate_userpass(
url: &str,
username: &str,
password: &str,
) -> StdResult<String, MinijinjaError> {
let client = reqwest::Client::new();
let auth_url = format!(
"{}/v1/auth/userpass/login/{}",
url.trim_end_matches('/'),
username
);
let mut payload = HashMap::new();
payload.insert("password", password);
let response = client
.post(&auth_url)
.json(&payload)
.send()
.await
.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Userpass authentication failed: {e}"),
)
})?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Userpass authentication failed: {error_text}"),
));
}
let response_text = response.text().await.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to read userpass response: {e}"),
)
})?;
let json_response: serde_json::Value = serde_json::from_str(&response_text).map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to parse userpass response: {e}"),
)
})?;
let token = json_response
.get("auth")
.and_then(|auth| auth.get("client_token"))
.and_then(|token| token.as_str())
.ok_or_else(|| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"No token in userpass response".to_string(),
)
})?;
Ok(token.to_string())
}
async fn authenticate_approle(
url: &str,
role_id: &str,
secret_id: &str,
) -> StdResult<String, MinijinjaError> {
let client = reqwest::Client::new();
let auth_url = format!("{}/v1/auth/approle/login", url.trim_end_matches('/'));
let mut payload = HashMap::new();
payload.insert("role_id", role_id);
payload.insert("secret_id", secret_id);
let response = client
.post(&auth_url)
.json(&payload)
.send()
.await
.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("AppRole authentication failed: {e}"),
)
})?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("AppRole authentication failed: {error_text}"),
));
}
let response_text = response.text().await.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to read approle response: {e}"),
)
})?;
let json_response: serde_json::Value = serde_json::from_str(&response_text).map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to parse approle response: {e}"),
)
})?;
let token = json_response
.get("auth")
.and_then(|auth| auth.get("client_token"))
.and_then(|token| token.as_str())
.ok_or_else(|| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"No token in approle response".to_string(),
)
})?;
Ok(token.to_string())
}
async fn authenticate_jwt(url: &str, jwt: &str, role: &str) -> StdResult<String, MinijinjaError> {
let client = reqwest::Client::new();
let auth_url = format!("{}/v1/auth/jwt/login", url.trim_end_matches('/'));
let mut payload = HashMap::new();
payload.insert("jwt", jwt);
payload.insert("role", role);
let response = client
.post(&auth_url)
.json(&payload)
.send()
.await
.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("JWT authentication failed: {e}"),
)
})?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("JWT authentication failed: {error_text}"),
));
}
let response_text = response.text().await.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to read jwt response: {e}"),
)
})?;
let json_response: serde_json::Value = serde_json::from_str(&response_text).map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to parse jwt response: {e}"),
)
})?;
let token = json_response
.get("auth")
.and_then(|auth| auth.get("client_token"))
.and_then(|token| token.as_str())
.ok_or_else(|| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"No token in jwt response".to_string(),
)
})?;
Ok(token.to_string())
}
async fn fetch_secret(
url: &str,
token: &str,
mount: &str,
path: &str,
field: Option<&str>,
namespace: &Option<String>,
return_format: &str,
) -> StdResult<Value, MinijinjaError> {
let _client_settings = VaultClientSettingsBuilder::default()
.address(url)
.token(token)
.build()
.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to configure Vault client: {e}"),
)
})?;
let vault_path = format!("{mount}/data/{path}");
let full_url = format!("{}/v1/{}", url.trim_end_matches('/'), vault_path);
let http_client = reqwest::Client::new();
let mut request = http_client.get(&full_url);
if !token.is_empty() {
request = request.header("X-Vault-Token", token);
}
if let Some(ns) = namespace
&& !ns.is_empty()
{
request = request.header("X-Vault-Namespace", ns);
}
let response = request.send().await.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("HTTP request failed: {e}"),
)
})?;
let status = response.status();
let response_text = response.text().await.map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to read response text: {e}"),
)
})?;
if !status.is_success() {
return Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Vault returned status {status}: {response_text}"),
));
}
let json_response: serde_json::Value = serde_json::from_str(&response_text).map_err(|e| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Failed to parse JSON response: {e}"),
)
})?;
let data = json_response.get("data").ok_or_else(|| {
MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"No 'data' field in Vault response".to_string(),
)
})?;
if data.is_null() {
return Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
"Vault response data is null".to_string(),
));
}
let secret_data = if let Some(nested_data) = data.get("data") {
nested_data } else {
data };
if let Some(field_name) = field {
if let Some(field_value) = secret_data.get(field_name) {
let value_str = match field_value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
Ok(Value::from(value_str))
} else {
Err(MinijinjaError::new(
MinijinjaErrorKind::InvalidOperation,
format!("Field '{field_name}' not found in secret"),
))
}
} else {
match return_format {
"dict" => {
convert_json_to_minijinja_value(secret_data)
}
"values" => {
if let Some(obj) = secret_data.as_object() {
let values: Vec<Value> = obj
.values()
.map(|v| {
convert_json_to_minijinja_value(v)
.unwrap_or_else(|_| Value::from(v.to_string()))
})
.collect();
Ok(Value::from(values))
} else {
convert_json_to_minijinja_value(secret_data)
}
}
"raw" => {
convert_json_to_minijinja_value(&json_response)
}
_ => {
convert_json_to_minijinja_value(secret_data)
}
}
}
}
fn convert_json_to_minijinja_value(
json_value: &serde_json::Value,
) -> StdResult<Value, MinijinjaError> {
match json_value {
serde_json::Value::Null => Ok(Value::from(())),
serde_json::Value::Bool(b) => Ok(Value::from(*b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::from(i))
} else if let Some(f) = n.as_f64() {
Ok(Value::from(f))
} else {
Ok(Value::from(n.to_string()))
}
}
serde_json::Value::String(s) => Ok(Value::from(s.clone())),
serde_json::Value::Array(arr) => {
let converted: Result<Vec<Value>, MinijinjaError> =
arr.iter().map(convert_json_to_minijinja_value).collect();
match converted {
Ok(vec) => Ok(Value::from(vec)),
Err(e) => Err(e),
}
}
serde_json::Value::Object(obj) => {
let mut map = std::collections::BTreeMap::new();
for (key, value) in obj.iter() {
match convert_json_to_minijinja_value(value) {
Ok(v) => {
map.insert(key.clone(), v);
}
Err(e) => return Err(e),
}
}
Ok(Value::from_serialize(&map))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_secret_path_with_field() {
let (path, field) = parse_secret_path("myapp/database:password").unwrap();
assert_eq!(path, "myapp/database");
assert_eq!(field, Some("password".to_string()));
}
#[test]
fn test_parse_secret_path_without_field() {
let (path, field) = parse_secret_path("myapp/config").unwrap();
assert_eq!(path, "myapp/config");
assert_eq!(field, None);
}
#[test]
fn test_parse_secret_path_with_kv2() {
let (path, field) = parse_secret_path("secret/data/myapp:password").unwrap();
assert_eq!(path, "secret/data/myapp");
assert_eq!(field, Some("password".to_string()));
}
#[test]
fn test_parse_secret_path_empty() {
let (path, field) = parse_secret_path("").unwrap();
assert_eq!(path, "");
assert_eq!(field, None);
}
#[test]
fn test_parse_secret_path_multiple_colons() {
let (path, field) = parse_secret_path("app:config:database:password").unwrap();
assert_eq!(path, "app");
assert_eq!(field, Some("config:database:password".to_string()));
}
}