mx-keyvault 0.1.0

Azure Key Vault integration for MultiversX Rust services.
Documentation
//! Azure `KeyVault` client for secure secret resolution.
//!
//! This crate provides a reusable client for fetching secrets from Azure `KeyVault`.
//! It supports both Managed Identity (for Azure-hosted services) and Client Secret
//! credentials (for local development).

#![cfg_attr(not(feature = "keyvault"), allow(dead_code))]

#[cfg(feature = "keyvault")]
pub use client::KeyVaultClient;
#[cfg(feature = "keyvault")]
pub use client::{CredentialConfig, CredentialType};

#[cfg(feature = "keyvault")]
mod client {
    use anyhow::{Context, Result};
    use azure_core::credentials::{Secret as CoreSecret, TokenCredential};
    use azure_identity::{ClientSecretCredential, DeveloperToolsCredential};
    use azure_security_keyvault_secrets::{SecretClient, models::Secret as KeyVaultSecret};
    use std::env;
    use std::sync::Arc;
    use tracing::{debug, warn};

    /// Represents the type of credential being used.
    #[derive(Debug, Clone, PartialEq, Eq)]
    pub enum CredentialType {
        /// Client secret credentials from environment variables.
        ClientSecret,
        /// Developer tools credential (Azure CLI, VS Code, etc.).
        DeveloperTools,
    }

    /// Configuration for credential resolution.
    #[derive(Debug, Clone)]
    pub struct CredentialConfig {
        pub tenant_id: Option<String>,
        pub client_id: Option<String>,
        pub client_secret: Option<String>,
        pub disable_managed_identity: bool,
    }

    impl CredentialConfig {
        /// Creates a new credential config from environment variables.
        pub fn from_env() -> Self {
            let tenant_id = env::var("AZURE_TENANT_ID").ok();
            let client_id = env::var("AZURE_CLIENT_ID").ok();
            let client_secret = env::var("AZURE_CLIENT_SECRET").ok();
            let disable_managed_identity =
                env::var("AZURE_IDENTITY_DISABLE_MANAGED_IDENTITY_CREDENTIAL")
                    .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
                    .unwrap_or(false);

            Self {
                tenant_id,
                client_id,
                client_secret,
                disable_managed_identity,
            }
        }

        /// Determines which credential type should be used based on the configuration.
        pub fn resolve_credential_type(&self) -> Result<CredentialType> {
            if self.tenant_id.is_some() && self.client_id.is_some() && self.client_secret.is_some()
            {
                Ok(CredentialType::ClientSecret)
            } else if self.disable_managed_identity {
                Err(anyhow::anyhow!(
                    "KeyVault enabled but no env credentials provided and managed identity disabled"
                ))
            } else {
                Ok(CredentialType::DeveloperTools)
            }
        }

        /// Checks if all client secret credentials are present.
        pub fn has_client_secret_credentials(&self) -> bool {
            self.tenant_id.is_some() && self.client_id.is_some() && self.client_secret.is_some()
        }

        /// Checks if any partial client secret credentials are present.
        pub fn has_partial_credentials(&self) -> bool {
            let count = [
                self.tenant_id.is_some(),
                self.client_id.is_some(),
                self.client_secret.is_some(),
            ]
            .iter()
            .filter(|&&v| v)
            .count();
            count > 0 && count < 3
        }
    }

    /// Client for fetching secrets from Azure `KeyVault`.
    pub struct KeyVaultClient {
        client: SecretClient,
    }

    impl std::fmt::Debug for KeyVaultClient {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            f.debug_struct("KeyVaultClient")
                .field("client", &"<SecretClient>")
                .finish()
        }
    }

    impl KeyVaultClient {
        /// Creates a new `KeyVault` client for the specified vault URL.
        ///
        /// Credentials are determined in the following order:
        /// 1. Environment variables: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`
        /// 2. Developer Tools credential (Azure CLI, VS Code, etc.) unless disabled
        ///    via `AZURE_IDENTITY_DISABLE_MANAGED_IDENTITY_CREDENTIAL=true`
        pub fn new(vault_url: &str) -> Result<Self> {
            Self::with_config(vault_url, CredentialConfig::from_env())
        }

        /// Creates a new `KeyVault` client with explicit credential configuration.
        pub fn with_config(vault_url: &str, config: CredentialConfig) -> Result<Self> {
            if vault_url.trim().is_empty() {
                return Err(anyhow::anyhow!("KeyVault URL cannot be empty"));
            }

            let credential = Self::create_credential(&config)?;
            let client = SecretClient::new(vault_url, credential, None)?;

            Ok(Self { client })
        }

        /// Creates Azure credential based on configuration.
        fn create_credential(config: &CredentialConfig) -> Result<Arc<dyn TokenCredential>> {
            match config.resolve_credential_type()? {
                CredentialType::ClientSecret => {
                    let tenant_id = config.tenant_id.as_ref().unwrap();
                    let client_id = config.client_id.clone().unwrap();
                    let client_secret = config.client_secret.clone().unwrap();

                    debug!(target: "keyvault", "using ClientSecretCredential from env");
                    Ok(ClientSecretCredential::new(
                        tenant_id,
                        client_id,
                        CoreSecret::new(client_secret),
                        None,
                    )?)
                }
                CredentialType::DeveloperTools => {
                    debug!(
                        target: "keyvault",
                        "using DeveloperToolsCredential (env creds not present)"
                    );
                    Ok(DeveloperToolsCredential::new(None)?)
                }
            }
        }

        /// Fetches a secret from `KeyVault` by name.
        ///
        /// Returns `Ok(None)` if the secret name is empty.
        /// Returns `Err` if the secret fetch fails.
        pub async fn fetch_secret(&self, name: &str) -> Result<Option<String>> {
            if name.trim().is_empty() {
                return Ok(None);
            }

            let resp = self
                .client
                .get_secret(name, None)
                .await
                .with_context(|| format!("failed to fetch secret '{name}' from KeyVault"))?;

            let body = resp.into_body();
            let secret: KeyVaultSecret = body
                .json()
                .with_context(|| format!("invalid secret payload for '{name}'"))?;

            if let Some(value) = secret.value {
                debug!(target: "keyvault", secret_name = name, "fetched secret from KeyVault");
                Ok(Some(value))
            } else {
                warn!(target: "keyvault", secret_name = name, "secret exists but has no value");
                Ok(None)
            }
        }

        /// Fetches multiple secrets from `KeyVault` in parallel.
        ///
        /// Returns a vector of `Option<String>` in the same order as the input names.
        /// Empty names will result in `None` values.
        pub async fn fetch_secrets(&self, names: &[&str]) -> Result<Vec<Option<String>>> {
            use futures::future::try_join_all;

            let futures = names.iter().map(|name| self.fetch_secret(name));
            try_join_all(futures).await
        }
    }
}

#[cfg(all(test, feature = "keyvault"))]
mod tests;

#[cfg(not(feature = "keyvault"))]
pub struct KeyVaultClient;

#[cfg(not(feature = "keyvault"))]
impl KeyVaultClient {
    pub fn new(_vault_url: &str) -> anyhow::Result<Self> {
        Err(anyhow::anyhow!(
            "KeyVault feature not enabled. Rebuild with --features keyvault"
        ))
    }
}