diff --git a/Cargo.lock b/Cargo.lock index 666c856059..d46038929e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,7 +619,7 @@ dependencies = [ [[package]] name = "azure_core" version = "0.20.0" -source = "git+https://github.com/azure/azure-sdk-for-rust.git?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" +source = "git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" dependencies = [ "async-trait", "base64 0.22.0", @@ -647,10 +647,10 @@ dependencies = [ [[package]] name = "azure_data_cosmos" version = "0.20.0" -source = "git+https://github.com/azure/azure-sdk-for-rust.git?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" +source = "git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" dependencies = [ "async-trait", - "azure_core 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust.git?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", + "azure_core 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", "bytes", "futures", "serde 1.0.197", @@ -686,12 +686,12 @@ dependencies = [ [[package]] name = "azure_identity" version = "0.20.0" -source = "git+https://github.com/azure/azure-sdk-for-rust.git?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" +source = "git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" dependencies = [ "async-lock 3.3.0", "async-process 2.2.2", "async-trait", - "azure_core 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust.git?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", + "azure_core 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", "futures", "oauth2", "pin-project", @@ -717,6 +717,19 @@ dependencies = [ "time", ] +[[package]] +name = "azure_security_keyvault" +version = "0.20.0" +source = "git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c#8c4caa251c3903d5eae848b41bb1d02a4d65231c" +dependencies = [ + "async-trait", + "azure_core 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", + "futures", + "serde 1.0.197", + "serde_json", + "time", +] + [[package]] name = "backtrace" version = "0.3.71" @@ -7786,6 +7799,9 @@ dependencies = [ name = "spin-factor-variables" version = "2.7.0-pre0" dependencies = [ + "azure_core 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", + "azure_identity 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", + "azure_security_keyvault 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", "dotenvy", "serde 1.0.197", "spin-expressions", @@ -7916,7 +7932,7 @@ version = "2.7.0-pre0" dependencies = [ "anyhow", "azure_data_cosmos", - "azure_identity 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust.git?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", + "azure_identity 0.20.0 (git+https://github.com/azure/azure-sdk-for-rust?rev=8c4caa251c3903d5eae848b41bb1d02a4d65231c)", "futures", "serde 1.0.197", "spin-core", @@ -8487,7 +8503,7 @@ dependencies = [ "async-trait", "azure_core 0.20.0 (registry+https://github.com/rust-lang/crates.io-index)", "azure_identity 0.20.0 (registry+https://github.com/rust-lang/crates.io-index)", - "azure_security_keyvault", + "azure_security_keyvault 0.20.0 (registry+https://github.com/rust-lang/crates.io-index)", "dotenvy", "once_cell", "serde 1.0.197", diff --git a/crates/factor-variables/Cargo.toml b/crates/factor-variables/Cargo.toml index 85f0687b18..60e0f507b7 100644 --- a/crates/factor-variables/Cargo.toml +++ b/crates/factor-variables/Cargo.toml @@ -5,6 +5,9 @@ authors = { workspace = true } edition = { workspace = true } [dependencies] +azure_security_keyvault = { git = "https://github.com/azure/azure-sdk-for-rust", rev = "8c4caa251c3903d5eae848b41bb1d02a4d65231c" } +azure_core = { git = "https://github.com/azure/azure-sdk-for-rust", rev = "8c4caa251c3903d5eae848b41bb1d02a4d65231c" } +azure_identity = { git = "https://github.com/azure/azure-sdk-for-rust", rev = "8c4caa251c3903d5eae848b41bb1d02a4d65231c" } dotenvy = "0.15" serde = { version = "1.0", features = ["rc"] } spin-expressions = { path = "../expressions" } diff --git a/crates/factor-variables/src/spin_cli/azure_key_vault.rs b/crates/factor-variables/src/spin_cli/azure_key_vault.rs new file mode 100644 index 0000000000..a832fa536b --- /dev/null +++ b/crates/factor-variables/src/spin_cli/azure_key_vault.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use anyhow::Context as _; +use azure_core::{auth::TokenCredential, Url}; +use azure_security_keyvault::SecretClient; +use serde::Deserialize; +use spin_expressions::{Key, Provider}; +use spin_factors::anyhow; +use spin_world::async_trait; +use tracing::{instrument, Level}; + +/// Azure KeyVault runtime config literal options for authentication +/// +/// Some of these fields are optional. Whether they are set determines whether environmental variables +/// will be used to resolve the information instead. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AzureKeyVaultVariablesConfig { + pub vault_url: String, + pub client_id: Option, + pub client_secret: Option, + pub tenant_id: Option, + pub authority_host: Option, +} + +#[derive(Debug, Copy, Clone, Deserialize, Default)] +pub enum AzureAuthorityHost { + #[default] + AzurePublicCloud, + AzureChina, + AzureGermany, + AzureGovernment, +} + +impl TryFrom for AzureKeyVaultAuthOptions { + type Error = anyhow::Error; + + fn try_from(value: AzureKeyVaultVariablesConfig) -> Result { + match (value.client_id, value.tenant_id, value.client_secret) { + (Some(client_id), Some(tenant_id), Some(client_secret)) => Ok( + AzureKeyVaultAuthOptions::RuntimeConfigValues{ + client_id, + client_secret, + tenant_id, + authority_host: value.authority_host.unwrap_or_default(), + } + ), + (None, None, None) => Ok(AzureKeyVaultAuthOptions::Environmental), + _ => anyhow::bail!("The current runtime config specifies some but not all of the Azure KeyVault 'client_id', 'client_secret', and 'tenant_id' values. Provide the missing values to authenticate to Azure KeyVault with the given service principal, or remove all these values to authenticate using ambient authentication (e.g. env vars, Azure CLI, Managed Identity, Workload Identity).") + } + } +} + +/// Azure Cosmos Key / Value enumeration for the possible authentication options +#[derive(Clone, Debug)] +pub enum AzureKeyVaultAuthOptions { + /// Runtime Config values indicates the service principal credentials have been supplied + RuntimeConfigValues { + client_id: String, + client_secret: String, + tenant_id: String, + authority_host: AzureAuthorityHost, + }, + /// Environmental indicates that the environment variables of the process should be used to + /// create the TokenCredential for the Cosmos client. This will use the Azure Rust SDK's + /// DefaultCredentialChain to derive the TokenCredential based on what environment variables + /// have been set. + /// + /// Service Principal with client secret: + /// - `AZURE_TENANT_ID`: ID of the service principal's Azure tenant. + /// - `AZURE_CLIENT_ID`: the service principal's client ID. + /// - `AZURE_CLIENT_SECRET`: one of the service principal's secrets. + /// + /// Service Principal with certificate: + /// - `AZURE_TENANT_ID`: ID of the service principal's Azure tenant. + /// - `AZURE_CLIENT_ID`: the service principal's client ID. + /// - `AZURE_CLIENT_CERTIFICATE_PATH`: path to a PEM or PKCS12 certificate file including the private key. + /// - `AZURE_CLIENT_CERTIFICATE_PASSWORD`: (optional) password for the certificate file. + /// + /// Workload Identity (Kubernetes, injected by the Workload Identity mutating webhook): + /// - `AZURE_TENANT_ID`: ID of the service principal's Azure tenant. + /// - `AZURE_CLIENT_ID`: the service principal's client ID. + /// - `AZURE_FEDERATED_TOKEN_FILE`: TokenFilePath is the path of a file containing a Kubernetes service account token. + /// + /// Managed Identity (User Assigned or System Assigned identities): + /// - `AZURE_CLIENT_ID`: (optional) if using a user assigned identity, this will be the client ID of the identity. + /// + /// Azure CLI: + /// - `AZURE_TENANT_ID`: (optional) use a specific tenant via the Azure CLI. + /// + /// Common across each: + /// - `AZURE_AUTHORITY_HOST`: (optional) the host for the identity provider. For example, for Azure public cloud the host defaults to "https://login.microsoftonline.com". + /// See also: https://github.com/Azure/azure-sdk-for-rust/blob/main/sdk/identity/README.md + Environmental, +} + +/// A provider that fetches variables from Azure Key Vault. +#[derive(Debug)] +pub struct AzureKeyVaultProvider { + secret_client: SecretClient, +} + +impl AzureKeyVaultProvider { + pub fn create( + vault_url: impl Into, + auth_options: AzureKeyVaultAuthOptions, + ) -> anyhow::Result { + let http_client = azure_core::new_http_client(); + let token_credential = match auth_options { + AzureKeyVaultAuthOptions::RuntimeConfigValues { + client_id, + client_secret, + tenant_id, + authority_host, + } => { + let credential = azure_identity::ClientSecretCredential::new( + http_client, + authority_host.into(), + tenant_id, + client_id, + client_secret, + ); + Arc::new(credential) as Arc + } + AzureKeyVaultAuthOptions::Environmental => azure_identity::create_default_credential()?, + }; + + Ok(Self { + secret_client: SecretClient::new(&vault_url.into(), token_credential)?, + }) + } +} + +#[async_trait] +impl Provider for AzureKeyVaultProvider { + #[instrument(name = "spin_variables.get_from_azure_key_vault", skip(self), err(level = Level::INFO), fields(otel.kind = "client"))] + async fn get(&self, key: &Key) -> anyhow::Result> { + let secret = self + .secret_client + .get(key.as_str()) + .await + .context("Failed to read variable from Azure Key Vault")?; + Ok(Some(secret.value)) + } +} + +impl From for Url { + fn from(value: AzureAuthorityHost) -> Self { + let url = match value { + AzureAuthorityHost::AzureChina => "https://login.chinacloudapi.cn/", + AzureAuthorityHost::AzureGovernment => "https://login.microsoftonline.us/", + AzureAuthorityHost::AzureGermany => "https://login.microsoftonline.de/", + AzureAuthorityHost::AzurePublicCloud => "https://login.microsoftonline.com/", + }; + Url::parse(url).unwrap() + } +} diff --git a/crates/factor-variables/src/spin_cli/mod.rs b/crates/factor-variables/src/spin_cli/mod.rs index f03a12472d..f86c52d4a2 100644 --- a/crates/factor-variables/src/spin_cli/mod.rs +++ b/crates/factor-variables/src/spin_cli/mod.rs @@ -1,9 +1,11 @@ //! The runtime configuration for the variables factor used in the Spin CLI. +mod azure_key_vault; mod env; mod statik; mod vault; +pub use azure_key_vault::*; pub use env::*; pub use statik::*; pub use vault::*; @@ -23,11 +25,11 @@ pub fn runtime_config_from_toml(table: &toml::Table) -> anyhow::Result = array.clone().try_into()?; - providers.extend( - provider_configs - .into_iter() - .map(VariableProviderConfiguration::into_provider), - ); + let new_providers = provider_configs + .into_iter() + .map(VariableProviderConfiguration::into_provider) + .collect::>>()?; + providers.extend(new_providers); Ok(RuntimeConfig { providers }) } @@ -35,6 +37,8 @@ pub fn runtime_config_from_toml(table: &toml::Table) -> anyhow::Result Box { - match self { + pub fn into_provider(self) -> anyhow::Result> { + let provider: Box = match self { VariableProviderConfiguration::Static(provider) => Box::new(provider), VariableProviderConfiguration::Env(config) => Box::new(env::EnvVariablesProvider::new( config.prefix, @@ -54,6 +58,10 @@ impl VariableProviderConfiguration { config.dotenv_path, )), VariableProviderConfiguration::Vault(provider) => Box::new(provider), - } + VariableProviderConfiguration::AzureKeyVault(config) => Box::new( + AzureKeyVaultProvider::create(config.vault_url.clone(), config.try_into()?)?, + ), + }; + Ok(provider) } }