mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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) <noreply@anthropic.com> * fix: gate a cloned anonymous app on the parent's own deployment rule Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state why a cloned anonymous app is gated more strictly than create_app Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: wrap an over-long comment line in clone_apps Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+14
@@ -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,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<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_repoints_app_identity_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"));
|
||||
// `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<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 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<Postgres>) -> 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<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
|
||||
);
|
||||
|
||||
|
||||
@@ -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<HashMap<i64, i64>> {
|
||||
// `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<i64> = 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::<i64>::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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user