fix: remove stale KMS openapi/description, restore stripped doc comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-06 15:18:05 +00:00
parent f32c3b3c88
commit baaefeda37
6 changed files with 331 additions and 130 deletions

View File

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

View File

@@ -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<dyn SecretBackend>,
@@ -40,6 +50,7 @@ lazy_static::lazy_static! {
static ref VAULT_BACKEND_CACHE: RwLock<Option<CachedVaultBackend>> = RwLock::new(None);
}
// Cached Azure Key Vault backend
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAzureKvBackend {
backend: Arc<dyn SecretBackend>,
@@ -51,6 +62,7 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
// Cached AWS Secrets Manager backend
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
@@ -62,12 +74,14 @@ lazy_static::lazy_static! {
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
}
/// Get the current secret backend based on global settings (EE only)
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? {
Some(value) => serde_json::from_value::<SecretBackendConfig>(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<Arc<dyn SecretBackend>> {
}
}
/// 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<Arc<dyn SecretBackend>> {
// 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<dyn SecretBackend> = {
#[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<Arc<dyn SecretBackend>> {
// Check if we have a cached backend with matching settings (read lock)
{
let cache = AZURE_KV_BACKEND_CACHE.read().await;
if let Some(ref cached) = *cache {
if cached.settings == settings { return Ok(cached.backend.clone()); }
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<dyn SecretBackend> = 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<dyn SecretBackend> =
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<bool> {
let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? {
Some(value) => serde_json::from_value::<SecretBackendConfig>(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<Vec<(String, String)>> {
// 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<Vec<(String, String)>> {
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)
}

View File

@@ -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<String>;
/// 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<String>,
/// Vault Enterprise namespace (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
/// 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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AzureKeyVaultSettings {
/// Azure Key Vault URL (e.g., "https://myvault.vault.azure.net")
pub vault_url: String,
/// Azure AD tenant ID
pub tenant_id: String,
/// Azure AD application (client) ID
pub client_id: String,
/// Azure AD client secret
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
/// Static Bearer token for testing/development (optional)
/// If provided, this is used instead of OAuth2 client credentials authentication
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
@@ -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<SecretMigrationFailure>,
}
/// 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,
}

View File

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

View File

@@ -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<dyn SecretBackend>,
@@ -49,6 +56,7 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
// Cached AWS Secrets Manager backend
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
@@ -60,6 +68,10 @@ lazy_static::lazy_static! {
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = 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<Arc<dyn SecretBackend>> {
Ok(Arc::new(DatabaseBackend::new(db.clone())))
@@ -86,11 +98,13 @@ pub async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
}
}
/// 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<Arc<dyn SecretBackend>> {
// 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<dyn SecretBackend> = {
#[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<Arc<dyn SecretBackend>> {
// Check if we have a cached backend with matching settings (read lock)
{
let cache = AZURE_KV_BACKEND_CACHE.read().await;
if let Some(ref cached) = *cache {
@@ -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<dyn SecretBackend> = 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<dyn SecretBackend> =
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<bool> {
Ok(false)
@@ -179,14 +224,14 @@ pub async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
Some(value) => serde_json::from_value::<SecretBackendConfig>(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<String> {
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<String> {
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<Option<String>> {
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)));

View File

@@ -683,11 +683,12 @@ export const settings: Record<string, Setting[]> = {
{
label: 'Backend type',
description:
'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault, 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': [