From 68c8388976619c2dc6aa22244a845f8e97cded81 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 6 Apr 2026 11:31:13 +0000 Subject: [PATCH] feat: switch from AWS KMS to AWS Secrets Manager as secret backend Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 50 +- backend/Cargo.toml | 2 +- backend/windmill-api-settings/src/lib.rs | 36 + backend/windmill-api/openapi.yaml | 81 +++ .../windmill-api/src/secret_backend_ext.rs | 148 ++--- backend/windmill-common/Cargo.toml | 4 +- .../src/secret_backend/aws_kms_oss.rs | 86 --- .../src/secret_backend/aws_sm_oss.rs | 69 ++ .../windmill-common/src/secret_backend/mod.rs | 77 +-- .../src/secret_backend/vault_oss.rs | 18 +- backend/windmill-common/src/variables.rs | 18 +- .../windmill-store/src/secret_backend_ext.rs | 183 ++---- .../SecretBackendConfig.svelte | 616 ++++-------------- 13 files changed, 482 insertions(+), 906 deletions(-) delete mode 100644 backend/windmill-common/src/secret_backend/aws_kms_oss.rs create mode 100644 backend/windmill-common/src/secret_backend/aws_sm_oss.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0706fce98e..ba525a9eca 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -979,30 +979,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "aws-sdk-kms" -version = "1.101.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dca55e9417ba817965206945209c061a77cf03380d93ce9556b0ea76499e02" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http 0.63.4", - "aws-smithy-json 0.62.4", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", -] - [[package]] name = "aws-sdk-rds" version = "1.126.0" @@ -1030,6 +1006,30 @@ dependencies = [ "url", ] +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b0b2985427bb081e54e759468d3af89fa2ccb17fb8b9e5b704ae2f8da10a3b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sqs" version = "1.77.0" @@ -16745,8 +16745,8 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-bedrockruntime", - "aws-sdk-kms", "aws-sdk-rds", + "aws-sdk-secretsmanager", "aws-sdk-sts", "aws-smithy-types", "aws-smithy-types-convert", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c72374535b..edd72f48fc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -560,7 +560,7 @@ aws-sdk-bedrock = "1.129.0" aws-sdk-bedrockruntime = "=1.122.0" aws-credential-types = "^1" aws-smithy-types = "^1" -aws-sdk-kms = "^1" +aws-sdk-secretsmanager = "^1" aws-sdk-sqs = "=1.77.0" aws-sdk-sts = "=1.79.0" aws-sdk-sso = "=1.77.0" diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 6f490bc7ea..64df6ea94b 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -1383,6 +1383,42 @@ pub async fn migrate_secrets_from_aws_kms( Ok(Json(report)) } +/// Test connection to AWS Secrets Manager +#[cfg(all(feature = "private", feature = "enterprise"))] +pub async fn test_aws_sm_backend( + Extension(db): Extension, + authed: ApiAuthed, + Json(settings): Json, +) -> Result { + require_super_admin(&db, &authed.email).await?; + windmill_common::secret_backend::test_aws_sm_connection(&settings).await?; + Ok("Successfully connected to AWS Secrets Manager".to_string()) +} + +/// Migrate existing secrets from database to AWS Secrets Manager +#[cfg(all(feature = "private", feature = "enterprise"))] +pub async fn migrate_secrets_to_aws_sm( + 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_aws_sm(&db, &settings).await?; + Ok(Json(report)) +} + +/// Migrate secrets from AWS Secrets Manager back to database +#[cfg(all(feature = "private", feature = "enterprise"))] +pub async fn migrate_secrets_from_aws_sm( + 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_aws_sm(&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 746a06316c..cb0076342c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1866,6 +1866,66 @@ paths: schema: $ref: "#/components/schemas/SecretMigrationReport" + /settings/test_aws_sm_backend: + post: + summary: test connection to AWS Secrets Manager + operationId: testAwsSmBackend + tags: + - setting + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AwsSecretsManagerSettings" + responses: + "200": + description: connection test result + content: + text/plain: + schema: + type: string + + /settings/migrate_secrets_to_aws_sm: + post: + summary: migrate secrets from database to AWS Secrets Manager + operationId: migrateSecretsToAwsSm + tags: + - setting + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AwsSecretsManagerSettings" + responses: + "200": + description: migration report + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMigrationReport" + + /settings/migrate_secrets_from_aws_sm: + post: + summary: migrate secrets from AWS Secrets Manager to database + operationId: migrateSecretsFromAwsSm + tags: + - setting + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AwsSecretsManagerSettings" + responses: + "200": + description: migration report + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMigrationReport" + /users/email: get: summary: get current user email (if logged in) @@ -19409,6 +19469,27 @@ components: type: string description: Custom endpoint URL for testing (e.g., LocalStack) + AwsSecretsManagerSettings: + type: object + required: + - region + properties: + region: + type: string + description: AWS region (e.g., us-east-1) + access_key_id: + type: string + description: AWS Access Key ID (optional, uses default credential chain if not provided) + secret_access_key: + type: string + description: AWS Secret Access Key (optional) + endpoint_url: + type: string + description: Custom endpoint URL for testing (e.g., LocalStack) + prefix: + type: string + description: Prefix for secret names (e.g., windmill/) + 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 ffd5d16c21..5f37d46341 100644 --- a/backend/windmill-api/src/secret_backend_ext.rs +++ b/backend/windmill-api/src/secret_backend_ext.rs @@ -6,11 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -//! Secret backend extension for the API layer -//! -//! This module provides helper functions for integrating the SecretBackend -//! trait with variable operations in the API. - #[cfg(all(feature = "private", feature = "enterprise"))] use std::sync::Arc; @@ -26,8 +21,8 @@ use windmill_common::secret_backend::{database::DatabaseBackend, SecretBackend}; use windmill_common::{ global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING}, secret_backend::{ - AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, - VaultSettings, + AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend, + AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings, }, }; @@ -56,13 +51,23 @@ lazy_static::lazy_static! { static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); } +#[cfg(all(feature = "private", feature = "enterprise"))] +struct CachedAwsSmBackend { + backend: Arc, + settings: AwsSecretsManagerSettings, +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +lazy_static::lazy_static! { + static ref AWS_SM_BACKEND_CACHE: RwLock> = RwLock::new(None); +} + #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_secret_backend(db: &DB) -> Result> { let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? { Some(value) => serde_json::from_value::(value).unwrap_or_default(), None => SecretBackendConfig::default(), }; - match config { SecretBackendConfig::Database => Ok(Arc::new(DatabaseBackend::new(db.clone()))), SecretBackendConfig::HashiCorpVault(settings) => { @@ -71,11 +76,8 @@ async fn get_secret_backend(db: &DB) -> Result> { SecretBackendConfig::AzureKeyVault(settings) => { get_or_create_azure_kv_backend(db, settings).await } - SecretBackendConfig::AwsKms(_) => { - // KMS backend: secrets are in DB encrypted by KMS. - // For API-layer operations (rename), we use the database backend - // since KMS secrets don't need external store operations. - Ok(Arc::new(DatabaseBackend::new(db.clone()))) + SecretBackendConfig::AwsSecretsManager(settings) => { + get_or_create_aws_sm_backend(db, settings).await } } } @@ -88,20 +90,13 @@ async fn get_or_create_vault_backend( { let cache = VAULT_BACKEND_CACHE.read().await; if let Some(ref cached) = *cache { - if cached.settings == settings { - return Ok(cached.backend.clone()); - } + if cached.settings == settings { return Ok(cached.backend.clone()); } } } - let mut cache = VAULT_BACKEND_CACHE.write().await; - if let Some(ref cached) = *cache { - if cached.settings == settings { - return Ok(cached.backend.clone()); - } + if cached.settings == settings { return Ok(cached.backend.clone()); } } - let backend: Arc = { #[cfg(feature = "openidconnect")] if settings.token.is_none() { @@ -109,13 +104,10 @@ async fn get_or_create_vault_backend( } else { Arc::new(VaultBackend::new(settings.clone())) } - #[cfg(not(feature = "openidconnect"))] Arc::new(VaultBackend::new(settings.clone())) }; - *cache = Some(CachedVaultBackend { backend: backend.clone(), settings }); - Ok(backend) } @@ -127,24 +119,36 @@ async fn get_or_create_azure_kv_backend( { let cache = AZURE_KV_BACKEND_CACHE.read().await; if let Some(ref cached) = *cache { - if cached.settings == settings { - return Ok(cached.backend.clone()); - } + if cached.settings == settings { return Ok(cached.backend.clone()); } } } - let mut cache = AZURE_KV_BACKEND_CACHE.write().await; - if let Some(ref cached) = *cache { - if cached.settings == settings { - return Ok(cached.backend.clone()); + if cached.settings == settings { return Ok(cached.backend.clone()); } + } + let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); + *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); + Ok(backend) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +async fn get_or_create_aws_sm_backend( + _db: &DB, + settings: AwsSecretsManagerSettings, +) -> Result> { + { + let cache = AWS_SM_BACKEND_CACHE.read().await; + if let Some(ref cached) = *cache { + if cached.settings == settings { return Ok(cached.backend.clone()); } } } - - let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); - - *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); - + let mut cache = AWS_SM_BACKEND_CACHE.write().await; + if let Some(ref cached) = *cache { + if cached.settings == settings { return Ok(cached.backend.clone()); } + } + let backend: Arc = + Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?); + *cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings }); Ok(backend) } @@ -154,35 +158,31 @@ async fn is_vault_backend_configured(db: &DB) -> Result { Some(value) => serde_json::from_value::(value).unwrap_or_default(), None => SecretBackendConfig::default(), }; - - // AwsKms intentionally excluded: KMS secrets are in the DB, no external rename needed Ok(matches!( config, - SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_) + SecretBackendConfig::HashiCorpVault(_) + | SecretBackendConfig::AzureKeyVault(_) + | SecretBackendConfig::AwsSecretsManager(_) )) } #[cfg(all(feature = "private", feature = "enterprise"))] -fn is_vault_stored_value(value: &str) -> bool { - value.starts_with("$vault:") -} +fn is_vault_stored_value(value: &str) -> bool { value.starts_with("$vault:") } #[cfg(all(feature = "private", feature = "enterprise"))] -fn is_azure_kv_stored_value(value: &str) -> bool { - value.starts_with("$azure_kv:") -} +fn is_azure_kv_stored_value(value: &str) -> bool { value.starts_with("$azure_kv:") } + +#[cfg(all(feature = "private", feature = "enterprise"))] +fn is_aws_sm_stored_value(value: &str) -> bool { value.starts_with("$aws_sm:") } #[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) + is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value) } #[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn rename_vault_secrets_with_prefix( - _db: &DB, - _workspace_id: &str, - _old_prefix: &str, - _new_prefix: &str, + _db: &DB, _workspace_id: &str, _old_prefix: &str, _new_prefix: &str, _variables: Vec<(String, String)>, ) -> Result> { Ok(vec![]) @@ -190,35 +190,27 @@ pub async fn rename_vault_secrets_with_prefix( #[cfg(all(feature = "private", feature = "enterprise"))] pub async fn rename_vault_secrets_with_prefix( - db: &DB, - workspace_id: &str, - old_prefix: &str, - new_prefix: &str, + db: &DB, workspace_id: &str, old_prefix: &str, new_prefix: &str, variables: Vec<(String, String)>, ) -> Result> { - if !is_vault_backend_configured(db).await? { - return Ok(vec![]); - } - + if !is_vault_backend_configured(db).await? { return Ok(vec![]); } let backend = get_secret_backend(db).await?; let mut updates = Vec::new(); for (old_path, value) in variables { - if !is_external_stored_value(&value) { - continue; - } + if !is_external_stored_value(&value) { continue; } - let marker_prefix = if is_azure_kv_stored_value(&value) { + let marker_prefix = if value.starts_with("$azure_kv:") { "$azure_kv:" + } else if value.starts_with("$aws_sm:") { + "$aws_sm:" } else { "$vault:" }; let new_path = if old_path.starts_with(old_prefix) { format!("{}{}", new_prefix, &old_path[old_prefix.len()..]) - } else { - continue; - }; + } else { continue; }; let secret_value = match backend.get_secret(workspace_id, &old_path).await { Ok(v) => v, @@ -227,37 +219,21 @@ pub async fn rename_vault_secrets_with_prefix( continue; } Err(e) => { - tracing::error!( - "Failed to read secret at {} during bulk rename: {}", - old_path, - e - ); + tracing::error!("Failed to read secret at {} during bulk rename: {}", old_path, e); continue; } }; - if let Err(e) = backend - .set_secret(workspace_id, &new_path, &secret_value) - .await - { - tracing::error!( - "Failed to write secret to {} during bulk rename: {}", - new_path, - e - ); + if let Err(e) = backend.set_secret(workspace_id, &new_path, &secret_value).await { + tracing::error!("Failed to write secret to {} during bulk rename: {}", new_path, e); continue; } if let Err(e) = backend.delete_secret(workspace_id, &old_path).await { - tracing::warn!( - "Failed to delete old secret at {} after rename: {}", - old_path, - e - ); + tracing::warn!("Failed to delete old secret at {} after rename: {}", old_path, e); } updates.push((old_path, format!("{}{}", marker_prefix, new_path))); } - Ok(updates) } diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index f484fc7547..c5a45428e9 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -9,7 +9,7 @@ default = [] enterprise = ["dep:aws-config"] instance_config_schema = ["dep:schemars"] local_reports = ["dep:rsa", "dep:aes-gcm"] -private = ["dep:aws-sdk-rds", "dep:aws-sdk-kms"] +private = ["dep:aws-sdk-rds", "dep:aws-sdk-secretsmanager"] jemalloc = ["dep:tikv-jemalloc-ctl"] tantivy = [] prometheus = ["dep:prometheus"] @@ -80,7 +80,7 @@ postgres-native-tls.workspace = true native-tls.workspace = true aws-smithy-types-convert = { workspace = true, optional = true } -aws-sdk-kms = { workspace = true, optional = true } +aws-sdk-secretsmanager = { workspace = true, optional = true } aws-sdk-rds = { workspace = true, optional = true } indexmap.workspace = true bytes.workspace = true diff --git a/backend/windmill-common/src/secret_backend/aws_kms_oss.rs b/backend/windmill-common/src/secret_backend/aws_kms_oss.rs deleted file mode 100644 index 2f89151c26..0000000000 --- a/backend/windmill-common/src/secret_backend/aws_kms_oss.rs +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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::{AwsKmsSettings, SecretBackend, SecretMigrationReport}; - -pub struct AwsKmsBackend; - -impl AwsKmsBackend { - pub fn new(_settings: AwsKmsSettings) -> Self { - AwsKmsBackend - } - - pub async fn encrypt_value(&self, _plaintext: &str) -> Result { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) - } - - pub async fn decrypt_value(&self, _ciphertext_b64: &str) -> Result { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) - } -} - -#[async_trait] -impl SecretBackend for AwsKmsBackend { - async fn get_secret(&self, _workspace_id: &str, _path: &str) -> Result { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) - } - - async fn set_secret(&self, _workspace_id: &str, _path: &str, _value: &str) -> Result<()> { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) - } - - async fn delete_secret(&self, _workspace_id: &str, _path: &str) -> Result<()> { - // No-op for KMS: secrets are stored in the database, not externally - Ok(()) - } - - fn backend_name(&self) -> &'static str { - "aws_kms" - } -} - -pub async fn test_aws_kms_connection(_settings: &AwsKmsSettings) -> Result<()> { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) -} - -pub async fn migrate_secrets_to_aws_kms( - _db: &DB, - _settings: &AwsKmsSettings, -) -> Result { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) -} - -pub async fn migrate_secrets_from_aws_kms( - _db: &DB, - _settings: &AwsKmsSettings, -) -> Result { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) -} - -pub async fn decrypt_aws_kms_value(_db: &DB, _value: &str) -> Result { - Err(Error::internal_err( - "AWS KMS integration requires Enterprise Edition".to_string(), - )) -} diff --git a/backend/windmill-common/src/secret_backend/aws_sm_oss.rs b/backend/windmill-common/src/secret_backend/aws_sm_oss.rs new file mode 100644 index 0000000000..da5e4e7327 --- /dev/null +++ b/backend/windmill-common/src/secret_backend/aws_sm_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::{AwsSecretsManagerSettings, SecretBackend, SecretMigrationReport}; + +pub struct AwsSecretsManagerBackend; + +impl AwsSecretsManagerBackend { + pub fn new(_settings: AwsSecretsManagerSettings) -> Self { + AwsSecretsManagerBackend + } +} + +#[async_trait] +impl SecretBackend for AwsSecretsManagerBackend { + async fn get_secret(&self, _workspace_id: &str, _path: &str) -> Result { + Err(Error::internal_err( + "AWS Secrets Manager integration requires Enterprise Edition".to_string(), + )) + } + + async fn set_secret(&self, _workspace_id: &str, _path: &str, _value: &str) -> Result<()> { + Err(Error::internal_err( + "AWS Secrets Manager integration requires Enterprise Edition".to_string(), + )) + } + + async fn delete_secret(&self, _workspace_id: &str, _path: &str) -> Result<()> { + Err(Error::internal_err( + "AWS Secrets Manager integration requires Enterprise Edition".to_string(), + )) + } + + fn backend_name(&self) -> &'static str { + "aws_secrets_manager" + } +} + +pub async fn test_aws_sm_connection(_settings: &AwsSecretsManagerSettings) -> Result<()> { + Err(Error::internal_err( + "AWS Secrets Manager integration requires Enterprise Edition".to_string(), + )) +} + +pub async fn migrate_secrets_to_aws_sm( + _db: &DB, + _settings: &AwsSecretsManagerSettings, +) -> Result { + Err(Error::internal_err( + "AWS Secrets Manager integration requires Enterprise Edition".to_string(), + )) +} + +pub async fn migrate_secrets_from_aws_sm( + _db: &DB, + _settings: &AwsSecretsManagerSettings, +) -> Result { + Err(Error::internal_err( + "AWS Secrets Manager 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 12a73488ba..bc739f9019 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -23,8 +23,8 @@ pub mod azure_kv_ee; pub mod azure_kv_oss; #[cfg(feature = "private")] -pub mod aws_kms_ee; -pub mod aws_kms_oss; +pub mod aws_sm_ee; +pub mod aws_sm_oss; #[cfg(test)] mod tests; @@ -42,10 +42,10 @@ pub use azure_kv_ee::*; pub use azure_kv_oss::*; #[cfg(feature = "private")] -pub use aws_kms_ee::*; +pub use aws_sm_ee::*; #[cfg(not(feature = "private"))] -pub use aws_kms_oss::*; +pub use aws_sm_oss::*; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -53,56 +53,22 @@ use serde::{Deserialize, Serialize}; use crate::error::Result; /// Trait for secret storage backends -/// -/// Implementations of this trait handle the storage and retrieval of secrets. -/// The default implementation stores secrets encrypted in the database. -/// Enterprise Edition supports HashiCorp Vault as an alternative backend. #[async_trait] pub trait SecretBackend: Send + Sync { - /// Retrieve a secret value - /// - /// # Arguments - /// * `workspace_id` - The workspace identifier - /// * `path` - The path/name of the secret variable - /// - /// # Returns - /// The decrypted secret value async fn get_secret(&self, workspace_id: &str, path: &str) -> Result; - - /// Store a secret value - /// - /// # Arguments - /// * `workspace_id` - The workspace identifier - /// * `path` - The path/name of the secret variable - /// * `value` - The plaintext secret value to store async fn set_secret(&self, workspace_id: &str, path: &str, value: &str) -> Result<()>; - - /// Delete a secret - /// - /// # Arguments - /// * `workspace_id` - The workspace identifier - /// * `path` - The path/name of the secret variable async fn delete_secret(&self, workspace_id: &str, path: &str) -> Result<()>; - - /// Get the name of this backend for logging/debugging fn backend_name(&self) -> &'static str; } /// Configuration for secret storage backend -/// -/// This enum is stored in global_settings and determines which backend -/// is used for secret storage at the instance level. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum SecretBackendConfig { - /// Store secrets encrypted in the database (default behavior) Database, - /// Store secrets in HashiCorp Vault (Enterprise Edition only) HashiCorpVault(VaultSettings), - /// Store secrets in Azure Key Vault (Enterprise Edition only) AzureKeyVault(AzureKeyVaultSettings), - /// Encrypt secrets with AWS KMS, store in database (Enterprise Edition only) - AwsKms(AwsKmsSettings), + AwsSecretsManager(AwsSecretsManagerSettings), } impl Default for SecretBackendConfig { @@ -114,78 +80,59 @@ impl Default for SecretBackendConfig { /// Settings for HashiCorp Vault integration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VaultSettings { - /// Vault server address (e.g., "https://vault.company.com:8200") pub address: String, - /// KV v2 mount path (e.g., "windmill") pub mount_path: String, - /// JWT auth role name configured in Vault (used for JWT/OIDC auth) - /// Optional - if not provided, token auth is used #[serde(skip_serializing_if = "Option::is_none")] pub jwt_role: Option, - /// Vault Enterprise namespace (optional) #[serde(skip_serializing_if = "Option::is_none")] pub namespace: Option, - /// Static Vault token for testing/development (optional) - /// If provided, this is used instead of JWT authentication #[serde(skip_serializing_if = "Option::is_none")] 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, } -/// Settings for AWS KMS integration +/// Settings for AWS Secrets Manager integration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct AwsKmsSettings { - /// KMS Key ID, Key ARN, Alias name, or Alias ARN - pub key_id: String, +pub struct AwsSecretsManagerSettings { /// AWS region (e.g., "us-east-1") pub region: String, - /// AWS Access Key ID (optional - uses default credential chain if not provided) + /// Static AWS access key ID (optional - uses default credential chain if not provided) #[serde(skip_serializing_if = "Option::is_none")] pub access_key_id: Option, - /// AWS Secret Access Key (optional) + /// Static AWS secret access key (optional) #[serde(skip_serializing_if = "Option::is_none")] pub secret_access_key: Option, /// Custom endpoint URL for LocalStack/testing (optional) #[serde(skip_serializing_if = "Option::is_none")] pub endpoint_url: Option, + /// Prefix for secret names in AWS Secrets Manager (e.g., "windmill/") + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option, } /// Result of a secret migration operation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecretMigrationReport { - /// Total number of secrets found pub total_secrets: usize, - /// Number of secrets successfully migrated pub migrated_count: usize, - /// Number of secrets that failed to migrate pub failed_count: usize, - /// Details of any failures pub failures: Vec, } /// Details of a failed secret migration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecretMigrationFailure { - /// Workspace ID where the secret is located pub workspace_id: String, - /// Path of the secret that failed to migrate pub path: String, - /// Error message pub error: String, } diff --git a/backend/windmill-common/src/secret_backend/vault_oss.rs b/backend/windmill-common/src/secret_backend/vault_oss.rs index 4a3e3acd01..25cd124ea5 100644 --- a/backend/windmill-common/src/secret_backend/vault_oss.rs +++ b/backend/windmill-common/src/secret_backend/vault_oss.rs @@ -6,11 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -//! HashiCorp Vault secret backend stubs (Open Source Edition) -//! -//! This module provides stub implementations for Vault integration. -//! The actual Vault integration requires Enterprise Edition. - use std::sync::Arc; use crate::db::DB; @@ -21,7 +16,6 @@ use super::{ VaultSettings, }; -/// Stub VaultBackend for OSS - all operations return EE required error pub struct VaultBackend; impl VaultBackend { @@ -55,10 +49,6 @@ impl SecretBackend for VaultBackend { } } -/// Create the appropriate secret backend based on configuration -/// -/// In OSS, always returns DatabaseBackend regardless of config. -/// Vault configuration is ignored with a warning. pub async fn create_secret_backend( db: DB, config: &SecretBackendConfig, @@ -79,9 +69,9 @@ pub async fn create_secret_backend( ); Ok(Arc::new(DatabaseBackend::new(db))) } - SecretBackendConfig::AwsKms(_) => { + SecretBackendConfig::AwsSecretsManager(_) => { tracing::warn!( - "AWS KMS is configured but requires Enterprise Edition. \ + "AWS Secrets Manager is configured but requires Enterprise Edition. \ Falling back to database backend." ); Ok(Arc::new(DatabaseBackend::new(db))) @@ -89,14 +79,12 @@ pub async fn create_secret_backend( } } -/// Test connection to Vault (OSS stub) pub async fn test_vault_connection(_settings: &VaultSettings, _db: Option<&DB>) -> Result<()> { Err(Error::internal_err( "HashiCorp Vault integration requires Enterprise Edition".to_string(), )) } -/// Migrate secrets from database to Vault (OSS stub) pub async fn migrate_secrets_to_vault( _db: &DB, _settings: &VaultSettings, @@ -106,7 +94,6 @@ pub async fn migrate_secrets_to_vault( )) } -/// Migrate secrets from Vault back to database (OSS stub) pub async fn migrate_secrets_to_database( _db: &DB, _settings: &VaultSettings, @@ -116,7 +103,6 @@ pub async fn migrate_secrets_to_database( )) } -/// Generate a JWT for Vault authentication (OSS stub) pub async fn generate_vault_jwt(_db: &DB, _vault_address: &str) -> Result { Err(Error::internal_err( "HashiCorp Vault integration requires Enterprise Edition".to_string(), diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index d47f3c0047..68c1ac36fd 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -188,17 +188,13 @@ pub async fn get_secret_value_as_admin( let r = if variable.is_secret { let value = variable.value; if !value.is_empty() { - if value.starts_with("$aws_kms:") { - crate::secret_backend::decrypt_aws_kms_value(db, &value).await? - } else { - let mc = build_crypt(db, w_id).await?; - decrypt(&mc, value).map_err(|e| { - crate::error::Error::internal_err(format!( - "Error decrypting variable {}: {}", - variable.path, e - )) - })? - } + let mc = build_crypt(db, w_id).await?; + decrypt(&mc, value).map_err(|e| { + crate::error::Error::internal_err(format!( + "Error decrypting variable {}: {}", + variable.path, e + )) + })? } else { "".to_string() } diff --git a/backend/windmill-store/src/secret_backend_ext.rs b/backend/windmill-store/src/secret_backend_ext.rs index 3f22e47613..cf4b262c60 100644 --- a/backend/windmill-store/src/secret_backend_ext.rs +++ b/backend/windmill-store/src/secret_backend_ext.rs @@ -6,11 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -//! Secret backend extension for the store layer -//! -//! This module provides helper functions for integrating the SecretBackend -//! trait with variable operations in the store. - use std::sync::Arc; use windmill_common::{ @@ -23,7 +18,10 @@ use windmill_common::{ #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::{ global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING}, - secret_backend::{AzureKeyVaultBackend, AzureKeyVaultSettings, AwsKmsBackend, AwsKmsSettings, SecretBackendConfig, VaultBackend, VaultSettings}, + secret_backend::{ + AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend, + AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings, + }, }; #[cfg(all(feature = "private", feature = "enterprise"))] @@ -52,14 +50,14 @@ lazy_static::lazy_static! { } #[cfg(all(feature = "private", feature = "enterprise"))] -struct CachedAwsKmsBackend { - backend: Arc, - settings: AwsKmsSettings, +struct CachedAwsSmBackend { + backend: Arc, + settings: AwsSecretsManagerSettings, } #[cfg(all(feature = "private", feature = "enterprise"))] lazy_static::lazy_static! { - static ref AWS_KMS_BACKEND_CACHE: RwLock> = RwLock::new(None); + static ref AWS_SM_BACKEND_CACHE: RwLock> = RwLock::new(None); } #[cfg(not(all(feature = "private", feature = "enterprise")))] @@ -82,9 +80,8 @@ pub async fn get_secret_backend(db: &DB) -> Result> { SecretBackendConfig::AzureKeyVault(settings) => { get_or_create_azure_kv_backend(db, settings).await } - SecretBackendConfig::AwsKms(settings) => { - let kms = get_or_create_aws_kms_backend(settings).await?; - Ok(kms as Arc) + SecretBackendConfig::AwsSecretsManager(settings) => { + get_or_create_aws_sm_backend(db, settings).await } } } @@ -102,15 +99,12 @@ async fn get_or_create_vault_backend( } } } - let mut cache = VAULT_BACKEND_CACHE.write().await; - if let Some(ref cached) = *cache { if cached.settings == settings { return Ok(cached.backend.clone()); } } - let backend: Arc = { #[cfg(feature = "openidconnect")] if settings.token.is_none() { @@ -118,13 +112,10 @@ async fn get_or_create_vault_backend( } else { Arc::new(VaultBackend::new(settings.clone())) } - #[cfg(not(feature = "openidconnect"))] Arc::new(VaultBackend::new(settings.clone())) }; - *cache = Some(CachedVaultBackend { backend: backend.clone(), settings }); - Ok(backend) } @@ -141,47 +132,39 @@ async fn get_or_create_azure_kv_backend( } } } - let mut cache = AZURE_KV_BACKEND_CACHE.write().await; - if let Some(ref cached) = *cache { if cached.settings == settings { return Ok(cached.backend.clone()); } } - let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); - *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); - Ok(backend) } #[cfg(all(feature = "private", feature = "enterprise"))] -async fn get_or_create_aws_kms_backend( - settings: AwsKmsSettings, -) -> Result> { +async fn get_or_create_aws_sm_backend( + _db: &DB, + settings: AwsSecretsManagerSettings, +) -> Result> { { - let cache = AWS_KMS_BACKEND_CACHE.read().await; + let cache = AWS_SM_BACKEND_CACHE.read().await; if let Some(ref cached) = *cache { if cached.settings == settings { return Ok(cached.backend.clone()); } } } - - let mut cache = AWS_KMS_BACKEND_CACHE.write().await; - + let mut cache = AWS_SM_BACKEND_CACHE.write().await; if let Some(ref cached) = *cache { if cached.settings == settings { return Ok(cached.backend.clone()); } } - - let backend = Arc::new(AwsKmsBackend::new(settings.clone()).await?); - - *cache = Some(CachedAwsKmsBackend { backend: backend.clone(), settings }); - + let backend: Arc = + Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?); + *cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings }); Ok(backend) } @@ -196,8 +179,12 @@ pub async fn is_vault_backend_configured(db: &DB) -> Result { Some(value) => serde_json::from_value::(value).unwrap_or_default(), None => SecretBackendConfig::default(), }; - - Ok(matches!(config, SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_))) + Ok(matches!( + config, + SecretBackendConfig::HashiCorpVault(_) + | SecretBackendConfig::AzureKeyVault(_) + | SecretBackendConfig::AwsSecretsManager(_) + )) } pub async fn get_secret_value( @@ -206,33 +193,7 @@ pub async fn get_secret_value( path: &str, encrypted_value: &str, ) -> Result { - if is_aws_kms_stored_value(encrypted_value) { - #[cfg(all(feature = "private", feature = "enterprise"))] - { - let config = - match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? { - Some(value) => { - serde_json::from_value::(value).unwrap_or_default() - } - None => SecretBackendConfig::default(), - }; - if let SecretBackendConfig::AwsKms(settings) = config { - let kms = get_or_create_aws_kms_backend(settings).await?; - let ciphertext_b64 = &encrypted_value["$aws_kms:".len()..]; - return kms.decrypt_value(ciphertext_b64).await; - } - return Err(Error::internal_err( - "Value has $aws_kms: prefix but AWS KMS is not configured".to_string(), - )); - } - #[cfg(not(all(feature = "private", feature = "enterprise")))] - return Err(Error::internal_err( - "AWS KMS requires Enterprise Edition".to_string(), - )); - } - let backend = get_secret_backend(db).await?; - match backend.backend_name() { "database" => { let mc = build_crypt(db, workspace_id).await?; @@ -240,8 +201,9 @@ pub async fn get_secret_value( Error::internal_err(format!("Error decrypting variable {}: {}", path, e)) }) } - "hashicorp_vault" => backend.get_secret(workspace_id, path).await, - "azure_key_vault" => backend.get_secret(workspace_id, path).await, + "hashicorp_vault" | "azure_key_vault" | "aws_secrets_manager" => { + backend.get_secret(workspace_id, path).await + } _ => Err(Error::internal_err(format!( "Unknown backend: {}", backend.backend_name() @@ -256,7 +218,6 @@ pub async fn store_secret_value( plain_value: &str, ) -> Result { let backend = get_secret_backend(db).await?; - match backend.backend_name() { "database" => { let mc = build_crypt(db, workspace_id).await?; @@ -270,30 +231,9 @@ pub async fn store_secret_value( backend.set_secret(workspace_id, path, plain_value).await?; Ok(format!("$azure_kv:{}", path)) } - "aws_kms" => { - #[cfg(all(feature = "private", feature = "enterprise"))] - { - let config = - match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? { - Some(value) => { - serde_json::from_value::(value) - .unwrap_or_default() - } - None => SecretBackendConfig::default(), - }; - if let SecretBackendConfig::AwsKms(settings) = config { - let kms = get_or_create_aws_kms_backend(settings).await?; - let ciphertext_b64 = kms.encrypt_value(plain_value).await?; - return Ok(format!("$aws_kms:{}", ciphertext_b64)); - } - Err(Error::internal_err( - "AWS KMS backend active but settings not found".to_string(), - )) - } - #[cfg(not(all(feature = "private", feature = "enterprise")))] - Err(Error::internal_err( - "AWS KMS requires Enterprise Edition".to_string(), - )) + "aws_secrets_manager" => { + backend.set_secret(workspace_id, path, plain_value).await?; + Ok(format!("$aws_sm:{}", path)) } _ => Err(Error::internal_err(format!( "Unknown backend: {}", @@ -323,12 +263,12 @@ pub fn is_azure_kv_stored_value(value: &str) -> bool { value.starts_with("$azure_kv:") } -pub fn is_aws_kms_stored_value(value: &str) -> bool { - value.starts_with("$aws_kms:") +pub fn is_aws_sm_stored_value(value: &str) -> bool { + value.starts_with("$aws_sm:") } pub fn is_external_stored_value(value: &str) -> bool { - is_vault_stored_value(value) || is_azure_kv_stored_value(value) + is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value) } #[cfg(not(all(feature = "private", feature = "enterprise")))] @@ -340,21 +280,14 @@ pub async fn rename_vault_secret( current_value: &str, ) -> Result> { if is_vault_stored_value(current_value) { - tracing::warn!( - "Variable has $vault: prefix but Vault requires Enterprise Edition. \ - Updating DB reference to {}", - new_path - ); 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))); } + if is_aws_sm_stored_value(current_value) { + return Ok(Some(format!("$aws_sm:{}", new_path))); + } Ok(None) } @@ -372,6 +305,8 @@ pub async fn rename_vault_secret( let marker_prefix = if current_value.starts_with("$azure_kv:") { "$azure_kv:" + } else if current_value.starts_with("$aws_sm:") { + "$aws_sm:" } else { "$vault:" }; @@ -380,9 +315,7 @@ pub async fn rename_vault_secret( tracing::warn!( "Variable value has {} prefix but external secret backend is not configured. \ Updating DB reference from {} to {}", - marker_prefix, - old_path, - new_path + marker_prefix, old_path, new_path ); return Ok(Some(format!("{}{}", marker_prefix, new_path))); } @@ -394,24 +327,19 @@ pub async fn rename_vault_secret( Err(Error::NotFound(_)) => { tracing::warn!( "Secret not found in backend at path {} during rename to {}", - old_path, - new_path + old_path, new_path ); return Ok(Some(format!("{}{}", marker_prefix, new_path))); } Err(e) => return Err(e), }; - backend - .set_secret(workspace_id, new_path, &secret_value) - .await?; + backend.set_secret(workspace_id, new_path, &secret_value).await?; if let Err(e) = backend.delete_secret(workspace_id, old_path).await { tracing::warn!( "Failed to delete old secret at {} after rename to {}: {}", - old_path, - new_path, - e + old_path, new_path, e ); } @@ -451,6 +379,8 @@ pub async fn rename_vault_secrets_with_prefix( let marker_prefix = if value.starts_with("$azure_kv:") { "$azure_kv:" + } else if value.starts_with("$aws_sm:") { + "$aws_sm:" } else { "$vault:" }; @@ -468,33 +398,18 @@ pub async fn rename_vault_secrets_with_prefix( continue; } Err(e) => { - tracing::error!( - "Failed to read secret at {} during bulk rename: {}", - old_path, - e - ); + tracing::error!("Failed to read secret at {} during bulk rename: {}", old_path, e); continue; } }; - if let Err(e) = backend - .set_secret(workspace_id, &new_path, &secret_value) - .await - { - tracing::error!( - "Failed to write secret to {} during bulk rename: {}", - new_path, - e - ); + if let Err(e) = backend.set_secret(workspace_id, &new_path, &secret_value).await { + tracing::error!("Failed to write secret to {} during bulk rename: {}", new_path, e); continue; } if let Err(e) = backend.delete_secret(workspace_id, &old_path).await { - tracing::warn!( - "Failed to delete old secret at {} after rename: {}", - old_path, - e - ); + tracing::warn!("Failed to delete old secret at {} after rename: {}", old_path, e); } updates.push((old_path, format!("{}{}", marker_prefix, new_path))); diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 3708801a2b..96c75b52a8 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, Cloud, KeyRound } 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' @@ -19,14 +19,13 @@ let { values, disabled = false }: Props = $props() - // Initialize default values if not set $effect(() => { if (!$values['secret_backend']) { $values['secret_backend'] = { type: 'Database' } } }) - let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsKms' = $derived( + let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsSecretsManager' = $derived( $values['secret_backend']?.type ?? 'Database' ) @@ -48,20 +47,17 @@ let migrateToAzureKvModalOpen = $state(false) let migrateFromAzureKvModalOpen = $state(false) - let testingAwsKmsConnection = $state(false) - let migratingToAwsKms = $state(false) - let migratingFromAwsKms = $state(false) - let migrateToAwsKmsModalOpen = $state(false) - let migrateFromAwsKmsModalOpen = $state(false) + let testingAwsSmConnection = $state(false) + let migratingToAwsSm = $state(false) + let migratingFromAwsSm = $state(false) + let migrateToAwsSmModalOpen = $state(false) + let migrateFromAwsSmModalOpen = $state(false) let vaultDisabled = $derived(!$enterpriseLicense) function setBackendType(type: string | undefined) { if (!type) return - if (type === 'HashiCorpVault' && vaultDisabled) return - if (type === 'AzureKeyVault' && vaultDisabled) return - if (type === 'AwsKms' && vaultDisabled) return - + if ((type === 'HashiCorpVault' || type === 'AzureKeyVault' || type === 'AwsSecretsManager') && vaultDisabled) return if (type === 'Database') { $values['secret_backend'] = { type: 'Database' } } else if (type === 'HashiCorpVault') { @@ -82,38 +78,24 @@ client_secret: $values['secret_backend']?.client_secret ?? null, token: $values['secret_backend']?.token ?? null } - } else if (type === 'AwsKms') { + } else if (type === 'AwsSecretsManager') { $values['secret_backend'] = { - type: 'AwsKms', - key_id: $values['secret_backend']?.key_id ?? '', + type: 'AwsSecretsManager', region: $values['secret_backend']?.region ?? 'us-east-1', access_key_id: $values['secret_backend']?.access_key_id ?? null, secret_access_key: $values['secret_backend']?.secret_access_key ?? null, - endpoint_url: $values['secret_backend']?.endpoint_url ?? null + endpoint_url: $values['secret_backend']?.endpoint_url ?? null, + prefix: $values['secret_backend']?.prefix ?? 'windmill/' } } } function setAuthMethod(method: string | undefined) { - if ( - !method || - !$values['secret_backend'] || - $values['secret_backend'].type !== 'HashiCorpVault' - ) - return - + if (!method || !$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return if (method === 'token') { - $values['secret_backend'] = { - ...$values['secret_backend'], - jwt_role: null, - token: $values['secret_backend'].token ?? '' - } + $values['secret_backend'] = { ...$values['secret_backend'], jwt_role: null, token: $values['secret_backend'].token ?? '' } } else if (method === 'jwt') { - $values['secret_backend'] = { - ...$values['secret_backend'], - token: null, - jwt_role: $values['secret_backend'].jwt_role ?? 'windmill-secrets' - } + $values['secret_backend'] = { ...$values['secret_backend'], token: null, jwt_role: $values['secret_backend'].jwt_role ?? 'windmill-secrets' } } } @@ -135,9 +117,7 @@ sendUserToast('Successfully connected to HashiCorp Vault') } catch (error: any) { sendUserToast('Failed to connect to Vault: ' + error.message, true) - } finally { - testingConnection = false - } + } finally { testingConnection = false } } async function migrateSecretsToVault() { @@ -145,17 +125,10 @@ migratingToVault = true try { const report = await SettingService.migrateSecretsToVault({ requestBody: getVaultSettings() }) - if (report.failed_count > 0) { - sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true) - } else { - sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to Vault`) - } - } catch (error: any) { - sendUserToast('Failed to migrate secrets to Vault: ' + error.message, true) - } finally { - migratingToVault = false - migrateToVaultModalOpen = false - } + if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true) + else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to Vault`) + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { migratingToVault = false; migrateToVaultModalOpen = false } } async function migrateSecretsToDatabase() { @@ -163,17 +136,10 @@ migratingToDatabase = true try { const report = await SettingService.migrateSecretsToDatabase({ requestBody: getVaultSettings() }) - if (report.failed_count > 0) { - sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true) - } 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 { - migratingToDatabase = false - migrateToDatabaseModalOpen = false - } + if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true) + else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to database`) + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { migratingToDatabase = false; migrateToDatabaseModalOpen = false } } function isVaultConfigValid(): boolean { @@ -201,11 +167,8 @@ 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 - } + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { testingAzureKvConnection = false } } async function migrateSecretsToAzureKv() { @@ -213,17 +176,10 @@ 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) - } 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 - } + if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true) + else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to Azure Key Vault`) + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { migratingToAzureKv = false; migrateToAzureKvModalOpen = false } } async function migrateSecretsFromAzureKv() { @@ -231,17 +187,10 @@ 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) - } 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 - } + if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true) + else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to database`) + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { migratingFromAzureKv = false; migrateFromAzureKvModalOpen = false } } function isAzureKvConfigValid(): boolean { @@ -254,119 +203,69 @@ ) } - function getAwsKmsSettings() { + function getAwsSmSettings() { return { - key_id: $values['secret_backend'].key_id, region: $values['secret_backend'].region, access_key_id: $values['secret_backend'].access_key_id || undefined, secret_access_key: $values['secret_backend'].secret_access_key || undefined, - endpoint_url: $values['secret_backend'].endpoint_url || undefined + endpoint_url: $values['secret_backend'].endpoint_url || undefined, + prefix: $values['secret_backend'].prefix || undefined } } - async function testAwsKmsConnection() { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return - testingAwsKmsConnection = true + async function testAwsSmConnection() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return + testingAwsSmConnection = true try { - await SettingService.testAwsKmsBackend({ requestBody: getAwsKmsSettings() }) - sendUserToast('Successfully connected to AWS KMS') - } catch (error: any) { - sendUserToast('Failed to connect to AWS KMS: ' + error.message, true) - } finally { - testingAwsKmsConnection = false - } + await SettingService.testAwsSmBackend({ requestBody: getAwsSmSettings() }) + sendUserToast('Successfully connected to AWS Secrets Manager') + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { testingAwsSmConnection = false } } - async function migrateSecretsToAwsKms() { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return - migratingToAwsKms = true + async function migrateSecretsToAwsSm() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return + migratingToAwsSm = true try { - const report = await SettingService.migrateSecretsToAwsKms({ requestBody: getAwsKmsSettings() }) - if (report.failed_count > 0) { - sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true) - } else { - sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to AWS KMS`) - } - } catch (error: any) { - sendUserToast('Failed to migrate secrets to AWS KMS: ' + error.message, true) - } finally { - migratingToAwsKms = false - migrateToAwsKmsModalOpen = false - } + const report = await SettingService.migrateSecretsToAwsSm({ requestBody: getAwsSmSettings() }) + if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true) + else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to AWS Secrets Manager`) + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { migratingToAwsSm = false; migrateToAwsSmModalOpen = false } } - async function migrateSecretsFromAwsKms() { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return - migratingFromAwsKms = true + async function migrateSecretsFromAwsSm() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return + migratingFromAwsSm = true try { - const report = await SettingService.migrateSecretsFromAwsKms({ requestBody: getAwsKmsSettings() }) - if (report.failed_count > 0) { - sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true) - } else { - sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to database`) - } - } catch (error: any) { - sendUserToast('Failed to migrate secrets from AWS KMS: ' + error.message, true) - } finally { - migratingFromAwsKms = false - migrateFromAwsKmsModalOpen = false - } + const report = await SettingService.migrateSecretsFromAwsSm({ requestBody: getAwsSmSettings() }) + if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true) + else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to database`) + } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } + finally { migratingFromAwsSm = false; migrateFromAwsSmModalOpen = false } } - function isAwsKmsConfigValid(): boolean { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return false - return ( - $values['secret_backend'].key_id?.trim() !== '' && - $values['secret_backend'].region?.trim() !== '' - ) + function isAwsSmConfigValid(): boolean { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return false + return $values['secret_backend'].region?.trim() !== '' } let baseUrl = $derived($values['base_url'] ?? 'https://your-windmill-instance.com')
-
setBackendType(v)}> {#snippet children({ item: toggleButton })} - - - - + + + + {/snippet} {#if vaultDisabled}
- HashiCorp Vault, Azure Key Vault, and AWS KMS integrations require Enterprise Edition + External secret store integrations require Enterprise Edition
{/if}
@@ -376,124 +275,54 @@

Database Storage (Default)

-

- Secrets are encrypted using workspace-specific keys and stored in the PostgreSQL database. -

+

Secrets are encrypted using workspace-specific keys and stored in the PostgreSQL database.

{:else if selectedType === 'HashiCorpVault'} -
-

- HashiCorp Vault Configuration - Beta -

-

- Store secrets in an external HashiCorp Vault instance. -

+

HashiCorp Vault Configuration Beta

+

Store secrets in an external HashiCorp Vault instance.

-
- - + +
-
- + The KV v2 secrets engine mount path in Vault - +
- -
Authentication Method setAuthMethod(v)}> {#snippet children({ item: toggleButton })} - - + + {/snippet}
- {#if authMethod === 'token'}
- - Static token for authentication. Recommended only for testing/development. + + Static token. Recommended only for testing/development.
{:else}
- - The JWT authentication role configured in Vault. - - + + The JWT authentication role configured in Vault. +
- Vault JWT Setup Instructions + Vault JWT Setup Instructions

Configure Vault to accept JWTs from Windmill:

-
-
# Enable JWT auth method
+								
+
# Enable JWT auth method
 vault auth enable jwt
 
 # Configure JWT auth with Windmill's JWKS endpoint
@@ -517,84 +346,39 @@ vault write auth/jwt/role/windmill-secrets \
   bound_audiences="{baseUrl}" \
   user_claim="email" \
   policies="windmill-secrets" \
-  ttl="1h"
+ ttl="1h"
-

- Replace windmill-secrets with your role name if different. -

{/if} -
- - Vault Enterprise namespace (leave empty if not using namespaces) - + + Vault Enterprise namespace +
- -
-
- -
- +
Secret Migration - - Migrate secrets between the database and HashiCorp Vault. Original values are NOT - deleted to allow for rollback. - + Original values are NOT deleted to allow for rollback.
-
- -
+

Database → Vault

-

Decrypt secrets from database and store in Vault

- +
-
- -
+

Vault → Database

-

Read secrets from Vault and encrypt in database

- +
{:else if selectedType === 'AzureKeyVault'} -
@@ -603,7 +387,6 @@ vault write auth/jwt/role/windmill-secrets \

Store secrets in an Azure Key Vault instance.

-
@@ -611,137 +394,91 @@ vault write auth/jwt/role/windmill-secrets \
- 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. + Static Bearer token for testing. If provided, OAuth2 is skipped.
-
-
- -
+
Secret Migration - Migrate secrets between the database and Azure Key Vault. Original values are NOT deleted to allow for rollback. + 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

- {:else if selectedType === 'AwsKms'} - + {:else if selectedType === 'AwsSecretsManager'}
- +
-

AWS KMS Configuration

-

- Encrypt secrets using AWS Key Management Service. Secrets remain stored in the database - but are encrypted/decrypted by KMS instead of Windmill's built-in encryption. -

+

AWS Secrets Manager Configuration

+

Store secrets in AWS Secrets Manager.

-
- - Key ID, Key ARN, Alias name (e.g. alias/my-key), or Alias ARN - + +
-
- - -
- -
- + If not provided, the default AWS credential chain is used (env vars, instance profile, EKS pod identity) - +
-
- +
-
- - Custom endpoint for LocalStack or other KMS-compatible services - + + Prefix for secret names in AWS Secrets Manager (default: windmill/) + +
+
+ + Custom endpoint for LocalStack or other compatible services +
- -
-
- -
- +
Secret Migration - - Migrate secrets between database encryption (workspace keys) and AWS KMS encryption. - Secrets stay in the database; only the encryption method changes. - + Original values are NOT deleted to allow for rollback.
-
- -
-

Database → AWS KMS

-

Re-encrypt secrets from workspace keys to AWS KMS

- +
+

Database → AWS Secrets Manager

+
-
- -
-

AWS KMS → Database

-

Re-encrypt secrets from AWS KMS to workspace keys

- +
+

AWS Secrets Manager → Database

+
@@ -750,102 +487,21 @@ vault write auth/jwt/role/windmill-secrets \ {/if}
- - { migrateToAwsKmsModalOpen = false }} - onConfirmed={migrateSecretsToAwsKms} -> - {#snippet children()} -
-

This will re-encrypt all existing secrets using AWS KMS. The process will:

-
    -
  1. Read all encrypted secrets from the database
  2. -
  3. Decrypt them using the workspace encryption keys
  4. -
  5. Re-encrypt them using AWS KMS
  6. -
  7. Update the database values in-place
  8. -
-

- Note: Secrets remain in the database. The encryption method changes from workspace keys to AWS KMS. -

-

Are you sure you want to proceed?

-
- {/snippet} + { migrateToAwsSmModalOpen = false }} onConfirmed={migrateSecretsToAwsSm}> + {#snippet children()}

This will copy all secrets from the database to AWS Secrets Manager.

Database values are NOT deleted automatically.

{/snippet}
- - - { migrateFromAwsKmsModalOpen = false }} - onConfirmed={migrateSecretsFromAwsKms} -> - {#snippet children()} -
-

This will re-encrypt all AWS KMS-encrypted secrets using workspace keys. The process will:

-
    -
  1. Find all secrets encrypted with AWS KMS
  2. -
  3. Decrypt them using AWS KMS
  4. -
  5. Re-encrypt them using workspace encryption keys
  6. -
  7. Update the database values in-place
  8. -
-

Are you sure you want to proceed?

-
- {/snippet} + { migrateFromAwsSmModalOpen = false }} onConfirmed={migrateSecretsFromAwsSm}> + {#snippet children()}

This will copy all secrets from AWS Secrets Manager back to the database.

AWS Secrets Manager values are NOT deleted automatically.

{/snippet}
- - - { 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.

-

Are you sure you want to proceed?

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

This will copy all secrets to Azure Key Vault.

Database values are NOT deleted automatically.

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

This will migrate all secrets from Azure Key Vault back to the database.

-

Note: Azure Key Vault values are NOT deleted automatically.

-

Are you sure you want to proceed?

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

This will copy all secrets from Azure Key Vault back to the database.

{/snippet}
- - - { migrateToVaultModalOpen = false }} onConfirmed={migrateSecretsToVault}> - {#snippet children()} -
-

This will migrate all existing secrets from the database to HashiCorp Vault.

-

Note: Database values are NOT deleted automatically.

-

Are you sure you want to proceed?

-
- {/snippet} + { migrateToVaultModalOpen = false }} onConfirmed={migrateSecretsToVault}> + {#snippet children()}

This will copy all secrets to HashiCorp Vault.

Database values are NOT deleted automatically.

{/snippet}
- - - { migrateToDatabaseModalOpen = false }} onConfirmed={migrateSecretsToDatabase}> - {#snippet children()} -
-

This will migrate all secrets from HashiCorp Vault back to the database.

-

Note: Vault values are NOT deleted automatically.

-

Are you sure you want to proceed?

-
- {/snippet} + { migrateToDatabaseModalOpen = false }} onConfirmed={migrateSecretsToDatabase}> + {#snippet children()}

This will copy all secrets from Vault back to the database.

{/snippet}