diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a7f74deae6..401a8213f7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -c6d55247a7fd951f561e0b2ad2ac13051274aa77 +c8d100d74b8de6bd26fc973d5edbd8853d54dd8b diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 6c6120f54b..5bd7f5c21e 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -33961,7 +33961,7 @@ }, "client_secret": { "type": "string", - "description": "Azure AD client secret" + "description": "Azure AD client secret. Optional — when omitted, the integration falls back to Azure Workload Identity Federation, exchanging the Kubernetes-projected service-account JWT at AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived secret stored)." }, "token": { "type": "string", diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 7585b06254..367b570b29 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -2977,7 +2977,12 @@ paths: description: Azure AD application (client) ID client_secret: type: string - description: Azure AD client secret + description: >- + Azure AD client secret. Optional — when omitted, the + integration falls back to Azure Workload Identity Federation, + exchanging the Kubernetes-projected service-account JWT at + AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived + secret stored). token: type: string description: >- diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3fc9f0897b..4a213e20a5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -20929,7 +20929,10 @@ components: description: Azure AD application (client) ID client_secret: type: string - description: Azure AD client secret + description: >- + Azure AD client secret. Optional — when omitted, the integration falls back to + Azure Workload Identity Federation, exchanging the Kubernetes-projected service-account + JWT at AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived secret stored). token: type: string description: Static Bearer token for testing/development (optional, if provided this is used instead of OAuth2 authentication) diff --git a/backend/windmill-common/src/secret_backend/mod.rs b/backend/windmill-common/src/secret_backend/mod.rs index 4c9389cac9..a75f51ea70 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -143,7 +143,10 @@ pub struct AzureKeyVaultSettings { pub tenant_id: String, /// Azure AD application (client) ID pub client_id: String, - /// Azure AD client secret + /// Azure AD client secret. Optional — when omitted, the integration falls back to + /// Azure Workload Identity Federation: the Kubernetes-projected service-account JWT at + /// `AZURE_FEDERATED_TOKEN_FILE` is exchanged with Entra ID for an access token (no + /// long-lived secret stored on the Windmill instance). #[serde(skip_serializing_if = "Option::is_none")] pub client_secret: Option, /// Static Bearer token for testing/development (optional) diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index c2ebc581aa..15db633264 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -26,9 +26,8 @@ } }) - let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsSecretsManager' = $derived( - $values['secret_backend']?.type ?? 'Database' - ) + let selectedType: 'Database' | 'HashiCorpVault' | 'AzureKeyVault' | 'AwsSecretsManager' = + $derived($values['secret_backend']?.type ?? 'Database') let authMethod: 'token' | 'jwt' = $derived.by(() => { const config = $values['secret_backend'] @@ -58,7 +57,11 @@ function setBackendType(type: string | undefined) { if (!type) return - if ((type === 'HashiCorpVault' || type === 'AzureKeyVault' || type === 'AwsSecretsManager') && vaultDisabled) return + if ( + (type === 'HashiCorpVault' || type === 'AzureKeyVault' || type === 'AwsSecretsManager') && + vaultDisabled + ) + return if (type === 'Database') { $values['secret_backend'] = { type: 'Database' } } else if (type === 'HashiCorpVault') { @@ -93,11 +96,24 @@ } function setAuthMethod(method: string | undefined) { - if (!method || !$values['secret_backend'] || $values['secret_backend'].type !== 'HashiCorpVault') return + if ( + !method || + !$values['secret_backend'] || + $values['secret_backend'].type !== 'HashiCorpVault' + ) + return if (method === 'token') { - $values['secret_backend'] = { ...$values['secret_backend'], jwt_role: null, token: $values['secret_backend'].token ?? '' } + $values['secret_backend'] = { + ...$values['secret_backend'], + jwt_role: null, + token: $values['secret_backend'].token ?? '' + } } else if (method === 'jwt') { - $values['secret_backend'] = { ...$values['secret_backend'], token: null, jwt_role: $values['secret_backend'].jwt_role ?? 'windmill-secrets' } + $values['secret_backend'] = { + ...$values['secret_backend'], + token: null, + jwt_role: $values['secret_backend'].jwt_role ?? 'windmill-secrets' + } } } @@ -120,7 +136,9 @@ 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() { @@ -128,25 +146,48 @@ migratingToVault = true try { 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 } + 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 migratingToDatabase = true try { - 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 } + 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() @@ -170,39 +211,68 @@ try { await SettingService.testAzureKvBackend({ requestBody: getAzureKvSettings() }) sendUserToast('Successfully connected to Azure Key Vault') - } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } - finally { testingAzureKvConnection = false } + } catch (error: any) { + sendUserToast('Failed: ' + error.message, true) + } finally { + testingAzureKvConnection = false + } } async function migrateSecretsToAzureKv() { if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return migratingToAzureKv = true try { - const report = await SettingService.migrateSecretsToAzureKv({ requestBody: getAzureKvSettings() }) - 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 } + const report = await SettingService.migrateSecretsToAzureKv({ + requestBody: getAzureKvSettings() + }) + 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() { if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return migratingFromAzureKv = true try { - const report = await SettingService.migrateSecretsFromAzureKv({ requestBody: getAzureKvSettings() }) - 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 } + const report = await SettingService.migrateSecretsFromAzureKv({ + requestBody: getAzureKvSettings() + }) + 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 { - if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') return false + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AzureKeyVault') + return false return ( $values['secret_backend'].vault_url?.trim() !== '' && $values['secret_backend'].tenant_id?.trim() !== '' && - $values['secret_backend'].client_id?.trim() !== '' && - (!!$values['secret_backend'].client_secret?.trim() || !!$values['secret_backend'].token?.trim()) + $values['secret_backend'].client_id?.trim() !== '' ) } @@ -222,8 +292,11 @@ try { await SettingService.testAwsSmBackend({ requestBody: getAwsSmSettings() }) sendUserToast('Successfully connected to AWS Secrets Manager') - } catch (error: any) { sendUserToast('Failed: ' + error.message, true) } - finally { testingAwsSmConnection = false } + } catch (error: any) { + sendUserToast('Failed: ' + error.message, true) + } finally { + testingAwsSmConnection = false + } } async function migrateSecretsToAwsSm() { @@ -231,25 +304,50 @@ 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 } + 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 } + 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 + if (!$values['secret_backend'] || $values['secret_backend'].type !== 'AwsSecretsManager') + return false return $values['secret_backend'].region?.trim() !== '' } @@ -260,10 +358,39 @@
setBackendType(v)}> {#snippet children({ item: toggleButton })} - - - - + + + + {/snippet} {#if vaultDisabled} @@ -278,7 +405,10 @@

Database Storage (Default)

-

Secrets are encrypted using workspace-specific keys and stored in the PostgreSQL database.

+

Secrets are encrypted using workspace-specific keys and stored in the PostgreSQL + database.

{:else if selectedType === 'HashiCorpVault'} @@ -286,46 +416,101 @@
-

HashiCorp Vault Configuration Beta

-

Store secrets in an external HashiCorp Vault instance.

+

HashiCorp Vault Configuration Beta

+

Store secrets in an external HashiCorp Vault instance.

- - + +
- + The KV v2 secrets engine mount path in Vault - +
Authentication Method setAuthMethod(v)}> {#snippet children({ item: toggleButton })} - - + + {/snippet}
{#if authMethod === 'token'}
- - Static token. Recommended only for testing/development. + + Static token. Recommended only for testing/development.
{:else}
- - The JWT authentication role configured in Vault. - + + The JWT authentication role configured in Vault. +
- Vault JWT Setup Instructions + Vault JWT Setup Instructions

Configure Vault to accept JWTs from Windmill:

-
-
# Enable JWT auth method
+								
+
# Enable JWT auth method
 vault auth enable jwt
 
 # Configure JWT auth with Windmill's JWKS endpoint
@@ -349,37 +534,82 @@ vault write auth/jwt/role/windmill-secrets \
   bound_audiences="{baseUrl}" \
   user_claim="email" \
   policies="windmill-secrets" \
-  ttl="1h"
+ ttl="1h"
{/if}
- + Vault Enterprise namespace - +
- - Disables TLS verification when connecting to Vault. Only enable for self-signed certificates in development. + + Disables TLS verification when connecting to Vault. Only enable for self-signed + certificates in development.
- +
Secret Migration - Original values are NOT deleted to allow for rollback. + Original values are NOT deleted to allow for rollback.
-
+

Database → Vault

- +
-
+

Vault → Database

- +
@@ -396,42 +626,109 @@ vault write auth/jwt/role/windmill-secrets \
- - + +
- - + +
- - + +
- + + Leave blank to use Azure Workload Identity. Requires + AZURE_FEDERATED_TOKEN_FILE on the Windmill process (auto-injected on AKS by + the workload-identity webhook; set manually on other Kubernetes clusters).
- - Static Bearer token for testing. If provided, OAuth2 is skipped. + + Static Bearer token for testing. If provided, OAuth2 is skipped.
- +
Secret Migration - Original values are NOT deleted to allow for rollback. + Original values are NOT deleted to allow for rollback.
-
+

Database → Azure Key Vault

- +
-
+

Azure Key Vault → Database

- +
@@ -442,50 +739,118 @@ vault write auth/jwt/role/windmill-secrets \
-

AWS Secrets Manager Configuration Beta

+

AWS Secrets Manager Configuration Beta

Store secrets in AWS Secrets Manager.

- - + +
- - If not provided, the default AWS credential chain is used (env vars, instance profile, EKS pod identity) - + + If not provided, the default AWS credential chain is used (env vars, instance profile, + EKS pod identity) +
- +
- - Prefix for secret names in AWS Secrets Manager (default: windmill/) - + + Prefix for secret names in AWS Secrets Manager (default: windmill/) +
- - Custom endpoint for LocalStack or other compatible services - + + Custom endpoint for LocalStack or other compatible services +
- +
Secret Migration - Original values are NOT deleted to allow for rollback. + Original values are NOT deleted to allow for rollback.
-
+

Database → AWS Secrets Manager

- +
-
+

AWS Secrets Manager → Database

- +
@@ -494,21 +859,105 @@ vault write auth/jwt/role/windmill-secrets \ {/if}
- { migrateToAwsSmModalOpen = false }} onConfirmed={migrateSecretsToAwsSm}> - {#snippet children()}

This will copy all secrets from the database to AWS Secrets Manager.

Database values are NOT deleted automatically.

{/snippet} + { + migrateToAwsSmModalOpen = false + }} + onConfirmed={migrateSecretsToAwsSm} +> + {#snippet children()}

This will copy all secrets from the database to AWS Secrets Manager.

Database values are NOT deleted automatically.

{/snippet}
- { migrateFromAwsSmModalOpen = false }} onConfirmed={migrateSecretsFromAwsSm}> - {#snippet children()}

This will copy all secrets from AWS Secrets Manager back to the database.

AWS Secrets Manager values are NOT deleted automatically.

{/snippet} + { + migrateFromAwsSmModalOpen = false + }} + onConfirmed={migrateSecretsFromAwsSm} +> + {#snippet children()}

This will copy all secrets from AWS Secrets Manager back to the database.

AWS Secrets Manager values are NOT deleted automatically.

{/snippet}
- { migrateToAzureKvModalOpen = false }} onConfirmed={migrateSecretsToAzureKv}> - {#snippet children()}

This will copy all secrets to Azure Key Vault.

Database values are NOT deleted automatically.

{/snippet} + { + migrateToAzureKvModalOpen = false + }} + onConfirmed={migrateSecretsToAzureKv} +> + {#snippet children()}

This will copy all secrets to Azure Key Vault.

Database values are NOT deleted automatically.

{/snippet}
- { migrateFromAzureKvModalOpen = false }} onConfirmed={migrateSecretsFromAzureKv}> - {#snippet children()}

This will copy all secrets from Azure Key Vault back to the database.

{/snippet} + { + migrateFromAzureKvModalOpen = false + }} + onConfirmed={migrateSecretsFromAzureKv} +> + {#snippet children()}

This will copy all secrets from Azure Key Vault back to the database.

{/snippet}
- { migrateToVaultModalOpen = false }} onConfirmed={migrateSecretsToVault}> - {#snippet children()}

This will copy all secrets to HashiCorp Vault.

Database values are NOT deleted automatically.

{/snippet} + { + migrateToVaultModalOpen = false + }} + onConfirmed={migrateSecretsToVault} +> + {#snippet children()}

This will copy all secrets to HashiCorp Vault.

Database values are NOT deleted automatically.

{/snippet}
- { migrateToDatabaseModalOpen = false }} onConfirmed={migrateSecretsToDatabase}> - {#snippet children()}

This will copy all secrets from Vault back to the database.

{/snippet} + { + migrateToDatabaseModalOpen = false + }} + onConfirmed={migrateSecretsToDatabase} +> + {#snippet children()}

This will copy all secrets from Vault back to the database.

{/snippet}