feat: add AWS Secrets Manager as secret storage backend (Beta) (#8734)
* feat: add AWS KMS as secret backend (EE) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: switch from AWS KMS to AWS Secrets Manager as secret backend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add AWS Secrets Manager integration tests (requires LocalStack) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: mark AWS Secrets Manager as beta Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove leftover KMS handler functions from api-settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to include AWS Secrets Manager EE impl Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use full commit hash in ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * sqlx --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
backend/.sqlx/query-243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c.json
generated
Normal file
26
backend/.sqlx/query-243588f12c62aa7913bc3a4ae11ecee8a1e03863735036b1192fca143f27641c.json
generated
Normal 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
25
backend/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
50fdd6b517c46e506b251b9fbe1c6218f601d369
|
||||
8e5b77ef1f07b5b6620e540fea580f1a3d7f7d8b
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -1855,6 +1855,129 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/settings/test_aws_kms_backend:
|
||||
post:
|
||||
summary: test connection to AWS KMS
|
||||
operationId: testAwsKmsBackend
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
description: AWS KMS settings to test
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwsKmsSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: connection test result
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/migrate_secrets_to_aws_kms:
|
||||
post:
|
||||
summary: migrate secrets from database to AWS KMS encryption
|
||||
operationId: migrateSecretsToAwsKms
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
description: AWS KMS settings for migration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwsKmsSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: migration report
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/settings/migrate_secrets_from_aws_kms:
|
||||
post:
|
||||
summary: migrate secrets from AWS KMS encryption to database
|
||||
operationId: migrateSecretsFromAwsKms
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
description: AWS KMS settings for migration source
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwsKmsSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: migration report
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/settings/test_aws_sm_backend:
|
||||
post:
|
||||
summary: test connection to AWS Secrets Manager
|
||||
operationId: testAwsSmBackend
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwsSecretsManagerSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: connection test result
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/migrate_secrets_to_aws_sm:
|
||||
post:
|
||||
summary: migrate secrets from database to AWS Secrets Manager
|
||||
operationId: migrateSecretsToAwsSm
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwsSecretsManagerSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: migration report
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/settings/migrate_secrets_from_aws_sm:
|
||||
post:
|
||||
summary: migrate secrets from AWS Secrets Manager to database
|
||||
operationId: migrateSecretsFromAwsSm
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwsSecretsManagerSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: migration report
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/users/email:
|
||||
get:
|
||||
summary: get current user email (if logged in)
|
||||
@@ -19405,6 +19528,49 @@ components:
|
||||
type: string
|
||||
description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication)
|
||||
|
||||
AwsKmsSettings:
|
||||
type: object
|
||||
required:
|
||||
- key_id
|
||||
- region
|
||||
properties:
|
||||
key_id:
|
||||
type: string
|
||||
description: KMS Key ID, Key ARN, Alias name, or Alias ARN
|
||||
region:
|
||||
type: string
|
||||
description: AWS region (e.g., us-east-1)
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS Access Key ID (optional, uses default credential chain if not provided)
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS Secret Access Key (optional)
|
||||
endpoint_url:
|
||||
type: string
|
||||
description: Custom endpoint URL for testing (e.g., LocalStack)
|
||||
|
||||
AwsSecretsManagerSettings:
|
||||
type: object
|
||||
required:
|
||||
- region
|
||||
properties:
|
||||
region:
|
||||
type: string
|
||||
description: AWS region (e.g., us-east-1)
|
||||
access_key_id:
|
||||
type: string
|
||||
description: AWS Access Key ID (optional, uses default credential chain if not provided)
|
||||
secret_access_key:
|
||||
type: string
|
||||
description: AWS Secret Access Key (optional)
|
||||
endpoint_url:
|
||||
type: string
|
||||
description: Custom endpoint URL for testing (e.g., LocalStack)
|
||||
prefix:
|
||||
type: string
|
||||
description: Prefix for secret names (e.g., windmill/)
|
||||
|
||||
SecretMigrationFailure:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -6,14 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Secret backend extension for the API layer
|
||||
//!
|
||||
//! This module provides helper functions for integrating the SecretBackend
|
||||
//! trait with variable operations in the API.
|
||||
//!
|
||||
//! Note: HashiCorp Vault integration requires Enterprise Edition.
|
||||
//! The OSS version only supports the database backend.
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -29,16 +21,14 @@ use windmill_common::secret_backend::{database::DatabaseBackend, SecretBackend};
|
||||
use windmill_common::{
|
||||
global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING},
|
||||
secret_backend::{
|
||||
AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend,
|
||||
VaultSettings,
|
||||
AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend,
|
||||
AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// Cached Vault backend to avoid recreating it for every request
|
||||
// This enables connection pooling and avoids repeated setup overhead
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
struct CachedVaultBackend {
|
||||
backend: Arc<dyn SecretBackend>,
|
||||
@@ -50,7 +40,6 @@ lazy_static::lazy_static! {
|
||||
static ref VAULT_BACKEND_CACHE: RwLock<Option<CachedVaultBackend>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
// Cached Azure Key Vault backend
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
struct CachedAzureKvBackend {
|
||||
backend: Arc<dyn SecretBackend>,
|
||||
@@ -62,14 +51,23 @@ lazy_static::lazy_static! {
|
||||
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
/// Get the current secret backend based on global settings (EE only)
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
struct CachedAwsSmBackend {
|
||||
backend: Arc<dyn SecretBackend>,
|
||||
settings: AwsSecretsManagerSettings,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
lazy_static::lazy_static! {
|
||||
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
|
||||
let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? {
|
||||
Some(value) => serde_json::from_value::<SecretBackendConfig>(value).unwrap_or_default(),
|
||||
None => SecretBackendConfig::default(),
|
||||
};
|
||||
|
||||
match config {
|
||||
SecretBackendConfig::Database => Ok(Arc::new(DatabaseBackend::new(db.clone()))),
|
||||
SecretBackendConfig::HashiCorpVault(settings) => {
|
||||
@@ -78,36 +76,27 @@ async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
|
||||
SecretBackendConfig::AzureKeyVault(settings) => {
|
||||
get_or_create_azure_kv_backend(db, settings).await
|
||||
}
|
||||
SecretBackendConfig::AwsSecretsManager(settings) => {
|
||||
get_or_create_aws_sm_backend(db, settings).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a cached Vault backend or create a new one if settings changed
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_or_create_vault_backend(
|
||||
_db: &DB,
|
||||
settings: VaultSettings,
|
||||
) -> Result<Arc<dyn SecretBackend>> {
|
||||
// Check if we have a cached backend with matching settings (read lock)
|
||||
{
|
||||
let cache = VAULT_BACKEND_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
if cached.settings == settings { return Ok(cached.backend.clone()); }
|
||||
}
|
||||
}
|
||||
|
||||
// Need to create a new backend - acquire write lock
|
||||
let mut cache = VAULT_BACKEND_CACHE.write().await;
|
||||
|
||||
// Double-check (another task may have created it while we waited)
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
if cached.settings == settings { return Ok(cached.backend.clone()); }
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
let backend: Arc<dyn SecretBackend> = {
|
||||
#[cfg(feature = "openidconnect")]
|
||||
if settings.token.is_none() {
|
||||
@@ -115,179 +104,136 @@ async fn get_or_create_vault_backend(
|
||||
} else {
|
||||
Arc::new(VaultBackend::new(settings.clone()))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "openidconnect"))]
|
||||
Arc::new(VaultBackend::new(settings.clone()))
|
||||
};
|
||||
|
||||
// Cache it
|
||||
*cache = Some(CachedVaultBackend { backend: backend.clone(), settings });
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Get a cached Azure Key Vault backend or create a new one if settings changed
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_or_create_azure_kv_backend(
|
||||
_db: &DB,
|
||||
settings: AzureKeyVaultSettings,
|
||||
) -> Result<Arc<dyn SecretBackend>> {
|
||||
// Check if we have a cached backend with matching settings (read lock)
|
||||
{
|
||||
let cache = AZURE_KV_BACKEND_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
if cached.settings == settings { return Ok(cached.backend.clone()); }
|
||||
}
|
||||
}
|
||||
|
||||
// Need to create a new backend - acquire write lock
|
||||
let mut cache = AZURE_KV_BACKEND_CACHE.write().await;
|
||||
|
||||
// Double-check (another task may have created it while we waited)
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
if cached.settings == settings { return Ok(cached.backend.clone()); }
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
let backend: Arc<dyn SecretBackend> = Arc::new(AzureKeyVaultBackend::new(settings.clone()));
|
||||
|
||||
// Cache it
|
||||
*cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings });
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Check if an external secret backend is currently configured (EE only)
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_or_create_aws_sm_backend(
|
||||
_db: &DB,
|
||||
settings: AwsSecretsManagerSettings,
|
||||
) -> Result<Arc<dyn SecretBackend>> {
|
||||
{
|
||||
let cache = AWS_SM_BACKEND_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings { return Ok(cached.backend.clone()); }
|
||||
}
|
||||
}
|
||||
let mut cache = AWS_SM_BACKEND_CACHE.write().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings { return Ok(cached.backend.clone()); }
|
||||
}
|
||||
let backend: Arc<dyn SecretBackend> =
|
||||
Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?);
|
||||
*cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings });
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
|
||||
let config = match load_value_from_global_settings(db, SECRET_BACKEND_SETTING).await? {
|
||||
Some(value) => serde_json::from_value::<SecretBackendConfig>(value).unwrap_or_default(),
|
||||
None => SecretBackendConfig::default(),
|
||||
};
|
||||
|
||||
Ok(matches!(
|
||||
config,
|
||||
SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_)
|
||||
SecretBackendConfig::HashiCorpVault(_)
|
||||
| SecretBackendConfig::AzureKeyVault(_)
|
||||
| SecretBackendConfig::AwsSecretsManager(_)
|
||||
))
|
||||
}
|
||||
|
||||
/// Check if a value is stored in Vault (indicated by the $vault: prefix)
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
fn is_vault_stored_value(value: &str) -> bool {
|
||||
value.starts_with("$vault:")
|
||||
}
|
||||
fn is_vault_stored_value(value: &str) -> bool { value.starts_with("$vault:") }
|
||||
|
||||
/// Check if a value is stored in Azure Key Vault (indicated by the $azure_kv: prefix)
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
fn is_azure_kv_stored_value(value: &str) -> bool {
|
||||
value.starts_with("$azure_kv:")
|
||||
}
|
||||
fn is_azure_kv_stored_value(value: &str) -> bool { value.starts_with("$azure_kv:") }
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
fn is_aws_sm_stored_value(value: &str) -> bool { value.starts_with("$aws_sm:") }
|
||||
|
||||
/// Check if a value is stored in any external secret backend
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
fn is_external_stored_value(value: &str) -> bool {
|
||||
is_vault_stored_value(value) || is_azure_kv_stored_value(value)
|
||||
is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value)
|
||||
}
|
||||
|
||||
/// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename)
|
||||
/// EE only feature.
|
||||
///
|
||||
/// This is used when renaming users where many secrets need their paths updated.
|
||||
/// Returns a list of (old_path, new_value) pairs for updating the database.
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub async fn rename_vault_secrets_with_prefix(
|
||||
_db: &DB,
|
||||
_workspace_id: &str,
|
||||
_old_prefix: &str,
|
||||
_new_prefix: &str,
|
||||
_db: &DB, _workspace_id: &str, _old_prefix: &str, _new_prefix: &str,
|
||||
_variables: Vec<(String, String)>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
// OSS: No Vault support, return empty
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
pub async fn rename_vault_secrets_with_prefix(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
old_prefix: &str,
|
||||
new_prefix: &str,
|
||||
variables: Vec<(String, String)>, // (path, value) pairs
|
||||
db: &DB, workspace_id: &str, old_prefix: &str, new_prefix: &str,
|
||||
variables: Vec<(String, String)>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
// Only process if an external secret backend is configured
|
||||
if !is_vault_backend_configured(db).await? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
if !is_vault_backend_configured(db).await? { return Ok(vec![]); }
|
||||
let backend = get_secret_backend(db).await?;
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for (old_path, value) in variables {
|
||||
// Only handle externally-stored values
|
||||
if !is_external_stored_value(&value) {
|
||||
continue;
|
||||
}
|
||||
if !is_external_stored_value(&value) { continue; }
|
||||
|
||||
// Determine the marker prefix from the stored value
|
||||
let marker_prefix = if is_azure_kv_stored_value(&value) {
|
||||
let marker_prefix = if value.starts_with("$azure_kv:") {
|
||||
"$azure_kv:"
|
||||
} else if value.starts_with("$aws_sm:") {
|
||||
"$aws_sm:"
|
||||
} else {
|
||||
"$vault:"
|
||||
};
|
||||
|
||||
// Calculate new path by replacing prefix
|
||||
let new_path = if old_path.starts_with(old_prefix) {
|
||||
format!("{}{}", new_prefix, &old_path[old_prefix.len()..])
|
||||
} else {
|
||||
continue; // Path doesn't match prefix, skip
|
||||
};
|
||||
} else { continue; };
|
||||
|
||||
// Read from old path
|
||||
let secret_value = match backend.get_secret(workspace_id, &old_path).await {
|
||||
Ok(v) => v,
|
||||
Err(Error::NotFound(_)) => {
|
||||
// Just update DB reference
|
||||
updates.push((old_path, format!("{}{}", marker_prefix, new_path)));
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to read secret at {} during bulk rename: {}",
|
||||
old_path,
|
||||
e
|
||||
);
|
||||
tracing::error!("Failed to read secret at {} during bulk rename: {}", old_path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Write to new path
|
||||
if let Err(e) = backend
|
||||
.set_secret(workspace_id, &new_path, &secret_value)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to write secret to {} during bulk rename: {}",
|
||||
new_path,
|
||||
e
|
||||
);
|
||||
if let Err(e) = backend.set_secret(workspace_id, &new_path, &secret_value).await {
|
||||
tracing::error!("Failed to write secret to {} during bulk rename: {}", new_path, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete from old path
|
||||
if let Err(e) = backend.delete_secret(workspace_id, &old_path).await {
|
||||
tracing::warn!(
|
||||
"Failed to delete old secret at {} after rename: {}",
|
||||
old_path,
|
||||
e
|
||||
);
|
||||
tracing::warn!("Failed to delete old secret at {} after rename: {}", old_path, e);
|
||||
}
|
||||
|
||||
updates.push((old_path, format!("{}{}", marker_prefix, new_path)));
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
69
backend/windmill-common/src/secret_backend/aws_sm_oss.rs
Normal file
69
backend/windmill-common/src/secret_backend/aws_sm_oss.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
@@ -22,6 +22,10 @@ pub mod vault_oss;
|
||||
pub mod azure_kv_ee;
|
||||
pub mod azure_kv_oss;
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
pub mod aws_sm_ee;
|
||||
pub mod aws_sm_oss;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -37,60 +41,34 @@ pub use azure_kv_ee::*;
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub use azure_kv_oss::*;
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
pub use aws_sm_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub use aws_sm_oss::*;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Trait for secret storage backends
|
||||
///
|
||||
/// Implementations of this trait handle the storage and retrieval of secrets.
|
||||
/// The default implementation stores secrets encrypted in the database.
|
||||
/// Enterprise Edition supports HashiCorp Vault as an alternative backend.
|
||||
#[async_trait]
|
||||
pub trait SecretBackend: Send + Sync {
|
||||
/// Retrieve a secret value
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `workspace_id` - The workspace identifier
|
||||
/// * `path` - The path/name of the secret variable
|
||||
///
|
||||
/// # Returns
|
||||
/// The decrypted secret value
|
||||
async fn get_secret(&self, workspace_id: &str, path: &str) -> Result<String>;
|
||||
|
||||
/// Store a secret value
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `workspace_id` - The workspace identifier
|
||||
/// * `path` - The path/name of the secret variable
|
||||
/// * `value` - The plaintext secret value to store
|
||||
async fn set_secret(&self, workspace_id: &str, path: &str, value: &str) -> Result<()>;
|
||||
|
||||
/// Delete a secret
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `workspace_id` - The workspace identifier
|
||||
/// * `path` - The path/name of the secret variable
|
||||
async fn delete_secret(&self, workspace_id: &str, path: &str) -> Result<()>;
|
||||
|
||||
/// Get the name of this backend for logging/debugging
|
||||
fn backend_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Configuration for secret storage backend
|
||||
///
|
||||
/// This enum is stored in global_settings and determines which backend
|
||||
/// is used for secret storage at the instance level.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SecretBackendConfig {
|
||||
/// Store secrets encrypted in the database (default behavior)
|
||||
Database,
|
||||
/// Store secrets in HashiCorp Vault (Enterprise Edition only)
|
||||
HashiCorpVault(VaultSettings),
|
||||
/// Store secrets in Azure Key Vault (Enterprise Edition only)
|
||||
AzureKeyVault(AzureKeyVaultSettings),
|
||||
AwsSecretsManager(AwsSecretsManagerSettings),
|
||||
}
|
||||
|
||||
impl Default for SecretBackendConfig {
|
||||
@@ -102,60 +80,59 @@ impl Default for SecretBackendConfig {
|
||||
/// Settings for HashiCorp Vault integration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VaultSettings {
|
||||
/// Vault server address (e.g., "https://vault.company.com:8200")
|
||||
pub address: String,
|
||||
/// KV v2 mount path (e.g., "windmill")
|
||||
pub mount_path: String,
|
||||
/// JWT auth role name configured in Vault (used for JWT/OIDC auth)
|
||||
/// Optional - if not provided, token auth is used
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwt_role: Option<String>,
|
||||
/// Vault Enterprise namespace (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
/// Static Vault token for testing/development (optional)
|
||||
/// If provided, this is used instead of JWT authentication
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AzureKeyVaultSettings {
|
||||
/// Azure Key Vault URL (e.g., "https://myvault.vault.azure.net")
|
||||
pub vault_url: String,
|
||||
/// Azure AD tenant ID
|
||||
pub tenant_id: String,
|
||||
/// Azure AD application (client) ID
|
||||
pub client_id: String,
|
||||
/// Azure AD client secret
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_secret: Option<String>,
|
||||
/// Static Bearer token for testing/development (optional)
|
||||
/// If provided, this is used instead of OAuth2 client credentials authentication
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Settings for AWS Secrets Manager integration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AwsSecretsManagerSettings {
|
||||
/// AWS region (e.g., "us-east-1")
|
||||
pub region: String,
|
||||
/// Static AWS access key ID (optional - uses default credential chain if not provided)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub access_key_id: Option<String>,
|
||||
/// Static AWS secret access key (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secret_access_key: Option<String>,
|
||||
/// Custom endpoint URL for LocalStack/testing (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_url: Option<String>,
|
||||
/// Prefix for secret names in AWS Secrets Manager (e.g., "windmill/")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a secret migration operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretMigrationReport {
|
||||
/// Total number of secrets found
|
||||
pub total_secrets: usize,
|
||||
/// Number of secrets successfully migrated
|
||||
pub migrated_count: usize,
|
||||
/// Number of secrets that failed to migrate
|
||||
pub failed_count: usize,
|
||||
/// Details of any failures
|
||||
pub failures: Vec<SecretMigrationFailure>,
|
||||
}
|
||||
|
||||
/// Details of a failed secret migration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecretMigrationFailure {
|
||||
/// Workspace ID where the secret is located
|
||||
pub workspace_id: String,
|
||||
/// Path of the secret that failed to migrate
|
||||
pub path: String,
|
||||
/// Error message
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! HashiCorp Vault secret backend stubs (Open Source Edition)
|
||||
//!
|
||||
//! This module provides stub implementations for Vault integration.
|
||||
//! The actual Vault integration requires Enterprise Edition.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::DB;
|
||||
@@ -21,7 +16,6 @@ use super::{
|
||||
VaultSettings,
|
||||
};
|
||||
|
||||
/// Stub VaultBackend for OSS - all operations return EE required error
|
||||
pub struct VaultBackend;
|
||||
|
||||
impl VaultBackend {
|
||||
@@ -55,10 +49,6 @@ impl SecretBackend for VaultBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the appropriate secret backend based on configuration
|
||||
///
|
||||
/// In OSS, always returns DatabaseBackend regardless of config.
|
||||
/// Vault configuration is ignored with a warning.
|
||||
pub async fn create_secret_backend(
|
||||
db: DB,
|
||||
config: &SecretBackendConfig,
|
||||
@@ -79,17 +69,22 @@ pub async fn create_secret_backend(
|
||||
);
|
||||
Ok(Arc::new(DatabaseBackend::new(db)))
|
||||
}
|
||||
SecretBackendConfig::AwsSecretsManager(_) => {
|
||||
tracing::warn!(
|
||||
"AWS Secrets Manager is configured but requires Enterprise Edition. \
|
||||
Falling back to database backend."
|
||||
);
|
||||
Ok(Arc::new(DatabaseBackend::new(db)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test connection to Vault (OSS stub)
|
||||
pub async fn test_vault_connection(_settings: &VaultSettings, _db: Option<&DB>) -> Result<()> {
|
||||
Err(Error::internal_err(
|
||||
"HashiCorp Vault integration requires Enterprise Edition".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Migrate secrets from database to Vault (OSS stub)
|
||||
pub async fn migrate_secrets_to_vault(
|
||||
_db: &DB,
|
||||
_settings: &VaultSettings,
|
||||
@@ -99,7 +94,6 @@ pub async fn migrate_secrets_to_vault(
|
||||
))
|
||||
}
|
||||
|
||||
/// Migrate secrets from Vault back to database (OSS stub)
|
||||
pub async fn migrate_secrets_to_database(
|
||||
_db: &DB,
|
||||
_settings: &VaultSettings,
|
||||
@@ -109,7 +103,6 @@ pub async fn migrate_secrets_to_database(
|
||||
))
|
||||
}
|
||||
|
||||
/// Generate a JWT for Vault authentication (OSS stub)
|
||||
pub async fn generate_vault_jwt(_db: &DB, _vault_address: &str) -> Result<String> {
|
||||
Err(Error::internal_err(
|
||||
"HashiCorp Vault integration requires Enterprise Edition".to_string(),
|
||||
|
||||
264
backend/windmill-common/tests/aws_sm_integration.rs
Normal file
264
backend/windmill-common/tests/aws_sm_integration.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Secret backend extension for the API layer
|
||||
//!
|
||||
//! This module provides helper functions for integrating the SecretBackend
|
||||
//! trait with variable operations in the API.
|
||||
//!
|
||||
//! Note: HashiCorp Vault integration requires Enterprise Edition.
|
||||
//! The OSS version only supports the database backend.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use windmill_common::{
|
||||
@@ -26,14 +18,15 @@ use windmill_common::{
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use windmill_common::{
|
||||
global_settings::{load_value_from_global_settings, SECRET_BACKEND_SETTING},
|
||||
secret_backend::{AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings},
|
||||
secret_backend::{
|
||||
AwsSecretsManagerBackend, AwsSecretsManagerSettings, AzureKeyVaultBackend,
|
||||
AzureKeyVaultSettings, SecretBackendConfig, VaultBackend, VaultSettings,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// Cached Vault backend to avoid recreating it for every request
|
||||
// This enables connection pooling and avoids repeated setup overhead
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
struct CachedVaultBackend {
|
||||
backend: Arc<dyn SecretBackend>,
|
||||
@@ -56,10 +49,17 @@ lazy_static::lazy_static! {
|
||||
static ref AZURE_KV_BACKEND_CACHE: RwLock<Option<CachedAzureKvBackend>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
/// Get the current secret backend based on global settings
|
||||
///
|
||||
/// OSS: Always returns DatabaseBackend
|
||||
/// EE: Returns configured backend (Database or Vault)
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
struct CachedAwsSmBackend {
|
||||
backend: Arc<dyn SecretBackend>,
|
||||
settings: AwsSecretsManagerSettings,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
lazy_static::lazy_static! {
|
||||
static ref AWS_SM_BACKEND_CACHE: RwLock<Option<CachedAwsSmBackend>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
|
||||
Ok(Arc::new(DatabaseBackend::new(db.clone())))
|
||||
@@ -80,16 +80,17 @@ pub async fn get_secret_backend(db: &DB) -> Result<Arc<dyn SecretBackend>> {
|
||||
SecretBackendConfig::AzureKeyVault(settings) => {
|
||||
get_or_create_azure_kv_backend(db, settings).await
|
||||
}
|
||||
SecretBackendConfig::AwsSecretsManager(settings) => {
|
||||
get_or_create_aws_sm_backend(db, settings).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a cached Vault backend or create a new one if settings changed
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_or_create_vault_backend(
|
||||
_db: &DB,
|
||||
settings: VaultSettings,
|
||||
) -> Result<Arc<dyn SecretBackend>> {
|
||||
// Check if we have a cached backend with matching settings (read lock)
|
||||
{
|
||||
let cache = VAULT_BACKEND_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
@@ -98,18 +99,12 @@ async fn get_or_create_vault_backend(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Need to create a new backend - acquire write lock
|
||||
let mut cache = VAULT_BACKEND_CACHE.write().await;
|
||||
|
||||
// Double-check (another task may have created it while we waited)
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
let backend: Arc<dyn SecretBackend> = {
|
||||
#[cfg(feature = "openidconnect")]
|
||||
if settings.token.is_none() {
|
||||
@@ -117,24 +112,18 @@ async fn get_or_create_vault_backend(
|
||||
} else {
|
||||
Arc::new(VaultBackend::new(settings.clone()))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "openidconnect"))]
|
||||
Arc::new(VaultBackend::new(settings.clone()))
|
||||
};
|
||||
|
||||
// Cache it
|
||||
*cache = Some(CachedVaultBackend { backend: backend.clone(), settings });
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Get a cached Azure Key Vault backend or create a new one if settings changed
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_or_create_azure_kv_backend(
|
||||
_db: &DB,
|
||||
settings: AzureKeyVaultSettings,
|
||||
) -> Result<Arc<dyn SecretBackend>> {
|
||||
// Check if we have a cached backend with matching settings (read lock)
|
||||
{
|
||||
let cache = AZURE_KV_BACKEND_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
@@ -143,30 +132,42 @@ async fn get_or_create_azure_kv_backend(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Need to create a new backend - acquire write lock
|
||||
let mut cache = AZURE_KV_BACKEND_CACHE.write().await;
|
||||
|
||||
// Double-check (another task may have created it while we waited)
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Create new backend
|
||||
let backend: Arc<dyn SecretBackend> = Arc::new(AzureKeyVaultBackend::new(settings.clone()));
|
||||
|
||||
// Cache it
|
||||
*cache = Some(CachedAzureKvBackend { backend: backend.clone(), settings });
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Check if a Vault backend is currently configured
|
||||
///
|
||||
/// OSS: Always returns false
|
||||
/// EE: Checks global settings
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
async fn get_or_create_aws_sm_backend(
|
||||
_db: &DB,
|
||||
settings: AwsSecretsManagerSettings,
|
||||
) -> Result<Arc<dyn SecretBackend>> {
|
||||
{
|
||||
let cache = AWS_SM_BACKEND_CACHE.read().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cache = AWS_SM_BACKEND_CACHE.write().await;
|
||||
if let Some(ref cached) = *cache {
|
||||
if cached.settings == settings {
|
||||
return Ok(cached.backend.clone());
|
||||
}
|
||||
}
|
||||
let backend: Arc<dyn SecretBackend> =
|
||||
Arc::new(AwsSecretsManagerBackend::new_with_client(settings.clone()).await?);
|
||||
*cache = Some(CachedAwsSmBackend { backend: backend.clone(), settings });
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub async fn is_vault_backend_configured(_db: &DB) -> Result<bool> {
|
||||
Ok(false)
|
||||
@@ -178,14 +179,14 @@ pub async fn is_vault_backend_configured(db: &DB) -> Result<bool> {
|
||||
Some(value) => serde_json::from_value::<SecretBackendConfig>(value).unwrap_or_default(),
|
||||
None => SecretBackendConfig::default(),
|
||||
};
|
||||
|
||||
Ok(matches!(config, SecretBackendConfig::HashiCorpVault(_) | SecretBackendConfig::AzureKeyVault(_)))
|
||||
Ok(matches!(
|
||||
config,
|
||||
SecretBackendConfig::HashiCorpVault(_)
|
||||
| SecretBackendConfig::AzureKeyVault(_)
|
||||
| SecretBackendConfig::AwsSecretsManager(_)
|
||||
))
|
||||
}
|
||||
|
||||
/// Get a secret value using the configured backend
|
||||
///
|
||||
/// For database backend: decrypts using workspace key
|
||||
/// For vault backend (EE only): fetches from Vault directly
|
||||
pub async fn get_secret_value(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
@@ -193,20 +194,14 @@ pub async fn get_secret_value(
|
||||
encrypted_value: &str,
|
||||
) -> Result<String> {
|
||||
let backend = get_secret_backend(db).await?;
|
||||
|
||||
match backend.backend_name() {
|
||||
"database" => {
|
||||
// Use existing database decryption
|
||||
let mc = build_crypt(db, workspace_id).await?;
|
||||
decrypt(&mc, encrypted_value.to_string()).map_err(|e| {
|
||||
Error::internal_err(format!("Error decrypting variable {}: {}", path, e))
|
||||
})
|
||||
}
|
||||
"hashicorp_vault" => {
|
||||
// Fetch from Vault directly
|
||||
backend.get_secret(workspace_id, path).await
|
||||
}
|
||||
"azure_key_vault" => {
|
||||
"hashicorp_vault" | "azure_key_vault" | "aws_secrets_manager" => {
|
||||
backend.get_secret(workspace_id, path).await
|
||||
}
|
||||
_ => Err(Error::internal_err(format!(
|
||||
@@ -216,10 +211,6 @@ pub async fn get_secret_value(
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a secret value using the configured backend
|
||||
///
|
||||
/// For database backend: encrypts using workspace key and returns encrypted value
|
||||
/// For vault backend (EE only): stores in Vault and returns a placeholder for DB storage
|
||||
pub async fn store_secret_value(
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
@@ -227,15 +218,12 @@ pub async fn store_secret_value(
|
||||
plain_value: &str,
|
||||
) -> Result<String> {
|
||||
let backend = get_secret_backend(db).await?;
|
||||
|
||||
match backend.backend_name() {
|
||||
"database" => {
|
||||
// Use existing database encryption
|
||||
let mc = build_crypt(db, workspace_id).await?;
|
||||
Ok(encrypt(&mc, plain_value))
|
||||
}
|
||||
"hashicorp_vault" => {
|
||||
// Store in Vault and return a marker for DB
|
||||
backend.set_secret(workspace_id, path, plain_value).await?;
|
||||
Ok(format!("$vault:{}", path))
|
||||
}
|
||||
@@ -243,6 +231,10 @@ pub async fn store_secret_value(
|
||||
backend.set_secret(workspace_id, path, plain_value).await?;
|
||||
Ok(format!("$azure_kv:{}", path))
|
||||
}
|
||||
"aws_secrets_manager" => {
|
||||
backend.set_secret(workspace_id, path, plain_value).await?;
|
||||
Ok(format!("$aws_sm:{}", path))
|
||||
}
|
||||
_ => Err(Error::internal_err(format!(
|
||||
"Unknown backend: {}",
|
||||
backend.backend_name()
|
||||
@@ -250,14 +242,9 @@ pub async fn store_secret_value(
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a secret from the configured backend (if using Vault)
|
||||
///
|
||||
/// For database backend: no-op (DB delete is handled separately)
|
||||
/// For vault backend (EE only): deletes from Vault
|
||||
pub async fn delete_secret_from_backend(db: &DB, workspace_id: &str, path: &str) -> Result<()> {
|
||||
if is_vault_backend_configured(db).await? {
|
||||
let backend = get_secret_backend(db).await?;
|
||||
// Ignore NotFound errors during deletion (secret might not exist in Vault)
|
||||
match backend.delete_secret(workspace_id, path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(Error::NotFound(_)) => Ok(()),
|
||||
@@ -268,22 +255,22 @@ pub async fn delete_secret_from_backend(db: &DB, workspace_id: &str, path: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a value is stored in Vault (indicated by the $vault: prefix)
|
||||
pub fn is_vault_stored_value(value: &str) -> bool {
|
||||
value.starts_with("$vault:")
|
||||
}
|
||||
|
||||
/// Check if a value is stored in Azure Key Vault (indicated by the $azure_kv: prefix)
|
||||
pub fn is_azure_kv_stored_value(value: &str) -> bool {
|
||||
value.starts_with("$azure_kv:")
|
||||
}
|
||||
|
||||
/// Check if a value is stored in any external secret backend
|
||||
pub fn is_external_stored_value(value: &str) -> bool {
|
||||
is_vault_stored_value(value) || is_azure_kv_stored_value(value)
|
||||
pub fn is_aws_sm_stored_value(value: &str) -> bool {
|
||||
value.starts_with("$aws_sm:")
|
||||
}
|
||||
|
||||
pub fn is_external_stored_value(value: &str) -> bool {
|
||||
is_vault_stored_value(value) || is_azure_kv_stored_value(value) || is_aws_sm_stored_value(value)
|
||||
}
|
||||
|
||||
/// Rename a secret in Vault when a variable path changes (EE only)
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub async fn rename_vault_secret(
|
||||
_db: &DB,
|
||||
@@ -293,21 +280,14 @@ pub async fn rename_vault_secret(
|
||||
current_value: &str,
|
||||
) -> Result<Option<String>> {
|
||||
if is_vault_stored_value(current_value) {
|
||||
tracing::warn!(
|
||||
"Variable has $vault: prefix but Vault requires Enterprise Edition. \
|
||||
Updating DB reference to {}",
|
||||
new_path
|
||||
);
|
||||
return Ok(Some(format!("$vault:{}", new_path)));
|
||||
}
|
||||
if is_azure_kv_stored_value(current_value) {
|
||||
tracing::warn!(
|
||||
"Variable has $azure_kv: prefix but Azure Key Vault requires Enterprise Edition. \
|
||||
Updating DB reference to {}",
|
||||
new_path
|
||||
);
|
||||
return Ok(Some(format!("$azure_kv:{}", new_path)));
|
||||
}
|
||||
if is_aws_sm_stored_value(current_value) {
|
||||
return Ok(Some(format!("$aws_sm:{}", new_path)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -325,6 +305,8 @@ pub async fn rename_vault_secret(
|
||||
|
||||
let marker_prefix = if current_value.starts_with("$azure_kv:") {
|
||||
"$azure_kv:"
|
||||
} else if current_value.starts_with("$aws_sm:") {
|
||||
"$aws_sm:"
|
||||
} else {
|
||||
"$vault:"
|
||||
};
|
||||
@@ -333,9 +315,7 @@ pub async fn rename_vault_secret(
|
||||
tracing::warn!(
|
||||
"Variable value has {} prefix but external secret backend is not configured. \
|
||||
Updating DB reference from {} to {}",
|
||||
marker_prefix,
|
||||
old_path,
|
||||
new_path
|
||||
marker_prefix, old_path, new_path
|
||||
);
|
||||
return Ok(Some(format!("{}{}", marker_prefix, new_path)));
|
||||
}
|
||||
@@ -347,31 +327,25 @@ pub async fn rename_vault_secret(
|
||||
Err(Error::NotFound(_)) => {
|
||||
tracing::warn!(
|
||||
"Secret not found in backend at path {} during rename to {}",
|
||||
old_path,
|
||||
new_path
|
||||
old_path, new_path
|
||||
);
|
||||
return Ok(Some(format!("{}{}", marker_prefix, new_path)));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
backend
|
||||
.set_secret(workspace_id, new_path, &secret_value)
|
||||
.await?;
|
||||
backend.set_secret(workspace_id, new_path, &secret_value).await?;
|
||||
|
||||
if let Err(e) = backend.delete_secret(workspace_id, old_path).await {
|
||||
tracing::warn!(
|
||||
"Failed to delete old secret at {} after rename to {}: {}",
|
||||
old_path,
|
||||
new_path,
|
||||
e
|
||||
old_path, new_path, e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(format!("{}{}", marker_prefix, new_path)))
|
||||
}
|
||||
|
||||
/// Bulk rename secrets in Vault when a path prefix changes (e.g., user rename)
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
pub async fn rename_vault_secrets_with_prefix(
|
||||
_db: &DB,
|
||||
@@ -405,6 +379,8 @@ pub async fn rename_vault_secrets_with_prefix(
|
||||
|
||||
let marker_prefix = if value.starts_with("$azure_kv:") {
|
||||
"$azure_kv:"
|
||||
} else if value.starts_with("$aws_sm:") {
|
||||
"$aws_sm:"
|
||||
} else {
|
||||
"$vault:"
|
||||
};
|
||||
@@ -422,33 +398,18 @@ pub async fn rename_vault_secrets_with_prefix(
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to read secret at {} during bulk rename: {}",
|
||||
old_path,
|
||||
e
|
||||
);
|
||||
tracing::error!("Failed to read secret at {} during bulk rename: {}", old_path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = backend
|
||||
.set_secret(workspace_id, &new_path, &secret_value)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to write secret to {} during bulk rename: {}",
|
||||
new_path,
|
||||
e
|
||||
);
|
||||
if let Err(e) = backend.set_secret(workspace_id, &new_path, &secret_value).await {
|
||||
tracing::error!("Failed to write secret to {} during bulk rename: {}", new_path, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = backend.delete_secret(workspace_id, &old_path).await {
|
||||
tracing::warn!(
|
||||
"Failed to delete old secret at {} after rename: {}",
|
||||
old_path,
|
||||
e
|
||||
);
|
||||
tracing::warn!("Failed to delete old secret at {} after rename: {}", old_path, e);
|
||||
}
|
||||
|
||||
updates.push((old_path, format!("{}{}", marker_prefix, new_path)));
|
||||
|
||||
@@ -683,11 +683,11 @@ export const settings: Record<string, Setting[]> = {
|
||||
{
|
||||
label: 'Backend type',
|
||||
description:
|
||||
'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault and Azure Key Vault as external secret stores.',
|
||||
'By default, secrets are encrypted and stored in the database. Enterprise Edition supports HashiCorp Vault, Azure Key Vault, and AWS KMS as external secret backends.',
|
||||
key: 'secret_backend',
|
||||
fieldType: 'secret_backend',
|
||||
storage: 'setting',
|
||||
ee_only: 'HashiCorp Vault and Azure Key Vault integrations are Enterprise Edition features'
|
||||
ee_only: 'HashiCorp Vault, Azure Key Vault, and AWS KMS integrations are Enterprise Edition features'
|
||||
}
|
||||
],
|
||||
'GitHub App': [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user