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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-06 11:31:13 +00:00
co-authored by Claude Opus 4.6
parent c57bc88aad
commit 68c8388976
13 changed files with 482 additions and 906 deletions
+25 -25
View File
@@ -979,30 +979,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "aws-sdk-kms"
version = "1.101.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dca55e9417ba817965206945209c061a77cf03380d93ce9556b0ea76499e02"
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-rds"
version = "1.126.0"
@@ -1030,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"
@@ -16745,8 +16745,8 @@ dependencies = [
"aws-config",
"aws-credential-types",
"aws-sdk-bedrockruntime",
"aws-sdk-kms",
"aws-sdk-rds",
"aws-sdk-secretsmanager",
"aws-sdk-sts",
"aws-smithy-types",
"aws-smithy-types-convert",
+1 -1
View File
@@ -560,7 +560,7 @@ aws-sdk-bedrock = "1.129.0"
aws-sdk-bedrockruntime = "=1.122.0"
aws-credential-types = "^1"
aws-smithy-types = "^1"
aws-sdk-kms = "^1"
aws-sdk-secretsmanager = "^1"
aws-sdk-sqs = "=1.77.0"
aws-sdk-sts = "=1.79.0"
aws-sdk-sso = "=1.77.0"
+36
View File
@@ -1383,6 +1383,42 @@ pub async fn migrate_secrets_from_aws_kms(
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
// ============================================================================
+81
View File
@@ -1866,6 +1866,66 @@ paths:
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)
@@ -19409,6 +19469,27 @@ components:
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:
+62 -86
View File
@@ -6,11 +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.
#[cfg(all(feature = "private", feature = "enterprise"))]
use std::sync::Arc;
@@ -26,8 +21,8 @@ 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,
},
};
@@ -56,13 +51,23 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
#[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) => {
@@ -71,11 +76,8 @@ async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
SecretBackendConfig::AzureKeyVault(settings) => {
get_or_create_azure_kv_backend(db, settings).await
}
SecretBackendConfig::AwsKms(_) => {
// KMS backend: secrets are in DB encrypted by KMS.
// For API-layer operations (rename), we use the database backend
// since KMS secrets don't need external store operations.
Ok(Arc::new(DatabaseBackend::new(db.clone())))
SecretBackendConfig::AwsSecretsManager(settings) => {
get_or_create_aws_sm_backend(db, settings).await
}
}
}
@@ -88,20 +90,13 @@ async fn get_or_create_vault_backend(
{
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()); }
}
}
let mut cache = VAULT_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> = {
#[cfg(feature = "openidconnect")]
if settings.token.is_none() {
@@ -109,13 +104,10 @@ 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 = Some(CachedVaultBackend { backend: backend.clone(), settings });
Ok(backend)
}
@@ -127,24 +119,36 @@ async fn get_or_create_azure_kv_backend(
{
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()); }
}
}
let mut cache = AZURE_KV_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(AzureKeyVaultBackend::new(settings.clone()));
*cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings });
Ok(backend)
}
#[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 backend: Arc<dyn SecretBackend> = Arc::new(AzureKeyVaultBackend::new(settings.clone()));
*cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings });
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)
}
@@ -154,35 +158,31 @@ async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
Some(value) => serde_json::from_value::<SecretBackendConfig>(value).unwrap_or_default(),
None => SecretBackendConfig::default(),
};
// AwsKms intentionally excluded: KMS secrets are in the DB, no external rename needed
Ok(matches!(
config,
SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_)
SecretBackendConfig::HashiCorpVault(_)
| SecretBackendConfig::AzureKeyVault(_)
| SecretBackendConfig::AwsSecretsManager(_)
))
}
#[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:") }
#[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:") }
#[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)
}
#[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)>> {
Ok(vec![])
@@ -190,35 +190,27 @@ pub async fn rename_vault_secrets_with_prefix(
#[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,
db: &DB, workspace_id: &str, old_prefix: &str, new_prefix: &str,
variables: Vec<(String, String)>,
) -> Result<Vec<(String, String)>> {
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 {
if !is_external_stored_value(&value) {
continue;
}
if !is_external_stored_value(&value) { continue; }
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:"
};
let new_path = if old_path.starts_with(old_prefix) {
format!("{}{}", new_prefix, &old_path[old_prefix.len()..])
} else {
continue;
};
} else { continue; };
let secret_value = match backend.get_secret(workspace_id, &old_path).await {
Ok(v) => v,
@@ -227,37 +219,21 @@ 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)));
}
Ok(updates)
}
+2 -2
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", "dep:aws-sdk-kms"]
private = ["dep:aws-sdk-rds", "dep:aws-sdk-secretsmanager"]
jemalloc = ["dep:tikv-jemalloc-ctl"]
tantivy = []
prometheus = ["dep:prometheus"]
@@ -80,7 +80,7 @@ postgres-native-tls.workspace = true
native-tls.workspace = true
aws-smithy-types-convert = { workspace = true, optional = true }
aws-sdk-kms = { workspace = true, optional = true }
aws-sdk-secretsmanager = { workspace = true, optional = true }
aws-sdk-rds = { workspace = true, optional = true }
indexmap.workspace = true
bytes.workspace = true
@@ -1,86 +0,0 @@
/*
* 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::{AwsKmsSettings, SecretBackend, SecretMigrationReport};
pub struct AwsKmsBackend;
impl AwsKmsBackend {
pub fn new(_settings: AwsKmsSettings) -> Self {
AwsKmsBackend
}
pub async fn encrypt_value(&self, _plaintext: &str) -> Result<String> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
pub async fn decrypt_value(&self, _ciphertext_b64: &str) -> Result<String> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
}
#[async_trait]
impl SecretBackend for AwsKmsBackend {
async fn get_secret(&self, _workspace_id: &str, _path: &str) -> Result<String> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
async fn set_secret(&self, _workspace_id: &str, _path: &str, _value: &str) -> Result<()> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
async fn delete_secret(&self, _workspace_id: &str, _path: &str) -> Result<()> {
// No-op for KMS: secrets are stored in the database, not externally
Ok(())
}
fn backend_name(&self) -> &'static str {
"aws_kms"
}
}
pub async fn test_aws_kms_connection(_settings: &AwsKmsSettings) -> Result<()> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
pub async fn migrate_secrets_to_aws_kms(
_db: &DB,
_settings: &AwsKmsSettings,
) -> Result<SecretMigrationReport> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
pub async fn migrate_secrets_from_aws_kms(
_db: &DB,
_settings: &AwsKmsSettings,
) -> Result<SecretMigrationReport> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
pub async fn decrypt_aws_kms_value(_db: &DB, _value: &str) -> Result<String> {
Err(Error::internal_err(
"AWS KMS integration requires Enterprise Edition".to_string(),
))
}
@@ -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(),
))
}
@@ -23,8 +23,8 @@ pub mod azure_kv_ee;
pub mod azure_kv_oss;
#[cfg(feature = "private")]
pub mod aws_kms_ee;
pub mod aws_kms_oss;
pub mod aws_sm_ee;
pub mod aws_sm_oss;
#[cfg(test)]
mod tests;
@@ -42,10 +42,10 @@ pub use azure_kv_ee::*;
pub use azure_kv_oss::*;
#[cfg(feature = "private")]
pub use aws_kms_ee::*;
pub use aws_sm_ee::*;
#[cfg(not(feature = "private"))]
pub use aws_kms_oss::*;
pub use aws_sm_oss::*;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
@@ -53,56 +53,22 @@ 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),
/// Encrypt secrets with AWS KMS, store in database (Enterprise Edition only)
AwsKms(AwsKmsSettings),
AwsSecretsManager(AwsSecretsManagerSettings),
}
impl Default for SecretBackendConfig {
@@ -114,78 +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 KMS integration
/// Settings for AWS Secrets Manager integration
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AwsKmsSettings {
/// KMS Key ID, Key ARN, Alias name, or Alias ARN
pub key_id: String,
pub struct AwsSecretsManagerSettings {
/// AWS region (e.g., "us-east-1")
pub region: String,
/// AWS Access Key ID (optional - uses default credential chain if not provided)
/// 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>,
/// AWS Secret Access Key (optional)
/// 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,
}
@@ -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,9 +69,9 @@ pub async fn create_secret_backend(
);
Ok(Arc::new(DatabaseBackend::new(db)))
}
SecretBackendConfig::AwsKms(_) => {
SecretBackendConfig::AwsSecretsManager(_) => {
tracing::warn!(
"AWS KMS is configured but requires Enterprise Edition. \
"AWS Secrets Manager is configured but requires Enterprise Edition. \
Falling back to database backend."
);
Ok(Arc::new(DatabaseBackend::new(db)))
@@ -89,14 +79,12 @@ 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,
@@ -106,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,
@@ -116,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(),
+7 -11
View File
@@ -188,17 +188,13 @@ pub async fn get_secret_value_as_admin(
let r = if variable.is_secret {
let value = variable.value;
if !value.is_empty() {
if value.starts_with("$aws_kms:") {
crate::secret_backend::decrypt_aws_kms_value(db, &value).await?
} else {
let mc = build_crypt(db, w_id).await?;
decrypt(&mc, value).map_err(|e| {
crate::error::Error::internal_err(format!(
"Error decrypting variable {}: {}",
variable.path, e
))
})?
}
let mc = build_crypt(db, w_id).await?;
decrypt(&mc, value).map_err(|e| {
crate::error::Error::internal_err(format!(
"Error decrypting variable {}: {}",
variable.path, e
))
})?
} else {
"".to_string()
}
+49 -134
View File
@@ -6,11 +6,6 @@
* LICENSE-AGPL for a copy of the license.
*/
//! Secret backend extension for the store layer
//!
//! This module provides helper functions for integrating the SecretBackend
//! trait with variable operations in the store.
use std::sync::Arc;
use windmill_common::{
@@ -23,7 +18,10 @@ 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, AwsKmsBackend, AwsKmsSettings, SecretBackendConfig, VaultBackend, VaultSettings},
secret_backend::{
AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend,
AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings,
},
};
#[cfg(all(feature = "private", feature = "enterprise"))]
@@ -52,14 +50,14 @@ lazy_static::lazy_static! {
}
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsKmsBackend {
backend: Arc<AwsKmsBackend>,
settings: AwsKmsSettings,
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
settings: AwsSecretsManagerSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AWS_KMS_BACKEND_CACHE: RwLock<Option<CachedAwsKmsBackend>> = RwLock::new(None);
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
@@ -82,9 +80,8 @@ 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::AwsKms(settings) => {
let kms = get_or_create_aws_kms_backend(settings).await?;
Ok(kms as Arc<dyn SecretBackend>)
SecretBackendConfig::AwsSecretsManager(settings) => {
get_or_create_aws_sm_backend(db, settings).await
}
}
}
@@ -102,15 +99,12 @@ async fn get_or_create_vault_backend(
}
}
}
let mut cache = VAULT_BACKEND_CACHE.write().await;
if let Some(ref cached) = *cache {
if cached.settings == settings {
return Ok(cached.backend.clone());
}
}
let backend: Arc<dyn SecretBackend> = {
#[cfg(feature = "openidconnect")]
if settings.token.is_none() {
@@ -118,13 +112,10 @@ 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 = Some(CachedVaultBackend { backend: backend.clone(), settings });
Ok(backend)
}
@@ -141,47 +132,39 @@ async fn get_or_create_azure_kv_backend(
}
}
}
let mut cache = AZURE_KV_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(AzureKeyVaultBackend::new(settings.clone()));
*cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings });
Ok(backend)
}
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_aws_kms_backend(
settings: AwsKmsSettings,
) -> Result<Arc<AwsKmsBackend>> {
async fn get_or_create_aws_sm_backend(
_db: &DB,
settings: AwsSecretsManagerSettings,
) -> Result<Arc<dyn SecretBackend>> {
{
let cache = AWS_KMS_BACKEND_CACHE.read().await;
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_KMS_BACKEND_CACHE.write().await;
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::new(AwsKmsBackend::new(settings.clone()).await?);
*cache = Some(CachedAwsKmsBackend { backend: backend.clone(), settings });
let backend: Arc<dyn SecretBackend> =
Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?);
*cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings });
Ok(backend)
}
@@ -196,8 +179,12 @@ 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(_)
))
}
pub async fn get_secret_value(
@@ -206,33 +193,7 @@ pub async fn get_secret_value(
path: &str,
encrypted_value: &str,
) -> Result<String> {
if is_aws_kms_stored_value(encrypted_value) {
#[cfg(all(feature = "private", feature = "enterprise"))]
{
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(),
};
if let SecretBackendConfig::AwsKms(settings) = config {
let kms = get_or_create_aws_kms_backend(settings).await?;
let ciphertext_b64 = &encrypted_value["$aws_kms:".len()..];
return kms.decrypt_value(ciphertext_b64).await;
}
return Err(Error::internal_err(
"Value has $aws_kms: prefix but AWS KMS is not configured".to_string(),
));
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
return Err(Error::internal_err(
"AWS KMS requires Enterprise Edition".to_string(),
));
}
let backend = get_secret_backend(db).await?;
match backend.backend_name() {
"database" => {
let mc = build_crypt(db, workspace_id).await?;
@@ -240,8 +201,9 @@ pub async fn get_secret_value(
Error::internal_err(format!("Error decrypting variable {}: {}", path, e))
})
}
"hashicorp_vault" => backend.get_secret(workspace_id, path).await,
"azure_key_vault" => backend.get_secret(workspace_id, path).await,
"hashicorp_vault" | "azure_key_vault" | "aws_secrets_manager" => {
backend.get_secret(workspace_id, path).await
}
_ => Err(Error::internal_err(format!(
"Unknown backend: {}",
backend.backend_name()
@@ -256,7 +218,6 @@ pub async fn store_secret_value(
plain_value: &str,
) -> Result<String> {
let backend = get_secret_backend(db).await?;
match backend.backend_name() {
"database" => {
let mc = build_crypt(db, workspace_id).await?;
@@ -270,30 +231,9 @@ pub async fn store_secret_value(
backend.set_secret(workspace_id, path, plain_value).await?;
Ok(format!("$azure_kv:{}", path))
}
"aws_kms" => {
#[cfg(all(feature = "private", feature = "enterprise"))]
{
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(),
};
if let SecretBackendConfig::AwsKms(settings) = config {
let kms = get_or_create_aws_kms_backend(settings).await?;
let ciphertext_b64 = kms.encrypt_value(plain_value).await?;
return Ok(format!("$aws_kms:{}", ciphertext_b64));
}
Err(Error::internal_err(
"AWS KMS backend active but settings not found".to_string(),
))
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
Err(Error::internal_err(
"AWS KMS requires Enterprise Edition".to_string(),
))
"aws_secrets_manager" => {
backend.set_secret(workspace_id, path, plain_value).await?;
Ok(format!("$aws_sm:{}", path))
}
_ => Err(Error::internal_err(format!(
"Unknown backend: {}",
@@ -323,12 +263,12 @@ pub fn is_azure_kv_stored_value(value: &str) -> bool {
value.starts_with("$azure_kv:")
}
pub fn is_aws_kms_stored_value(value: &str) -> bool {
value.starts_with("$aws_kms:")
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_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value)
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
@@ -340,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)
}
@@ -372,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:"
};
@@ -380,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)));
}
@@ -394,24 +327,19 @@ 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
);
}
@@ -451,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:"
};
@@ -468,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)));
@@ -4,7 +4,7 @@
import { SettingService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import TextInput from '../text_input/TextInput.svelte'
import { Database, Lock, Server, ArrowLeft, ArrowRight, Cloud, KeyRound } from 'lucide-svelte'
import { Database, Lock, Server, ArrowLeft, ArrowRight, Cloud } from 'lucide-svelte'
import type { Writable } from 'svelte/store'
import { enterpriseLicense } from '$lib/stores'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -19,14 +19,13 @@
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' | 'AwsKms' = $derived(
let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsSecretsManager' = $derived(
$values['secret_backend']?.type ?? 'Database'
)
@@ -48,20 +47,17 @@
let migrateToAzureKvModalOpen = $state(false)
let migrateFromAzureKvModalOpen = $state(false)
let testingAwsKmsConnection = $state(false)
let migratingToAwsKms = $state(false)
let migratingFromAwsKms = $state(false)
let migrateToAwsKmsModalOpen = $state(false)
let migrateFromAwsKmsModalOpen = $state(false)
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
if (type === 'HashiCorpVault' && vaultDisabled) return
if (type === 'AzureKeyVault' && vaultDisabled) return
if (type === 'AwsKms' && vaultDisabled) return
if ((type === 'HashiCorpVault' || type === 'AzureKeyVault' || type === 'AwsSecretsManager') && vaultDisabled) return
if (type === 'Database') {
$values['secret_backend'] = { type: 'Database' }
} else if (type === 'HashiCorpVault') {
@@ -82,38 +78,24 @@
client_secret: $values['secret_backend']?.client_secret ?? null,
token: $values['secret_backend']?.token ?? null
}
} else if (type === 'AwsKms') {
} else if (type === 'AwsSecretsManager') {
$values['secret_backend'] = {
type: 'AwsKms',
key_id: $values['secret_backend']?.key_id ?? '',
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
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') {
$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') {
$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' }
}
}
@@ -135,9 +117,7 @@
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() {
@@ -145,17 +125,10 @@
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)
} 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
}
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() {
@@ -163,17 +136,10 @@
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)
} 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
}
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 {
@@ -201,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() {
@@ -213,17 +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)
} 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() {
@@ -231,17 +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)
} 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 {
@@ -254,119 +203,69 @@
)
}
function getAwsKmsSettings() {
function getAwsSmSettings() {
return {
key_id: $values['secret_backend'].key_id,
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
endpoint_url: $values['secret_backend'].endpoint_url || undefined,
prefix: $values['secret_backend'].prefix || undefined
}
}
async function testAwsKmsConnection() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return
testingAwsKmsConnection = true
async function testAwsSmConnection() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return
testingAwsSmConnection = true
try {
await SettingService.testAwsKmsBackend({ requestBody: getAwsKmsSettings() })
sendUserToast('Successfully connected to AWS KMS')
} catch (error: any) {
sendUserToast('Failed to connect to AWS KMS: ' + error.message, true)
} finally {
testingAwsKmsConnection = false
}
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 migrateSecretsToAwsKms() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return
migratingToAwsKms = true
async function migrateSecretsToAwsSm() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return
migratingToAwsSm = true
try {
const report = await SettingService.migrateSecretsToAwsKms({ requestBody: getAwsKmsSettings() })
if (report.failed_count > 0) {
sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true)
} else {
sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to AWS KMS`)
}
} catch (error: any) {
sendUserToast('Failed to migrate secrets to AWS KMS: ' + error.message, true)
} finally {
migratingToAwsKms = false
migrateToAwsKmsModalOpen = false
}
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 migrateSecretsFromAwsKms() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return
migratingFromAwsKms = true
async function migrateSecretsFromAwsSm() {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') return
migratingFromAwsSm = true
try {
const report = await SettingService.migrateSecretsFromAwsKms({ requestBody: getAwsKmsSettings() })
if (report.failed_count > 0) {
sendUserToast(`Migration completed with errors: ${report.migrated_count}/${report.total_secrets} secrets migrated, ${report.failed_count} failed`, true)
} else {
sendUserToast(`Successfully migrated ${report.migrated_count}/${report.total_secrets} secrets to database`)
}
} catch (error: any) {
sendUserToast('Failed to migrate secrets from AWS KMS: ' + error.message, true)
} finally {
migratingFromAwsKms = false
migrateFromAwsKmsModalOpen = false
}
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 isAwsKmsConfigValid(): boolean {
if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsKms') return false
return (
$values['secret_backend'].key_id?.trim() !== '' &&
$values['secret_backend'].region?.trim() !== ''
)
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="AwsKms"
label="AWS KMS"
tooltip={vaultDisabled
? 'AWS KMS integration requires Enterprise Edition'
: 'Encrypt secrets with AWS KMS (stored in database)'}
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" 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, Azure Key Vault, and AWS KMS integrations require Enterprise Edition</EEOnly>
<EEOnly>External secret store integrations require Enterprise Edition</EEOnly>
</div>
{/if}
</div>
@@ -376,124 +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}
/>
<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
@@ -517,84 +346,39 @@ 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>
<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">
<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>
<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} />
@@ -603,7 +387,6 @@ vault write auth/jwt/role/windmill-secrets \
<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>
@@ -611,137 +394,91 @@ vault write auth/jwt/role/windmill-secrets \
</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 }} 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 }} 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>
<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>
<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>
<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>
<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">
<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 → 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>
<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">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>
</div>
</div>
</div>
</div>
</div>
{:else if selectedType === 'AwsKms'}
<!-- AWS KMS Configuration -->
{:else if selectedType === 'AwsSecretsManager'}
<div class="space-y-4 p-4 border rounded-lg">
<div class="flex items-center gap-2 mb-4">
<KeyRound class="text-primary" size={20} />
<Cloud class="text-primary" size={20} />
<div>
<p class="text-sm font-medium text-emphasis">AWS KMS Configuration</p>
<p class="text-xs text-secondary">
Encrypt secrets using AWS Key Management Service. Secrets remain stored in the database
but are encrypted/decrypted by KMS instead of Windmill's built-in encryption.
</p>
<p class="text-sm font-medium text-emphasis">AWS Secrets Manager Configuration</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_kms_key_id" class="block text-xs font-semibold text-emphasis">KMS Key ID</label>
<span class="text-2xs text-secondary">Key ID, Key ARN, Alias name (e.g. alias/my-key), or Alias ARN</span>
<TextInput inputProps={{ type: 'text', id: 'aws_kms_key_id', placeholder: 'alias/windmill-secrets', disabled }} bind:value={$values['secret_backend'].key_id} />
<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_kms_region" class="block text-xs font-semibold text-emphasis">Region</label>
<TextInput inputProps={{ type: 'text', id: 'aws_kms_region', placeholder: 'us-east-1', disabled }} bind:value={$values['secret_backend'].region} />
</div>
<div class="flex flex-col gap-1">
<label for="aws_kms_access_key_id" class="block text-xs font-semibold text-emphasis">Access Key ID (optional)</label>
<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_kms_access_key_id', placeholder: 'AKIA...', disabled }} bind:value={$values['secret_backend'].access_key_id} />
<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_kms_secret_access_key" class="block text-xs font-semibold text-emphasis">Secret Access Key (optional)</label>
<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_kms_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 KMS-compatible services</span>
<TextInput inputProps={{ type: 'text', id: 'aws_kms_endpoint_url', placeholder: 'http://localhost:4566', disabled }} bind:value={$values['secret_backend'].endpoint_url} />
<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>
<!-- Action Buttons -->
<div class="flex flex-col gap-4 pt-4 border-t">
<div class="flex gap-2">
<Button
unifiedSize="md"
variant="accent"
onclick={testAwsKmsConnection}
disabled={disabled || !isAwsKmsConfigValid() || testingAwsKmsConnection}
loading={testingAwsKmsConnection}
startIcon={{ icon: Server }}
>
Test Connection
</Button>
</div>
<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">
Migrate secrets between database encryption (workspace keys) and AWS KMS encryption.
Secrets stay in the database; only the encryption method changes.
</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} /><KeyRound size={16} />
</div>
<p class="text-xs font-medium mb-2">Database → AWS KMS</p>
<p class="text-2xs text-secondary mb-3">Re-encrypt secrets from workspace keys to AWS KMS</p>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateToAwsKmsModalOpen = true)} disabled={disabled || !isAwsKmsConfigValid() || migratingToAwsKms} startIcon={{ icon: ArrowRight }}>
Migrate to AWS KMS
</Button>
<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">
<KeyRound size={16} /><ArrowLeft size={16} /><Database size={16} />
</div>
<p class="text-xs font-medium mb-2">AWS KMS → Database</p>
<p class="text-2xs text-secondary mb-3">Re-encrypt secrets from AWS KMS to workspace keys</p>
<Button unifiedSize="sm" variant="default" onclick={() => (migrateFromAwsKmsModalOpen = true)} disabled={disabled || !isAwsKmsConfigValid() || migratingFromAwsKms} startIcon={{ icon: ArrowLeft }}>
Migrate to Database
</Button>
<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>
@@ -750,102 +487,21 @@ vault write auth/jwt/role/windmill-secrets \
{/if}
</div>
<!-- Migrate to AWS KMS Modal -->
<ConfirmationModal
title="Migrate Secrets to AWS KMS"
confirmationText="Migrate"
open={migrateToAwsKmsModalOpen}
loading={migratingToAwsKms}
type="reload"
onCanceled={() => { migrateToAwsKmsModalOpen = false }}
onConfirmed={migrateSecretsToAwsKms}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>This will re-encrypt all existing secrets using AWS KMS. 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>Re-encrypt them using AWS KMS</li>
<li>Update the database values in-place</li>
</ol>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">
Note: Secrets remain in the database. The encryption method changes from workspace keys to AWS KMS.
</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 AWS KMS to Database Modal -->
<ConfirmationModal
title="Migrate Secrets to Database"
confirmationText="Migrate"
open={migrateFromAwsKmsModalOpen}
loading={migratingFromAwsKms}
type="reload"
onCanceled={() => { migrateFromAwsKmsModalOpen = false }}
onConfirmed={migrateSecretsFromAwsKms}
>
{#snippet children()}
<div class="flex flex-col gap-2">
<p>This will re-encrypt all AWS KMS-encrypted secrets using workspace keys. The process will:</p>
<ol class="list-decimal list-inside text-sm space-y-1">
<li>Find all secrets encrypted with AWS KMS</li>
<li>Decrypt them using AWS KMS</li>
<li>Re-encrypt them using workspace encryption keys</li>
<li>Update the database values in-place</li>
</ol>
<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 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.</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 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.</p>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">Note: Azure Key Vault values are NOT deleted automatically.</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>
<!-- 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.</p>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">Note: Database values are NOT deleted automatically.</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
<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>
<!-- 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.</p>
<p class="text-yellow-600 dark:text-yellow-400 text-sm mt-2">Note: Vault values are NOT deleted automatically.</p>
<p>Are you sure you want to proceed?</p>
</div>
{/snippet}
<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>