fix: replicate external secret backend secrets when forking a workspace (#10007)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-08 14:59:26 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent b847ca2bc7
commit f65fe7bf58
2 changed files with 93 additions and 3 deletions
@@ -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"
}
@@ -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