fix: scope a fork's cloned app policy and custom path to its creator (#10589)

* fix: scope cloned app policy and custom path to the fork's creator

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the app custom-path scoping rule across its call sites

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: tighten the cloned-app-policy comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the execution_mode and custom-path scoping rationale

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-07 12:37:04 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 0459dda1bf
commit 8e95bfe615
7 changed files with 224 additions and 29 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -3,6 +3,97 @@ 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<Postgres>,
token: &str,
) -> anyhow::Result<(serde_json::Value, Option<String>)> {
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_downgrades_app_policy_for_unprivileged_creator(
db: Pool<Postgres>,
) -> 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"));
assert_eq!(policy["execution_mode"], json!("publisher"));
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<Postgres>) -> 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 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 +197,9 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool<Postgres>) -> 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
);
@@ -5377,16 +5377,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?;
@@ -5440,7 +5440,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?;
@@ -5451,7 +5451,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?;
@@ -6392,11 +6392,45 @@ 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. Anonymous apps also lose their unauthenticated endpoint;
/// re-publishing goes through the app API, which enforces the anonymous-deployment rule.
fn downgrade_cloned_app_policy(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()),
);
// A policy without `execution_mode` gets one too: `Policy` declares no serde default
// for the field, so such a row does not read back as a policy until something writes it.
let anonymous = obj
.get("execution_mode")
.and_then(|m| m.as_str())
.unwrap_or("anonymous")
== "anonymous";
if anonymous {
obj.insert(
"execution_mode".to_string(),
serde_json::Value::String("publisher".to_string()),
);
}
}
async fn clone_apps(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
authed: &ApiAuthed,
) -> Result<HashMap<i64, i64>> {
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
@@ -6416,10 +6450,21 @@ async fn clone_apps(
let mut latest_version_ids: HashSet<i64> = HashSet::new();
// Clone apps with new IDs
for app in apps {
for mut app in apps {
if let Some(&current_version) = app.versions.last() {
latest_version_ids.insert(current_version);
}
if !preserve_identity {
downgrade_cloned_app_policy(&mut app.policy, authed);
}
// An instance-wide custom path addresses one app (`create_app` rejects one already
// taken in any workspace), so a clone that kept it would make the parent's live
// public URL resolve to either row.
let custom_path = if windmill_common::apps::custom_path_is_workspace_scoped() {
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)
@@ -6430,7 +6475,7 @@ async fn clone_apps(
app.policy,
&Vec::<i64>::new(), // Start with empty versions array
app.extra_perms,
app.custom_path,
custom_path,
)
.fetch_one(&mut **tx)
.await?;
@@ -7283,14 +7328,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:
+5 -7
View File
@@ -56,7 +56,7 @@ use std::str;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
apps::{AppScriptId, ListAppQuery},
auth::TOKEN_PREFIX_LEN,
cache::{self, future::FutureCachedExt},
db::{DbWithOptAuthed, UserDB},
@@ -1114,13 +1114,13 @@ async fn custom_path_exists(
Extension(db): Extension<DB>,
Path((w_id, custom_path)): Path<(String, String)>,
) -> JsonResult<bool> {
let as_workspaced_route = APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let scoped = windmill_common::apps::custom_path_is_workspace_scoped();
let exists =
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2))",
custom_path,
if *CLOUD_HOSTED || as_workspaced_route { Some(&w_id) } else { None }
if scoped { Some(&w_id) } else { None }
)
.fetch_one(&db)
.await?.unwrap_or(false);
@@ -2179,8 +2179,7 @@ async fn create_app_internal<'a>(
}
if let Some(custom_path) = &app.custom_path {
require_admin(authed.is_admin, &authed.username)?;
let scoped =
*CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let scoped = windmill_common::apps::custom_path_is_workspace_scoped();
let conflict = sqlx::query!(
"SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1",
@@ -3055,8 +3054,7 @@ async fn update_app_internal<'a>(
if let Some(ncustom_path) = &ns.custom_path {
require_admin(authed.is_admin, &authed.username)?;
let scoped =
*CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed);
let scoped = windmill_common::apps::custom_path_is_workspace_scoped();
if ncustom_path.is_empty() {
sqlb.set("custom_path", "NULL");
+7
View File
@@ -18,6 +18,13 @@ lazy_static::lazy_static! {
pub static ref APP_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
}
/// Whether an app's custom path names it within its workspace rather than instance-wide.
/// A path stored under a narrower scope than the one its resolver applies leaves two apps
/// answering the same public URL, so storage and resolution must decide it the same way.
pub fn custom_path_is_workspace_scoped() -> bool {
*crate::worker::CLOUD_HOSTED || APP_WORKSPACED_ROUTE.load(std::sync::atomic::Ordering::Relaxed)
}
/// Traverse FlowValue while invoking provided by caller callback on leafs
// #[async_recursion::async_recursion(?Send)]
pub fn traverse_app_inline_scripts<