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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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': [
|
||||
|
||||
Reference in New Issue
Block a user