From f65fe7bf585d353f7d88746e947d68e2f351e516 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 8 Jul 2026 16:59:26 +0200 Subject: [PATCH] fix: replicate external secret backend secrets when forking a workspace (#10007) Co-authored-by: Claude Fable 5 --- ...819520a2ba987fd29859845c2177563ab8cfb.json | 28 ++++++++ .../windmill-api-workspaces/src/workspaces.rs | 68 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json diff --git a/backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json b/backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json new file mode 100644 index 0000000000..18c75f5722 --- /dev/null +++ b/backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path, value FROM variable\n WHERE workspace_id = $1 AND is_secret = true AND value != ''", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 0b5b600bf1..a0980cde2d 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -71,6 +71,9 @@ use hyper::StatusCode; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Postgres, Transaction}; use windmill_common::oauth2::InstanceEvent; +use windmill_common::secret_backend::{ + get_secret_backend, is_external_stored_value, is_vault_backend_configured, +}; use windmill_common::utils::not_found_if_none; lazy_static::lazy_static! { @@ -4115,6 +4118,7 @@ async fn create_workspace( // their drafts would dangle as orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, + db: &DB, source_workspace_id: &str, target_workspace_id: &str, authed_email: &str, @@ -4146,8 +4150,8 @@ async fn clone_workspace_data( // Clone resources clone_resources(tx, source_workspace_id, target_workspace_id).await?; - // Clone variables with re-encryption - clone_variables(tx, source_workspace_id, target_workspace_id).await?; + // Clone variables (including external secret backend replication) + clone_variables(tx, db, source_workspace_id, target_workspace_id).await?; // Clone scripts with new hashes clone_scripts(tx, source_workspace_id, target_workspace_id).await?; @@ -4652,6 +4656,7 @@ async fn clone_resources( async fn clone_variables( tx: &mut Transaction<'_, Postgres>, + db: &DB, source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { @@ -4666,6 +4671,56 @@ async fn clone_variables( .execute(&mut **tx) .await?; + // With an external secret backend (Vault / Azure KV / AWS SM), the copied + // `value` is only a `$vault:`/`$azure_kv:`/`$aws_sm:` marker: the actual + // secret lives in the external store under a key derived from + // (workspace_id, path). The row copy above therefore leaves the fork's + // markers pointing at keys that don't exist — replicate each secret under + // the fork's workspace id. + if is_vault_backend_configured(db).await? { + let secret_variables = sqlx::query!( + "SELECT path, value FROM variable + WHERE workspace_id = $1 AND is_secret = true AND value != ''", + target_workspace_id, + ) + .fetch_all(&mut **tx) + .await?; + + let backend = get_secret_backend(db).await?; + for variable in secret_variables + .into_iter() + .filter(|v| is_external_stored_value(&v.value)) + { + match backend + .get_secret(source_workspace_id, &variable.path) + .await + { + Ok(plain_value) => { + backend + .set_secret(target_workspace_id, &variable.path, &plain_value) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to replicate secret variable {} to the external secret backend for the forked workspace: {e}", + variable.path + )) + })?; + } + // The source secret is unreadable (e.g. deleted out-of-band from + // the external store), so the variable is equally broken in the + // source workspace — don't let it block forking. + Err(e) => { + tracing::warn!( + workspace_id = %source_workspace_id, + path = %variable.path, + error = %e, + "Could not read secret variable from the external secret backend while forking; the forked variable will not resolve" + ); + } + } + } + } + Ok(()) } @@ -5635,7 +5690,14 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id, &authed.email).await?; + clone_workspace_data( + &mut tx, + &db, + &parent_workspace_id, + &forked_id, + &authed.email, + ) + .await?; // Clone triggers and schedules unconditionally, always with mode='disabled' / // enabled=false. Disabled rows have no side effects (no listener