From 06c6b8780c919e6f110bd05ffeaed8bc065ddf56 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 10 Aug 2026 22:59:21 +0200 Subject: [PATCH] fix: scope a fork's cloned app policy and custom path to its creator (#10595) * fix: scope a fork's cloned app policy and custom path to its creator Co-Authored-By: Claude Opus 5 (1M context) * fix: gate a cloned anonymous app on the parent's own deployment rule Co-Authored-By: Claude Opus 5 (1M context) * docs: state why a cloned anonymous app is gated more strictly than create_app Co-Authored-By: Claude Opus 5 (1M context) * style: wrap an over-long comment line in clone_apps Co-Authored-By: Claude Opus 5 (1M context) * fix: clone an app's execution_mode unchanged Forcing `publisher` on a cloned app was a speed bump rather than a boundary: protection rules are workspace-scoped and are not cloned, so the fork's creator can publish an anonymous app there with no rule in the way. It was also the one policy field a deploy back to the parent carries verbatim, since `update_app` recomputes the identity but writes the policy wholesale, so a fork's copy could silently close the parent's public endpoint. The identity rewrite is what closes the hole this addresses: the fork's endpoint no longer runs as whoever the parent published it as. Co-Authored-By: Claude Opus 5 * fix: ignore an app's run-as identity when comparing workspaces `compare_two_apps` hashed the whole policy, so a fork whose apps were re-pointed at their creator reported every one of them as changed. Nothing could clear those entries: the deploy offers the target's current identity, the deployer's, or a typed-in one, never the source's, so the difference survives however many times the item is deployed. `script` and `flow` already compare no identity. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- ...3ff0eb564e80be2ecb740ca56b6561a52751a.json | 14 ++ ...30629a82f8d2150be91cff20ad7e400e0135f.json | 22 ++ ...85362af4957db48382f5bc6e54a9d2e9f6db7.json | 12 + ...3435b1a929d89b07bf332d47553da7c8c0abc.json | 14 ++ ...18457ec947b75968bfceb56e289795a606d4e.json | 12 + ...ad253054ee609cc1e767d81bde35ca271259d.json | 23 ++ ...8d556534d74b06570bb16be7a2f4530451651.json | 26 +++ ...918bc042b58714ba5d66568770e7f11153ece.json | 14 ++ .../tests/fork_clone_on_behalf_of.rs | 205 +++++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 82 +++++-- 10 files changed, 401 insertions(+), 23 deletions(-) create mode 100644 backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json create mode 100644 backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json create mode 100644 backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json create mode 100644 backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json create mode 100644 backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json create mode 100644 backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json create mode 100644 backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json create mode 100644 backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json diff --git a/backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json b/backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json new file mode 100644 index 0000000000..78094958e3 --- /dev/null +++ b/backend/.sqlx/query-0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0556788004b198ace5808d450013ff0eb564e80be2ecb740ca56b6561a52751a" +} diff --git a/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json new file mode 100644 index 0000000000..05c3ac8a7a --- /dev/null +++ b/backend/.sqlx/query-10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path)\n VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path')\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "10cc330ff3f839a55faeffded3d30629a82f8d2150be91cff20ad7e400e0135f" +} diff --git a/backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json b/backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json new file mode 100644 index 0000000000..dd22a57595 --- /dev/null +++ b/backend/.sqlx/query-56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-cmp', 'u/test-user/identity_only', 'app', 0, 1, NULL),\n ('test-workspace', 'wm-fork-cmp', 'u/test-user/summary_too', 'app', 0, 1, NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "56abff1f56e1ab8685211e3753785362af4957db48382f5bc6e54a9d2e9f6db7" +} diff --git a/backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json b/backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json new file mode 100644 index 0000000000..e22fb02d1b --- /dev/null +++ b/backend/.sqlx/query-68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET policy = policy || $1::jsonb\n WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/identity_only'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "68a76bac7f8f2ef7e2dc09e01df3435b1a929d89b07bf332d47553da7c8c0abc" +} diff --git a/backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json b/backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json new file mode 100644 index 0000000000..62a0d5251c --- /dev/null +++ b/backend/.sqlx/query-a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET summary = 'edited'\n WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/summary_too'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a9b1568fcf5da28377250adc54818457ec947b75968bfceb56e289795a606d4e" +} diff --git a/backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json b/backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json new file mode 100644 index 0000000000..488412cd34 --- /dev/null +++ b/backend/.sqlx/query-de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions)\n VALUES ('test-workspace', $1, 'original', $2, '{}')\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "de39333852e7b0809cda406320cad253054ee609cc1e767d81bde35ca271259d" +} diff --git a/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json new file mode 100644 index 0000000000..395d05d6da --- /dev/null +++ b/backend/.sqlx/query-e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true + ] + }, + "hash": "e9ba32def4f06ee51b89819951a8d556534d74b06570bb16be7a2f4530451651" +} diff --git a/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json new file mode 100644 index 0000000000..a2751953bd --- /dev/null +++ b/backend/.sqlx/query-fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH v AS (\n INSERT INTO app_version (app_id, value, created_by)\n VALUES ($1, '{}'::json, 'test-user') RETURNING id\n )\n UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fc115fe14c69b9dd7e7571aea6d918bc042b58714ba5d66568770e7f11153ece" +} diff --git a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs index 93c9f5adaf..dae02f3e5a 100644 --- a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs +++ b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs @@ -3,6 +3,203 @@ use sqlx::{Pool, Postgres}; use windmill_test_utils::*; +/// Seed an anonymous public app owned by the parent's admin, then fork as `token`. Returns the +/// cloned app's policy and custom path. +async fn fork_with_public_app( + db: &Pool, + token: &str, +) -> anyhow::Result<(serde_json::Value, Option)> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + let app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path) + VALUES ('test-workspace', 'u/test-user/pub', '', $1, '{}', 'pub-path') + RETURNING id", + json!({ + "on_behalf_of": "u/test-user", + "on_behalf_of_email": "test@windmill.dev", + "execution_mode": "anonymous", + }) + ) + .fetch_one(db) + .await?; + // The clone re-aggregates `versions` from `app_version`, so an app without one lands in the + // fork with a NULL array. + sqlx::query!( + "WITH v AS ( + INSERT INTO app_version (app_id, value, created_by) + VALUES ($1, '{}'::json, 'test-user') RETURNING id + ) + UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + app_id + ) + .execute(db) + .await?; + + let resp = reqwest::Client::new() + .post(format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "id": "wm-fork-app", "name": "Fork", "color": "#0000ff" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + let cloned = + sqlx::query!("SELECT policy, custom_path FROM app WHERE workspace_id = 'wm-fork-app'") + .fetch_one(db) + .await?; + Ok((cloned.policy, cloned.custom_path)) +} + +/// An app policy's `on_behalf_of` is the identity anonymous and publisher executions queue jobs +/// under, and the fork's endpoint outlives any revocation in the parent — so a creator who may +/// not preserve someone else's identity must not receive one by forking. `test-user-2` is a +/// plain member of the parent. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_repoints_app_identity_for_unprivileged_creator( + db: Pool, +) -> anyhow::Result<()> { + let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN_2").await?; + + assert_eq!(policy["on_behalf_of"], json!("u/test-user-2")); + assert_eq!(policy["on_behalf_of_email"], json!("test2@windmill.dev")); + // `execution_mode` rides along untouched — see `clone_apps`. + assert_eq!(policy["execution_mode"], json!("anonymous")); + assert_eq!(custom_path, None); + + Ok(()) +} + +/// An admin could have set any of this through the app API, so their fork keeps the policy — which +/// is also what keeps dev workspaces, always admin-created, behaving like their parent. The custom +/// path still goes: it is the instance-wide address of the parent's live public app, and two rows +/// claiming it make it resolve to either one. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_keeps_app_policy_for_admin_creator(db: Pool) -> anyhow::Result<()> { + let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN").await?; + + assert_eq!(policy["on_behalf_of"], json!("u/test-user")); + assert_eq!(policy["on_behalf_of_email"], json!("test@windmill.dev")); + assert_eq!(policy["execution_mode"], json!("anonymous")); + assert_eq!(custom_path, None); + + Ok(()) +} + +/// A cloned app's identity is re-pointed at an unprivileged fork creator, and no deploy can +/// converge that back — the deployer picks the target's own value, their own, or a typed-in one, +/// never the source's. Reporting it would leave an entry the merge UI can never clear, on every +/// app in such a fork. A summary change alongside it keeps this honest. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_ignores_app_identity(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + let client = reqwest::Client::new(); + + for path in ["u/test-user/identity_only", "u/test-user/summary_too"] { + let app_id = sqlx::query_scalar!( + "INSERT INTO app (workspace_id, path, summary, policy, versions) + VALUES ('test-workspace', $1, 'original', $2, '{}') + RETURNING id", + path, + json!({ "on_behalf_of": "u/test-user", "on_behalf_of_email": "test@windmill.dev" }) + ) + .fetch_one(&db) + .await?; + sqlx::query!( + "WITH v AS ( + INSERT INTO app_version (app_id, value, created_by) + VALUES ($1, '{}'::json, 'test-user') RETURNING id + ) + UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = $1", + app_id + ) + .execute(&db) + .await?; + } + + let resp = client + .post(format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": "wm-fork-cmp", "name": "Fork", "color": "#0000ff" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + // Forked as an admin, so both apps arrive identical. Diverge each on one axis. + sqlx::query!( + "UPDATE app SET policy = policy || $1::jsonb + WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/identity_only'", + json!({ "on_behalf_of": "u/someone-else", "on_behalf_of_email": "else@windmill.dev" }) + ) + .execute(&db) + .await?; + sqlx::query!( + "UPDATE app SET summary = 'edited' + WHERE workspace_id = 'wm-fork-cmp' AND path = 'u/test-user/summary_too'" + ) + .execute(&db) + .await?; + + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) + VALUES ('test-workspace', 'wm-fork-cmp', 'u/test-user/identity_only', 'app', 0, 1, NULL), + ('test-workspace', 'wm-fork-cmp', 'u/test-user/summary_too', 'app', 0, 1, NULL)" + ) + .execute(&db) + .await?; + // The bootstrap migration flags pre-existing workspaces as untallied, which short-circuits + // the comparison. + sqlx::query!("DELETE FROM skip_workspace_diff_tally") + .execute(&db) + .await?; + + let comparison: serde_json::Value = client + .get(format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-cmp" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + let listed: Vec<&str> = comparison["diffs"] + .as_array() + .expect("diffs array") + .iter() + .filter_map(|d| d["path"].as_str()) + .collect(); + + assert!( + !listed.contains(&"u/test-user/identity_only"), + "an identity-only difference must not be reported: {listed:?}" + ); + assert!( + listed.contains(&"u/test-user/summary_too"), + "a real change must still be reported: {listed:?}" + ); + + Ok(()) +} + /// A principal only means something in the workspace whose `usr`/`group_` rows define it, and a /// fork copies the creator and the groups but not the rest of the membership. Carrying one over /// blindly would leave a runnable naming somebody who cannot authenticate there; dropping them @@ -106,11 +303,9 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool) -> any .count(); assert_eq!(orphaned, 0, "a dropped principal leaves no address behind"); assert_eq!( - sqlx::query_scalar!( - "SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'" - ) - .fetch_one(&db) - .await?, + sqlx::query_scalar!("SELECT on_behalf_of FROM flow WHERE workspace_id = 'wm-fork-obo'") + .fetch_one(&db) + .await?, None ); diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index f69c9dbd9a..e0c751ebfb 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5378,16 +5378,16 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } -// `authed_email` is the forker's email — `clone_drafts` only carries this -// user's per-user drafts (and the legacy NULL-email workspace draft, if any) -// across, since other users aren't added to the fork's `usr` table and -// their drafts would dangle as orphans. +// `authed` is the forker — `clone_drafts` only carries this user's per-user +// drafts (and the legacy NULL-email workspace draft, if any) across, since other +// users aren't added to the fork's `usr` table and 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, + authed: &ApiAuthed, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -5441,7 +5441,7 @@ async fn clone_workspace_data( clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; // Clone apps with new IDs and app scripts - let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id).await?; + let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; @@ -5452,7 +5452,7 @@ async fn clone_workspace_data( // own a `usr` row in the fork (see `clone_workspace_full`) so their // drafts would dangle and the home-page `draft_users` aggregate would // surface them as duplicate legacy entries. - clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + clone_drafts(tx, source_workspace_id, target_workspace_id, &authed.email).await?; // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -6393,11 +6393,35 @@ async fn clone_flow_nodes( Ok(()) } +/// Re-point a cloned app policy at the fork's creator, the way `create_app` / `update_app` +/// do for a caller who may not preserve someone else's identity: `on_behalf_of` is what +/// anonymous and publisher executions queue jobs under, and the fork's endpoint outlives +/// any revocation in the parent. +fn repoint_cloned_app_identity(policy: &mut serde_json::Value, authed: &ApiAuthed) { + let Some(obj) = policy.as_object_mut() else { + return; + }; + obj.insert( + "on_behalf_of".to_string(), + serde_json::Value::String(username_to_permissioned_as(&authed.username)), + ); + obj.insert( + "on_behalf_of_email".to_string(), + serde_json::Value::String(authed.email.clone()), + ); +} + async fn clone_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed: &ApiAuthed, ) -> Result> { + // `execution_mode` is cloned as-is: protection rules are workspace-scoped and not cloned, so + // forcing `publisher` is only a speed bump (the creator can publish an anonymous app in the + // fork freely), and it is the one policy field a deploy back to the parent carries verbatim — + // `update_app` recomputes the identity but writes the policy wholesale. + let preserve_identity = windmill_common::can_preserve_on_behalf_of(authed); // Get all apps from source workspace let apps = sqlx::query!( "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path @@ -6417,10 +6441,25 @@ async fn clone_apps( let mut latest_version_ids: HashSet = HashSet::new(); // Clone apps with new IDs - for app in apps { + for mut app in apps { if let Some(¤t_version) = app.versions.last() { latest_version_ids.insert(current_version); } + if !preserve_identity { + repoint_cloned_app_identity(&mut app.policy, authed); + } + // Both halves of what `create_app` demands to set a custom path: admin, and — unless + // paths are scoped per workspace — that nobody else holds it. Cloning one instance-wide + // would leave the parent's live public URL, resolved with no workspace filter and no + // ordering, answering from either row. + let scoped = *CLOUD_HOSTED + || windmill_common::apps::APP_WORKSPACED_ROUTE + .load(std::sync::atomic::Ordering::Relaxed); + let custom_path = if scoped && authed.is_admin { + app.custom_path + } else { + None + }; let new_app_id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -6431,7 +6470,7 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.custom_path, + custom_path, ) .fetch_one(&mut **tx) .await?; @@ -7284,14 +7323,8 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = clone_workspace_data( - &mut tx, - &db, - &parent_workspace_id, - &forked_id, - &authed.email, - ) - .await + if let Err(e) = + clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: @@ -10988,6 +11021,19 @@ async fn compare_two_flows( }); } +/// The policy minus the identity pair. Deploying cannot converge a difference there — the +/// target recomputes the identity from the deployer's own choice, which offers its current +/// value, the deployer, or a typed-in one, never the source's — so listing an app for it +/// alone leaves an entry no deploy can clear. `script` and `flow` compare no identity either. +fn policy_without_identity(policy: &serde_json::Value) -> serde_json::Value { + let mut policy = policy.clone(); + if let Some(obj) = policy.as_object_mut() { + obj.remove("on_behalf_of"); + obj.remove("on_behalf_of_email"); + } + policy +} + async fn compare_two_apps( db: &DB, source_workspace_id: &str, @@ -11028,7 +11074,7 @@ async fn compare_two_apps( // Check metadata and content differences if let (Some(source), Some(target)) = (&source_app, &target_app) { if source.summary != target.summary - || source.policy != target.policy + || policy_without_identity(&source.policy) != policy_without_identity(&target.policy) || source.value != target.value || source.raw_app != target.raw_app {