fix: re-point unresolvable identities in drafts a move copies into a fork

Fork creation re-points an app policy or trigger in a cloned draft that
names nobody in the fork at its creator, since a deploy there rejects an
identity that does not resolve. The transfer copied drafts without that
step, and replace_existing overwrote the clone's repaired copy. The draft
half of the repoint is now shared and runs on the rows a transfer copies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-26 11:35:12 -07:00
co-authored by Claude Opus 5.5
parent 750a926774
commit bc08c7ffb0
3 changed files with 97 additions and 33 deletions
@@ -439,6 +439,34 @@ async fn test_transfer_drafts_to_fork(db: Pool<Postgres>) -> anyhow::Result<()>
assert_eq!(fork_count("u/test-user/a").await?, 1);
assert_eq!(fork_count("u/test-user/b").await?, 1);
// An app policy naming someone who is not a member of the fork is re-pointed at the user, as
// the fork's own clone does: a deploy in the fork rejects an identity that does not resolve.
let r = c
.post(save_url(port, "raw_app", "u/test-user/app"))
.json(&json!({ "value": { "policy": {
"on_behalf_of": "u/not-in-fork", "on_behalf_of_email": "ghost@windmill.dev"
} } }))
.send()
.await?;
assert_eq!(r.status(), 200, "{}", r.text().await?);
let r = c
.post(&transfer_url)
.json(&json!({
"target_workspace": fork,
"items": [{ "kind": "raw_app", "path": "u/test-user/app" }]
}))
.send()
.await?;
assert_eq!(r.status(), 200, "{}", r.text().await?);
let on_behalf_of: String = sqlx::query_scalar(
"SELECT value->'policy'->>'on_behalf_of' FROM draft
WHERE workspace_id = $1 AND path = 'u/test-user/app'",
)
.bind(fork)
.fetch_one(&db)
.await?;
assert_eq!(on_behalf_of, "u/test-user");
// Only a fork of the source is a valid target.
let r = c
.post(format!(
@@ -8580,6 +8580,65 @@ fn principal_resolves_sql(principal: &str) -> String {
)
}
/// The draft half of `repoint_unresolvable_cloned_identities`: an app policy or trigger in a draft
/// copied into `target_workspace_id` that names nobody there is re-pointed at `authed`. `items`
/// narrows it to `authed`'s own rows at those keys, for drafts copied into an existing workspace.
pub async fn repoint_unresolvable_draft_identities(
tx: &mut Transaction<'_, Postgres>,
target_workspace_id: &str,
authed: &ApiAuthed,
items: Option<&[(windmill_common::user_drafts::UserDraftItemKind, String)]>,
) -> Result<()> {
let principal = username_to_permissioned_as(&authed.username);
let kinds: Option<Vec<&str>> = items.map(|i| i.iter().map(|(k, _)| k.as_str()).collect());
let paths: Option<Vec<&str>> = items.map(|i| i.iter().map(|(_, p)| p.as_str()).collect());
// A draft holding a genuine NUL escape (the rule of `json_text_has_nul_escape`) is left as it
// is, since parsing it would abort the fork. The check must stay in a CASE: json `->>` raises on
// a NUL anywhere in the value, and Postgres reorders plain AND conditions.
let nul_escape = r"(^|[^\\])(\\\\)*\\u0000";
sqlx::query(&format!(
"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value),
ARRAY['policy', 'on_behalf_of'], to_jsonb($2::text)),
ARRAY['policy', 'on_behalf_of_email'], to_jsonb($3::text)))
WHERE workspace_id = $1 AND typ IN ('app', 'raw_app')
AND ($5::text[] IS NULL OR (email = $7
AND (typ::text, path) IN (SELECT * FROM unnest($5::text[], $6::text[]))))
AND CASE WHEN value::text ~ $4 THEN false
ELSE value->'policy'->>'on_behalf_of' IS NOT NULL AND NOT ({}) END",
principal_resolves_sql("(value->'policy'->>'on_behalf_of')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(&authed.email)
.bind(nul_escape)
.bind(kinds.as_deref())
.bind(paths.as_deref())
.bind(&authed.email)
.execute(&mut **tx)
.await?;
sqlx::query(&format!(
"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value),
ARRAY['permissioned_as'], to_jsonb($2::text)))
WHERE workspace_id = $1 AND starts_with(typ::text, 'trigger_')
AND ($4::text[] IS NULL OR (email = $6
AND (typ::text, path) IN (SELECT * FROM unnest($4::text[], $5::text[]))))
AND CASE WHEN value::text ~ $3 THEN false
ELSE value->>'permissioned_as' IS NOT NULL AND NOT ({}) END",
principal_resolves_sql("(value->>'permissioned_as')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(nul_escape)
.bind(kinds.as_deref())
.bind(paths.as_deref())
.bind(&authed.email)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Re-point the identities a fork clones verbatim at its creator when they name nobody in the fork,
/// once its membership is final so copied members keep theirs. Unlike scripts and flows these
/// cannot drop the identity: an app deploy rejects a preserved one that does not resolve, and
@@ -8604,39 +8663,7 @@ async fn repoint_unresolvable_cloned_identities(
.execute(&mut **tx)
.await?;
// A draft holding a genuine NUL escape (the rule of `json_text_has_nul_escape`) is left as it
// is, since parsing it would abort the fork. The check must stay in a CASE: json `->>` raises on
// a NUL anywhere in the value, and Postgres reorders plain AND conditions.
let nul_escape = r"(^|[^\\])(\\\\)*\\u0000";
sqlx::query(&format!(
"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value),
ARRAY['policy', 'on_behalf_of'], to_jsonb($2::text)),
ARRAY['policy', 'on_behalf_of_email'], to_jsonb($3::text)))
WHERE workspace_id = $1 AND typ IN ('app', 'raw_app')
AND CASE WHEN value::text ~ $4 THEN false
ELSE value->'policy'->>'on_behalf_of' IS NOT NULL AND NOT ({}) END",
principal_resolves_sql("(value->'policy'->>'on_behalf_of')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(&authed.email)
.bind(nul_escape)
.execute(&mut **tx)
.await?;
sqlx::query(&format!(
"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value),
ARRAY['permissioned_as'], to_jsonb($2::text)))
WHERE workspace_id = $1 AND starts_with(typ::text, 'trigger_')
AND CASE WHEN value::text ~ $3 THEN false
ELSE value->>'permissioned_as' IS NOT NULL AND NOT ({}) END",
principal_resolves_sql("(value->>'permissioned_as')")
))
.bind(target_workspace_id)
.bind(&principal)
.bind(nul_escape)
.execute(&mut **tx)
.await?;
repoint_unresolvable_draft_identities(tx, target_workspace_id, authed, None).await?;
let column_resolves = principal_resolves_sql("permissioned_as");
+9
View File
@@ -1203,6 +1203,15 @@ async fn transfer_drafts(
Some(&locked),
)
.await?;
// A copied app policy or trigger may name someone who is not a member of the fork, as a
// fork's own clone may; fork creation re-points those at the user, and so does this.
windmill_api_workspaces::workspaces::repoint_unresolvable_draft_identities(
&mut tx,
target,
target_authed,
Some(&copied),
)
.await?;
}
let mut removed = vec![];