Compare commits

...

10 Commits

Author SHA1 Message Date
Ruben Fiszel
baaefeda37 fix: remove stale KMS openapi/description, restore stripped doc comments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:18:05 +00:00
Ruben Fiszel
f32c3b3c88 sqlx 2026-04-06 14:46:12 +00:00
Ruben Fiszel
18a1acc212 fix: use full commit hash in ee-repo-ref.txt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:40:46 +00:00
Ruben Fiszel
45978a566f chore: update ee-repo-ref to include AWS Secrets Manager EE impl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:35:55 +00:00
Ruben Fiszel
6e0b85c5d5 fix: remove leftover KMS handler functions from api-settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:24:18 +00:00
Ruben Fiszel
9177c1f944 Merge branch 'main' into aws-kms-secret-backend 2026-04-06 09:54:59 -04:00
Ruben Fiszel
c053c04601 feat: mark AWS Secrets Manager as beta
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:45:28 +00:00
Ruben Fiszel
6acd508c02 test: add AWS Secrets Manager integration tests (requires LocalStack)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:19:32 +00:00
Ruben Fiszel
68c8388976 feat: switch from AWS KMS to AWS Secrets Manager as secret backend
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:31:13 +00:00
Ruben Fiszel
c57bc88aad feat: add AWS KMS as secret backend (EE)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:03:13 +00:00
16 changed files with 898 additions and 566 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,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)
@@ -19405,6 +19465,27 @@ components:
type: string
description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication)
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

@@ -29,8 +29,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,
},
};
@@ -62,6 +62,18 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
// Cached AWS Secrets Manager backend
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
settings: AwsSecretsManagerSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
}
/// Get the current secret backend based on global settings (EE only)
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
@@ -78,6 +90,9 @@ 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
}
}
}
@@ -161,6 +176,37 @@ async fn get_or_create_azure_kv_backend(
Ok(backend)
}
/// Get a cached AWS SM backend or create a new one if settings changed
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_aws_sm_backend(
_db: &DB,
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)
}
/// Check if an external secret backend is currently configured (EE only)
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
@@ -171,7 +217,7 @@ async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
Ok(matches!(
config,
SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_)
SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_) | SecretBackendConfig::AwsSecretsManager(_)
))
}
@@ -187,10 +233,16 @@ fn is_azure_kv_stored_value(value: &str) -> bool {
value.starts_with("$azure_kv:")
}
/// Check if a value is stored in AWS Secrets Manager
#[cfg(all(feature = "private", feature = "enterprise"))]
fn is_aws_sm_stored_value(value: &str) -> bool {
value.starts_with("$aws_sm:")
}
/// 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)
@@ -235,6 +287,8 @@ pub async fn rename_vault_secrets_with_prefix(
// Determine the marker prefix from the stored value
let marker_prefix = if is_azure_kv_stored_value(&value) {
"$azure_kv:"
} else if is_aws_sm_stored_value(&value) {
"$aws_sm:"
} else {
"$vault:"
};

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,6 +41,12 @@ 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};
@@ -91,6 +101,8 @@ pub enum SecretBackendConfig {
HashiCorpVault(VaultSettings),
/// Store secrets in Azure Key Vault (Enterprise Edition only)
AzureKeyVault(AzureKeyVaultSettings),
/// Store secrets in AWS Secrets Manager (Enterprise Edition only)
AwsSecretsManager(AwsSecretsManagerSettings),
}
impl Default for SecretBackendConfig {
@@ -136,6 +148,25 @@ pub struct AzureKeyVaultSettings {
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 {

View File

@@ -79,6 +79,13 @@ 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)))
}
}
}

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

@@ -26,7 +26,7 @@ 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"))]
@@ -56,6 +56,18 @@ lazy_static::lazy_static! {
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
}
// Cached AWS Secrets Manager backend
#[cfg(all(feature = "private", feature = "enterprise"))]
struct CachedAwsSmBackend {
backend: Arc<dyn SecretBackend>,
settings: AwsSecretsManagerSettings,
}
#[cfg(all(feature = "private", feature = "enterprise"))]
lazy_static::lazy_static! {
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
}
/// Get the current secret backend based on global settings
///
/// OSS: Always returns DatabaseBackend
@@ -80,6 +92,9 @@ 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
}
}
}
@@ -163,6 +178,37 @@ async fn get_or_create_azure_kv_backend(
Ok(backend)
}
/// Get a cached AWS SM backend or create a new one if settings changed
#[cfg(all(feature = "private", feature = "enterprise"))]
async fn get_or_create_aws_sm_backend(
_db: &DB,
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)
}
/// Check if a Vault backend is currently configured
///
/// OSS: Always returns false
@@ -179,7 +225,7 @@ pub async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
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
@@ -209,6 +255,9 @@ pub async fn get_secret_value(
"azure_key_vault" => {
backend.get_secret(workspace_id, path).await
}
"aws_secrets_manager" => {
backend.get_secret(workspace_id, path).await
}
_ => Err(Error::internal_err(format!(
"Unknown backend: {}",
backend.backend_name()
@@ -243,6 +292,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()
@@ -278,9 +331,14 @@ pub fn is_azure_kv_stored_value(value: &str) -> bool {
value.starts_with("$azure_kv:")
}
/// Check if a value is stored in AWS Secrets Manager (indicated by the $aws_sm: prefix)
pub fn is_aws_sm_stored_value(value: &str) -> bool {
value.starts_with("$aws_sm:")
}
/// Check if a value is stored in any external secret backend
pub fn is_external_stored_value(value: &str) -> bool {
is_vault_stored_value(value) || is_azure_kv_stored_value(value)
is_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)
@@ -308,6 +366,14 @@ pub async fn rename_vault_secret(
);
return Ok(Some(format!("$azure_kv:{}", new_path)));
}
if is_aws_sm_stored_value(current_value) {
tracing::warn!(
"Variable has $aws_sm: prefix but AWS Secrets Manager requires Enterprise Edition. \
Updating DB reference to {}",
new_path
);
return Ok(Some(format!("$aws_sm:{}", new_path)));
}
Ok(None)
}
@@ -325,6 +391,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:"
};
@@ -405,6 +473,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:"
};

View File

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