diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f1b50211a0..cf11aff274 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1855,69 +1855,6 @@ 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 @@ -19528,28 +19465,6 @@ 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: diff --git a/backend/windmill-api/src/secret_backend_ext.rs b/backend/windmill-api/src/secret_backend_ext.rs index 5f37d46341..d38adbe14a 100644 --- a/backend/windmill-api/src/secret_backend_ext.rs +++ b/backend/windmill-api/src/secret_backend_ext.rs @@ -6,6 +6,14 @@ * 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,6 +37,8 @@ use windmill_common::{ #[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, @@ -40,6 +50,7 @@ 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, @@ -51,6 +62,7 @@ lazy_static::lazy_static! { static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); } +// Cached AWS Secrets Manager backend #[cfg(all(feature = "private", feature = "enterprise"))] struct CachedAwsSmBackend { backend: Arc, @@ -62,12 +74,14 @@ lazy_static::lazy_static! { static ref AWS_SM_BACKEND_CACHE: RwLock> = RwLock::new(None); } +/// Get the current secret backend based on global settings (EE only) #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_secret_backend(db: &DB) -> Result> { 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) => { @@ -82,21 +96,33 @@ async fn get_secret_backend(db: &DB) -> Result> { } } +/// 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() { @@ -104,33 +130,53 @@ 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) } +/// Get a cached AWS SM backend or create a new one if settings changed #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_or_create_aws_sm_backend( _db: &DB, @@ -139,101 +185,163 @@ async fn get_or_create_aws_sm_backend( { let cache = AWS_SM_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 = AWS_SM_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(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?); + *cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings }); + Ok(backend) } +/// Check if an external secret backend is currently configured (EE only) #[cfg(all(feature = "private", feature = "enterprise"))] async fn is_vault_backend_configured(db: &DB) -> Result { let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? { Some(value) => serde_json::from_value::(value).unwrap_or_default(), None => SecretBackendConfig::default(), }; + Ok(matches!( config, - SecretBackendConfig::HashiCorpVault(_) - | SecretBackendConfig::AzureKeyVault(_) - | SecretBackendConfig::AwsSecretsManager(_) + 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:") +} +/// Check if a value is stored in AWS Secrets Manager #[cfg(all(feature = "private", feature = "enterprise"))] -fn is_aws_sm_stored_value(value: &str) -> bool { value.starts_with("$aws_sm:") } +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_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)>, + db: &DB, + workspace_id: &str, + old_prefix: &str, + new_prefix: &str, + variables: Vec<(String, String)>, // (path, value) pairs ) -> Result> { - if !is_vault_backend_configured(db).await? { return Ok(vec![]); } + // Only process if an external secret backend is configured + 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; } + // Only handle externally-stored values + if !is_external_stored_value(&value) { + continue; + } - let marker_prefix = if value.starts_with("$azure_kv:") { + // Determine the marker prefix from the stored value + let marker_prefix = if is_azure_kv_stored_value(&value) { "$azure_kv:" - } else if value.starts_with("$aws_sm:") { + } else if is_aws_sm_stored_value(&value) { "$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; }; + } else { + continue; // Path doesn't match prefix, skip + }; + // 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; } }; - 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); + // 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 + ); 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/src/secret_backend/mod.rs b/backend/windmill-common/src/secret_backend/mod.rs index bc739f9019..2e0d6d0f21 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -53,21 +53,55 @@ 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), + /// Store secrets in AWS Secrets Manager (Enterprise Edition only) AwsSecretsManager(AwsSecretsManagerSettings), } @@ -80,23 +114,36 @@ 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, } @@ -123,16 +170,23 @@ pub struct AwsSecretsManagerSettings { /// 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 25cd124ea5..5c34302bf7 100644 --- a/backend/windmill-common/src/secret_backend/vault_oss.rs +++ b/backend/windmill-common/src/secret_backend/vault_oss.rs @@ -6,6 +6,11 @@ * 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; @@ -16,6 +21,7 @@ use super::{ VaultSettings, }; +/// Stub VaultBackend for OSS - all operations return EE required error pub struct VaultBackend; impl VaultBackend { @@ -49,6 +55,10 @@ 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,12 +89,14 @@ 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, @@ -94,6 +106,7 @@ 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, @@ -103,6 +116,7 @@ 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-store/src/secret_backend_ext.rs b/backend/windmill-store/src/secret_backend_ext.rs index cf4b262c60..a83cb56b9e 100644 --- a/backend/windmill-store/src/secret_backend_ext.rs +++ b/backend/windmill-store/src/secret_backend_ext.rs @@ -6,6 +6,14 @@ * 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::{ @@ -18,15 +26,14 @@ use windmill_common::{ #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::{ global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING}, - secret_backend::{ - AwsSecretsManagerBackend, AwsSecretsManagerSettings, 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, @@ -49,6 +56,7 @@ lazy_static::lazy_static! { static ref AZURE_KV_BACKEND_CACHE: RwLock> = RwLock::new(None); } +// Cached AWS Secrets Manager backend #[cfg(all(feature = "private", feature = "enterprise"))] struct CachedAwsSmBackend { backend: Arc, @@ -60,6 +68,10 @@ lazy_static::lazy_static! { static ref AWS_SM_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(not(all(feature = "private", feature = "enterprise")))] pub async fn get_secret_backend(db: &DB) -> Result> { Ok(Arc::new(DatabaseBackend::new(db.clone()))) @@ -86,11 +98,13 @@ pub async fn get_secret_backend(db: &DB) -> Result> { } } +/// 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 { @@ -99,12 +113,18 @@ 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() { @@ -112,18 +132,24 @@ 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 { @@ -132,17 +158,27 @@ 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) } +/// Get a cached AWS SM backend or create a new one if settings changed #[cfg(all(feature = "private", feature = "enterprise"))] async fn get_or_create_aws_sm_backend( _db: &DB, @@ -156,18 +192,27 @@ async fn get_or_create_aws_sm_backend( } } } + 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) } +/// Check if a Vault backend is currently configured +/// +/// OSS: Always returns false +/// EE: Checks global settings #[cfg(not(all(feature = "private", feature = "enterprise")))] pub async fn is_vault_backend_configured(_db: &DB) -> Result { Ok(false) @@ -179,14 +224,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(_) - | SecretBackendConfig::AwsSecretsManager(_) - )) + + 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, @@ -194,14 +239,23 @@ 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" | "azure_key_vault" | "aws_secrets_manager" => { + "hashicorp_vault" => { + // Fetch from Vault directly + backend.get_secret(workspace_id, path).await + } + "azure_key_vault" => { + backend.get_secret(workspace_id, path).await + } + "aws_secrets_manager" => { backend.get_secret(workspace_id, path).await } _ => Err(Error::internal_err(format!( @@ -211,6 +265,10 @@ 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, @@ -218,12 +276,15 @@ 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)) } @@ -242,9 +303,14 @@ 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(()), @@ -255,22 +321,27 @@ 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 AWS Secrets Manager (indicated by the $aws_sm: prefix) pub 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 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, @@ -280,12 +351,27 @@ 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) { + tracing::warn!( + "Variable has $aws_sm: prefix but AWS Secrets Manager requires Enterprise Edition. \ + Updating DB reference to {}", + new_path + ); return Ok(Some(format!("$aws_sm:{}", new_path))); } Ok(None) @@ -315,7 +401,9 @@ 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))); } @@ -327,25 +415,31 @@ 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, @@ -398,18 +492,33 @@ 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 cd8ed5efc0..35fec1ac79 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -683,11 +683,12 @@ export const settings: Record = { { label: 'Backend type', description: - '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.', + 'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault, Azure Key Vault, and AWS Secrets Manager as external secret backends.', key: 'secret_backend', fieldType: 'secret_backend', storage: 'setting', - ee_only: 'HashiCorp Vault, Azure Key Vault, and AWS KMS integrations are Enterprise Edition features' + ee_only: + 'HashiCorp Vault, Azure Key Vault, and AWS Secrets Manager integrations are Enterprise Edition features' } ], 'GitHub App': [