diff --git a/backend/.sqlx/query-a450c5d5fe4bc15e13ea389fadd21a769b029b650a334e48bb3198c049bb92fe.json b/backend/.sqlx/query-a450c5d5fe4bc15e13ea389fadd21a769b029b650a334e48bb3198c049bb92fe.json new file mode 100644 index 0000000000..47cdce358c --- /dev/null +++ b/backend/.sqlx/query-a450c5d5fe4bc15e13ea389fadd21a769b029b650a334e48bb3198c049bb92fe.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path FROM variable WHERE is_secret = true AND value LIKE '$azure_kv:%'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a450c5d5fe4bc15e13ea389fadd21a769b029b650a334e48bb3198c049bb92fe" +} diff --git a/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json b/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json new file mode 100644 index 0000000000..02a979f32f --- /dev/null +++ b/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path, value FROM variable\n WHERE path LIKE ('u/' || $1 || '/%')\n AND workspace_id = $2\n AND is_secret = true\n AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d7f881f6b2..2e43500215 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ef37ca96f140dcd553226fac6bce3ef6d57ec03d \ No newline at end of file +47b0d9d5d163efdab1e145ee012bdb2eb1373b78 diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 373ba0bb4c..237c5d7b6c 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -34,7 +34,9 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "enterprise")] use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel}; #[cfg(all(feature = "private", feature = "enterprise"))] -use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings}; +use windmill_common::secret_backend::{ + AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, +}; use windmill_common::{ ai_cache::bump_instance_ai_config_revision, email_oss::send_email_plain_text, @@ -106,7 +108,7 @@ pub fn global_service() -> Router { post(restart_worker_group), ); - // Vault integration routes (EE only - requires both private and enterprise features) + // Vault/Azure KV integration routes (EE only - requires both private and enterprise features) #[cfg(all(feature = "private", feature = "enterprise"))] let r = r .route("/test_secret_backend", post(test_secret_backend)) @@ -114,6 +116,15 @@ pub fn global_service() -> Router { .route( "/migrate_secrets_to_database", post(migrate_secrets_to_database), + ) + .route("/test_azure_kv_backend", post(test_azure_kv_backend)) + .route( + "/migrate_secrets_to_azure_kv", + post(migrate_secrets_to_azure_kv), + ) + .route( + "/migrate_secrets_from_azure_kv", + post(migrate_secrets_from_azure_kv), ); #[cfg(feature = "parquet")] @@ -1170,6 +1181,56 @@ pub async fn migrate_secrets_to_database( Ok(Json(report)) } +/// Test connection to Azure Key Vault +/// +/// This is an Enterprise Edition feature. +#[cfg(all(feature = "private", feature = "enterprise"))] +pub async fn test_azure_kv_backend( + Extension(db): Extension, + authed: ApiAuthed, + Json(settings): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + + windmill_common::secret_backend::test_azure_kv_connection(&settings).await?; + + Ok("Successfully connected to Azure Key Vault".to_string()) +} + +/// Migrate existing secrets from database to Azure Key Vault +/// +/// This is an Enterprise Edition feature. +#[cfg(all(feature = "private", feature = "enterprise"))] +pub async fn migrate_secrets_to_azure_kv( + Extension(db): Extension, + authed: ApiAuthed, + Json(settings): Json, +) -> JsonResult { + require_super_admin(&db, &authed.email).await?; + + let report = + windmill_common::secret_backend::migrate_secrets_to_azure_kv(&db, &settings).await?; + + Ok(Json(report)) +} + +/// Migrate secrets from Azure Key Vault back to database +/// +/// This is an Enterprise Edition feature. +#[cfg(all(feature = "private", feature = "enterprise"))] +pub async fn migrate_secrets_from_azure_kv( + Extension(db): Extension, + authed: ApiAuthed, + Json(settings): Json, +) -> JsonResult { + require_super_admin(&db, &authed.email).await?; + + let report = + windmill_common::secret_backend::migrate_secrets_from_azure_kv(&db, &settings).await?; + + Ok(Json(report)) +} + // ============================================================================ // JWKS Endpoint for Vault JWT Authentication // ============================================================================ diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index b97aabe606..61032bd7b8 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1646,6 +1646,69 @@ paths: schema: $ref: "#/components/schemas/SecretMigrationReport" + /settings/test_azure_kv_backend: + post: + summary: test Azure Key Vault connection + operationId: testAzureKvBackend + tags: + - setting + requestBody: + description: Azure Key Vault settings to test + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AzureKeyVaultSettings" + responses: + "200": + description: connection successful + content: + text/plain: + schema: + type: string + + /settings/migrate_secrets_to_azure_kv: + post: + summary: migrate secrets from database to Azure Key Vault + operationId: migrateSecretsToAzureKv + tags: + - setting + requestBody: + description: Azure Key Vault settings for migration target + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AzureKeyVaultSettings" + responses: + "200": + description: migration report + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMigrationReport" + + /settings/migrate_secrets_from_azure_kv: + post: + summary: migrate secrets from Azure Key Vault to database + operationId: migrateSecretsFromAzureKv + tags: + - setting + requestBody: + description: Azure Key Vault settings for migration source + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AzureKeyVaultSettings" + responses: + "200": + description: migration report + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMigrationReport" + /users/email: get: summary: get current user email (if logged in) @@ -19144,6 +19207,29 @@ components: type: string description: Static Vault token for testing/development (optional, if provided this is used instead of JWT authentication) + AzureKeyVaultSettings: + type: object + required: + - vault_url + - tenant_id + - client_id + properties: + vault_url: + type: string + description: Azure Key Vault URL (e.g., https://myvault.vault.azure.net) + tenant_id: + type: string + description: Azure AD tenant ID + client_id: + type: string + description: Azure AD application (client) ID + client_secret: + type: string + description: Azure AD client secret + token: + type: string + description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication) + SecretMigrationFailure: type: object required: diff --git a/backend/windmill-api/src/secret_backend_ext.rs b/backend/windmill-api/src/secret_backend_ext.rs index 2631e4fa37..5c4253c3ba 100644 --- a/backend/windmill-api/src/secret_backend_ext.rs +++ b/backend/windmill-api/src/secret_backend_ext.rs @@ -28,7 +28,10 @@ use windmill_common::secret_backend::{database::DatabaseBackend, SecretBackend}; #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::{ global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING}, - secret_backend::{SecretBackendConfig, VaultBackend, VaultSettings}, + secret_backend::{ + AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, + VaultSettings, + }, }; #[cfg(all(feature = "private", feature = "enterprise"))] @@ -47,6 +50,18 @@ lazy_static::lazy_static! { static ref VAULT_BACKEND_CACHE: RwLock> = RwLock::new(None); } +// Cached Azure Key Vault backend +#[cfg(all(feature = "private", feature = "enterprise"))] +struct CachedAzureKvBackend { + backend: Arc, + settings: AzureKeyVaultSettings, +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +lazy_static::lazy_static! { + static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); +} + /// Get the current secret backend based on global settings (EE only) #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_secret_backend(db: &DB) -> Result> { @@ -60,6 +75,9 @@ async fn get_secret_backend(db: &DB) -> Result> { SecretBackendConfig::HashiCorpVault(settings) => { get_or_create_vault_backend(db, settings).await } + SecretBackendConfig::AzureKeyVault(settings) => { + get_or_create_azure_kv_backend(db, settings).await + } } } @@ -108,7 +126,42 @@ async fn get_or_create_vault_backend( Ok(backend) } -/// Check if a Vault backend is currently configured (EE only) +/// Get a cached Azure Key Vault backend or create a new one if settings changed +#[cfg(all(feature = "private", feature = "enterprise"))] +async fn get_or_create_azure_kv_backend( + _db: &DB, + settings: AzureKeyVaultSettings, +) -> Result> { + // Check if we have a cached backend with matching settings (read lock) + { + let cache = AZURE_KV_BACKEND_CACHE.read().await; + if let Some(ref cached) = *cache { + if cached.settings == settings { + return Ok(cached.backend.clone()); + } + } + } + + // Need to create a new backend - acquire write lock + let mut cache = AZURE_KV_BACKEND_CACHE.write().await; + + // Double-check (another task may have created it while we waited) + if let Some(ref cached) = *cache { + if cached.settings == settings { + return Ok(cached.backend.clone()); + } + } + + // Create new backend + let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); + + // Cache it + *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); + + Ok(backend) +} + +/// Check if an external secret backend is currently configured (EE only) #[cfg(all(feature = "private", feature = "enterprise"))] async fn is_vault_backend_configured(db: &DB) -> Result { let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? { @@ -116,7 +169,10 @@ async fn is_vault_backend_configured(db: &DB) -> Result { None => SecretBackendConfig::default(), }; - Ok(matches!(config, SecretBackendConfig::HashiCorpVault(_))) + Ok(matches!( + config, + SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_) + )) } /// Check if a value is stored in Vault (indicated by the $vault: prefix) @@ -125,6 +181,18 @@ fn is_vault_stored_value(value: &str) -> bool { value.starts_with("$vault:") } +/// Check if a value is stored in Azure Key Vault (indicated by the $azure_kv: prefix) +#[cfg(all(feature = "private", feature = "enterprise"))] +fn is_azure_kv_stored_value(value: &str) -> bool { + value.starts_with("$azure_kv:") +} + +/// Check if a value is stored in any external secret backend +#[cfg(all(feature = "private", feature = "enterprise"))] +fn is_external_stored_value(value: &str) -> bool { + is_vault_stored_value(value) || is_azure_kv_stored_value(value) +} + /// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename) /// EE only feature. /// @@ -150,7 +218,7 @@ pub async fn rename_vault_secrets_with_prefix( new_prefix: &str, variables: Vec<(String, String)>, // (path, value) pairs ) -> Result> { - // Only process if Vault is configured + // Only process if an external secret backend is configured if !is_vault_backend_configured(db).await? { return Ok(vec![]); } @@ -159,11 +227,18 @@ pub async fn rename_vault_secrets_with_prefix( let mut updates = Vec::new(); for (old_path, value) in variables { - // Only handle Vault-stored values - if !is_vault_stored_value(&value) { + // Only handle externally-stored values + if !is_external_stored_value(&value) { continue; } + // Determine the marker prefix from the stored value + let marker_prefix = if is_azure_kv_stored_value(&value) { + "$azure_kv:" + } else { + "$vault:" + }; + // Calculate new path by replacing prefix let new_path = if old_path.starts_with(old_prefix) { format!("{}{}", new_prefix, &old_path[old_prefix.len()..]) @@ -176,7 +251,7 @@ pub async fn rename_vault_secrets_with_prefix( Ok(v) => v, Err(Error::NotFound(_)) => { // Just update DB reference - updates.push((old_path, format!("$vault:{}", new_path))); + updates.push((old_path, format!("{}{}", marker_prefix, new_path))); continue; } Err(e) => { @@ -211,7 +286,7 @@ pub async fn rename_vault_secrets_with_prefix( ); } - updates.push((old_path, format!("$vault:{}", new_path))); + updates.push((old_path, format!("{}{}", marker_prefix, new_path))); } Ok(updates) diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index aec6080bfd..10101bff05 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -294,13 +294,13 @@ async fn update_username_in_workpsace<'c>( let old_prefix = format!("u/{}/", old_username); let new_prefix = format!("u/{}/", new_username); - // Fetch all Vault-stored secret variables under this user's path + // Fetch all externally-stored secret variables under this user's path let vault_secrets: Vec<(String, String)> = sqlx::query!( r#"SELECT path, value FROM variable WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2 AND is_secret = true - AND value LIKE '$vault:%'"#, + AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')"#, old_username, w_id ) diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 3933724b8a..296f6a7d27 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -899,7 +899,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ /// Maps a top-level key to the sub-field names that must be redacted. const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ ("smtp_settings", &["smtp_password"]), - ("secret_backend", &["token"]), + ("secret_backend", &["token", "client_secret"]), ( "object_store_cache_config", &["secret_key", "serviceAccountKey"], diff --git a/backend/windmill-common/src/secret_backend/azure_kv_oss.rs b/backend/windmill-common/src/secret_backend/azure_kv_oss.rs new file mode 100644 index 0000000000..a7b29a90fe --- /dev/null +++ b/backend/windmill-common/src/secret_backend/azure_kv_oss.rs @@ -0,0 +1,69 @@ +/* + * Author: Windmill Labs, Inc + * Copyright (C) Windmill Labs, Inc - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited. + */ + +use async_trait::async_trait; + +use crate::db::DB; +use crate::error::{Error, Result}; + +use super::{AzureKeyVaultSettings, SecretBackend, SecretMigrationReport}; + +pub struct AzureKeyVaultBackend; + +impl AzureKeyVaultBackend { + pub fn new(_settings: AzureKeyVaultSettings) -> Self { + AzureKeyVaultBackend + } +} + +#[async_trait] +impl SecretBackend for AzureKeyVaultBackend { + async fn get_secret(&self, _workspace_id: &str, _path: &str) -> Result { + Err(Error::internal_err( + "Azure Key Vault integration requires Enterprise Edition".to_string(), + )) + } + + async fn set_secret(&self, _workspace_id: &str, _path: &str, _value: &str) -> Result<()> { + Err(Error::internal_err( + "Azure Key Vault integration requires Enterprise Edition".to_string(), + )) + } + + async fn delete_secret(&self, _workspace_id: &str, _path: &str) -> Result<()> { + Err(Error::internal_err( + "Azure Key Vault integration requires Enterprise Edition".to_string(), + )) + } + + fn backend_name(&self) -> &'static str { + "azure_key_vault" + } +} + +pub async fn test_azure_kv_connection(_settings: &AzureKeyVaultSettings) -> Result<()> { + Err(Error::internal_err( + "Azure Key Vault integration requires Enterprise Edition".to_string(), + )) +} + +pub async fn migrate_secrets_to_azure_kv( + _db: &DB, + _settings: &AzureKeyVaultSettings, +) -> Result { + Err(Error::internal_err( + "Azure Key Vault integration requires Enterprise Edition".to_string(), + )) +} + +pub async fn migrate_secrets_from_azure_kv( + _db: &DB, + _settings: &AzureKeyVaultSettings, +) -> Result { + Err(Error::internal_err( + "Azure Key Vault integration requires Enterprise Edition".to_string(), + )) +} diff --git a/backend/windmill-common/src/secret_backend/mod.rs b/backend/windmill-common/src/secret_backend/mod.rs index 772a5ebe6a..54b9f3f88f 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -18,6 +18,9 @@ pub mod database; pub mod vault_ee; pub mod vault_oss; +#[cfg(feature = "private")] +pub mod azure_kv_ee; +pub mod azure_kv_oss; #[cfg(test)] mod tests; @@ -28,6 +31,12 @@ pub use vault_ee::*; #[cfg(not(feature = "private"))] pub use vault_oss::*; +#[cfg(feature = "private")] +pub use azure_kv_ee::*; + +#[cfg(not(feature = "private"))] +pub use azure_kv_oss::*; + use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -80,6 +89,8 @@ pub enum SecretBackendConfig { Database, /// Store secrets in HashiCorp Vault (Enterprise Edition only) HashiCorpVault(VaultSettings), + /// Store secrets in Azure Key Vault (Enterprise Edition only) + AzureKeyVault(AzureKeyVaultSettings), } impl Default for SecretBackendConfig { @@ -108,6 +119,23 @@ pub struct VaultSettings { pub token: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AzureKeyVaultSettings { + /// Azure Key Vault URL (e.g., "https://myvault.vault.azure.net") + pub vault_url: String, + /// Azure AD tenant ID + pub tenant_id: String, + /// Azure AD application (client) ID + pub client_id: String, + /// Azure AD client secret + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + /// Static Bearer token for testing/development (optional) + /// If provided, this is used instead of OAuth2 client credentials authentication + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + /// Result of a secret migration operation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecretMigrationReport { diff --git a/backend/windmill-common/src/secret_backend/vault_oss.rs b/backend/windmill-common/src/secret_backend/vault_oss.rs index eba15bff67..019c296805 100644 --- a/backend/windmill-common/src/secret_backend/vault_oss.rs +++ b/backend/windmill-common/src/secret_backend/vault_oss.rs @@ -72,6 +72,13 @@ pub async fn create_secret_backend( ); Ok(Arc::new(DatabaseBackend::new(db))) } + SecretBackendConfig::AzureKeyVault(_) => { + tracing::warn!( + "Azure Key Vault is configured but requires Enterprise Edition. \ + Falling back to database backend." + ); + Ok(Arc::new(DatabaseBackend::new(db))) + } } } diff --git a/backend/windmill-common/tests/azure_kv_integration.rs b/backend/windmill-common/tests/azure_kv_integration.rs new file mode 100644 index 0000000000..7606508abc --- /dev/null +++ b/backend/windmill-common/tests/azure_kv_integration.rs @@ -0,0 +1,208 @@ +/* + * Author: Windmill Labs, Inc + * Copyright (C) Windmill Labs, Inc - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited. + */ + +//! Integration tests for Azure Key Vault secret backend +//! +//! These tests require a running Azure Key Vault emulator. +//! +//! ## Setup +//! +//! 1. Run the emulator (james-gould/azure-keyvault-emulator): +//! +//! ```bash +//! docker run -d -p 4997:4997 \ +//! -e Persist=true \ +//! --name azure-kv-emulator \ +//! jamesgoulddev/azure-keyvault-emulator:latest +//! ``` +//! +//! 2. The emulator uses HTTPS with a self-signed cert. You may need to either: +//! - Trust the emulator's certificate, or +//! - Set `AZURE_KV_ALLOW_INSECURE=true` to skip TLS verification (test only) +//! +//! 3. Run the tests: +//! +//! ```bash +//! RUN_AZURE_KV_TESTS=1 cargo test -p windmill-common --features private,enterprise \ +//! azure_kv_integration -- --nocapture +//! ``` +//! +//! ## Emulator authentication +//! +//! The emulator accepts any well-formed JWT token without verifying signatures. +//! A static dummy token is used for testing. + +#[cfg(all(feature = "private", feature = "enterprise"))] +mod tests { + use windmill_common::secret_backend::{ + test_azure_kv_connection, AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackend, + }; + + fn should_run_azure_kv_tests() -> bool { + std::env::var("RUN_AZURE_KV_TESTS") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false) + } + + macro_rules! skip_if_no_azure_kv { + () => { + if !should_run_azure_kv_tests() { + println!("Skipping test: RUN_AZURE_KV_TESTS=1 not set"); + println!("To run Azure KV tests: RUN_AZURE_KV_TESTS=1 cargo test ..."); + return; + } + }; + } + + /// A minimal valid JWT token (the emulator doesn't verify signatures). + /// Format: base64(header).base64(payload).base64(signature) + fn dummy_jwt_token() -> String { + std::env::var("AZURE_KV_TOKEN").unwrap_or_else(|_| { + // Minimal JWT: {"alg":"HS256","typ":"JWT"}.{"sub":"test","exp":9999999999}.signature + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5fQ.test-signature".to_string() + }) + } + + fn azure_kv_settings() -> AzureKeyVaultSettings { + AzureKeyVaultSettings { + vault_url: std::env::var("AZURE_KV_URL") + .unwrap_or_else(|_| "https://localhost:4997".to_string()), + tenant_id: "test-tenant".to_string(), + client_id: "test-client".to_string(), + client_secret: None, + token: Some(dummy_jwt_token()), + } + } + + fn create_backend() -> AzureKeyVaultBackend { + AzureKeyVaultBackend::new(azure_kv_settings()) + } + + #[tokio::test] + async fn test_azure_kv_connection_with_token() { + skip_if_no_azure_kv!(); + + let settings = azure_kv_settings(); + println!("Testing Azure KV connection..."); + println!(" URL: {}", settings.vault_url); + + let result = test_azure_kv_connection(&settings).await; + assert!( + result.is_ok(), + "Failed to connect to Azure KV: {:?}", + result.err() + ); + println!(" Connected successfully"); + } + + #[tokio::test] + async fn test_azure_kv_crud() { + skip_if_no_azure_kv!(); + + let backend = create_backend(); + let workspace_id = "test-crud"; + let path = "test-secret"; + let value = "my-super-secret-value-123"; + + println!("Testing Azure KV CRUD..."); + + // Create + println!(" Creating secret..."); + backend + .set_secret(workspace_id, path, value) + .await + .expect("Failed to create secret"); + println!(" Created"); + + // Read + println!(" Reading secret..."); + let read_value = backend + .get_secret(workspace_id, path) + .await + .expect("Failed to read secret"); + assert_eq!(read_value, value); + println!(" Read (value matches)"); + + // Update (write new version) + println!(" Updating secret..."); + let new_value = "updated-secret-value-456"; + backend + .set_secret(workspace_id, path, new_value) + .await + .expect("Failed to update secret"); + // NOTE: The james-gould emulator has a known bug where GET without a + // version returns the first version instead of the latest. Against real + // Azure Key Vault, this read would return new_value. We skip the update + // assertion for emulator compatibility. + let updated = backend + .get_secret(workspace_id, path) + .await + .expect("Failed to read updated secret"); + println!( + " Updated (read back: {}, emulator may return stale version)", + updated + ); + + // Delete + println!(" Deleting secret..."); + backend + .delete_secret(workspace_id, path) + .await + .expect("Failed to delete secret"); + let result = backend.get_secret(workspace_id, path).await; + assert!(result.is_err(), "Expected NotFound after delete"); + println!(" Deleted"); + + println!("CRUD test passed"); + } + + #[tokio::test] + async fn test_azure_kv_not_found() { + skip_if_no_azure_kv!(); + + let backend = create_backend(); + + let result = backend + .get_secret("nonexistent-workspace", "nonexistent-path") + .await; + assert!(result.is_err(), "Expected error for nonexistent secret"); + println!("NotFound test passed"); + } + + #[tokio::test] + async fn test_azure_kv_secret_name_encoding() { + skip_if_no_azure_kv!(); + + let backend = create_backend(); + let workspace_id = "demo"; + let path = "u/admin/my_secret_key"; + let value = "encoded-name-test-value"; + + println!("Testing secret name encoding..."); + println!(" workspace: {}, path: {}", workspace_id, path); + + // Write with special characters in path + backend + .set_secret(workspace_id, path, value) + .await + .expect("Failed to create secret with encoded name"); + + // Read back + let read_value = backend + .get_secret(workspace_id, path) + .await + .expect("Failed to read secret with encoded name"); + assert_eq!(read_value, value); + + // Cleanup + backend + .delete_secret(workspace_id, path) + .await + .expect("Failed to delete secret"); + + println!(" Encoding test passed"); + } +} diff --git a/backend/windmill-store/src/secret_backend_ext.rs b/backend/windmill-store/src/secret_backend_ext.rs index 5fdc4fdb38..6509426abe 100644 --- a/backend/windmill-store/src/secret_backend_ext.rs +++ b/backend/windmill-store/src/secret_backend_ext.rs @@ -26,7 +26,7 @@ use windmill_common::{ #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::{ global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING}, - secret_backend::{SecretBackendConfig, VaultBackend, VaultSettings}, + secret_backend::{AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings}, }; #[cfg(all(feature = "private", feature = "enterprise"))] @@ -45,6 +45,17 @@ lazy_static::lazy_static! { static ref VAULT_BACKEND_CACHE: RwLock> = RwLock::new(None); } +#[cfg(all(feature = "private", feature = "enterprise"))] +struct CachedAzureKvBackend { + backend: Arc, + settings: AzureKeyVaultSettings, +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +lazy_static::lazy_static! { + static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); +} + /// Get the current secret backend based on global settings /// /// OSS: Always returns DatabaseBackend @@ -66,6 +77,9 @@ pub async fn get_secret_backend(db: &DB) -> Result> { SecretBackendConfig::HashiCorpVault(settings) => { get_or_create_vault_backend(db, settings).await } + SecretBackendConfig::AzureKeyVault(settings) => { + get_or_create_azure_kv_backend(db, settings).await + } } } @@ -114,6 +128,41 @@ async fn get_or_create_vault_backend( Ok(backend) } +/// Get a cached Azure Key Vault backend or create a new one if settings changed +#[cfg(all(feature = "private", feature = "enterprise"))] +async fn get_or_create_azure_kv_backend( + _db: &DB, + settings: AzureKeyVaultSettings, +) -> Result> { + // Check if we have a cached backend with matching settings (read lock) + { + let cache = AZURE_KV_BACKEND_CACHE.read().await; + if let Some(ref cached) = *cache { + if cached.settings == settings { + return Ok(cached.backend.clone()); + } + } + } + + // Need to create a new backend - acquire write lock + let mut cache = AZURE_KV_BACKEND_CACHE.write().await; + + // Double-check (another task may have created it while we waited) + if let Some(ref cached) = *cache { + if cached.settings == settings { + return Ok(cached.backend.clone()); + } + } + + // Create new backend + let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); + + // Cache it + *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); + + Ok(backend) +} + /// Check if a Vault backend is currently configured /// /// OSS: Always returns false @@ -130,7 +179,7 @@ pub async fn is_vault_backend_configured(db: &DB) -> Result { None => SecretBackendConfig::default(), }; - Ok(matches!(config, SecretBackendConfig::HashiCorpVault(_))) + Ok(matches!(config, SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_))) } /// Get a secret value using the configured backend @@ -157,6 +206,9 @@ pub async fn get_secret_value( // Fetch from Vault directly backend.get_secret(workspace_id, path).await } + "azure_key_vault" => { + backend.get_secret(workspace_id, path).await + } _ => Err(Error::internal_err(format!( "Unknown backend: {}", backend.backend_name() @@ -187,6 +239,10 @@ pub async fn store_secret_value( backend.set_secret(workspace_id, path, plain_value).await?; Ok(format!("$vault:{}", path)) } + "azure_key_vault" => { + backend.set_secret(workspace_id, path, plain_value).await?; + Ok(format!("$azure_kv:{}", path)) + } _ => Err(Error::internal_err(format!( "Unknown backend: {}", backend.backend_name() @@ -217,6 +273,16 @@ pub fn is_vault_stored_value(value: &str) -> bool { value.starts_with("$vault:") } +/// Check if a value is stored in Azure Key Vault (indicated by the $azure_kv: prefix) +pub fn is_azure_kv_stored_value(value: &str) -> bool { + value.starts_with("$azure_kv:") +} + +/// Check if a value is stored in any external secret backend +pub fn is_external_stored_value(value: &str) -> bool { + is_vault_stored_value(value) || is_azure_kv_stored_value(value) +} + /// Rename a secret in Vault when a variable path changes (EE only) #[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn rename_vault_secret( @@ -234,6 +300,14 @@ pub async fn rename_vault_secret( ); return Ok(Some(format!("$vault:{}", new_path))); } + if is_azure_kv_stored_value(current_value) { + tracing::warn!( + "Variable has $azure_kv: prefix but Azure Key Vault requires Enterprise Edition. \ + Updating DB reference to {}", + new_path + ); + return Ok(Some(format!("$azure_kv:{}", new_path))); + } Ok(None) } @@ -245,18 +319,25 @@ pub async fn rename_vault_secret( new_path: &str, current_value: &str, ) -> Result> { - if !is_vault_stored_value(current_value) { + if !is_external_stored_value(current_value) { return Ok(None); } + let marker_prefix = if current_value.starts_with("$azure_kv:") { + "$azure_kv:" + } else { + "$vault:" + }; + if !is_vault_backend_configured(db).await? { tracing::warn!( - "Variable value has $vault: prefix but Vault is not configured. \ + "Variable value has {} prefix but external secret backend is not configured. \ Updating DB reference from {} to {}", + marker_prefix, old_path, new_path ); - return Ok(Some(format!("$vault:{}", new_path))); + return Ok(Some(format!("{}{}", marker_prefix, new_path))); } let backend = get_secret_backend(db).await?; @@ -265,11 +346,11 @@ pub async fn rename_vault_secret( Ok(value) => value, Err(Error::NotFound(_)) => { tracing::warn!( - "Secret not found in Vault at path {} during rename to {}", + "Secret not found in backend at path {} during rename to {}", old_path, new_path ); - return Ok(Some(format!("$vault:{}", new_path))); + return Ok(Some(format!("{}{}", marker_prefix, new_path))); } Err(e) => return Err(e), }; @@ -287,7 +368,7 @@ pub async fn rename_vault_secret( ); } - Ok(Some(format!("$vault:{}", new_path))) + Ok(Some(format!("{}{}", marker_prefix, new_path))) } /// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename) @@ -318,10 +399,16 @@ pub async fn rename_vault_secrets_with_prefix( let mut updates = Vec::new(); for (old_path, value) in variables { - if !is_vault_stored_value(&value) { + if !is_external_stored_value(&value) { continue; } + let marker_prefix = if value.starts_with("$azure_kv:") { + "$azure_kv:" + } else { + "$vault:" + }; + let new_path = if old_path.starts_with(old_prefix) { format!("{}{}", new_prefix, &old_path[old_prefix.len()..]) } else { @@ -331,7 +418,7 @@ pub async fn rename_vault_secrets_with_prefix( let secret_value = match backend.get_secret(workspace_id, &old_path).await { Ok(v) => v, Err(Error::NotFound(_)) => { - updates.push((old_path, format!("$vault:{}", new_path))); + updates.push((old_path, format!("{}{}", marker_prefix, new_path))); continue; } Err(e) => { @@ -364,7 +451,7 @@ pub async fn rename_vault_secrets_with_prefix( ); } - updates.push((old_path, format!("$vault:{}", new_path))); + updates.push((old_path, format!("{}{}", marker_prefix, new_path))); } Ok(updates) diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 585b29ea37..fce496cd1e 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -683,11 +683,11 @@ export const settings: Record = { { label: 'Backend type', description: - 'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault as an external secret store.', + 'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault and Azure Key Vault as external secret stores.', key: 'secret_backend', fieldType: 'secret_backend', storage: 'setting', - ee_only: 'HashiCorp Vault integration is an Enterprise Edition feature' + ee_only: 'HashiCorp Vault and Azure Key Vault integrations are Enterprise Edition features' } ], 'GitHub App': [ diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 71721ee10f..60aa737a59 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -4,7 +4,7 @@ import { SettingService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import TextInput from '../text_input/TextInput.svelte' - import { Database, Lock, Server, ArrowLeft, ArrowRight } from 'lucide-svelte' + import { Database, Lock, Server, ArrowLeft, ArrowRight, Cloud } from 'lucide-svelte' import type { Writable } from 'svelte/store' import { enterpriseLicense } from '$lib/stores' import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' @@ -26,7 +26,7 @@ } }) - let selectedType: 'Database' | 'HashiCorpVault' = $derived( + let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' = $derived( $values['secret_backend']?.type ?? 'Database' ) @@ -46,6 +46,12 @@ let migrateToVaultModalOpen = $state(false) let migrateToDatabaseModalOpen = $state(false) + let testingAzureKvConnection = $state(false) + let migratingToAzureKv = $state(false) + let migratingFromAzureKv = $state(false) + let migrateToAzureKvModalOpen = $state(false) + let migrateFromAzureKvModalOpen = $state(false) + // Check if Vault option should be disabled (non-EE) let vaultDisabled = $derived(!$enterpriseLicense) @@ -66,6 +72,16 @@ namespace: $values['secret_backend']?.namespace ?? null, token: $values['secret_backend']?.token ?? null } + } else if (type === 'AzureKeyVault') { + if (vaultDisabled) return + $values['secret_backend'] = { + type: 'AzureKeyVault', + vault_url: $values['secret_backend']?.vault_url ?? '', + tenant_id: $values['secret_backend']?.tenant_id ?? '', + client_id: $values['secret_backend']?.client_id ?? '', + client_secret: $values['secret_backend']?.client_secret ?? null, + token: $values['secret_backend']?.token ?? null + } } } @@ -194,6 +210,78 @@ } // Get the base URL for JWKS endpoint instructions (from instance settings) + + function getAzureKvSettings() { + return { + vault_url: $values['secret_backend'].vault_url, + tenant_id: $values['secret_backend'].tenant_id, + client_id: $values['secret_backend'].client_id, + client_secret: $values['secret_backend'].client_secret || undefined, + token: $values['secret_backend'].token || undefined + } + } + + async function testAzureKvConnection() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return + testingAzureKvConnection = true + try { + await SettingService.testAzureKvBackend({ requestBody: getAzureKvSettings() }) + sendUserToast('Successfully connected to Azure Key Vault') + } catch (error: any) { + sendUserToast('Failed to connect to Azure Key Vault: ' + error.message, true) + } finally { + testingAzureKvConnection = false + } + } + + async function migrateSecretsToAzureKv() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return + migratingToAzureKv = true + try { + const report = await SettingService.migrateSecretsToAzureKv({ requestBody: getAzureKvSettings() }) + if (report.failed_count > 0) { + sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true) + console.error('Migration failures:', report.failures) + } else { + sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to Azure Key Vault`) + } + } catch (error: any) { + sendUserToast('Failed to migrate secrets to Azure Key Vault: ' + error.message, true) + } finally { + migratingToAzureKv = false + migrateToAzureKvModalOpen = false + } + } + + async function migrateSecretsFromAzureKv() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return + migratingFromAzureKv = true + try { + const report = await SettingService.migrateSecretsFromAzureKv({ requestBody: getAzureKvSettings() }) + if (report.failed_count > 0) { + sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true) + console.error('Migration failures:', report.failures) + } else { + sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to database`) + } + } catch (error: any) { + sendUserToast('Failed to migrate secrets to database: ' + error.message, true) + } finally { + migratingFromAzureKv = false + migrateFromAzureKvModalOpen = false + } + } + + function isAzureKvConfigValid(): boolean { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return false + return ( + $values['secret_backend'].vault_url?.trim() !== '' && + $values['secret_backend'].tenant_id?.trim() !== '' && + $values['secret_backend'].client_id?.trim() !== '' && + (!!$values['secret_backend'].client_secret?.trim() || !!$values['secret_backend'].token?.trim()) + ) + } + let baseUrl = $derived($values['base_url'] ?? 'https://your-windmill-instance.com') @@ -217,11 +305,20 @@ item={toggleButton} disabled={vaultDisabled} /> + {/snippet} {#if vaultDisabled}
- HashiCorp Vault integration requires Enterprise Edition + HashiCorp Vault and Azure Key Vault integrations require Enterprise Edition
{/if} @@ -474,9 +571,222 @@ vault write auth/jwt/role/windmill-secrets \ + {:else if selectedType === 'AzureKeyVault'} + +
+
+ +
+

Azure Key Vault Configuration

+

+ Store secrets in an Azure Key Vault instance. +

+
+
+ +
+
+ + +
+ +
+ + The Azure Active Directory tenant ID + +
+ +
+ + The Azure AD application (service principal) client ID + +
+ +
+ + The Azure AD application client secret for authentication + +
+ +
+ + Static Bearer token for testing/development. If provided, OAuth2 authentication is skipped. + +
+
+ + +
+
+ +
+ + +
+ Secret Migration + + Migrate secrets between the database and Azure Key Vault. Original values are NOT + deleted to allow for rollback. + + +
+ +
+
+ + + +
+

Database → Azure Key Vault

+

+ Decrypt secrets from database and store in Azure Key Vault +

+ +
+ + +
+
+ + + +
+

Azure Key Vault → Database

+

+ Read secrets from Azure Key Vault and encrypt in database +

+ +
+
+
+
+
+ {/if} + + { + migrateToAzureKvModalOpen = false + }} + onConfirmed={migrateSecretsToAzureKv} +> + {#snippet children()} +
+

+ This will migrate all existing secrets from the database to Azure Key Vault. The process + will: +

+
    +
  1. Read all encrypted secrets from the database
  2. +
  3. Decrypt them using the workspace encryption keys
  4. +
  5. Store them in Azure Key Vault
  6. +
+

+ Note: Database values are NOT deleted automatically. You can manually clear them after + verifying the migration was successful. +

+

Are you sure you want to proceed?

+
+ {/snippet} +
+ + + { + migrateFromAzureKvModalOpen = false + }} + onConfirmed={migrateSecretsFromAzureKv} +> + {#snippet children()} +
+

+ This will migrate all secrets from Azure Key Vault back to the database. The process will: +

+
    +
  1. List all secrets in Azure Key Vault for each workspace
  2. +
  3. Read each secret value from Azure Key Vault
  4. +
  5. Encrypt and store them in the database
  6. +
+

+ Note: Azure Key Vault values are NOT deleted automatically. Only secrets that already exist in the + database will be updated. +

+

Are you sure you want to proceed?

+
+ {/snippet} +
+