diff --git a/backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json b/backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json deleted file mode 100644 index e7b808f911..0000000000 --- a/backend/.sqlx/query-0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091.json +++ /dev/null @@ -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" -} diff --git a/backend/.sqlx/query-eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814.json b/backend/.sqlx/query-eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814.json new file mode 100644 index 0000000000..b7cfa590d5 --- /dev/null +++ b/backend/.sqlx/query-eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814.json @@ -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" +} diff --git a/backend/tests/app_run_mode_lock_strip.rs b/backend/tests/app_run_mode_lock_strip.rs index d13d34a6c7..7a1bba75f6 100644 --- a/backend/tests/app_run_mode_lock_strip.rs +++ b/backend/tests/app_run_mode_lock_strip.rs @@ -289,7 +289,10 @@ async fn test_app_script_run_derives_path(db: Pool) -> 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) -> 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) -> 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(()) +} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 453422403d..8343b4595d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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" )));