diff --git a/backend/.sqlx/query-243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c.json b/backend/.sqlx/query-243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c.json new file mode 100644 index 0000000000..7c7d5785c5 --- /dev/null +++ b/backend/.sqlx/query-243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, path FROM variable WHERE is_secret = true AND value LIKE '$aws_sm:%'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d5aafa6c41..2385f000d5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1006,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" @@ -16722,6 +16746,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-bedrockruntime", "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 beb4aab0af..7cd8feebb5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -560,6 +560,7 @@ aws-sdk-bedrock = "1.129.0" aws-sdk-bedrockruntime = "=1.122.0" aws-credential-types = "^1" aws-smithy-types = "^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/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3c35dba360..b554862c0c 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -50fdd6b517c46e506b251b9fbe1c6218f601d369 \ No newline at end of file +8e5b77ef1f07b5b6620e540fea580f1a3d7f7d8b diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 84cb1af879..1ca22b98b7 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize}; use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel}; #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::secret_backend::{ - AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, + AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ ai_cache::bump_instance_ai_config_revision, @@ -131,6 +131,15 @@ pub fn global_service() -> Router { .route( "/migrate_secrets_from_azure_kv", post(migrate_secrets_from_azure_kv), + ) + .route("/test_aws_sm_backend", post(test_aws_sm_backend)) + .route( + "/migrate_secrets_to_aws_sm", + post(migrate_secrets_to_aws_sm), + ) + .route( + "/migrate_secrets_from_aws_sm", + post(migrate_secrets_from_aws_sm), ); #[cfg(feature = "parquet")] @@ -1284,6 +1293,43 @@ pub async fn migrate_secrets_from_azure_kv( 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 5b206291c0..f1b50211a0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1855,6 +1855,129 @@ paths: schema: $ref: "#/components/schemas/SecretMigrationReport" + /settings/test_aws_kms_backend: + post: + summary: test connection to AWS KMS + operationId: testAwsKmsBackend + tags: + - setting + requestBody: + description: AWS KMS settings to test + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AwsKmsSettings" + responses: + "200": + description: connection test result + content: + text/plain: + schema: + type: string + + /settings/migrate_secrets_to_aws_kms: + post: + summary: migrate secrets from database to AWS KMS encryption + operationId: migrateSecretsToAwsKms + tags: + - setting + requestBody: + description: AWS KMS settings for migration + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AwsKmsSettings" + responses: + "200": + description: migration report + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMigrationReport" + + /settings/migrate_secrets_from_aws_kms: + post: + summary: migrate secrets from AWS KMS encryption to database + operationId: migrateSecretsFromAwsKms + tags: + - setting + requestBody: + description: AWS KMS settings for migration source + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AwsKmsSettings" + responses: + "200": + description: migration report + content: + application/json: + 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) @@ -19405,6 +19528,49 @@ components: type: string description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication) + AwsKmsSettings: + type: object + required: + - key_id + - region + properties: + key_id: + type: string + description: KMS Key ID, Key ARN, Alias name, or Alias ARN + 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) + + 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 5c4253c3ba..5f37d46341 100644 --- a/backend/windmill-api/src/secret_backend_ext.rs +++ b/backend/windmill-api/src/secret_backend_ext.rs @@ -6,14 +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. -//! -//! Note: HashiCorp Vault integration requires Enterprise Edition. -//! The OSS version only supports the database backend. - #[cfg(all(feature = "private", feature = "enterprise"))] use std::sync::Arc; @@ -29,16 +21,14 @@ 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, }, }; #[cfg(all(feature = "private", feature = "enterprise"))] use tokio::sync::RwLock; -// Cached Vault backend to avoid recreating it for every request -// This enables connection pooling and avoids repeated setup overhead #[cfg(all(feature = "private", feature = "enterprise"))] struct CachedVaultBackend { backend: Arc, @@ -50,7 +40,6 @@ lazy_static::lazy_static! { static ref VAULT_BACKEND_CACHE: RwLock> = RwLock::new(None); } -// Cached Azure Key Vault backend #[cfg(all(feature = "private", feature = "enterprise"))] struct CachedAzureKvBackend { backend: Arc, @@ -62,14 +51,23 @@ lazy_static::lazy_static! { static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); } -/// Get the current secret backend based on global settings (EE only) +#[cfg(all(feature = "private", feature = "enterprise"))] +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) => { @@ -78,36 +76,27 @@ async fn get_secret_backend(db: &DB) -> Result> { SecretBackendConfig::AzureKeyVault(settings) => { get_or_create_azure_kv_backend(db, settings).await } + SecretBackendConfig::AwsSecretsManager(settings) => { + get_or_create_aws_sm_backend(db, settings).await + } } } -/// Get a cached Vault backend or create a new one if settings changed #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_or_create_vault_backend( _db: &DB, settings: VaultSettings, ) -> Result> { - // Check if we have a cached backend with matching settings (read lock) { 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()); } } } - - // Need to create a new backend - acquire write lock let mut cache = VAULT_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()); - } + if cached.settings == settings { return Ok(cached.backend.clone()); } } - - // Create new backend let backend: Arc = { #[cfg(feature = "openidconnect")] if settings.token.is_none() { @@ -115,179 +104,136 @@ 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 it *cache = Some(CachedVaultBackend { backend: backend.clone(), settings }); - Ok(backend) } -/// Get a cached Azure Key Vault backend or create a new one if settings changed #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_or_create_azure_kv_backend( _db: &DB, settings: AzureKeyVaultSettings, ) -> Result> { - // Check if we have a cached backend with matching settings (read lock) { let cache = AZURE_KV_BACKEND_CACHE.read().await; if let Some(ref cached) = *cache { - if cached.settings == settings { - return Ok(cached.backend.clone()); - } + 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()); - } + if cached.settings == settings { return Ok(cached.backend.clone()); } } - - // Create new backend let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); - - // Cache it *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); - Ok(backend) } -/// Check if an external secret backend is currently configured (EE only) +#[cfg(all(feature = "private", feature = "enterprise"))] +async fn 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 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) +} + #[cfg(all(feature = "private", feature = "enterprise"))] async fn is_vault_backend_configured(db: &DB) -> Result { let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? { Some(value) => serde_json::from_value::(value).unwrap_or_default(), None => SecretBackendConfig::default(), }; - Ok(matches!( config, - SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_) + SecretBackendConfig::HashiCorpVault(_) + | SecretBackendConfig::AzureKeyVault(_) + | SecretBackendConfig::AwsSecretsManager(_) )) } -/// Check if a value is stored in Vault (indicated by the $vault: prefix) #[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:") } -/// 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:") -} +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:") } -/// 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) + is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value) } -/// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename) -/// EE only feature. -/// -/// This is used when renaming users where many secrets need their paths updated. -/// Returns a list of (old_path, new_value) pairs for updating the database. #[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> { - // OSS: No Vault support, return empty Ok(vec![]) } #[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, - variables: Vec<(String, String)>, // (path, value) pairs + db: &DB, workspace_id: &str, old_prefix: &str, new_prefix: &str, + variables: Vec<(String, String)>, ) -> Result> { - // Only process if an external secret backend is configured - 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 { - // Only handle externally-stored values - if !is_external_stored_value(&value) { - continue; - } + 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) { + let marker_prefix = if value.starts_with("$azure_kv:") { "$azure_kv:" + } else if value.starts_with("$aws_sm:") { + "$aws_sm:" } 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()..]) - } else { - continue; // Path doesn't match prefix, skip - }; + } else { continue; }; - // Read from old path let secret_value = match backend.get_secret(workspace_id, &old_path).await { Ok(v) => v, Err(Error::NotFound(_)) => { - // Just update DB reference updates.push((old_path, format!("{}{}", marker_prefix, new_path))); 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; } }; - // Write to new path - 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; } - // Delete from old path 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 fcf69c13ee..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"] +private = ["dep:aws-sdk-rds", "dep:aws-sdk-secretsmanager"] jemalloc = ["dep:tikv-jemalloc-ctl"] tantivy = [] prometheus = ["dep:prometheus"] @@ -80,6 +80,7 @@ postgres-native-tls.workspace = true native-tls.workspace = true aws-smithy-types-convert = { 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/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 296f6a7d27..46224c29ec 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -899,7 +899,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ /// Maps a top-level key to the sub-field names that must be redacted. const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ ("smtp_settings", &["smtp_password"]), - ("secret_backend", &["token", "client_secret"]), + ("secret_backend", &["token", "client_secret", "secret_access_key"]), ( "object_store_cache_config", &["secret_key", "serviceAccountKey"], 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 54b9f3f88f..bc739f9019 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -22,6 +22,10 @@ pub mod vault_oss; pub mod azure_kv_ee; pub mod azure_kv_oss; +#[cfg(feature = "private")] +pub mod aws_sm_ee; +pub mod aws_sm_oss; + #[cfg(test)] mod tests; @@ -37,60 +41,34 @@ pub use azure_kv_ee::*; #[cfg(not(feature = "private"))] pub use azure_kv_oss::*; +#[cfg(feature = "private")] +pub use aws_sm_ee::*; + +#[cfg(not(feature = "private"))] +pub use aws_sm_oss::*; + use async_trait::async_trait; 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), + AwsSecretsManager(AwsSecretsManagerSettings), } impl Default for SecretBackendConfig { @@ -102,60 +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 Secrets Manager integration +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AwsSecretsManagerSettings { + /// AWS region (e.g., "us-east-1") + pub region: String, + /// 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, + /// 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 019c296805..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,17 +69,22 @@ pub async fn create_secret_backend( ); Ok(Arc::new(DatabaseBackend::new(db))) } + SecretBackendConfig::AwsSecretsManager(_) => { + tracing::warn!( + "AWS Secrets Manager is configured but requires Enterprise Edition. \ + Falling back to database backend." + ); + Ok(Arc::new(DatabaseBackend::new(db))) + } } } -/// 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, @@ -99,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, @@ -109,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/tests/aws_sm_integration.rs b/backend/windmill-common/tests/aws_sm_integration.rs new file mode 100644 index 0000000000..3b9aa2ece8 --- /dev/null +++ b/backend/windmill-common/tests/aws_sm_integration.rs @@ -0,0 +1,264 @@ +//! Integration tests for AWS Secrets Manager secret backend. +//! +//! These tests require a running LocalStack instance with the `secretsmanager` service. +//! +//! ## Setup +//! +//! 1. Start LocalStack: +//! +//! ```bash +//! docker run -d --name localstack -p 4566:4566 \ +//! -e SERVICES=secretsmanager \ +//! localstack/localstack:3.8 +//! ``` +//! +//! 2. Run the tests: +//! +//! ```bash +//! RUN_AWS_SM_TESTS=1 cargo test -p windmill-common --features private,enterprise \ +//! aws_sm_integration -- --nocapture +//! ``` +//! +//! ## Environment variables +//! +//! - `RUN_AWS_SM_TESTS=1` - Required to run the tests +//! - `AWS_SM_ENDPOINT` - LocalStack endpoint (default: http://localhost:4566) +//! - `AWS_SM_REGION` - AWS region (default: us-east-1) + +#[cfg(all(feature = "private", feature = "enterprise"))] +mod tests { + use windmill_common::secret_backend::{ + test_aws_sm_connection, AwsSecretsManagerBackend, AwsSecretsManagerSettings, + SecretBackend, + }; + + fn should_run_aws_sm_tests() -> bool { + std::env::var("RUN_AWS_SM_TESTS") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false) + } + + macro_rules! skip_if_no_aws_sm { + () => { + if !should_run_aws_sm_tests() { + println!("Skipping test: RUN_AWS_SM_TESTS=1 not set"); + println!("To run: RUN_AWS_SM_TESTS=1 cargo test -p windmill-common --features private,enterprise aws_sm_integration -- --nocapture"); + println!("Requires: docker run -d -p 4566:4566 -e SERVICES=secretsmanager localstack/localstack:3.8"); + return; + } + }; + } + + fn test_settings() -> AwsSecretsManagerSettings { + AwsSecretsManagerSettings { + region: std::env::var("AWS_SM_REGION").unwrap_or_else(|_| "us-east-1".to_string()), + access_key_id: Some("test".to_string()), + secret_access_key: Some("test".to_string()), + endpoint_url: Some( + std::env::var("AWS_SM_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:4566".to_string()), + ), + prefix: Some("windmill-test/".to_string()), + } + } + + #[tokio::test] + async fn test_connection() { + skip_if_no_aws_sm!(); + let result = test_aws_sm_connection(&test_settings()).await; + assert!(result.is_ok(), "Connection test failed: {:?}", result.err()); + println!(" ✓ Connection test passed"); + } + + #[tokio::test] + async fn test_create_and_get_secret() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + backend + .set_secret("test-ws", "u/admin/my_secret", "super-secret-value") + .await + .unwrap(); + let value = backend.get_secret("test-ws", "u/admin/my_secret").await.unwrap(); + assert_eq!(value, "super-secret-value"); + + backend.delete_secret("test-ws", "u/admin/my_secret").await.unwrap(); + println!(" ✓ Create + Get + Delete roundtrip passed"); + } + + #[tokio::test] + async fn test_update_secret() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + backend + .set_secret("test-ws", "u/admin/update_test", "original") + .await + .unwrap(); + backend + .set_secret("test-ws", "u/admin/update_test", "updated-value") + .await + .unwrap(); + let value = backend + .get_secret("test-ws", "u/admin/update_test") + .await + .unwrap(); + assert_eq!(value, "updated-value"); + + backend + .delete_secret("test-ws", "u/admin/update_test") + .await + .unwrap(); + println!(" ✓ Update secret passed"); + } + + #[tokio::test] + async fn test_delete_and_verify_gone() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + backend + .set_secret("test-ws", "u/admin/delete_test", "to-be-deleted") + .await + .unwrap(); + backend + .delete_secret("test-ws", "u/admin/delete_test") + .await + .unwrap(); + + let result = backend.get_secret("test-ws", "u/admin/delete_test").await; + assert!( + matches!(result, Err(windmill_common::error::Error::NotFound(_))), + "Expected NotFound after delete, got: {:?}", + result + ); + println!(" ✓ Delete + verify gone passed"); + } + + #[tokio::test] + async fn test_delete_nonexistent_is_ok() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + let result = backend + .delete_secret("test-ws", "u/admin/never_existed") + .await; + assert!( + result.is_ok(), + "Delete of nonexistent should be Ok, got: {:?}", + result.err() + ); + println!(" ✓ Delete nonexistent is Ok"); + } + + #[tokio::test] + async fn test_get_nonexistent_returns_not_found() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + let result = backend + .get_secret("test-ws", "u/admin/does_not_exist") + .await; + assert!( + matches!(result, Err(windmill_common::error::Error::NotFound(_))), + "Expected NotFound, got: {:?}", + result + ); + println!(" ✓ Get nonexistent returns NotFound"); + } + + #[tokio::test] + async fn test_unicode_and_special_chars() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + let secret_value = "p@$$w0rd with unicode: \u{1F512}\u{1F511} and &\"quotes\""; + backend + .set_secret("test-ws", "u/admin/unicode_test", secret_value) + .await + .unwrap(); + let retrieved = backend + .get_secret("test-ws", "u/admin/unicode_test") + .await + .unwrap(); + assert_eq!(retrieved, secret_value); + + backend + .delete_secret("test-ws", "u/admin/unicode_test") + .await + .unwrap(); + println!(" ✓ Unicode and special chars passed"); + } + + #[tokio::test] + async fn test_workspace_isolation() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + + // Create secrets in different workspaces with the same path + backend + .set_secret("workspace-a", "u/admin/shared_name", "value-a") + .await + .unwrap(); + backend + .set_secret("workspace-b", "u/admin/shared_name", "value-b") + .await + .unwrap(); + + // Each workspace should see its own value + let a = backend + .get_secret("workspace-a", "u/admin/shared_name") + .await + .unwrap(); + let b = backend + .get_secret("workspace-b", "u/admin/shared_name") + .await + .unwrap(); + assert_eq!(a, "value-a"); + assert_eq!(b, "value-b"); + + // Cleanup + backend + .delete_secret("workspace-a", "u/admin/shared_name") + .await + .unwrap(); + backend + .delete_secret("workspace-b", "u/admin/shared_name") + .await + .unwrap(); + println!(" ✓ Workspace isolation passed"); + } + + #[tokio::test] + async fn test_backend_name() { + skip_if_no_aws_sm!(); + let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) + .await + .unwrap(); + assert_eq!(backend.backend_name(), "aws_secrets_manager"); + println!(" ✓ Backend name is correct"); + } +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod tests { + #[test] + fn test_aws_sm_requires_enterprise() { + println!("AWS Secrets Manager integration tests require Enterprise Edition features"); + println!("Run with: cargo test -p windmill-common --features private,enterprise"); + } +} diff --git a/backend/windmill-store/src/secret_backend_ext.rs b/backend/windmill-store/src/secret_backend_ext.rs index 6509426abe..cf4b262c60 100644 --- a/backend/windmill-store/src/secret_backend_ext.rs +++ b/backend/windmill-store/src/secret_backend_ext.rs @@ -6,14 +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. -//! -//! Note: HashiCorp Vault integration requires Enterprise Edition. -//! The OSS version only supports the database backend. - use std::sync::Arc; use windmill_common::{ @@ -26,14 +18,15 @@ 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, SecretBackendConfig, VaultBackend, VaultSettings}, + secret_backend::{ + AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend, + AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings, + }, }; #[cfg(all(feature = "private", feature = "enterprise"))] use tokio::sync::RwLock; -// Cached Vault backend to avoid recreating it for every request -// This enables connection pooling and avoids repeated setup overhead #[cfg(all(feature = "private", feature = "enterprise"))] struct CachedVaultBackend { backend: Arc, @@ -56,10 +49,17 @@ lazy_static::lazy_static! { static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); } -/// Get the current secret backend based on global settings -/// -/// OSS: Always returns DatabaseBackend -/// EE: Returns configured backend (Database or Vault) +#[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(not(all(feature = "private", feature = "enterprise")))] pub async fn get_secret_backend(db: &DB) -> Result> { Ok(Arc::new(DatabaseBackend::new(db.clone()))) @@ -80,16 +80,17 @@ pub async fn get_secret_backend(db: &DB) -> Result> { SecretBackendConfig::AzureKeyVault(settings) => { get_or_create_azure_kv_backend(db, settings).await } + SecretBackendConfig::AwsSecretsManager(settings) => { + get_or_create_aws_sm_backend(db, settings).await + } } } -/// Get a cached Vault backend or create a new one if settings changed #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_or_create_vault_backend( _db: &DB, settings: VaultSettings, ) -> Result> { - // Check if we have a cached backend with matching settings (read lock) { let cache = VAULT_BACKEND_CACHE.read().await; if let Some(ref cached) = *cache { @@ -98,18 +99,12 @@ async fn get_or_create_vault_backend( } } } - - // Need to create a new backend - acquire write lock let mut cache = VAULT_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 = { #[cfg(feature = "openidconnect")] if settings.token.is_none() { @@ -117,24 +112,18 @@ 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 it *cache = Some(CachedVaultBackend { backend: backend.clone(), settings }); - Ok(backend) } -/// Get a cached Azure Key Vault backend or create a new one if settings changed #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_or_create_azure_kv_backend( _db: &DB, settings: AzureKeyVaultSettings, ) -> Result> { - // Check if we have a cached backend with matching settings (read lock) { let cache = AZURE_KV_BACKEND_CACHE.read().await; if let Some(ref cached) = *cache { @@ -143,30 +132,42 @@ async fn get_or_create_azure_kv_backend( } } } - - // Need to create a new backend - acquire write lock let mut cache = AZURE_KV_BACKEND_CACHE.write().await; - - // Double-check (another task may have created it while we waited) if let Some(ref cached) = *cache { if cached.settings == settings { return Ok(cached.backend.clone()); } } - - // Create new backend let backend: Arc = Arc::new(AzureKeyVaultBackend::new(settings.clone())); - - // Cache it *cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings }); - Ok(backend) } -/// Check if a Vault backend is currently configured -/// -/// OSS: Always returns false -/// EE: Checks global settings +#[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 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) +} + #[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn is_vault_backend_configured(_db: &DB) -> Result { Ok(false) @@ -178,14 +179,14 @@ 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(_) + )) } -/// Get a secret value using the configured backend -/// -/// For database backend: decrypts using workspace key -/// For vault backend (EE only): fetches from Vault directly pub async fn get_secret_value( db: &DB, workspace_id: &str, @@ -193,20 +194,14 @@ pub async fn get_secret_value( encrypted_value: &str, ) -> Result { let backend = get_secret_backend(db).await?; - match backend.backend_name() { "database" => { - // Use existing database decryption let mc = build_crypt(db, workspace_id).await?; decrypt(&mc, encrypted_value.to_string()).map_err(|e| { Error::internal_err(format!("Error decrypting variable {}: {}", path, e)) }) } - "hashicorp_vault" => { - // Fetch from Vault directly - backend.get_secret(workspace_id, path).await - } - "azure_key_vault" => { + "hashicorp_vault" | "azure_key_vault" | "aws_secrets_manager" => { backend.get_secret(workspace_id, path).await } _ => Err(Error::internal_err(format!( @@ -216,10 +211,6 @@ pub async fn get_secret_value( } } -/// Store a secret value using the configured backend -/// -/// For database backend: encrypts using workspace key and returns encrypted value -/// For vault backend (EE only): stores in Vault and returns a placeholder for DB storage pub async fn store_secret_value( db: &DB, workspace_id: &str, @@ -227,15 +218,12 @@ pub async fn store_secret_value( plain_value: &str, ) -> Result { let backend = get_secret_backend(db).await?; - match backend.backend_name() { "database" => { - // Use existing database encryption let mc = build_crypt(db, workspace_id).await?; Ok(encrypt(&mc, plain_value)) } "hashicorp_vault" => { - // Store in Vault and return a marker for DB backend.set_secret(workspace_id, path, plain_value).await?; Ok(format!("$vault:{}", path)) } @@ -243,6 +231,10 @@ pub async fn store_secret_value( backend.set_secret(workspace_id, path, plain_value).await?; Ok(format!("$azure_kv:{}", path)) } + "aws_secrets_manager" => { + backend.set_secret(workspace_id, path, plain_value).await?; + Ok(format!("$aws_sm:{}", path)) + } _ => Err(Error::internal_err(format!( "Unknown backend: {}", backend.backend_name() @@ -250,14 +242,9 @@ pub async fn store_secret_value( } } -/// Delete a secret from the configured backend (if using Vault) -/// -/// For database backend: no-op (DB delete is handled separately) -/// For vault backend (EE only): deletes from Vault pub async fn delete_secret_from_backend(db: &DB, workspace_id: &str, path: &str) -> Result<()> { if is_vault_backend_configured(db).await? { let backend = get_secret_backend(db).await?; - // Ignore NotFound errors during deletion (secret might not exist in Vault) match backend.delete_secret(workspace_id, path).await { Ok(()) => Ok(()), Err(Error::NotFound(_)) => Ok(()), @@ -268,22 +255,22 @@ pub async fn delete_secret_from_backend(db: &DB, workspace_id: &str, path: &str) } } -/// Check if a value is stored in Vault (indicated by the $vault: prefix) 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) +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_aws_sm_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( _db: &DB, @@ -293,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) } @@ -325,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:" }; @@ -333,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))); } @@ -347,31 +327,25 @@ 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 ); } Ok(Some(format!("{}{}", marker_prefix, new_path))) } -/// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename) #[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn rename_vault_secrets_with_prefix( _db: &DB, @@ -405,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:" }; @@ -422,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.ts b/frontend/src/lib/components/instanceSettings.ts index 64fd1a4ade..cd8ed5efc0 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -683,11 +683,11 @@ export const settings: Record = { { label: 'Backend type', description: - 'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault and Azure Key Vault as external secret stores.', + 'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault, Azure Key Vault, and AWS KMS as external secret backends.', key: 'secret_backend', fieldType: 'secret_backend', storage: 'setting', - ee_only: 'HashiCorp Vault and Azure Key Vault integrations are Enterprise Edition features' + ee_only: 'HashiCorp Vault, Azure Key Vault, and AWS KMS integrations are Enterprise Edition features' } ], 'GitHub App': [ diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 60aa737a59..8e33b75300 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -19,24 +19,19 @@ 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' = $derived( + let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsSecretsManager' = $derived( $values['secret_backend']?.type ?? 'Database' ) - // Derive auth method from current config - // We check jwt_role === null because setAuthMethod explicitly sets jwt_role to null for token mode - // and sets token to null for jwt mode. This allows empty token values while still tracking the selection. let authMethod: 'token' | 'jwt' = $derived.by(() => { const config = $values['secret_backend'] if (!config || config.type !== 'HashiCorpVault') return 'jwt' - // If jwt_role is explicitly null, we're in token mode; otherwise jwt mode return config.jwt_role === null ? 'token' : 'jwt' }) @@ -52,15 +47,17 @@ let migrateToAzureKvModalOpen = $state(false) let migrateFromAzureKvModalOpen = $state(false) - // Check if Vault option should be disabled (non-EE) + 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 - // Prevent selecting Vault in non-EE - if (type === 'HashiCorpVault' && vaultDisabled) { - return - } + if ((type === 'HashiCorpVault' || type === 'AzureKeyVault' || type === 'AwsSecretsManager') && vaultDisabled) return if (type === 'Database') { $values['secret_backend'] = { type: 'Database' } } else if (type === 'HashiCorpVault') { @@ -73,7 +70,6 @@ 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 ?? '', @@ -82,31 +78,24 @@ client_secret: $values['secret_backend']?.client_secret ?? null, token: $values['secret_backend']?.token ?? null } + } else if (type === 'AwsSecretsManager') { + $values['secret_backend'] = { + 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, + 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') { - // Clear JWT role when switching to token auth - $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') { - // Clear token when switching to JWT auth - $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' } } } @@ -121,96 +110,47 @@ } async function testVaultConnection() { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') { - return - } - + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return testingConnection = true try { - await SettingService.testSecretBackend({ - requestBody: getVaultSettings() - }) + await SettingService.testSecretBackend({ requestBody: getVaultSettings() }) 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() { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') { - return - } - + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return 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 - ) - console.error('Migration failures:', report.failures) - } 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 - } + const report = await SettingService.migrateSecretsToVault({ requestBody: getVaultSettings() }) + 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() { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') { - return - } - + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return 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 - ) - 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 { - migratingToDatabase = false - migrateToDatabaseModalOpen = false - } + const report = await SettingService.migrateSecretsToDatabase({ requestBody: getVaultSettings() }) + 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 { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') { - return false - } + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return false const hasAddress = $values['secret_backend'].address?.trim() !== '' const hasMountPath = $values['secret_backend'].mount_path?.trim() !== '' const hasToken = $values['secret_backend'].token?.trim() const hasJwtRole = $values['secret_backend'].jwt_role?.trim() - - // Must have address and mount path, plus either token OR jwt_role (not both) return hasAddress && hasMountPath && (hasToken || hasJwtRole) } - // Get the base URL for JWKS endpoint instructions (from instance settings) - function getAzureKvSettings() { return { vault_url: $values['secret_backend'].vault_url, @@ -227,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() { @@ -239,18 +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) - 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 - } + 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() { @@ -258,18 +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) - 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 - } + 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 { @@ -282,43 +203,69 @@ ) } + function getAwsSmSettings() { + return { + 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, + prefix: $values['secret_backend'].prefix || undefined + } + } + + async function testAwsSmConnection() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return + testingAwsSmConnection = true + try { + 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 migrateSecretsToAwsSm() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return + migratingToAwsSm = true + try { + 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 migrateSecretsFromAwsSm() { + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return + migratingFromAwsSm = true + try { + 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 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 and Azure Key Vault integrations require Enterprise Edition + External secret store integrations require Enterprise Edition
{/if}
@@ -328,125 +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
@@ -470,382 +346,162 @@ 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'} -

Azure Key Vault Configuration

-

- Store secrets in an Azure Key Vault instance. -

+

Store secrets in an Azure Key Vault instance.

-
- - + +
-
- - The Azure Active Directory tenant ID - + +
-
- - The Azure AD application (service principal) client ID - + +
-
- - The Azure AD application client secret for authentication +
-
- - Static Bearer token for testing/development. If provided, OAuth2 authentication is skipped. + + 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 === 'AwsSecretsManager'} +
+
+ +
+

AWS Secrets Manager Configuration Beta

+

Store secrets in AWS Secrets Manager.

+
+
+
+
+ + +
+
+ + If not provided, the default AWS credential chain is used (env vars, instance profile, EKS pod identity) + +
+
+ + +
+
+ + Prefix for secret names in AWS Secrets Manager (default: windmill/) + +
+
+ + Custom endpoint for LocalStack or other compatible services + +
+
+
+ +
+ Secret Migration + Original values are NOT deleted to allow for rollback. +
+
+
+

Database → AWS Secrets Manager

+ +
+
+
+

AWS Secrets Manager → Database

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

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

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

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

-

Are you sure you want to proceed?

-
- {/snippet} + { 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}
- - - { - migrateFromAzureKvModalOpen = false - }} - onConfirmed={migrateSecretsFromAzureKv} -> - {#snippet children()} -
-

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

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

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

-

Are you sure you want to proceed?

-
- {/snippet} + { 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}
- - - { - migrateToVaultModalOpen = false - }} - onConfirmed={migrateSecretsToVault} -> - {#snippet children()} -
-

- This will migrate all existing secrets from the database to HashiCorp 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 HashiCorp Vault under the configured mount path
  6. -
-

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

-

Are you sure you want to proceed?

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

This will copy all secrets to Azure Key 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. The process will: -

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

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

-

Are you sure you want to proceed?

-
- {/snippet} + { 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 copy all secrets to HashiCorp Vault.

Database values are NOT deleted automatically.

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

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

{/snippet}