use super::{Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use bitwarden::Client;
use bitwarden::auth::login::AccessTokenLoginRequest;
use bitwarden::secrets_manager::ClientSecretsExt;
use bitwarden::secrets_manager::secrets::{
SecretCreateRequest, SecretIdentifiersByProjectRequest, SecretPutRequest, SecretResponse,
SecretsGetRequest,
};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BwsConfig {
pub project_id: uuid::Uuid,
}
impl TryFrom<&ProviderUrl> for BwsConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "bws" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for bws provider. Expected 'bws'.",
url.scheme()
)));
}
let project_id_str = url.host().filter(|s| !s.is_empty()).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"BWS project ID is required. Use format: bws://project-uuid".to_string(),
)
})?;
let project_id = uuid::Uuid::parse_str(&project_id_str).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid BWS project UUID '{}': {}. Use format: bws://a9230ec4-5507-4870-b8b5-b3f500587e4c",
project_id_str, e
))
})?;
Ok(Self { project_id })
}
}
pub struct BwsProvider {
config: BwsConfig,
client: OnceLock<Client>,
secrets_cache: OnceLock<Vec<SecretResponse>>,
}
crate::register_provider! {
struct: BwsProvider,
config: BwsConfig,
name: "bws",
description: "Bitwarden Secrets Manager",
schemes: ["bws"],
examples: ["bws://a9230ec4-5507-4870-b8b5-b3f500587e4c"],
}
impl BwsProvider {
pub fn new(config: BwsConfig) -> Self {
Self {
config,
client: OnceLock::new(),
secrets_cache: OnceLock::new(),
}
}
fn sanitize_error(message: &str) -> String {
if let Ok(token) = std::env::var("BWS_ACCESS_TOKEN") {
if !token.is_empty() {
return message.replace(&token, "[REDACTED]");
}
}
message.to_string()
}
async fn ensure_client(&self) -> Result<&Client> {
if let Some(client) = self.client.get() {
return Ok(client);
}
let token = std::env::var("BWS_ACCESS_TOKEN").map_err(|_| {
SecretSpecError::ProviderOperationFailed(
"BWS_ACCESS_TOKEN environment variable is not set. \
Generate an access token from the Bitwarden Secrets Manager web interface \
and set it as BWS_ACCESS_TOKEN."
.to_string(),
)
})?;
if token.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(
"BWS_ACCESS_TOKEN environment variable is empty. \
Generate an access token from the Bitwarden Secrets Manager web interface."
.to_string(),
));
}
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let client = Client::new(None);
client
.auth()
.login_access_token(&AccessTokenLoginRequest {
access_token: token,
state_file: None,
})
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(Self::sanitize_error(&format!(
"Failed to authenticate with Bitwarden Secrets Manager: {}",
e
)))
})?;
Ok(self.client.get_or_init(|| client))
}
async fn ensure_secrets(&self) -> Result<&Vec<SecretResponse>> {
if let Some(secrets) = self.secrets_cache.get() {
return Ok(secrets);
}
let secrets = self.fetch_secrets().await?;
Ok(self.secrets_cache.get_or_init(|| secrets))
}
async fn fetch_secrets(&self) -> Result<Vec<SecretResponse>> {
let client = self.ensure_client().await?;
let identifiers = client
.secrets()
.list_by_project(&SecretIdentifiersByProjectRequest {
project_id: self.config.project_id,
})
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(Self::sanitize_error(&format!(
"Failed to list secrets in BWS project '{}': {}",
self.config.project_id, e
)))
})?;
if identifiers.data.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<uuid::Uuid> = identifiers.data.iter().map(|s| s.id).collect();
let secrets = client
.secrets()
.get_by_ids(SecretsGetRequest { ids })
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(Self::sanitize_error(&format!(
"Failed to fetch secret values from BWS project '{}': {}",
self.config.project_id, e
)))
})?;
Ok(secrets.data)
}
async fn get_secret_async(
&self,
_project: &str,
key: &str,
_profile: &str,
) -> Result<Option<SecretString>> {
let secrets = self.ensure_secrets().await?;
for secret in secrets {
if secret.key == key {
return Ok(Some(SecretString::new(secret.value.clone().into())));
}
}
Ok(None)
}
async fn set_secret_async(
&self,
_project: &str,
key: &str,
value: &SecretString,
_profile: &str,
) -> Result<()> {
let client = self.ensure_client().await?;
let org_id = client
.internal
.get_access_token_organization()
.ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"Failed to determine organization ID from BWS access token. \
Ensure the access token is valid."
.to_string(),
)
})?;
let fresh_secrets = self.fetch_secrets().await?;
let existing = fresh_secrets.iter().find(|s| s.key == key);
if let Some(existing_secret) = existing {
client
.secrets()
.update(&SecretPutRequest {
id: existing_secret.id,
organization_id: org_id.into(),
key: key.to_string(),
value: value.expose_secret().to_string(),
note: existing_secret.note.clone(),
project_ids: existing_secret.project_id.map(|id| vec![id]),
})
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(Self::sanitize_error(&format!(
"Failed to update secret '{}' in BWS: {}",
key, e
)))
})?;
} else {
client
.secrets()
.create(&SecretCreateRequest {
organization_id: org_id.into(),
key: key.to_string(),
value: value.expose_secret().to_string(),
note: String::new(),
project_ids: Some(vec![self.config.project_id]),
})
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(Self::sanitize_error(&format!(
"Failed to create secret '{}' in BWS: {}",
key, e
)))
})?;
}
Ok(())
}
async fn get_batch_async(
&self,
_project: &str,
keys: &[&str],
_profile: &str,
) -> Result<HashMap<String, SecretString>> {
let secrets = self.ensure_secrets().await?;
let mut results = HashMap::new();
for secret in secrets {
if keys.contains(&secret.key.as_str()) {
results.insert(
secret.key.clone(),
SecretString::new(secret.value.clone().into()),
);
}
}
Ok(results)
}
}
impl Provider for BwsProvider {
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
format!("bws://{}", self.config.project_id)
}
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
super::block_on(self.get_secret_async(project, key, profile))
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
super::block_on(self.set_secret_async(project, key, value, profile))
}
fn allows_set(&self) -> bool {
true
}
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
super::block_on(self.get_batch_async(project, keys, profile))
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
fn provider_url(s: &str) -> ProviderUrl {
ProviderUrl::new(Url::parse(s).unwrap())
}
#[test]
fn test_bws_config_valid_uuid() {
let url = provider_url("bws://a9230ec4-5507-4870-b8b5-b3f500587e4c");
let config = BwsConfig::try_from(&url).unwrap();
assert_eq!(
config.project_id,
uuid::Uuid::parse_str("a9230ec4-5507-4870-b8b5-b3f500587e4c").unwrap()
);
}
#[test]
fn test_bws_config_missing_project_id() {
let url = provider_url("bws://");
let result = BwsConfig::try_from(&url);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("project ID is required"),
"Error should mention project ID is required, got: {}",
err_msg
);
}
#[test]
fn test_bws_config_invalid_uuid() {
let url = provider_url("bws://not-a-valid-uuid");
let result = BwsConfig::try_from(&url);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Invalid BWS project UUID"),
"Error should mention invalid UUID, got: {}",
err_msg
);
}
#[test]
fn test_bws_config_wrong_scheme() {
let url = provider_url("gcsm://a9230ec4-5507-4870-b8b5-b3f500587e4c");
let result = BwsConfig::try_from(&url);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("Invalid scheme"),
"Error should mention invalid scheme, got: {}",
err_msg
);
}
#[test]
fn test_bws_provider_metadata() {
let config = BwsConfig {
project_id: uuid::Uuid::parse_str("a9230ec4-5507-4870-b8b5-b3f500587e4c").unwrap(),
};
let provider = BwsProvider::new(config);
assert_eq!(provider.name(), "bws");
assert_eq!(provider.uri(), "bws://a9230ec4-5507-4870-b8b5-b3f500587e4c");
assert!(provider.allows_set());
}
#[test]
fn test_bws_access_token_missing_produces_clear_error() {
if std::env::var("BWS_ACCESS_TOKEN").is_ok() {
return;
}
let config = BwsConfig {
project_id: uuid::Uuid::parse_str("a9230ec4-5507-4870-b8b5-b3f500587e4c").unwrap(),
};
let provider = BwsProvider::new(config);
let result = provider.get("test_project", "TEST_KEY", "default");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("BWS_ACCESS_TOKEN"),
"Error should mention BWS_ACCESS_TOKEN, got: {}",
err_msg
);
}
}