fix: let deployed low-code apps run database studio queries again (#11816)

* fix: let deployed low-code apps run runtime-built inline queries again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: limit id-less app runs to the database studio's sql languages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover the deployed-row exclusion with sql content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-16 14:23:51 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 33d4049db0
commit 7c63a30edd
4 changed files with 164 additions and 36 deletions
@@ -1,30 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT l.id IS NOT NULL as \"has_lite!\",\n coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', $3::bigint)), false) as \"is_deployed_row!\"\n FROM app a\n LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_lite!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "is_deployed_row!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
null,
null
]
},
"hash": "0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "SELECT l.id IS NOT NULL as \"has_lite!\",\n CASE WHEN $3::bigint IS NULL THEN NOT EXISTS (\n SELECT 1 FROM app_script s\n WHERE s.app = a.id AND s.code_sha256 = $4::text\n AND jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', s.id)))\n ELSE coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', $3::bigint)), false)\n END as \"may_run!\"\n FROM app a\n LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_lite!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "may_run!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814"
}
+104 -1
View File
@@ -289,7 +289,10 @@ async fn test_app_script_run_derives_path(db: Pool<Postgres>) -> anyhow::Result<
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 200, "deployed app script run must be accepted: {body}");
assert_eq!(
status, 200,
"deployed app script run must be accepted: {body}"
);
let uuid = uuid::Uuid::parse_str(body.trim())?;
let (_, _, _, _, runnable_path) = job_fields(&db, uuid).await?;
assert_eq!(
@@ -300,3 +303,103 @@ async fn test_app_script_run_derives_path(db: Pool<Postgres>) -> anyhow::Result<
Ok(())
}
/// Once the deployed version has its lite value, a deployed row's code runs only through
/// that row, while id-less SQL the frontend builds at runtime (database studio queries)
/// still runs on its content pin.
#[sqlx::test(fixtures("base"))]
async fn test_run_mode_id_less_code_with_lite(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
const RUNTIME_CONTENT: &str = "SELECT 1;\n";
const DEPLOYED_CONTENT: &str = "SELECT 2;\n";
let app_path = "u/test-user/lite_id_less";
let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN")
.json(&json!({
"path": app_path,
"summary": "",
"value": {},
"policy": {
"execution_mode": "viewer",
"triggerables_v2": {
format!("comp:{}", rawscript_pin(DEPLOYED_CONTENT)): { "static_inputs": {}, "one_of_inputs": {} },
format!("comp:{}", rawscript_pin(RUNTIME_CONTENT)): { "static_inputs": {}, "one_of_inputs": {} }
}
}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
let sha = rawscript_pin(DEPLOYED_CONTENT)
.trim_start_matches("rawscript/")
.to_string();
let script_id: i64 = sqlx::query_scalar(
"INSERT INTO app_script (app, hash, code, code_sha256)
SELECT id, $2, $3, $2 FROM app WHERE path = $1 RETURNING id",
)
.bind(app_path)
.bind(&sha)
.bind(DEPLOYED_CONTENT)
.fetch_one(&db)
.await?;
sqlx::query(
"INSERT INTO app_version_lite (id, value)
SELECT versions[array_upper(versions, 1)], $2 FROM app WHERE path = $1",
)
.bind(app_path)
.bind(
json!({ "grid": [{ "data": { "componentInput": { "runnable": {
"inlineScript": { "id": script_id, "language": "postgresql" }
} } } }] }),
)
.execute(&db)
.await?;
let run = |content: &'static str, language: &'static str| {
authed(
client().post(format!("{ws}/apps_u/execute_component/{app_path}")),
"SECRET_TOKEN_2",
)
.json(&json!({
"args": {},
"component": "comp",
"raw_code": { "language": language, "content": content }
}))
.send()
};
let resp = run(RUNTIME_CONTENT, "postgresql").await?;
let status = resp.status();
assert_eq!(
status,
200,
"id-less runtime-built code must run: {}",
resp.text().await?
);
// The pin covers content only (it also pins frontend scripts), so a pinned content
// must not run id-less in a language the database studio never emits.
let resp = run(RUNTIME_CONTENT, "bash").await?;
let status = resp.status();
assert_eq!(
status,
401,
"id-less code must not run as a non-SQL language: {}",
resp.text().await?
);
let resp = run(DEPLOYED_CONTENT, "postgresql").await?;
let status = resp.status();
assert_eq!(
status,
401,
"a deployed row's code must not run id-less: {}",
resp.text().await?
);
Ok(())
}
+29 -5
View File
@@ -68,6 +68,7 @@ use windmill_common::{
get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion,
JobPayload, JobTriggerKind, RawCode,
},
scripts::ScriptLang,
triggers::TriggerMetadata,
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
users::username_to_permissioned_as,
@@ -4682,23 +4683,46 @@ async fn execute_component(
// The policy key covers only an inline script's own code, not the lock and modules
// its `app_script` row runs with, so it cannot tell the deployed row from an older
// one (or from id-less code, which runs with neither). Once the deployed version has
// its lite value, inline code runs only through the rows that value points at.
// its lite value, a deployed row's code runs only through that row, and id-less code
// only as the database studio's runtime-built queries, which have no row. Those are
// limited to the languages it emits: the pin covers content, not language, and the
// policy also pins frontend scripts, which must never run as e.g. bash.
// Checked on every run: a deploy moves it.
if is_inline_raw_code {
if let Some(raw_code) = payload.raw_code.as_ref() {
let id_less_sql = payload.id.is_none()
&& matches!(
raw_code.language,
ScriptLang::Postgresql
| ScriptLang::Mysql
| ScriptLang::Mssql
| ScriptLang::Snowflake
| ScriptLang::Bigquery
| ScriptLang::DuckDb
);
let id_less_sha256 = id_less_sql
.then(|| format!("{:x}", Sha256::digest(raw_code.content.as_bytes())));
let deployed = sqlx::query!(
"SELECT l.id IS NOT NULL as \"has_lite!\",
coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',
jsonb_build_object('id', $3::bigint)), false) as \"is_deployed_row!\"
CASE WHEN $3::bigint IS NULL THEN NOT EXISTS (
SELECT 1 FROM app_script s
WHERE s.app = a.id AND s.code_sha256 = $4::text
AND jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',
jsonb_build_object('id', s.id)))
ELSE coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',
jsonb_build_object('id', $3::bigint)), false)
END as \"may_run!\"
FROM app a
LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]
WHERE a.path = $1 AND a.workspace_id = $2",
path,
&w_id,
payload.id,
id_less_sha256,
)
.fetch_optional(&db)
.await?;
if deployed.is_some_and(|d| d.has_lite && !d.is_deployed_row) {
let may_run = payload.id.is_some() || id_less_sql;
if deployed.is_some_and(|d| d.has_lite && !(may_run && d.may_run)) {
return Err(Error::NotAuthorized(format!(
"This runnable is not part of the deployed version of app {path}, reload the app"
)));