mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
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:
co-authored by
Claude Opus 5.5
parent
33d4049db0
commit
7c63a30edd
-30
@@ -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"
|
||||
}
|
||||
+31
@@ -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"
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)));
|
||||
|
||||
Reference in New Issue
Block a user