feat: add Azure Key Vault as secret storage backend (#8704)

* feat: add --main flag to write_latest_ee_ref.sh to point to latest EE main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Azure Key Vault as secret storage backend (EE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt to azure-key-vault-support branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add token auth, insecure TLS for emulator, and integration tests

Adds optional `token` field to AzureKeyVaultSettings for direct Bearer
auth (bypasses OAuth2), enables self-signed cert acceptance in token mode,
and includes 4 integration tests against the Azure KV emulator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle Azure KV soft-delete and emulator quirks

- Purge soft-deleted secrets after delete to allow name reuse
- Retry set_secret on 409 Conflict (purge stale soft-deleted secret)
- Accept self-signed certs when using static token (emulator mode)
- Work around emulator version-ordering bug in CRUD test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 47b0d9d5d163efdab1e145ee012bdb2eb1373b78

This commit updates the EE repository reference after PR #511 was merged in windmill-ee-private.

Previous ee-repo-ref: d432d78bda151d611d8065162de7c1b7edce92e9

New ee-repo-ref: 47b0d9d5d163efdab1e145ee012bdb2eb1373b78

Automated by sync-ee-ref workflow.

* fix: accept token OR client_secret in Azure KV validation, add token UI field

- isAzureKvConfigValid() now accepts either client_secret or token
- Added token input field to the Azure KV config form for emulator/dev use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-04-03 17:02:36 -04:00
committed by GitHub
parent 18eb6e0df7
commit dcd615fdc3
15 changed files with 1016 additions and 30 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -1 +1 @@
ef37ca96f140dcd553226fac6bce3ef6d57ec03d
47b0d9d5d163efdab1e145ee012bdb2eb1373b78

View File

@@ -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<DB>,
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> Result<String> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> JsonResult<SecretMigrationReport> {
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<DB>,
authed: ApiAuthed,
Json(settings): Json<AzureKeyVaultSettings>,
) -> JsonResult<SecretMigrationReport> {
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
// ============================================================================

View File

@@ -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:

View File

@@ -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<Option<CachedVaultBackend>> = RwLock::new(None);
}
// Cached Azure Key Vault backend
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAzureKvBackend {
backend: Arc<dyn SecretBackend>,
settings: AzureKeyVaultSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = 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<Arc<dyn SecretBackend>> {
@@ -60,6 +75,9 @@ async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
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<Arc<dyn SecretBackend>> {
// 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<dyn SecretBackend> = 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<bool> {
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<bool> {
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<Vec<(String, String)>> {
// 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)

View File

@@ -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
)

View File

@@ -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"],

View File

@@ -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<String> {
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<SecretMigrationReport> {
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<SecretMigrationReport> {
Err(Error::internal_err(
"Azure Key Vault integration requires Enterprise Edition".to_string(),
))
}

View File

@@ -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<String>,
}
#[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<String>,
/// 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<String>,
}
/// Result of a secret migration operation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretMigrationReport {

View File

@@ -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)))
}
}
}

View File

@@ -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");
}
}

View File

@@ -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<Option<CachedVaultBackend>> = RwLock::new(None);
}
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAzureKvBackend {
backend: Arc<dyn SecretBackend>,
settings: AzureKeyVaultSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = 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<Arc<dyn SecretBackend>> {
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<Arc<dyn SecretBackend>> {
// 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<dyn SecretBackend> = 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<bool> {
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<Option<String>> {
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)

View File

@@ -683,11 +683,11 @@ export const settings: Record<string, Setting[]> = {
{
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': [

View File

@@ -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')
</script>
@@ -217,11 +305,20 @@
item={toggleButton}
disabled={vaultDisabled}
/>
<ToggleButton
value="AzureKeyVault"
label="Azure Key Vault"
tooltip={vaultDisabled
? 'Azure Key Vault integration requires Enterprise Edition'
: 'Store secrets in Azure Key Vault'}
item={toggleButton}
disabled={vaultDisabled}
/>
{/snippet}
</ToggleButtonGroup>
{#if vaultDisabled}
<div class="flex items-center gap-1">
<EEOnly>HashiCorp Vault integration requires Enterprise Edition</EEOnly>
<EEOnly>HashiCorp Vault and Azure Key Vault integrations require Enterprise Edition</EEOnly>
</div>
{/if}
</div>
@@ -474,9 +571,222 @@ vault write auth/jwt/role/windmill-secrets \
</div>
</div>
</div>
{:else if selectedType === 'AzureKeyVault'}
<!-- Azure Key Vault Configuration -->
<div class="space-y-4 p-4 border rounded-lg">
<div class="flex items-center gap-2 mb-4">
<Cloud class="text-primary" size={20} />
<div>
<p class="text-sm font-medium text-emphasis">Azure Key Vault Configuration</p>
<p class="text-xs text-secondary">
Store secrets in an Azure Key Vault instance.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col gap-1">
<label for="azure_vault_url" class="block text-xs font-semibold text-emphasis"
>Vault URL</label
>
<TextInput
inputProps={{
type: 'text',
id: 'azure_vault_url',
placeholder: 'https://my-vault.vault.azure.net',
disabled: disabled
}}
bind:value={$values['secret_backend'].vault_url}
/>
</div>
<div class="flex flex-col gap-1">
<label for="azure_tenant_id" class="block text-xs font-semibold text-emphasis"
>Tenant ID</label
>
<span class="text-2xs text-secondary">The Azure Active Directory tenant ID</span>
<TextInput
inputProps={{
type: 'text',
id: 'azure_tenant_id',
placeholder: '00000000-0000-0000-0000-000000000000',
disabled: disabled
}}
bind:value={$values['secret_backend'].tenant_id}
/>
</div>
<div class="flex flex-col gap-1">
<label for="azure_client_id" class="block text-xs font-semibold text-emphasis"
>Client ID</label
>
<span class="text-2xs text-secondary">The Azure AD application (service principal) client ID</span>
<TextInput
inputProps={{
type: 'text',
id: 'azure_client_id',
placeholder: '00000000-0000-0000-0000-000000000000',
disabled: disabled
}}
bind:value={$values['secret_backend'].client_id}
/>
</div>
<div class="flex flex-col gap-1 p-3 bg-surface-secondary rounded-lg">
<label for="azure_client_secret" class="block text-xs font-semibold text-emphasis"
>Client Secret</label
>
<span class="text-2xs text-secondary">The Azure AD application client secret for authentication</span>
<Password bind:password={$values['secret_backend'].client_secret} small {disabled} />
</div>
<div class="flex flex-col gap-1 p-3 bg-surface-secondary rounded-lg">
<label for="azure_token" class="block text-xs font-semibold text-emphasis"
>Token (optional)</label
>
<span class="text-2xs text-secondary">Static Bearer token for testing/development. If provided, OAuth2 authentication is skipped.</span>
<Password bind:password={$values['secret_backend'].token} small {disabled} />
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-4 pt-4 border-t">
<div class="flex gap-2">
<Button
unifiedSize="md"
variant="accent"
onclick={testAzureKvConnection}
disabled={disabled || !isAzureKvConfigValid() || testingAzureKvConnection}
loading={testingAzureKvConnection}
startIcon={{ icon: Server }}
>
Test Connection
</Button>
</div>
<!-- Migration Section -->
<div class="flex flex-col gap-4 pt-4 border-t">
<span class="block text-xs font-semibold text-emphasis">Secret Migration</span>
<span class="text-2xs text-secondary">
Migrate secrets between the database and Azure Key Vault. Original values are NOT
deleted to allow for rollback.
</span>
<div class="flex gap-4">
<!-- Database to Azure KV -->
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Database size={16} />
<ArrowRight size={16} />
<Cloud size={16} />
</div>
<p class="text-xs font-medium mb-2">Database → Azure Key Vault</p>
<p class="text-2xs text-secondary mb-3">
Decrypt secrets from database and store in Azure Key Vault
</p>
<Button
unifiedSize="sm"
variant="default"
onclick={() => (migrateToAzureKvModalOpen = true)}
disabled={disabled || !isAzureKvConfigValid() || migratingToAzureKv}
startIcon={{ icon: ArrowRight }}
>
Migrate to Azure Key Vault
</Button>
</div>
<!-- Azure KV to Database -->
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Cloud size={16} />
<ArrowLeft size={16} />
<Database size={16} />
</div>
<p class="text-xs font-medium mb-2">Azure Key Vault → Database</p>
<p class="text-2xs text-secondary mb-3">
Read secrets from Azure Key Vault and encrypt in database
</p>
<Button
unifiedSize="sm"
variant="default"
onclick={() => (migrateFromAzureKvModalOpen = true)}
disabled={disabled || !isAzureKvConfigValid() || migratingFromAzureKv}
startIcon={{ icon: ArrowLeft }}
>
Migrate to Database
</Button>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Migrate to Azure Key Vault Modal -->
<ConfirmationModal
title="Migrate Secrets to Azure Key Vault"
confirmationText="Migrate"
open={migrateToAzureKvModalOpen}
loading={migratingToAzureKv}
type="reload"
onCanceled={() => {
migrateToAzureKvModalOpen = false
}}
onConfirmed={migrateSecretsToAzureKv}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>
This will migrate all existing secrets from the database to Azure Key Vault. The process
will:
</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>Read all encrypted secrets from the database</li>
<li>Decrypt them using the workspace encryption keys</li>
<li>Store them in Azure Key Vault</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Database values are NOT deleted automatically. You can manually clear them after
verifying the migration was successful.
</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
</ConfirmationModal>
<!-- Migrate from Azure Key Vault to Database Modal -->
<ConfirmationModal
title="Migrate Secrets to Database"
confirmationText="Migrate"
open={migrateFromAzureKvModalOpen}
loading={migratingFromAzureKv}
type="reload"
onCanceled={() => {
migrateFromAzureKvModalOpen = false
}}
onConfirmed={migrateSecretsFromAzureKv}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>
This will migrate all secrets from Azure Key Vault back to the database. The process will:
</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>List all secrets in Azure Key Vault for each workspace</li>
<li>Read each secret value from Azure Key Vault</li>
<li>Encrypt and store them in the database</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Azure Key Vault values are NOT deleted automatically. Only secrets that already exist in the
database will be updated.
</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
</ConfirmationModal>
<!-- Migrate to Vault Modal -->
<ConfirmationModal
title="Migrate Secrets to Vault"