feat: add AWS Secrets Manager as secret storage backend (Beta) (#8734)

* feat: add AWS KMS as secret backend (EE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: switch from AWS KMS to AWS Secrets Manager as secret backend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add AWS Secrets Manager integration tests (requires LocalStack)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: mark AWS Secrets Manager as beta

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove leftover KMS handler functions from api-settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to include AWS Secrets Manager EE impl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use full commit hash in ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* sqlx

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-06 11:17:15 -04:00
committed by GitHub
parent a78eb6e93d
commit 09bbc18bb7
16 changed files with 988 additions and 857 deletions

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path FROM variable WHERE is_secret = true AND value LIKE '$aws_sm:%'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c"
}

25
backend/Cargo.lock generated
View File

@@ -1006,6 +1006,30 @@ dependencies = [
"url",
]
[[package]]
name = "aws-sdk-secretsmanager"
version = "1.100.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b0b2985427bb081e54e759468d3af89fa2ccb17fb8b9e5b704ae2f8da10a3b"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.63.4",
"aws-smithy-json 0.62.4",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.4.0",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-sqs"
version = "1.77.0"
@@ -16722,6 +16746,7 @@ dependencies = [
"aws-credential-types",
"aws-sdk-bedrockruntime",
"aws-sdk-rds",
"aws-sdk-secretsmanager",
"aws-sdk-sts",
"aws-smithy-types",
"aws-smithy-types-convert",

View File

@@ -560,6 +560,7 @@ aws-sdk-bedrock = "1.129.0"
aws-sdk-bedrockruntime = "=1.122.0"
aws-credential-types = "^1"
aws-smithy-types = "^1"
aws-sdk-secretsmanager = "^1"
aws-sdk-sqs = "=1.77.0"
aws-sdk-sts = "=1.79.0"
aws-sdk-sso = "=1.77.0"

View File

@@ -1 +1 @@
50fdd6b517c46e506b251b9fbe1c6218f601d369
8e5b77ef1f07b5b6620e540fea580f1a3d7f7d8b

View File

@@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize};
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::secret_backend::{
AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
};
use windmill_common::{
ai_cache::bump_instance_ai_config_revision,
@@ -131,6 +131,15 @@ pub fn global_service() -> Router {
.route(
"/migrate_secrets_from_azure_kv",
post(migrate_secrets_from_azure_kv),
)
.route("/test_aws_sm_backend", post(test_aws_sm_backend))
.route(
"/migrate_secrets_to_aws_sm",
post(migrate_secrets_to_aws_sm),
)
.route(
"/migrate_secrets_from_aws_sm",
post(migrate_secrets_from_aws_sm),
);
#[cfg(feature = "parquet")]
@@ -1284,6 +1293,43 @@ pub async fn migrate_secrets_from_azure_kv(
Ok(Json(report))
}
/// Test connection to AWS Secrets Manager
#[cfg(all(feature = "private", feature = "enterprise"))]
pub async fn test_aws_sm_backend(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
windmill_common::secret_backend::test_aws_sm_connection(&settings).await?;
Ok("Successfully connected to AWS Secrets Manager".to_string())
}
/// Migrate existing secrets from database to AWS Secrets Manager
#[cfg(all(feature = "private", feature = "enterprise"))]
pub async fn migrate_secrets_to_aws_sm(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
let report = windmill_common::secret_backend::migrate_secrets_to_aws_sm(&db, &settings).await?;
Ok(Json(report))
}
/// Migrate secrets from AWS Secrets Manager back to database
#[cfg(all(feature = "private", feature = "enterprise"))]
pub async fn migrate_secrets_from_aws_sm(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Json(settings): Json<AwsSecretsManagerSettings>,
) -> JsonResult<SecretMigrationReport> {
require_super_admin(&db, &authed.email).await?;
let report =
windmill_common::secret_backend::migrate_secrets_from_aws_sm(&db, &settings).await?;
Ok(Json(report))
}
// ============================================================================
// JWKS Endpoint for Vault JWT Authentication
// ============================================================================

View File

@@ -1855,6 +1855,129 @@ paths:
schema:
$ref: "#/components/schemas/SecretMigrationReport"
/settings/test_aws_kms_backend:
post:
summary: test connection to AWS KMS
operationId: testAwsKmsBackend
tags:
- setting
requestBody:
description: AWS KMS settings to test
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AwsKmsSettings"
responses:
"200":
description: connection test result
content:
text/plain:
schema:
type: string
/settings/migrate_secrets_to_aws_kms:
post:
summary: migrate secrets from database to AWS KMS encryption
operationId: migrateSecretsToAwsKms
tags:
- setting
requestBody:
description: AWS KMS settings for migration
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AwsKmsSettings"
responses:
"200":
description: migration report
content:
application/json:
schema:
$ref: "#/components/schemas/SecretMigrationReport"
/settings/migrate_secrets_from_aws_kms:
post:
summary: migrate secrets from AWS KMS encryption to database
operationId: migrateSecretsFromAwsKms
tags:
- setting
requestBody:
description: AWS KMS settings for migration source
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AwsKmsSettings"
responses:
"200":
description: migration report
content:
application/json:
schema:
$ref: "#/components/schemas/SecretMigrationReport"
/settings/test_aws_sm_backend:
post:
summary: test connection to AWS Secrets Manager
operationId: testAwsSmBackend
tags:
- setting
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AwsSecretsManagerSettings"
responses:
"200":
description: connection test result
content:
text/plain:
schema:
type: string
/settings/migrate_secrets_to_aws_sm:
post:
summary: migrate secrets from database to AWS Secrets Manager
operationId: migrateSecretsToAwsSm
tags:
- setting
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AwsSecretsManagerSettings"
responses:
"200":
description: migration report
content:
application/json:
schema:
$ref: "#/components/schemas/SecretMigrationReport"
/settings/migrate_secrets_from_aws_sm:
post:
summary: migrate secrets from AWS Secrets Manager to database
operationId: migrateSecretsFromAwsSm
tags:
- setting
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AwsSecretsManagerSettings"
responses:
"200":
description: migration report
content:
application/json:
schema:
$ref: "#/components/schemas/SecretMigrationReport"
/users/email:
get:
summary: get current user email (if logged in)
@@ -19405,6 +19528,49 @@ components:
type: string
description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication)
AwsKmsSettings:
type: object
required:
- key_id
- region
properties:
key_id:
type: string
description: KMS Key ID, Key ARN, Alias name, or Alias ARN
region:
type: string
description: AWS region (e.g., us-east-1)
access_key_id:
type: string
description: AWS Access Key ID (optional, uses default credential chain if not provided)
secret_access_key:
type: string
description: AWS Secret Access Key (optional)
endpoint_url:
type: string
description: Custom endpoint URL for testing (e.g., LocalStack)
AwsSecretsManagerSettings:
type: object
required:
- region
properties:
region:
type: string
description: AWS region (e.g., us-east-1)
access_key_id:
type: string
description: AWS Access Key ID (optional, uses default credential chain if not provided)
secret_access_key:
type: string
description: AWS Secret Access Key (optional)
endpoint_url:
type: string
description: Custom endpoint URL for testing (e.g., LocalStack)
prefix:
type: string
description: Prefix for secret names (e.g., windmill/)
SecretMigrationFailure:
type: object
required:

View File

@@ -6,14 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
//! Secret backend extension for the API layer
//!
//! This module provides helper functions for integrating the SecretBackend
//! trait with variable operations in the API.
//!
//! Note: HashiCorp Vault integration requires Enterprise Edition.
//! The OSS version only supports the database backend.
#[cfg(all(feature = "private", feature = "enterprise"))]
use std::sync::Arc;
@@ -29,16 +21,14 @@ use windmill_common::secret_backend::{database::DatabaseBackend, SecretBackend};
use windmill_common::{
global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING},
secret_backend::{
AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend,
VaultSettings,
AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend,
AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings,
},
};
#[cfg(all(feature = "private", feature = "enterprise"))]
use tokio::sync::RwLock;
// Cached Vault backend to avoid recreating it for every request
// This enables connection pooling and avoids repeated setup overhead
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedVaultBackend {
backend: Arc<dyn SecretBackend>,
@@ -50,7 +40,6 @@ 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>,
@@ -62,14 +51,23 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
/// Get the current secret backend based on global settings (EE only)
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
settings: AwsSecretsManagerSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
}
#[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) => {
@@ -78,36 +76,27 @@ async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
SecretBackendConfig::AzureKeyVault(settings) => {
get_or_create_azure_kv_backend(db, settings).await
}
SecretBackendConfig::AwsSecretsManager(settings) => {
get_or_create_aws_sm_backend(db, settings).await
}
}
}
/// Get a cached Vault backend or create a new one if settings changed
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_vault_backend(
_db: &DB,
settings: VaultSettings,
) -> Result<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() {
@@ -115,179 +104,136 @@ async fn get_or_create_vault_backend(
} else {
Arc::new(VaultBackend::new(settings.clone()))
}
#[cfg(not(feature = "openidconnect"))]
Arc::new(VaultBackend::new(settings.clone()))
};
// Cache it
*cache = Some(CachedVaultBackend { backend: backend.clone(), settings });
Ok(backend)
}
/// Get a cached Azure Key Vault backend or create a new one if settings changed
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_azure_kv_backend(
_db: &DB,
settings: AzureKeyVaultSettings,
) -> Result<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)
}
/// Check if an external secret backend is currently configured (EE only)
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_aws_sm_backend(
_db: &DB,
settings: AwsSecretsManagerSettings,
) -> Result<Arc<dyn SecretBackend>> {
{
let cache = AWS_SM_BACKEND_CACHE.read().await;
if let Some(ref cached) = *cache {
if cached.settings == settings { return Ok(cached.backend.clone()); }
}
}
let mut cache = AWS_SM_BACKEND_CACHE.write().await;
if let Some(ref cached) = *cache {
if cached.settings == settings { return Ok(cached.backend.clone()); }
}
let backend: Arc<dyn SecretBackend> =
Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?);
*cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings });
Ok(backend)
}
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn is_vault_backend_configured(db: &DB) -> Result<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::HashiCorpVault(_)
| SecretBackendConfig::AzureKeyVault(_)
| SecretBackendConfig::AwsSecretsManager(_)
))
}
/// Check if a value is stored in Vault (indicated by the $vault: prefix)
#[cfg(all(feature = "private", feature = "enterprise"))]
fn is_vault_stored_value(value: &str) -> bool {
value.starts_with("$vault:")
}
fn is_vault_stored_value(value: &str) -> bool { value.starts_with("$vault:") }
/// Check if a value is stored in Azure Key Vault (indicated by the $azure_kv: prefix)
#[cfg(all(feature = "private", feature = "enterprise"))]
fn is_azure_kv_stored_value(value: &str) -> bool {
value.starts_with("$azure_kv:")
}
fn is_azure_kv_stored_value(value: &str) -> bool { value.starts_with("$azure_kv:") }
#[cfg(all(feature = "private", feature = "enterprise"))]
fn is_aws_sm_stored_value(value: &str) -> bool { value.starts_with("$aws_sm:") }
/// Check if a value is stored in any external secret backend
#[cfg(all(feature = "private", feature = "enterprise"))]
fn is_external_stored_value(value: &str) -> bool {
is_vault_stored_value(value) || is_azure_kv_stored_value(value)
is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value)
}
/// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename)
/// EE only feature.
///
/// This is used when renaming users where many secrets need their paths updated.
/// Returns a list of (old_path, new_value) pairs for updating the database.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn rename_vault_secrets_with_prefix(
_db: &DB,
_workspace_id: &str,
_old_prefix: &str,
_new_prefix: &str,
_db: &DB, _workspace_id: &str, _old_prefix: &str, _new_prefix: &str,
_variables: Vec<(String, String)>,
) -> Result<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)>, // (path, value) pairs
db: &DB, workspace_id: &str, old_prefix: &str, new_prefix: &str,
variables: Vec<(String, String)>,
) -> Result<Vec<(String, String)>> {
// Only process if an external secret backend is configured
if !is_vault_backend_configured(db).await? {
return Ok(vec![]);
}
if !is_vault_backend_configured(db).await? { return Ok(vec![]); }
let backend = get_secret_backend(db).await?;
let mut updates = Vec::new();
for (old_path, value) in variables {
// Only handle externally-stored values
if !is_external_stored_value(&value) {
continue;
}
if !is_external_stored_value(&value) { continue; }
// Determine the marker prefix from the stored value
let marker_prefix = if is_azure_kv_stored_value(&value) {
let marker_prefix = if value.starts_with("$azure_kv:") {
"$azure_kv:"
} else if value.starts_with("$aws_sm:") {
"$aws_sm:"
} else {
"$vault:"
};
// Calculate new path by replacing prefix
let new_path = if old_path.starts_with(old_prefix) {
format!("{}{}", new_prefix, &old_path[old_prefix.len()..])
} else {
continue; // Path doesn't match prefix, skip
};
} else { continue; };
// Read from old path
let secret_value = match backend.get_secret(workspace_id, &old_path).await {
Ok(v) => v,
Err(Error::NotFound(_)) => {
// Just update DB reference
updates.push((old_path, format!("{}{}", marker_prefix, new_path)));
continue;
}
Err(e) => {
tracing::error!(
"Failed to read secret at {} during bulk rename: {}",
old_path,
e
);
tracing::error!("Failed to read secret at {} during bulk rename: {}", old_path, e);
continue;
}
};
// Write to new path
if let Err(e) = backend
.set_secret(workspace_id, &new_path, &secret_value)
.await
{
tracing::error!(
"Failed to write secret to {} during bulk rename: {}",
new_path,
e
);
if let Err(e) = backend.set_secret(workspace_id, &new_path, &secret_value).await {
tracing::error!("Failed to write secret to {} during bulk rename: {}", new_path, e);
continue;
}
// Delete from old path
if let Err(e) = backend.delete_secret(workspace_id, &old_path).await {
tracing::warn!(
"Failed to delete old secret at {} after rename: {}",
old_path,
e
);
tracing::warn!("Failed to delete old secret at {} after rename: {}", old_path, e);
}
updates.push((old_path, format!("{}{}", marker_prefix, new_path)));
}
Ok(updates)
}

View File

@@ -9,7 +9,7 @@ default = []
enterprise = ["dep:aws-config"]
instance_config_schema = ["dep:schemars"]
local_reports = ["dep:rsa", "dep:aes-gcm"]
private = ["dep:aws-sdk-rds"]
private = ["dep:aws-sdk-rds", "dep:aws-sdk-secretsmanager"]
jemalloc = ["dep:tikv-jemalloc-ctl"]
tantivy = []
prometheus = ["dep:prometheus"]
@@ -80,6 +80,7 @@ postgres-native-tls.workspace = true
native-tls.workspace = true
aws-smithy-types-convert = { workspace = true, optional = true }
aws-sdk-secretsmanager = { workspace = true, optional = true }
aws-sdk-rds = { workspace = true, optional = true }
indexmap.workspace = true
bytes.workspace = true

View File

@@ -899,7 +899,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[
/// Maps a top-level key to the sub-field names that must be redacted.
const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[
("smtp_settings", &["smtp_password"]),
("secret_backend", &["token", "client_secret"]),
("secret_backend", &["token", "client_secret", "secret_access_key"]),
(
"object_store_cache_config",
&["secret_key", "serviceAccountKey"],

View File

@@ -0,0 +1,69 @@
/*
* Author: Windmill Labs, Inc
* Copyright (C) Windmill Labs, Inc - All Rights Reserved
* Unauthorized copying of this file, via any medium is strictly prohibited.
*/
use async_trait::async_trait;
use crate::db::DB;
use crate::error::{Error, Result};
use super::{AwsSecretsManagerSettings, SecretBackend, SecretMigrationReport};
pub struct AwsSecretsManagerBackend;
impl AwsSecretsManagerBackend {
pub fn new(_settings: AwsSecretsManagerSettings) -> Self {
AwsSecretsManagerBackend
}
}
#[async_trait]
impl SecretBackend for AwsSecretsManagerBackend {
async fn get_secret(&self, _workspace_id: &str, _path: &str) -> Result<String> {
Err(Error::internal_err(
"AWS Secrets Manager integration requires Enterprise Edition".to_string(),
))
}
async fn set_secret(&self, _workspace_id: &str, _path: &str, _value: &str) -> Result<()> {
Err(Error::internal_err(
"AWS Secrets Manager integration requires Enterprise Edition".to_string(),
))
}
async fn delete_secret(&self, _workspace_id: &str, _path: &str) -> Result<()> {
Err(Error::internal_err(
"AWS Secrets Manager integration requires Enterprise Edition".to_string(),
))
}
fn backend_name(&self) -> &'static str {
"aws_secrets_manager"
}
}
pub async fn test_aws_sm_connection(_settings: &AwsSecretsManagerSettings) -> Result<()> {
Err(Error::internal_err(
"AWS Secrets Manager integration requires Enterprise Edition".to_string(),
))
}
pub async fn migrate_secrets_to_aws_sm(
_db: &DB,
_settings: &AwsSecretsManagerSettings,
) -> Result<SecretMigrationReport> {
Err(Error::internal_err(
"AWS Secrets Manager integration requires Enterprise Edition".to_string(),
))
}
pub async fn migrate_secrets_from_aws_sm(
_db: &DB,
_settings: &AwsSecretsManagerSettings,
) -> Result<SecretMigrationReport> {
Err(Error::internal_err(
"AWS Secrets Manager integration requires Enterprise Edition".to_string(),
))
}

View File

@@ -22,6 +22,10 @@ pub mod vault_oss;
pub mod azure_kv_ee;
pub mod azure_kv_oss;
#[cfg(feature = "private")]
pub mod aws_sm_ee;
pub mod aws_sm_oss;
#[cfg(test)]
mod tests;
@@ -37,60 +41,34 @@ pub use azure_kv_ee::*;
#[cfg(not(feature = "private"))]
pub use azure_kv_oss::*;
#[cfg(feature = "private")]
pub use aws_sm_ee::*;
#[cfg(not(feature = "private"))]
pub use aws_sm_oss::*;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::Result;
/// Trait for secret storage backends
///
/// Implementations of this trait handle the storage and retrieval of secrets.
/// The default implementation stores secrets encrypted in the database.
/// Enterprise Edition supports HashiCorp Vault as an alternative backend.
#[async_trait]
pub trait SecretBackend: Send + Sync {
/// Retrieve a secret value
///
/// # Arguments
/// * `workspace_id` - The workspace identifier
/// * `path` - The path/name of the secret variable
///
/// # Returns
/// The decrypted secret value
async fn get_secret(&self, workspace_id: &str, path: &str) -> Result<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),
AwsSecretsManager(AwsSecretsManagerSettings),
}
impl Default for SecretBackendConfig {
@@ -102,60 +80,59 @@ impl Default for SecretBackendConfig {
/// Settings for HashiCorp Vault integration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VaultSettings {
/// Vault server address (e.g., "https://vault.company.com:8200")
pub address: String,
/// KV v2 mount path (e.g., "windmill")
pub mount_path: String,
/// JWT auth role name configured in Vault (used for JWT/OIDC auth)
/// Optional - if not provided, token auth is used
#[serde(skip_serializing_if = "Option::is_none")]
pub jwt_role: Option<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>,
}
/// Settings for AWS Secrets Manager integration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AwsSecretsManagerSettings {
/// AWS region (e.g., "us-east-1")
pub region: String,
/// Static AWS access key ID (optional - uses default credential chain if not provided)
#[serde(skip_serializing_if = "Option::is_none")]
pub access_key_id: Option<String>,
/// Static AWS secret access key (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_access_key: Option<String>,
/// Custom endpoint URL for LocalStack/testing (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_url: Option<String>,
/// Prefix for secret names in AWS Secrets Manager (e.g., "windmill/")
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
}
/// 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,11 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
//! HashiCorp Vault secret backend stubs (Open Source Edition)
//!
//! This module provides stub implementations for Vault integration.
//! The actual Vault integration requires Enterprise Edition.
use std::sync::Arc;
use crate::db::DB;
@@ -21,7 +16,6 @@ use super::{
VaultSettings,
};
/// Stub VaultBackend for OSS - all operations return EE required error
pub struct VaultBackend;
impl VaultBackend {
@@ -55,10 +49,6 @@ impl SecretBackend for VaultBackend {
}
}
/// Create the appropriate secret backend based on configuration
///
/// In OSS, always returns DatabaseBackend regardless of config.
/// Vault configuration is ignored with a warning.
pub async fn create_secret_backend(
db: DB,
config: &SecretBackendConfig,
@@ -79,17 +69,22 @@ pub async fn create_secret_backend(
);
Ok(Arc::new(DatabaseBackend::new(db)))
}
SecretBackendConfig::AwsSecretsManager(_) => {
tracing::warn!(
"AWS Secrets Manager is configured but requires Enterprise Edition. \
Falling back to database backend."
);
Ok(Arc::new(DatabaseBackend::new(db)))
}
}
}
/// Test connection to Vault (OSS stub)
pub async fn test_vault_connection(_settings: &VaultSettings, _db: Option<&DB>) -> Result<()> {
Err(Error::internal_err(
"HashiCorp Vault integration requires Enterprise Edition".to_string(),
))
}
/// Migrate secrets from database to Vault (OSS stub)
pub async fn migrate_secrets_to_vault(
_db: &DB,
_settings: &VaultSettings,
@@ -99,7 +94,6 @@ pub async fn migrate_secrets_to_vault(
))
}
/// Migrate secrets from Vault back to database (OSS stub)
pub async fn migrate_secrets_to_database(
_db: &DB,
_settings: &VaultSettings,
@@ -109,7 +103,6 @@ pub async fn migrate_secrets_to_database(
))
}
/// Generate a JWT for Vault authentication (OSS stub)
pub async fn generate_vault_jwt(_db: &DB, _vault_address: &str) -> Result<String> {
Err(Error::internal_err(
"HashiCorp Vault integration requires Enterprise Edition".to_string(),

View File

@@ -0,0 +1,264 @@
//! Integration tests for AWS Secrets Manager secret backend.
//!
//! These tests require a running LocalStack instance with the `secretsmanager` service.
//!
//! ## Setup
//!
//! 1. Start LocalStack:
//!
//! ```bash
//! docker run -d --name localstack -p 4566:4566 \
//! -e SERVICES=secretsmanager \
//! localstack/localstack:3.8
//! ```
//!
//! 2. Run the tests:
//!
//! ```bash
//! RUN_AWS_SM_TESTS=1 cargo test -p windmill-common --features private,enterprise \
//! aws_sm_integration -- --nocapture
//! ```
//!
//! ## Environment variables
//!
//! - `RUN_AWS_SM_TESTS=1` - Required to run the tests
//! - `AWS_SM_ENDPOINT` - LocalStack endpoint (default: http://localhost:4566)
//! - `AWS_SM_REGION` - AWS region (default: us-east-1)
#[cfg(all(feature = "private", feature = "enterprise"))]
mod tests {
use windmill_common::secret_backend::{
test_aws_sm_connection, AwsSecretsManagerBackend, AwsSecretsManagerSettings,
SecretBackend,
};
fn should_run_aws_sm_tests() -> bool {
std::env::var("RUN_AWS_SM_TESTS")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false)
}
macro_rules! skip_if_no_aws_sm {
() => {
if !should_run_aws_sm_tests() {
println!("Skipping test: RUN_AWS_SM_TESTS=1 not set");
println!("To run: RUN_AWS_SM_TESTS=1 cargo test -p windmill-common --features private,enterprise aws_sm_integration -- --nocapture");
println!("Requires: docker run -d -p 4566:4566 -e SERVICES=secretsmanager localstack/localstack:3.8");
return;
}
};
}
fn test_settings() -> AwsSecretsManagerSettings {
AwsSecretsManagerSettings {
region: std::env::var("AWS_SM_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
access_key_id: Some("test".to_string()),
secret_access_key: Some("test".to_string()),
endpoint_url: Some(
std::env::var("AWS_SM_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:4566".to_string()),
),
prefix: Some("windmill-test/".to_string()),
}
}
#[tokio::test]
async fn test_connection() {
skip_if_no_aws_sm!();
let result = test_aws_sm_connection(&test_settings()).await;
assert!(result.is_ok(), "Connection test failed: {:?}", result.err());
println!(" ✓ Connection test passed");
}
#[tokio::test]
async fn test_create_and_get_secret() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
backend
.set_secret("test-ws", "u/admin/my_secret", "super-secret-value")
.await
.unwrap();
let value = backend.get_secret("test-ws", "u/admin/my_secret").await.unwrap();
assert_eq!(value, "super-secret-value");
backend.delete_secret("test-ws", "u/admin/my_secret").await.unwrap();
println!(" ✓ Create + Get + Delete roundtrip passed");
}
#[tokio::test]
async fn test_update_secret() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
backend
.set_secret("test-ws", "u/admin/update_test", "original")
.await
.unwrap();
backend
.set_secret("test-ws", "u/admin/update_test", "updated-value")
.await
.unwrap();
let value = backend
.get_secret("test-ws", "u/admin/update_test")
.await
.unwrap();
assert_eq!(value, "updated-value");
backend
.delete_secret("test-ws", "u/admin/update_test")
.await
.unwrap();
println!(" ✓ Update secret passed");
}
#[tokio::test]
async fn test_delete_and_verify_gone() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
backend
.set_secret("test-ws", "u/admin/delete_test", "to-be-deleted")
.await
.unwrap();
backend
.delete_secret("test-ws", "u/admin/delete_test")
.await
.unwrap();
let result = backend.get_secret("test-ws", "u/admin/delete_test").await;
assert!(
matches!(result, Err(windmill_common::error::Error::NotFound(_))),
"Expected NotFound after delete, got: {:?}",
result
);
println!(" ✓ Delete + verify gone passed");
}
#[tokio::test]
async fn test_delete_nonexistent_is_ok() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
let result = backend
.delete_secret("test-ws", "u/admin/never_existed")
.await;
assert!(
result.is_ok(),
"Delete of nonexistent should be Ok, got: {:?}",
result.err()
);
println!(" ✓ Delete nonexistent is Ok");
}
#[tokio::test]
async fn test_get_nonexistent_returns_not_found() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
let result = backend
.get_secret("test-ws", "u/admin/does_not_exist")
.await;
assert!(
matches!(result, Err(windmill_common::error::Error::NotFound(_))),
"Expected NotFound, got: {:?}",
result
);
println!(" ✓ Get nonexistent returns NotFound");
}
#[tokio::test]
async fn test_unicode_and_special_chars() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
let secret_value = "p@$$w0rd with unicode: \u{1F512}\u{1F511} and <xml>&\"quotes\"";
backend
.set_secret("test-ws", "u/admin/unicode_test", secret_value)
.await
.unwrap();
let retrieved = backend
.get_secret("test-ws", "u/admin/unicode_test")
.await
.unwrap();
assert_eq!(retrieved, secret_value);
backend
.delete_secret("test-ws", "u/admin/unicode_test")
.await
.unwrap();
println!(" ✓ Unicode and special chars passed");
}
#[tokio::test]
async fn test_workspace_isolation() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
// Create secrets in different workspaces with the same path
backend
.set_secret("workspace-a", "u/admin/shared_name", "value-a")
.await
.unwrap();
backend
.set_secret("workspace-b", "u/admin/shared_name", "value-b")
.await
.unwrap();
// Each workspace should see its own value
let a = backend
.get_secret("workspace-a", "u/admin/shared_name")
.await
.unwrap();
let b = backend
.get_secret("workspace-b", "u/admin/shared_name")
.await
.unwrap();
assert_eq!(a, "value-a");
assert_eq!(b, "value-b");
// Cleanup
backend
.delete_secret("workspace-a", "u/admin/shared_name")
.await
.unwrap();
backend
.delete_secret("workspace-b", "u/admin/shared_name")
.await
.unwrap();
println!(" ✓ Workspace isolation passed");
}
#[tokio::test]
async fn test_backend_name() {
skip_if_no_aws_sm!();
let backend = AwsSecretsManagerBackend::new_with_client(test_settings())
.await
.unwrap();
assert_eq!(backend.backend_name(), "aws_secrets_manager");
println!(" ✓ Backend name is correct");
}
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
mod tests {
#[test]
fn test_aws_sm_requires_enterprise() {
println!("AWS Secrets Manager integration tests require Enterprise Edition features");
println!("Run with: cargo test -p windmill-common --features private,enterprise");
}
}

View File

@@ -6,14 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
//! Secret backend extension for the API layer
//!
//! This module provides helper functions for integrating the SecretBackend
//! trait with variable operations in the API.
//!
//! Note: HashiCorp Vault integration requires Enterprise Edition.
//! The OSS version only supports the database backend.
use std::sync::Arc;
use windmill_common::{
@@ -26,14 +18,15 @@ use windmill_common::{
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::{
global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING},
secret_backend::{AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings},
secret_backend::{
AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend,
AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings,
},
};
#[cfg(all(feature = "private", feature = "enterprise"))]
use tokio::sync::RwLock;
// Cached Vault backend to avoid recreating it for every request
// This enables connection pooling and avoids repeated setup overhead
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedVaultBackend {
backend: Arc<dyn SecretBackend>,
@@ -56,10 +49,17 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
/// Get the current secret backend based on global settings
///
/// OSS: Always returns DatabaseBackend
/// EE: Returns configured backend (Database or Vault)
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
settings: AwsSecretsManagerSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
}
#[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())))
@@ -80,16 +80,17 @@ pub async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
SecretBackendConfig::AzureKeyVault(settings) => {
get_or_create_azure_kv_backend(db, settings).await
}
SecretBackendConfig::AwsSecretsManager(settings) => {
get_or_create_aws_sm_backend(db, settings).await
}
}
}
/// Get a cached Vault backend or create a new one if settings changed
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_vault_backend(
_db: &DB,
settings: VaultSettings,
) -> Result<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 {
@@ -98,18 +99,12 @@ async fn get_or_create_vault_backend(
}
}
}
// Need to create a new backend - acquire write lock
let mut cache = VAULT_BACKEND_CACHE.write().await;
// Double-check (another task may have created it while we waited)
if let Some(ref cached) = *cache {
if cached.settings == settings {
return Ok(cached.backend.clone());
}
}
// Create new backend
let backend: Arc<dyn SecretBackend> = {
#[cfg(feature = "openidconnect")]
if settings.token.is_none() {
@@ -117,24 +112,18 @@ async fn get_or_create_vault_backend(
} else {
Arc::new(VaultBackend::new(settings.clone()))
}
#[cfg(not(feature = "openidconnect"))]
Arc::new(VaultBackend::new(settings.clone()))
};
// Cache it
*cache = Some(CachedVaultBackend { backend: backend.clone(), settings });
Ok(backend)
}
/// Get a cached Azure Key Vault backend or create a new one if settings changed
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_azure_kv_backend(
_db: &DB,
settings: AzureKeyVaultSettings,
) -> Result<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 {
@@ -143,30 +132,42 @@ async fn get_or_create_azure_kv_backend(
}
}
}
// Need to create a new backend - acquire write lock
let mut cache = AZURE_KV_BACKEND_CACHE.write().await;
// Double-check (another task may have created it while we waited)
if let Some(ref cached) = *cache {
if cached.settings == settings {
return Ok(cached.backend.clone());
}
}
// Create new backend
let backend: Arc<dyn SecretBackend> = Arc::new(AzureKeyVaultBackend::new(settings.clone()));
// Cache it
*cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings });
Ok(backend)
}
/// Check if a Vault backend is currently configured
///
/// OSS: Always returns false
/// EE: Checks global settings
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_aws_sm_backend(
_db: &DB,
settings: AwsSecretsManagerSettings,
) -> Result<Arc<dyn SecretBackend>> {
{
let cache = AWS_SM_BACKEND_CACHE.read().await;
if let Some(ref cached) = *cache {
if cached.settings == settings {
return Ok(cached.backend.clone());
}
}
}
let mut cache = AWS_SM_BACKEND_CACHE.write().await;
if let Some(ref cached) = *cache {
if cached.settings == settings {
return Ok(cached.backend.clone());
}
}
let backend: Arc<dyn SecretBackend> =
Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?);
*cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings });
Ok(backend)
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn is_vault_backend_configured(_db: &DB) -> Result<bool> {
Ok(false)
@@ -178,14 +179,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(_)))
Ok(matches!(
config,
SecretBackendConfig::HashiCorpVault(_)
| SecretBackendConfig::AzureKeyVault(_)
| SecretBackendConfig::AwsSecretsManager(_)
))
}
/// Get a secret value using the configured backend
///
/// For database backend: decrypts using workspace key
/// For vault backend (EE only): fetches from Vault directly
pub async fn get_secret_value(
db: &DB,
workspace_id: &str,
@@ -193,20 +194,14 @@ pub async fn get_secret_value(
encrypted_value: &str,
) -> Result<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" => {
// Fetch from Vault directly
backend.get_secret(workspace_id, path).await
}
"azure_key_vault" => {
"hashicorp_vault" | "azure_key_vault" | "aws_secrets_manager" => {
backend.get_secret(workspace_id, path).await
}
_ => Err(Error::internal_err(format!(
@@ -216,10 +211,6 @@ pub async fn get_secret_value(
}
}
/// Store a secret value using the configured backend
///
/// For database backend: encrypts using workspace key and returns encrypted value
/// For vault backend (EE only): stores in Vault and returns a placeholder for DB storage
pub async fn store_secret_value(
db: &DB,
workspace_id: &str,
@@ -227,15 +218,12 @@ pub async fn store_secret_value(
plain_value: &str,
) -> Result<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))
}
@@ -243,6 +231,10 @@ pub async fn store_secret_value(
backend.set_secret(workspace_id, path, plain_value).await?;
Ok(format!("$azure_kv:{}", path))
}
"aws_secrets_manager" => {
backend.set_secret(workspace_id, path, plain_value).await?;
Ok(format!("$aws_sm:{}", path))
}
_ => Err(Error::internal_err(format!(
"Unknown backend: {}",
backend.backend_name()
@@ -250,14 +242,9 @@ pub async fn store_secret_value(
}
}
/// Delete a secret from the configured backend (if using Vault)
///
/// For database backend: no-op (DB delete is handled separately)
/// For vault backend (EE only): deletes from Vault
pub async fn delete_secret_from_backend(db: &DB, workspace_id: &str, path: &str) -> Result<()> {
if is_vault_backend_configured(db).await? {
let backend = get_secret_backend(db).await?;
// Ignore NotFound errors during deletion (secret might not exist in Vault)
match backend.delete_secret(workspace_id, path).await {
Ok(()) => Ok(()),
Err(Error::NotFound(_)) => Ok(()),
@@ -268,22 +255,22 @@ pub async fn delete_secret_from_backend(db: &DB, workspace_id: &str, path: &str)
}
}
/// Check if a value is stored in Vault (indicated by the $vault: prefix)
pub fn is_vault_stored_value(value: &str) -> bool {
value.starts_with("$vault:")
}
/// Check if a value is stored in Azure Key Vault (indicated by the $azure_kv: prefix)
pub fn is_azure_kv_stored_value(value: &str) -> bool {
value.starts_with("$azure_kv:")
}
/// Check if a value is stored in any external secret backend
pub fn is_external_stored_value(value: &str) -> bool {
is_vault_stored_value(value) || is_azure_kv_stored_value(value)
pub fn is_aws_sm_stored_value(value: &str) -> bool {
value.starts_with("$aws_sm:")
}
pub fn is_external_stored_value(value: &str) -> bool {
is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value)
}
/// Rename a secret in Vault when a variable path changes (EE only)
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn rename_vault_secret(
_db: &DB,
@@ -293,21 +280,14 @@ pub async fn rename_vault_secret(
current_value: &str,
) -> Result<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) {
return Ok(Some(format!("$aws_sm:{}", new_path)));
}
Ok(None)
}
@@ -325,6 +305,8 @@ pub async fn rename_vault_secret(
let marker_prefix = if current_value.starts_with("$azure_kv:") {
"$azure_kv:"
} else if current_value.starts_with("$aws_sm:") {
"$aws_sm:"
} else {
"$vault:"
};
@@ -333,9 +315,7 @@ pub async fn rename_vault_secret(
tracing::warn!(
"Variable value has {} prefix but external secret backend is not configured. \
Updating DB reference from {} to {}",
marker_prefix,
old_path,
new_path
marker_prefix, old_path, new_path
);
return Ok(Some(format!("{}{}", marker_prefix, new_path)));
}
@@ -347,31 +327,25 @@ pub async fn rename_vault_secret(
Err(Error::NotFound(_)) => {
tracing::warn!(
"Secret not found in backend at path {} during rename to {}",
old_path,
new_path
old_path, new_path
);
return Ok(Some(format!("{}{}", marker_prefix, new_path)));
}
Err(e) => return Err(e),
};
backend
.set_secret(workspace_id, new_path, &secret_value)
.await?;
backend.set_secret(workspace_id, new_path, &secret_value).await?;
if let Err(e) = backend.delete_secret(workspace_id, old_path).await {
tracing::warn!(
"Failed to delete old secret at {} after rename to {}: {}",
old_path,
new_path,
e
old_path, new_path, e
);
}
Ok(Some(format!("{}{}", marker_prefix, new_path)))
}
/// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename)
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn rename_vault_secrets_with_prefix(
_db: &DB,
@@ -405,6 +379,8 @@ pub async fn rename_vault_secrets_with_prefix(
let marker_prefix = if value.starts_with("$azure_kv:") {
"$azure_kv:"
} else if value.starts_with("$aws_sm:") {
"$aws_sm:"
} else {
"$vault:"
};
@@ -422,33 +398,18 @@ pub async fn rename_vault_secrets_with_prefix(
continue;
}
Err(e) => {
tracing::error!(
"Failed to read secret at {} during bulk rename: {}",
old_path,
e
);
tracing::error!("Failed to read secret at {} during bulk rename: {}", old_path, e);
continue;
}
};
if let Err(e) = backend
.set_secret(workspace_id, &new_path, &secret_value)
.await
{
tracing::error!(
"Failed to write secret to {} during bulk rename: {}",
new_path,
e
);
if let Err(e) = backend.set_secret(workspace_id, &new_path, &secret_value).await {
tracing::error!("Failed to write secret to {} during bulk rename: {}", new_path, e);
continue;
}
if let Err(e) = backend.delete_secret(workspace_id, &old_path).await {
tracing::warn!(
"Failed to delete old secret at {} after rename: {}",
old_path,
e
);
tracing::warn!("Failed to delete old secret at {} after rename: {}", old_path, e);
}
updates.push((old_path, format!("{}{}", marker_prefix, new_path)));

View File

@@ -683,11 +683,11 @@ 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 and Azure Key Vault as external secret stores.',
'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault, Azure Key Vault, and AWS KMS as external secret backends.',
key: 'secret_backend',
fieldType: 'secret_backend',
storage: 'setting',
ee_only: 'HashiCorp Vault and Azure Key Vault integrations are Enterprise Edition features'
ee_only: 'HashiCorp Vault, Azure Key Vault, and AWS KMS integrations are Enterprise Edition features'
}
],
'GitHub App': [

View File

@@ -19,24 +19,19 @@
let { values, disabled = false }: Props = $props()
// Initialize default values if not set
$effect(() => {
if (!$values['secret_backend']) {
$values['secret_backend'] = { type: 'Database' }
}
})
let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' = $derived(
let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsSecretsManager' = $derived(
$values['secret_backend']?.type ?? 'Database'
)
// Derive auth method from current config
// We check jwt_role === null because setAuthMethod explicitly sets jwt_role to null for token mode
// and sets token to null for jwt mode. This allows empty token values while still tracking the selection.
let authMethod: 'token' | 'jwt' = $derived.by(() => {
const config = $values['secret_backend']
if (!config || config.type !== 'HashiCorpVault') return 'jwt'
// If jwt_role is explicitly null, we're in token mode; otherwise jwt mode
return config.jwt_role === null ? 'token' : 'jwt'
})
@@ -52,15 +47,17 @@
let migrateToAzureKvModalOpen = $state(false)
let migrateFromAzureKvModalOpen = $state(false)
// Check if Vault option should be disabled (non-EE)
let testingAwsSmConnection = $state(false)
let migratingToAwsSm = $state(false)
let migratingFromAwsSm = $state(false)
let migrateToAwsSmModalOpen = $state(false)
let migrateFromAwsSmModalOpen = $state(false)
let vaultDisabled = $derived(!$enterpriseLicense)
function setBackendType(type: string | undefined) {
if (!type) return
// Prevent selecting Vault in non-EE
if (type === 'HashiCorpVault' && vaultDisabled) {
return
}
if ((type === 'HashiCorpVault' || type === 'AzureKeyVault' || type === 'AwsSecretsManager') && vaultDisabled) return
if (type === 'Database') {
$values['secret_backend'] = { type: 'Database' }
} else if (type === 'HashiCorpVault') {
@@ -73,7 +70,6 @@
token: $values['secret_backend']?.token ?? null
}
} else if (type === 'AzureKeyVault') {
if (vaultDisabled) return
$values['secret_backend'] = {
type: 'AzureKeyVault',
vault_url: $values['secret_backend']?.vault_url ?? '',
@@ -82,31 +78,24 @@
client_secret: $values['secret_backend']?.client_secret ?? null,
token: $values['secret_backend']?.token ?? null
}
} else if (type === 'AwsSecretsManager') {
$values['secret_backend'] = {
type: 'AwsSecretsManager',
region: $values['secret_backend']?.region ?? 'us-east-1',
access_key_id: $values['secret_backend']?.access_key_id ?? null,
secret_access_key: $values['secret_backend']?.secret_access_key ?? null,
endpoint_url: $values['secret_backend']?.endpoint_url ?? null,
prefix: $values['secret_backend']?.prefix ?? 'windmill/'
}
}
}
function setAuthMethod(method: string | undefined) {
if (
!method ||
!$values['secret_backend'] ||
$values['secret_backend'].type !== 'HashiCorpVault'
)
return
if (!method || !$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return
if (method === 'token') {
// Clear JWT role when switching to token auth
$values['secret_backend'] = {
...$values['secret_backend'],
jwt_role: null,
token: $values['secret_backend'].token ?? ''
}
$values['secret_backend'] = { ...$values['secret_backend'], jwt_role: null, token: $values['secret_backend'].token ?? '' }
} else if (method === 'jwt') {
// Clear token when switching to JWT auth
$values['secret_backend'] = {
...$values['secret_backend'],
token: null,
jwt_role: $values['secret_backend'].jwt_role ?? 'windmill-secrets'
}
$values['secret_backend'] = { ...$values['secret_backend'], token: null, jwt_role: $values['secret_backend'].jwt_role ?? 'windmill-secrets' }
}
}
@@ -121,96 +110,47 @@
}
async function testVaultConnection() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') {
return
}
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return
testingConnection = true
try {
await SettingService.testSecretBackend({
requestBody: getVaultSettings()
})
await SettingService.testSecretBackend({ requestBody: getVaultSettings() })
sendUserToast('Successfully connected to HashiCorp Vault')
} catch (error: any) {
sendUserToast('Failed to connect to Vault: ' + error.message, true)
} finally {
testingConnection = false
}
} finally { testingConnection = false }
}
async function migrateSecretsToVault() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') {
return
}
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return
migratingToVault = true
try {
const report = await SettingService.migrateSecretsToVault({
requestBody: getVaultSettings()
})
if (report.failed_count > 0) {
sendUserToast(
`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`,
true
)
console.error('Migration failures:', report.failures)
} else {
sendUserToast(
`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to Vault`
)
}
} catch (error: any) {
sendUserToast('Failed to migrate secrets to Vault: ' + error.message, true)
} finally {
migratingToVault = false
migrateToVaultModalOpen = false
}
const report = await SettingService.migrateSecretsToVault({ requestBody: getVaultSettings() })
if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true)
else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to Vault`)
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { migratingToVault = false; migrateToVaultModalOpen = false }
}
async function migrateSecretsToDatabase() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') {
return
}
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return
migratingToDatabase = true
try {
const report = await SettingService.migrateSecretsToDatabase({
requestBody: getVaultSettings()
})
if (report.failed_count > 0) {
sendUserToast(
`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`,
true
)
console.error('Migration failures:', report.failures)
} else {
sendUserToast(
`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to database`
)
}
} catch (error: any) {
sendUserToast('Failed to migrate secrets to database: ' + error.message, true)
} finally {
migratingToDatabase = false
migrateToDatabaseModalOpen = false
}
const report = await SettingService.migrateSecretsToDatabase({ requestBody: getVaultSettings() })
if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true)
else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to database`)
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { migratingToDatabase = false; migrateToDatabaseModalOpen = false }
}
function isVaultConfigValid(): boolean {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') {
return false
}
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return false
const hasAddress = $values['secret_backend'].address?.trim() !== ''
const hasMountPath = $values['secret_backend'].mount_path?.trim() !== ''
const hasToken = $values['secret_backend'].token?.trim()
const hasJwtRole = $values['secret_backend'].jwt_role?.trim()
// Must have address and mount path, plus either token OR jwt_role (not both)
return hasAddress && hasMountPath && (hasToken || hasJwtRole)
}
// Get the base URL for JWKS endpoint instructions (from instance settings)
function getAzureKvSettings() {
return {
vault_url: $values['secret_backend'].vault_url,
@@ -227,11 +167,8 @@
try {
await SettingService.testAzureKvBackend({ requestBody: getAzureKvSettings() })
sendUserToast('Successfully connected to Azure Key Vault')
} catch (error: any) {
sendUserToast('Failed to connect to Azure Key Vault: ' + error.message, true)
} finally {
testingAzureKvConnection = false
}
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { testingAzureKvConnection = false }
}
async function migrateSecretsToAzureKv() {
@@ -239,18 +176,10 @@
migratingToAzureKv = true
try {
const report = await SettingService.migrateSecretsToAzureKv({ requestBody: getAzureKvSettings() })
if (report.failed_count > 0) {
sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true)
console.error('Migration failures:', report.failures)
} else {
sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to Azure Key Vault`)
}
} catch (error: any) {
sendUserToast('Failed to migrate secrets to Azure Key Vault: ' + error.message, true)
} finally {
migratingToAzureKv = false
migrateToAzureKvModalOpen = false
}
if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true)
else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to Azure Key Vault`)
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { migratingToAzureKv = false; migrateToAzureKvModalOpen = false }
}
async function migrateSecretsFromAzureKv() {
@@ -258,18 +187,10 @@
migratingFromAzureKv = true
try {
const report = await SettingService.migrateSecretsFromAzureKv({ requestBody: getAzureKvSettings() })
if (report.failed_count > 0) {
sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true)
console.error('Migration failures:', report.failures)
} else {
sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to database`)
}
} catch (error: any) {
sendUserToast('Failed to migrate secrets to database: ' + error.message, true)
} finally {
migratingFromAzureKv = false
migrateFromAzureKvModalOpen = false
}
if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true)
else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to database`)
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { migratingFromAzureKv = false; migrateFromAzureKvModalOpen = false }
}
function isAzureKvConfigValid(): boolean {
@@ -282,43 +203,69 @@
)
}
function getAwsSmSettings() {
return {
region: $values['secret_backend'].region,
access_key_id: $values['secret_backend'].access_key_id || undefined,
secret_access_key: $values['secret_backend'].secret_access_key || undefined,
endpoint_url: $values['secret_backend'].endpoint_url || undefined,
prefix: $values['secret_backend'].prefix || undefined
}
}
async function testAwsSmConnection() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return
testingAwsSmConnection = true
try {
await SettingService.testAwsSmBackend({ requestBody: getAwsSmSettings() })
sendUserToast('Successfully connected to AWS Secrets Manager')
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { testingAwsSmConnection = false }
}
async function migrateSecretsToAwsSm() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return
migratingToAwsSm = true
try {
const report = await SettingService.migrateSecretsToAwsSm({ requestBody: getAwsSmSettings() })
if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true)
else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to AWS Secrets Manager`)
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { migratingToAwsSm = false; migrateToAwsSmModalOpen = false }
}
async function migrateSecretsFromAwsSm() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return
migratingFromAwsSm = true
try {
const report = await SettingService.migrateSecretsFromAwsSm({ requestBody: getAwsSmSettings() })
if (report.failed_count > 0) sendUserToast(`Migration: ${report.migrated_count}/${report.total_secrets} migrated, ${report.failed_count} failed`, true)
else sendUserToast(`Migrated ${report.migrated_count}/${report.total_secrets} secrets to database`)
} catch (error: any) { sendUserToast('Failed: ' + error.message, true) }
finally { migratingFromAwsSm = false; migrateFromAwsSmModalOpen = false }
}
function isAwsSmConfigValid(): boolean {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return false
return $values['secret_backend'].region?.trim() !== ''
}
let baseUrl = $derived($values['base_url'] ?? 'https://your-windmill-instance.com')
</script>
<div class="space-y-6">
<!-- Backend Type Selector -->
<div class="flex flex-col gap-2 mt-1">
<ToggleButtonGroup selected={selectedType} onSelected={(v) => setBackendType(v)}>
{#snippet children({ item: toggleButton })}
<ToggleButton
value="Database"
label="Database"
tooltip="Store secrets encrypted in the database (default)"
item={toggleButton}
/>
<ToggleButton
value="HashiCorpVault"
label="HashiCorp Vault (Beta)"
tooltip={vaultDisabled
? 'HashiCorp Vault integration requires Enterprise Edition'
: 'Store secrets in HashiCorp Vault (Beta feature)'}
item={toggleButton}
disabled={vaultDisabled}
/>
<ToggleButton
value="AzureKeyVault"
label="Azure Key Vault"
tooltip={vaultDisabled
? 'Azure Key Vault integration requires Enterprise Edition'
: 'Store secrets in Azure Key Vault'}
item={toggleButton}
disabled={vaultDisabled}
/>
<ToggleButton value="Database" label="Database" tooltip="Store secrets encrypted in the database (default)" item={toggleButton} />
<ToggleButton value="HashiCorpVault" label="HashiCorp Vault (Beta)" tooltip={vaultDisabled ? 'Requires Enterprise Edition' : 'Store secrets in HashiCorp Vault'} item={toggleButton} disabled={vaultDisabled} />
<ToggleButton value="AzureKeyVault" label="Azure Key Vault" tooltip={vaultDisabled ? 'Requires Enterprise Edition' : 'Store secrets in Azure Key Vault'} item={toggleButton} disabled={vaultDisabled} />
<ToggleButton value="AwsSecretsManager" label="AWS Secrets Manager (Beta)" tooltip={vaultDisabled ? 'Requires Enterprise Edition' : 'Store secrets in AWS Secrets Manager'} item={toggleButton} disabled={vaultDisabled} />
{/snippet}
</ToggleButtonGroup>
{#if vaultDisabled}
<div class="flex items-center gap-1">
<EEOnly>HashiCorp Vault and Azure Key Vault integrations require Enterprise Edition</EEOnly>
<EEOnly>External secret store integrations require Enterprise Edition</EEOnly>
</div>
{/if}
</div>
@@ -328,125 +275,54 @@
<Database class="text-primary" size={20} />
<div>
<p class="text-sm font-medium text-emphasis">Database Storage (Default)</p>
<p class="text-xs text-secondary">
Secrets are encrypted using workspace-specific keys and stored in the PostgreSQL database.
</p>
<p class="text-xs text-secondary">Secrets are encrypted using workspace-specific keys and stored in the PostgreSQL database.</p>
</div>
</div>
{:else if selectedType === 'HashiCorpVault'}
<!-- Vault Configuration -->
<div class="space-y-4 p-4 border rounded-lg">
<div class="flex items-center gap-2 mb-4">
<Lock class="text-primary" size={20} />
<div>
<p class="text-sm font-medium text-emphasis">
HashiCorp Vault Configuration
<span
class="ml-2 px-1.5 py-0.5 text-2xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 rounded"
>Beta</span
>
</p>
<p class="text-xs text-secondary">
Store secrets in an external HashiCorp Vault instance.
</p>
<p class="text-sm font-medium text-emphasis">HashiCorp Vault Configuration <span class="ml-2 px-1.5 py-0.5 text-2xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 rounded">Beta</span></p>
<p class="text-xs text-secondary">Store secrets in an external HashiCorp Vault instance.</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col gap-1">
<label for="vault_address" class="block text-xs font-semibold text-emphasis"
>Vault Address</label
>
<TextInput
inputProps={{
type: 'text',
id: 'vault_address',
placeholder: 'https://vault.company.com:8200',
disabled: disabled
}}
bind:value={$values['secret_backend'].address}
/>
<label for="vault_address" class="block text-xs font-semibold text-emphasis">Vault Address</label>
<TextInput inputProps={{ type: 'text', id: 'vault_address', placeholder: 'https://vault.company.com:8200', disabled }} bind:value={$values['secret_backend'].address} />
</div>
<div class="flex flex-col gap-1">
<label for="vault_mount_path" class="block text-xs font-semibold text-emphasis"
>KV Mount Path</label
>
<label for="vault_mount_path" class="block text-xs font-semibold text-emphasis">KV Mount Path</label>
<span class="text-2xs text-secondary">The KV v2 secrets engine mount path in Vault</span>
<TextInput
inputProps={{
type: 'text',
id: 'vault_mount_path',
placeholder: 'windmill',
disabled: disabled
}}
bind:value={$values['secret_backend'].mount_path}
/>
<TextInput inputProps={{ type: 'text', id: 'vault_mount_path', placeholder: 'windmill', disabled }} bind:value={$values['secret_backend'].mount_path} />
</div>
<!-- Authentication Method Toggle -->
<div class="flex flex-col gap-2">
<span class="block text-xs font-semibold text-emphasis">Authentication Method</span>
<ToggleButtonGroup selected={authMethod} onSelected={(v) => setAuthMethod(v)}>
{#snippet children({ item: toggleButton })}
<ToggleButton
value="jwt"
label="JWT Auth"
tooltip="Authenticate using Windmill-signed JWTs (recommended for production)"
item={toggleButton}
{disabled}
/>
<ToggleButton
value="token"
label="Static Token"
tooltip="Use a static Vault token (for testing/development)"
item={toggleButton}
{disabled}
/>
<ToggleButton value="jwt" label="JWT Auth" tooltip="Authenticate using Windmill-signed JWTs" item={toggleButton} {disabled} />
<ToggleButton value="token" label="Static Token" tooltip="Use a static Vault token" item={toggleButton} {disabled} />
{/snippet}
</ToggleButtonGroup>
</div>
{#if authMethod === 'token'}
<div class="flex flex-col gap-1 p-3 bg-surface-secondary rounded-lg">
<label for="vault_token" class="block text-xs font-semibold text-emphasis"
>Vault Token</label
>
<span class="text-2xs text-secondary"
>Static token for authentication. Recommended only for testing/development.</span
>
<label for="vault_token" class="block text-xs font-semibold text-emphasis">Vault Token</label>
<span class="text-2xs text-secondary">Static token. Recommended only for testing/development.</span>
<Password bind:password={$values['secret_backend'].token} small {disabled} />
</div>
{:else}
<div class="flex flex-col gap-2 p-3 bg-surface-secondary rounded-lg">
<label for="vault_jwt_role" class="block text-xs font-semibold text-emphasis"
>JWT Auth Role</label
>
<span class="text-2xs text-secondary"
>The JWT authentication role configured in Vault.</span
>
<TextInput
inputProps={{
type: 'text',
id: 'vault_jwt_role',
placeholder: 'windmill-secrets',
disabled: disabled
}}
bind:value={$values['secret_backend'].jwt_role}
/>
<!-- Vault JWT Setup Instructions -->
<label for="vault_jwt_role" class="block text-xs font-semibold text-emphasis">JWT Auth Role</label>
<span class="text-2xs text-secondary">The JWT authentication role configured in Vault.</span>
<TextInput inputProps={{ type: 'text', id: 'vault_jwt_role', placeholder: 'windmill-secrets', disabled }} bind:value={$values['secret_backend'].jwt_role} />
<details class="mt-2">
<summary class="text-xs font-medium text-secondary cursor-pointer hover:text-primary"
>Vault JWT Setup Instructions</summary
>
<summary class="text-xs font-medium text-secondary cursor-pointer hover:text-primary">Vault JWT Setup Instructions</summary>
<div class="mt-2 p-2 bg-surface rounded text-2xs text-secondary space-y-2">
<p>Configure Vault to accept JWTs from Windmill:</p>
<div
class="bg-gray-100 dark:bg-gray-800 p-2 rounded font-mono text-2xs overflow-x-auto"
>
<pre
># Enable JWT auth method
<div class="bg-gray-100 dark:bg-gray-800 p-2 rounded font-mono text-2xs overflow-x-auto">
<pre># Enable JWT auth method
vault auth enable jwt
# Configure JWT auth with Windmill's JWKS endpoint
@@ -470,382 +346,162 @@ vault write auth/jwt/role/windmill-secrets \
bound_audiences="{baseUrl}" \
user_claim="email" \
policies="windmill-secrets" \
ttl="1h"</pre
>
ttl="1h"</pre>
</div>
<p class="text-yellow-600 dark:text-yellow-400">
Replace <code>windmill-secrets</code> with your role name if different.
</p>
</div>
</details>
</div>
{/if}
<div class="flex flex-col gap-1">
<label for="vault_namespace" class="block text-xs font-semibold text-emphasis"
>Namespace (optional)</label
>
<span class="text-2xs text-secondary"
>Vault Enterprise namespace (leave empty if not using namespaces)</span
>
<TextInput
inputProps={{
type: 'text',
id: 'vault_namespace',
placeholder: 'admin/my-namespace',
disabled: disabled
}}
bind:value={$values['secret_backend'].namespace}
/>
<label for="vault_namespace" class="block text-xs font-semibold text-emphasis">Namespace (optional)</label>
<span class="text-2xs text-secondary">Vault Enterprise namespace</span>
<TextInput inputProps={{ type: 'text', id: 'vault_namespace', placeholder: 'admin/my-namespace', disabled }} bind:value={$values['secret_backend'].namespace} />
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-4 pt-4 border-t">
<div class="flex gap-2">
<Button
unifiedSize="md"
variant="accent"
onclick={testVaultConnection}
disabled={disabled || !isVaultConfigValid() || testingConnection}
loading={testingConnection}
startIcon={{ icon: Server }}
>
Test Connection
</Button>
</div>
<!-- Migration Section -->
<Button unifiedSize="md" variant="accent" onclick={testVaultConnection} disabled={disabled || !isVaultConfigValid() || testingConnection} loading={testingConnection} startIcon={{ icon: Server }}>Test Connection</Button>
<div class="flex flex-col gap-4 pt-4 border-t">
<span class="block text-xs font-semibold text-emphasis">Secret Migration</span>
<span class="text-2xs text-secondary">
Migrate secrets between the database and HashiCorp Vault. Original values are NOT
deleted to allow for rollback.
</span>
<span class="text-2xs text-secondary">Original values are NOT deleted to allow for rollback.</span>
<div class="flex gap-4">
<!-- Database to Vault -->
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Database size={16} />
<ArrowRight size={16} />
<Lock size={16} />
</div>
<div class="flex items-center gap-2 mb-2"><Database size={16} /><ArrowRight size={16} /><Lock size={16} /></div>
<p class="text-xs font-medium mb-2">Database → Vault</p>
<p class="text-2xs text-secondary mb-3">
Decrypt secrets from database and store in Vault
</p>
<Button
unifiedSize="sm"
variant="default"
onclick={() => (migrateToVaultModalOpen = true)}
disabled={disabled || !isVaultConfigValid() || migratingToVault}
startIcon={{ icon: ArrowRight }}
>
Migrate to Vault
</Button>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateToVaultModalOpen = true)} disabled={disabled || !isVaultConfigValid() || migratingToVault} startIcon={{ icon: ArrowRight }}>Migrate to Vault</Button>
</div>
<!-- Vault to Database -->
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Lock size={16} />
<ArrowLeft size={16} />
<Database size={16} />
</div>
<div class="flex items-center gap-2 mb-2"><Lock size={16} /><ArrowLeft size={16} /><Database size={16} /></div>
<p class="text-xs font-medium mb-2">Vault → Database</p>
<p class="text-2xs text-secondary mb-3">
Read secrets from Vault and encrypt in database
</p>
<Button
unifiedSize="sm"
variant="default"
onclick={() => (migrateToDatabaseModalOpen = true)}
disabled={disabled || !isVaultConfigValid() || migratingToDatabase}
startIcon={{ icon: ArrowLeft }}
>
Migrate to Database
</Button>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateToDatabaseModalOpen = true)} disabled={disabled || !isVaultConfigValid() || migratingToDatabase} startIcon={{ icon: ArrowLeft }}>Migrate to Database</Button>
</div>
</div>
</div>
</div>
</div>
{:else if selectedType === 'AzureKeyVault'}
<!-- Azure Key Vault Configuration -->
<div class="space-y-4 p-4 border rounded-lg">
<div class="flex items-center gap-2 mb-4">
<Cloud class="text-primary" size={20} />
<div>
<p class="text-sm font-medium text-emphasis">Azure Key Vault Configuration</p>
<p class="text-xs text-secondary">
Store secrets in an Azure Key Vault instance.
</p>
<p class="text-xs text-secondary">Store secrets in an Azure Key Vault instance.</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col gap-1">
<label for="azure_vault_url" class="block text-xs font-semibold text-emphasis"
>Vault URL</label
>
<TextInput
inputProps={{
type: 'text',
id: 'azure_vault_url',
placeholder: 'https://my-vault.vault.azure.net',
disabled: disabled
}}
bind:value={$values['secret_backend'].vault_url}
/>
<label for="azure_vault_url" class="block text-xs font-semibold text-emphasis">Vault URL</label>
<TextInput inputProps={{ type: 'text', id: 'azure_vault_url', placeholder: 'https://my-vault.vault.azure.net', disabled }} bind:value={$values['secret_backend'].vault_url} />
</div>
<div class="flex flex-col gap-1">
<label for="azure_tenant_id" class="block text-xs font-semibold text-emphasis"
>Tenant ID</label
>
<span class="text-2xs text-secondary">The Azure Active Directory tenant ID</span>
<TextInput
inputProps={{
type: 'text',
id: 'azure_tenant_id',
placeholder: '00000000-0000-0000-0000-000000000000',
disabled: disabled
}}
bind:value={$values['secret_backend'].tenant_id}
/>
<label for="azure_tenant_id" class="block text-xs font-semibold text-emphasis">Tenant ID</label>
<TextInput inputProps={{ type: 'text', id: 'azure_tenant_id', placeholder: '00000000-0000-0000-0000-000000000000', disabled }} bind:value={$values['secret_backend'].tenant_id} />
</div>
<div class="flex flex-col gap-1">
<label for="azure_client_id" class="block text-xs font-semibold text-emphasis"
>Client ID</label
>
<span class="text-2xs text-secondary">The Azure AD application (service principal) client ID</span>
<TextInput
inputProps={{
type: 'text',
id: 'azure_client_id',
placeholder: '00000000-0000-0000-0000-000000000000',
disabled: disabled
}}
bind:value={$values['secret_backend'].client_id}
/>
<label for="azure_client_id" class="block text-xs font-semibold text-emphasis">Client ID</label>
<TextInput inputProps={{ type: 'text', id: 'azure_client_id', placeholder: '00000000-0000-0000-0000-000000000000', disabled }} bind:value={$values['secret_backend'].client_id} />
</div>
<div class="flex flex-col gap-1 p-3 bg-surface-secondary rounded-lg">
<label for="azure_client_secret" class="block text-xs font-semibold text-emphasis"
>Client Secret</label
>
<span class="text-2xs text-secondary">The Azure AD application client secret for authentication</span>
<label for="azure_client_secret" class="block text-xs font-semibold text-emphasis">Client Secret</label>
<Password bind:password={$values['secret_backend'].client_secret} small {disabled} />
</div>
<div class="flex flex-col gap-1 p-3 bg-surface-secondary rounded-lg">
<label for="azure_token" class="block text-xs font-semibold text-emphasis"
>Token (optional)</label
>
<span class="text-2xs text-secondary">Static Bearer token for testing/development. If provided, OAuth2 authentication is skipped.</span>
<label for="azure_token" class="block text-xs font-semibold text-emphasis">Token (optional)</label>
<span class="text-2xs text-secondary">Static Bearer token for testing. If provided, OAuth2 is skipped.</span>
<Password bind:password={$values['secret_backend'].token} small {disabled} />
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col gap-4 pt-4 border-t">
<div class="flex gap-2">
<Button
unifiedSize="md"
variant="accent"
onclick={testAzureKvConnection}
disabled={disabled || !isAzureKvConfigValid() || testingAzureKvConnection}
loading={testingAzureKvConnection}
startIcon={{ icon: Server }}
>
Test Connection
</Button>
</div>
<!-- Migration Section -->
<Button unifiedSize="md" variant="accent" onclick={testAzureKvConnection} disabled={disabled || !isAzureKvConfigValid() || testingAzureKvConnection} loading={testingAzureKvConnection} startIcon={{ icon: Server }}>Test Connection</Button>
<div class="flex flex-col gap-4 pt-4 border-t">
<span class="block text-xs font-semibold text-emphasis">Secret Migration</span>
<span class="text-2xs text-secondary">
Migrate secrets between the database and Azure Key Vault. Original values are NOT
deleted to allow for rollback.
</span>
<span class="text-2xs text-secondary">Original values are NOT deleted to allow for rollback.</span>
<div class="flex gap-4">
<!-- Database to Azure KV -->
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Database size={16} />
<ArrowRight size={16} />
<Cloud size={16} />
</div>
<div class="flex items-center gap-2 mb-2"><Database size={16} /><ArrowRight size={16} /><Cloud size={16} /></div>
<p class="text-xs font-medium mb-2">Database → Azure Key Vault</p>
<p class="text-2xs text-secondary mb-3">
Decrypt secrets from database and store in Azure Key Vault
</p>
<Button
unifiedSize="sm"
variant="default"
onclick={() => (migrateToAzureKvModalOpen = true)}
disabled={disabled || !isAzureKvConfigValid() || migratingToAzureKv}
startIcon={{ icon: ArrowRight }}
>
Migrate to Azure Key Vault
</Button>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateToAzureKvModalOpen = true)} disabled={disabled || !isAzureKvConfigValid() || migratingToAzureKv} startIcon={{ icon: ArrowRight }}>Migrate to Azure KV</Button>
</div>
<!-- Azure KV to Database -->
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Cloud size={16} />
<ArrowLeft size={16} />
<Database size={16} />
</div>
<div class="flex items-center gap-2 mb-2"><Cloud size={16} /><ArrowLeft size={16} /><Database size={16} /></div>
<p class="text-xs font-medium mb-2">Azure Key Vault → Database</p>
<p class="text-2xs text-secondary mb-3">
Read secrets from Azure Key Vault and encrypt in database
</p>
<Button
unifiedSize="sm"
variant="default"
onclick={() => (migrateFromAzureKvModalOpen = true)}
disabled={disabled || !isAzureKvConfigValid() || migratingFromAzureKv}
startIcon={{ icon: ArrowLeft }}
>
Migrate to Database
</Button>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateFromAzureKvModalOpen = true)} disabled={disabled || !isAzureKvConfigValid() || migratingFromAzureKv} startIcon={{ icon: ArrowLeft }}>Migrate to Database</Button>
</div>
</div>
</div>
</div>
</div>
{:else if selectedType === 'AwsSecretsManager'}
<div class="space-y-4 p-4 border rounded-lg">
<div class="flex items-center gap-2 mb-4">
<Cloud class="text-primary" size={20} />
<div>
<p class="text-sm font-medium text-emphasis">AWS Secrets Manager Configuration <span class="ml-2 px-1.5 py-0.5 text-2xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 rounded">Beta</span></p>
<p class="text-xs text-secondary">Store secrets in AWS Secrets Manager.</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="flex flex-col gap-1">
<label for="aws_sm_region" class="block text-xs font-semibold text-emphasis">Region</label>
<TextInput inputProps={{ type: 'text', id: 'aws_sm_region', placeholder: 'us-east-1', disabled }} bind:value={$values['secret_backend'].region} />
</div>
<div class="flex flex-col gap-1">
<label for="aws_sm_access_key_id" class="block text-xs font-semibold text-emphasis">Access Key ID (optional)</label>
<span class="text-2xs text-secondary">If not provided, the default AWS credential chain is used (env vars, instance profile, EKS pod identity)</span>
<TextInput inputProps={{ type: 'text', id: 'aws_sm_access_key_id', placeholder: 'AKIA...', disabled }} bind:value={$values['secret_backend'].access_key_id} />
</div>
<div class="flex flex-col gap-1 p-3 bg-surface-secondary rounded-lg">
<label for="aws_sm_secret_access_key" class="block text-xs font-semibold text-emphasis">Secret Access Key (optional)</label>
<Password bind:password={$values['secret_backend'].secret_access_key} small {disabled} />
</div>
<div class="flex flex-col gap-1">
<label for="aws_sm_prefix" class="block text-xs font-semibold text-emphasis">Secret Name Prefix (optional)</label>
<span class="text-2xs text-secondary">Prefix for secret names in AWS Secrets Manager (default: windmill/)</span>
<TextInput inputProps={{ type: 'text', id: 'aws_sm_prefix', placeholder: 'windmill/', disabled }} bind:value={$values['secret_backend'].prefix} />
</div>
<div class="flex flex-col gap-1">
<label for="aws_sm_endpoint_url" class="block text-xs font-semibold text-emphasis">Endpoint URL (optional)</label>
<span class="text-2xs text-secondary">Custom endpoint for LocalStack or other compatible services</span>
<TextInput inputProps={{ type: 'text', id: 'aws_sm_endpoint_url', placeholder: 'http://localhost:4566', disabled }} bind:value={$values['secret_backend'].endpoint_url} />
</div>
</div>
<div class="flex flex-col gap-4 pt-4 border-t">
<Button unifiedSize="md" variant="accent" onclick={testAwsSmConnection} disabled={disabled || !isAwsSmConfigValid() || testingAwsSmConnection} loading={testingAwsSmConnection} startIcon={{ icon: Server }}>Test Connection</Button>
<div class="flex flex-col gap-4 pt-4 border-t">
<span class="block text-xs font-semibold text-emphasis">Secret Migration</span>
<span class="text-2xs text-secondary">Original values are NOT deleted to allow for rollback.</span>
<div class="flex gap-4">
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2"><Database size={16} /><ArrowRight size={16} /><Cloud size={16} /></div>
<p class="text-xs font-medium mb-2">Database → AWS Secrets Manager</p>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateToAwsSmModalOpen = true)} disabled={disabled || !isAwsSmConfigValid() || migratingToAwsSm} startIcon={{ icon: ArrowRight }}>Migrate to AWS SM</Button>
</div>
<div class="flex-1 p-3 border rounded-lg">
<div class="flex items-center gap-2 mb-2"><Cloud size={16} /><ArrowLeft size={16} /><Database size={16} /></div>
<p class="text-xs font-medium mb-2">AWS Secrets Manager → Database</p>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateFromAwsSmModalOpen = true)} disabled={disabled || !isAwsSmConfigValid() || migratingFromAwsSm} startIcon={{ icon: ArrowLeft }}>Migrate to Database</Button>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Migrate to Azure Key Vault Modal -->
<ConfirmationModal
title="Migrate Secrets to Azure Key Vault"
confirmationText="Migrate"
open={migrateToAzureKvModalOpen}
loading={migratingToAzureKv}
type="reload"
onCanceled={() => {
migrateToAzureKvModalOpen = false
}}
onConfirmed={migrateSecretsToAzureKv}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>
This will migrate all existing secrets from the database to Azure Key Vault. The process
will:
</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>Read all encrypted secrets from the database</li>
<li>Decrypt them using the workspace encryption keys</li>
<li>Store them in Azure Key Vault</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Database values are NOT deleted automatically. You can manually clear them after
verifying the migration was successful.
</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
<ConfirmationModal title="Migrate to AWS Secrets Manager" confirmationText="Migrate" open={migrateToAwsSmModalOpen} loading={migratingToAwsSm} type="reload" onCanceled={() => { migrateToAwsSmModalOpen = false }} onConfirmed={migrateSecretsToAwsSm}>
{#snippet children()}<div class="flex flex-col gap-2"><p>This will copy all secrets from the database to AWS Secrets Manager.</p><p class="text-yellow-600 dark:text-yellow-400 text-sm">Database values are NOT deleted automatically.</p></div>{/snippet}
</ConfirmationModal>
<!-- Migrate from Azure Key Vault to Database Modal -->
<ConfirmationModal
title="Migrate Secrets to Database"
confirmationText="Migrate"
open={migrateFromAzureKvModalOpen}
loading={migratingFromAzureKv}
type="reload"
onCanceled={() => {
migrateFromAzureKvModalOpen = false
}}
onConfirmed={migrateSecretsFromAzureKv}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>
This will migrate all secrets from Azure Key Vault back to the database. The process will:
</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>List all secrets in Azure Key Vault for each workspace</li>
<li>Read each secret value from Azure Key Vault</li>
<li>Encrypt and store them in the database</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Azure Key Vault values are NOT deleted automatically. Only secrets that already exist in the
database will be updated.
</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
<ConfirmationModal title="Migrate to Database" confirmationText="Migrate" open={migrateFromAwsSmModalOpen} loading={migratingFromAwsSm} type="reload" onCanceled={() => { migrateFromAwsSmModalOpen = false }} onConfirmed={migrateSecretsFromAwsSm}>
{#snippet children()}<div class="flex flex-col gap-2"><p>This will copy all secrets from AWS Secrets Manager back to the database.</p><p class="text-yellow-600 dark:text-yellow-400 text-sm">AWS Secrets Manager values are NOT deleted automatically.</p></div>{/snippet}
</ConfirmationModal>
<!-- Migrate to Vault Modal -->
<ConfirmationModal
title="Migrate Secrets to Vault"
confirmationText="Migrate"
open={migrateToVaultModalOpen}
loading={migratingToVault}
type="reload"
onCanceled={() => {
migrateToVaultModalOpen = false
}}
onConfirmed={migrateSecretsToVault}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>
This will migrate all existing secrets from the database to HashiCorp Vault. The process
will:
</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>Read all encrypted secrets from the database</li>
<li>Decrypt them using the workspace encryption keys</li>
<li>Store them in HashiCorp Vault under the configured mount path</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Database values are NOT deleted automatically. You can manually clear them after
verifying the migration was successful.
</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
<ConfirmationModal title="Migrate to Azure Key Vault" confirmationText="Migrate" open={migrateToAzureKvModalOpen} loading={migratingToAzureKv} type="reload" onCanceled={() => { migrateToAzureKvModalOpen = false }} onConfirmed={migrateSecretsToAzureKv}>
{#snippet children()}<div class="flex flex-col gap-2"><p>This will copy all secrets to Azure Key Vault.</p><p class="text-yellow-600 dark:text-yellow-400 text-sm">Database values are NOT deleted automatically.</p></div>{/snippet}
</ConfirmationModal>
<!-- Migrate to Database Modal -->
<ConfirmationModal
title="Migrate Secrets to Database"
confirmationText="Migrate"
open={migrateToDatabaseModalOpen}
loading={migratingToDatabase}
type="reload"
onCanceled={() => {
migrateToDatabaseModalOpen = false
}}
onConfirmed={migrateSecretsToDatabase}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>
This will migrate all secrets from HashiCorp Vault back to the database. The process will:
</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>List all secrets in Vault for each workspace</li>
<li>Read each secret value from Vault</li>
<li>Encrypt and store them in the database</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Vault values are NOT deleted automatically. Only secrets that already exist in the
database will be updated.
</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
<ConfirmationModal title="Migrate to Database" confirmationText="Migrate" open={migrateFromAzureKvModalOpen} loading={migratingFromAzureKv} type="reload" onCanceled={() => { migrateFromAzureKvModalOpen = false }} onConfirmed={migrateSecretsFromAzureKv}>
{#snippet children()}<div class="flex flex-col gap-2"><p>This will copy all secrets from Azure Key Vault back to the database.</p></div>{/snippet}
</ConfirmationModal>
<ConfirmationModal title="Migrate to Vault" confirmationText="Migrate" open={migrateToVaultModalOpen} loading={migratingToVault} type="reload" onCanceled={() => { migrateToVaultModalOpen = false }} onConfirmed={migrateSecretsToVault}>
{#snippet children()}<div class="flex flex-col gap-2"><p>This will copy all secrets to HashiCorp Vault.</p><p class="text-yellow-600 dark:text-yellow-400 text-sm">Database values are NOT deleted automatically.</p></div>{/snippet}
</ConfirmationModal>
<ConfirmationModal title="Migrate to Database" confirmationText="Migrate" open={migrateToDatabaseModalOpen} loading={migratingToDatabase} type="reload" onCanceled={() => { migrateToDatabaseModalOpen = false }} onConfirmed={migrateSecretsToDatabase}>
{#snippet children()}<div class="flex flex-col gap-2"><p>This will copy all secrets from Vault back to the database.</p></div>{/snippet}
</ConfirmationModal>