From e0d6dc1a1997514bfcaa8615de61daff4a2f81ca Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 31 Jul 2026 00:05:15 +0200 Subject: [PATCH 01/32] fix: harden flow-orchestration token refresh (mint from job_perms) (#10419) * fix: harden flow-orchestration token refresh (mint from job_perms) * refactor: address review nits on flow token refresh --- backend/src/monitor.rs | 10 +-- backend/windmill-common/src/auth.rs | 65 +++++++++++++++ backend/windmill-queue/src/jobs.rs | 11 +-- .../windmill-worker/src/result_processor.rs | 82 +++++++++++-------- 4 files changed, 119 insertions(+), 49 deletions(-) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index ccff5c2c7b..336956415e 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -46,7 +46,7 @@ use windmill_common::otel_oss::{ use windmill_common::{ agent_workers::DECODED_AGENT_TOKEN, apps::APP_WORKSPACED_ROUTE, - auth::create_token_for_owner, + auth::{create_token_for_owner, ephemeral_script_token_label}, ee_oss::CriticalErrorChannel, email_oss::send_email_if_possible, error, @@ -4662,13 +4662,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n continue; } if let Some(job) = job.unwrap() { - let label = if job.permissioned_as != format!("u/{}", job.created_by) - && job.permissioned_as != job.created_by - { - format!("ephemeral-script-end-user-{}", job.created_by) - } else { - "ephemeral-script".to_string() - }; + let label = ephemeral_script_token_label(&job.permissioned_as, &job.created_by); let token = create_token_for_owner( &db, &job.workspace_id, diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index bd6bf1ef9d..cbaa392cd4 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -496,6 +496,31 @@ pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>( .await } +/// A job token is refreshed once its remaining lifetime drops below this. It must exceed the +/// 60s `jsonwebtoken` exp leeway, otherwise a token that still validates now could expire +/// mid-orchestration after being judged fresh. +pub const JOB_TOKEN_REFRESH_MARGIN_SECS: i64 = 120; + +/// Seconds until an internal job JWT expires, or `None` when `token` is not a decodable job +/// JWT (e.g. the empty test token). The signature is intentionally not verified: the value only +/// gates whether to refresh the token, never whose identity to assume. +pub fn job_token_remaining_lifetime_secs(token: &str) -> Option { + let raw = token.strip_prefix("jwt_")?; + let claims: JWTAuthClaims = jwt::decode_without_verify(raw).ok()?; + Some(claims.exp as i64 - Utc::now().timestamp()) +} + +/// Label for an ephemeral job token. For a job run on behalf of an end user (its +/// `permissioned_as` differs from its `created_by`) it encodes that end user so +/// `username_override_from_label` can recover them; otherwise it is the plain script label. +pub fn ephemeral_script_token_label(permissioned_as: &str, created_by: &str) -> String { + if permissioned_as != format!("u/{created_by}") && permissioned_as != created_by { + format!("ephemeral-script-end-user-{created_by}") + } else { + "ephemeral-script".to_string() + } +} + #[tracing::instrument(level = "trace", skip_all)] pub async fn create_token_for_owner( db: &DB, @@ -679,6 +704,46 @@ pub mod aws { #[cfg(test)] mod tests { use super::is_user_token; + use super::{job_token_remaining_lifetime_secs, JWTAuthClaims, JOB_TOKEN_REFRESH_MARGIN_SECS}; + + fn job_jwt(exp_offset_secs: i64) -> String { + let claims = JWTAuthClaims { + email: String::new(), + username: String::new(), + is_admin: false, + is_operator: false, + groups: vec![], + folders: vec![], + label: None, + workspace_id: None, + workspace_ids: None, + exp: (chrono::Utc::now().timestamp() + exp_offset_secs) as usize, + job_id: None, + scopes: None, + audit_span: None, + }; + // Signature is irrelevant — the gate decodes without verifying — so any key works. + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &jsonwebtoken::EncodingKey::from_secret(b"test"), + ) + .unwrap(); + format!("jwt_{token}") + } + + #[test] + fn remaining_lifetime_reflects_exp_and_flags_near_expiry() { + // A token minted for less than the margin reads as needing a refresh... + let short = job_token_remaining_lifetime_secs(&job_jwt(30)).unwrap(); + assert!(short < JOB_TOKEN_REFRESH_MARGIN_SECS); + // ...a long-lived one does not... + let long = job_token_remaining_lifetime_secs(&job_jwt(10_000)).unwrap(); + assert!(long >= JOB_TOKEN_REFRESH_MARGIN_SECS); + // ...and a non-JWT token (e.g. the empty test-run token) yields no lifetime. + assert!(job_token_remaining_lifetime_secs("not-a-jwt").is_none()); + assert!(job_token_remaining_lifetime_secs("").is_none()); + } #[test] fn user_tokens_are_editable() { diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c4f83f4c4b..ebae8a2647 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -3397,13 +3397,10 @@ impl PulledJob { pub async fn create_token(db: &DB, job: &MiniPulledJob, perms: Option) -> String { // skipping test runs if job.workspace_id != "" { - let label = if job.permissioned_as != format!("u/{}", job.created_by) - && job.permissioned_as != job.created_by - { - format!("ephemeral-script-end-user-{}", job.created_by) - } else { - "ephemeral-script".to_string() - }; + let label = windmill_common::auth::ephemeral_script_token_label( + &job.permissioned_as, + &job.created_by, + ); windmill_common::auth::create_token_for_owner( db, &job.workspace_id, diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index bf3993bfe6..ccf12939d7 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -690,46 +690,60 @@ pub async fn handle_receive_completed_job( #[cfg(feature = "benchmark")] bench: &mut BenchmarkIter, ) -> Option> { let workspace = jc.job.workspace_id.clone(); - // The client built here drives post-completion orchestration (the next step's input - // transforms fetch prior step results), which outlives the finished step. The step's own - // token has a `SCRIPT_TOKEN_EXPIRY` lifetime, so reusing it would fail that orchestration - // once the step itself ran longer than the token lives; refresh it when the step is old enough. - let token_maybe_expired = jc - .duration - .is_some_and(|d| d as u64 >= *windmill_common::worker::SCRIPT_TOKEN_EXPIRY * 1000 / 2); - let token = if jc.job.is_flow_step() && token_maybe_expired { - // Mirror `create_token`'s label so run-on-behalf-of flows keep their end-user override. - let label = if jc.job.permissioned_as != format!("u/{}", jc.job.created_by) - && jc.job.permissioned_as != jc.job.created_by - { - format!("ephemeral-script-end-user-{}", jc.job.created_by) - } else { - "ephemeral-script".to_string() - }; - match windmill_common::auth::create_token_for_owner( - db, - &jc.job.workspace_id, + // This client drives post-completion orchestration (the next step's input transforms fetch + // prior results) and outlives the finished step, so the step's own token can already be near + // expiry. Refresh it — but only from the server-written job_perms row, never from the + // completion payload's owner fields, which are untrusted on the agent-worker path. + let token = if jc.job.is_flow_step() + && windmill_common::auth::job_token_remaining_lifetime_secs(&jc.token) + .is_some_and(|r| r < windmill_common::auth::JOB_TOKEN_REFRESH_MARGIN_SECS) + { + let label = windmill_common::auth::ephemeral_script_token_label( &jc.job.permissioned_as, - &label, - *windmill_common::worker::SCRIPT_TOKEN_EXPIRY, - &jc.job.permissioned_as_email, - &jc.job.id, - None, - Some(format!( - "job-span-{}", - jc.job.flow_innermost_root_job.unwrap_or(jc.job.id) - )), - ) - .warn_after_seconds(5) - .await - { - Ok(t) => t, - Err(e) => { + &jc.job.created_by, + ); + match windmill_common::auth::get_job_perms(db, &jc.job.id, &jc.job.workspace_id).await { + Ok(Some(perms)) => windmill_common::auth::create_token_for_owner( + db, + &jc.job.workspace_id, + &jc.job.permissioned_as, + &label, + *windmill_common::worker::SCRIPT_TOKEN_EXPIRY, + &jc.job.permissioned_as_email, + &jc.job.id, + Some(perms), + Some(format!( + "job-span-{}", + jc.job.flow_innermost_root_job.unwrap_or(jc.job.id) + )), + ) + .warn_after_seconds(5) + .await + .unwrap_or_else(|e| { tracing::warn!( "could not mint fresh flow-orchestration token for job {}, reusing step token: {e:#}", jc.job.id ); jc.token.clone() + }), + // No perms row (e.g. a zombie replay after the queue row was reaped): keep the step + // token rather than minting an identity from untrusted payload fields. The token is + // near expiry, so trace it — the downstream fetch may hit the original failure. + Ok(None) => { + tracing::warn!( + "no job_perms row to refresh flow-orchestration token for job {}, reusing step token", + jc.job.id + ); + jc.token.clone() + } + // A transient DB error must not silently reuse the near-expired token without a trace, + // or the very failure this guards against recurs invisibly. + Err(e) => { + tracing::warn!( + "could not load job_perms to refresh flow-orchestration token for job {}, reusing step token: {e:#}", + jc.job.id + ); + jc.token.clone() } } } else { From 08827121a94ac2625f673ae334a52331a3b23b26 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 31 Jul 2026 08:41:24 +0200 Subject: [PATCH 02/32] chore: upgrade vite to 8.2.0 in frontend (#10422) * chore: upgrade vite to 8.2.0 in frontend * chore: drop vestigial @rollup/rollup-linux-x64-gnu optional dep --- frontend/package-lock.json | 414 +++++++++++++++++-------------------- frontend/package.json | 3 +- 2 files changed, 193 insertions(+), 224 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ab6621c30..bbae4d0618 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -157,13 +157,12 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.13", + "vite": "^8.2.0", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "^4.35.0", "fsevents": "^2.3.3" }, "peerDependencies": { @@ -1099,22 +1098,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "license": "MIT", "optional": true, "dependencies": { @@ -1122,10 +1119,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "license": "MIT", "optional": true, "dependencies": { @@ -1656,22 +1652,24 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", + "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@noble/hashes": { @@ -1726,9 +1724,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "devOptional": true, "license": "MIT", "funding": { @@ -1802,13 +1800,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1819,13 +1816,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1836,13 +1832,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1853,13 +1848,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1870,13 +1864,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1887,13 +1880,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1904,13 +1899,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1921,13 +1918,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1938,13 +1937,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1955,13 +1956,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1972,13 +1975,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1989,13 +1994,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2006,32 +2010,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", - "cpu": [ - "wasm32" - ], - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2042,13 +2041,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2065,19 +2063,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@scalar/openapi-parser": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@scalar/openapi-parser/-/openapi-parser-0.15.0.tgz", @@ -2351,10 +2336,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -7679,7 +7663,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8339,9 +8323,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "devOptional": true, "license": "MPL-2.0", "dependencies": { @@ -8355,27 +8339,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8390,13 +8373,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8411,13 +8393,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8432,13 +8413,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8453,13 +8433,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8474,13 +8453,15 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8495,13 +8476,15 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8516,13 +8499,15 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8537,13 +8522,15 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8558,13 +8545,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8579,13 +8565,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10785,9 +10770,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "devOptional": true, "funding": [ { @@ -10805,7 +10790,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11497,9 +11482,9 @@ "license": "MIT" }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "devOptional": true, "funding": [ { @@ -12195,13 +12180,13 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -12211,21 +12196,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/roughjs": { @@ -13292,21 +13277,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -13835,9 +13805,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -14086,7 +14056,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14373,17 +14343,17 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "devOptional": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", - "tinyglobby": "^0.2.16" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -14399,7 +14369,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/frontend/package.json b/frontend/package.json index 01e68d2acf..a979096694 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,7 +73,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.13", + "vite": "^8.2.0", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" @@ -647,7 +647,6 @@ } }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "^4.35.0", "fsevents": "^2.3.3" } } From 3716a71fd76f66b58bc29977b4f6a10ed97cea16 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 31 Jul 2026 11:21:14 +0200 Subject: [PATCH 03/32] fix: credit the token owner instead of the token label in the audit trail (#10423) * fix: credit the token owner instead of the token label in the audit trail Co-Authored-By: Claude Opus 5 (1M context) * fix: address review findings on token-owner audit attribution Co-Authored-By: Claude Opus 5 (1M context) * fix: carry token-label provenance explicitly instead of inferring it Co-Authored-By: Claude Opus 5 (1M context) * chore: point ee-repo-ref at the companion branch Co-Authored-By: Claude Opus 5 (1M context) * fix: trust only non-forgeable token labels to name the acting entity Co-Authored-By: Claude Opus 5 (1M context) * fix: reject reserved system-token labels at token creation Co-Authored-By: Claude Opus 5 (1M context) * fix: narrow the token-label guard to server-minted namespaces Co-Authored-By: Claude Opus 5 (1M context) * fix: add the provenance field to the remaining ApiAuthed literals Co-Authored-By: Claude Opus 5 (1M context) * fix: stop trusting the email- label, which no mint produces Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/ee-repo-ref.txt | 2 +- backend/tests/postgres_trigger_scope.rs | 1 + backend/tests/trigger_listener_queries.rs | 1 + backend/windmill-api-auth/src/auth.rs | 63 +++++++++---- backend/windmill-api-auth/src/lib.rs | 90 ++++++++++++++++++- .../tests/native_triggers.rs | 1 + .../tests/token_label_idor.rs | 31 +++++++ backend/windmill-api-users/src/users.rs | 15 ++++ .../windmill-api-workspaces/src/workspaces.rs | 2 + backend/windmill-api/src/apps.rs | 6 +- backend/windmill-api/src/jobs.rs | 8 +- backend/windmill-api/src/lib.rs | 1 + backend/windmill-common/src/auth.rs | 16 ++++ .../auditLogs/AuditLogDetails.svelte | 15 ++++ .../auditLogs/AuditLogsTable.svelte | 15 +++- 15 files changed, 239 insertions(+), 28 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f008a12d4c..4d93d36307 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -aa05ca8e97fc8265cd724753a80db37f83243254 +94d1b4f0a10bfbc1fdc0c3bfd38d31cdae77d89a \ No newline at end of file diff --git a/backend/tests/postgres_trigger_scope.rs b/backend/tests/postgres_trigger_scope.rs index 1172e03ab6..d07435f414 100644 --- a/backend/tests/postgres_trigger_scope.rs +++ b/backend/tests/postgres_trigger_scope.rs @@ -20,6 +20,7 @@ fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed { folders: vec![], scopes: Some(scopes.into_iter().map(str::to_string).collect()), username_override: None, + username_override_is_token_label: false, token_prefix: None, read_only: false, } diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index c3f6357d4c..16b4e6c55e 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -173,6 +173,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed { folders: vec![], scopes: None, username_override: None, + username_override_is_token_label: false, token_prefix: None, read_only: false, } diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 5219b41e9b..988b22790d 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -196,7 +196,8 @@ impl AuthCache { tracing::error!("JWT auth error: workspace_id mismatch"); return None; } - let username_override = username_override_from_label(claims.label); + let (username_override, username_override_is_token_label) = + username_override_from_label(claims.label); let authed = ApiAuthed { email: claims.email, @@ -211,6 +212,7 @@ impl AuthCache { // WM_TOKEN) keeps full user privileges as before. scopes: claims.scopes, username_override, + username_override_is_token_label, token_prefix: claims.audit_span, read_only: false, }; @@ -265,7 +267,8 @@ impl AuthCache { (Some(owner), Some(email), super_admin, _, label, read_only) if w_id.is_some() => { - let username_override = username_override_from_label(label); + let (username_override, username_override_is_token_label) = + username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { let lookup = if super_admin { @@ -308,6 +311,7 @@ impl AuthCache { folders, scopes: None, username_override, + username_override_is_token_label, token_prefix: Some(safe_token_prefix(token)), read_only, }) @@ -358,6 +362,7 @@ impl AuthCache { folders, scopes: None, username_override, + username_override_is_token_label, token_prefix: Some(safe_token_prefix(token)), read_only, }) @@ -386,7 +391,8 @@ impl AuthCache { } } (_, Some(email), super_admin, scopes, label, read_only) => { - let username_override = username_override_from_label(label); + let (username_override, username_override_is_token_label) = + username_override_from_label(label); if w_id.is_some() { let row_o = sqlx::query!( "SELECT username, is_admin, operator FROM usr WHERE @@ -429,6 +435,7 @@ impl AuthCache { folders, scopes, username_override, + username_override_is_token_label, token_prefix: Some(safe_token_prefix(token)), read_only, }) @@ -450,6 +457,7 @@ impl AuthCache { folders: vec![], scopes, username_override, + username_override_is_token_label, token_prefix: Some(safe_token_prefix(token)), read_only, }), @@ -473,6 +481,7 @@ impl AuthCache { folders: Vec::new(), scopes, username_override, + username_override_is_token_label, token_prefix: Some(safe_token_prefix(token)), read_only, }) @@ -508,6 +517,7 @@ impl AuthCache { folders: Vec::new(), scopes: None, username_override: None, + username_override_is_token_label: false, token_prefix: Some(safe_token_prefix(token)), read_only: false, }; @@ -715,6 +725,7 @@ fn no_auth_admin_authed() -> ApiAuthed { folders: Vec::new(), scopes: None, username_override: None, + username_override_is_token_label: false, token_prefix: None, read_only: false, } @@ -835,27 +846,47 @@ pub async fn resolve_opt_job_authed( Err((Error::NotAuthorized("Unauthorized".to_string()), parts)) } -fn username_override_from_label(label: Option) -> Option { +/// Returns the override and whether it names the token's *label* rather than the entity that +/// fired the request. Callers must not re-derive the second element from the first: the +/// `ephemeral-script-end-user-` arm forwards a `created_by` verbatim, and `created_by` is +/// unconstrained, so it may itself look like any of these shapes. +/// +/// Only namespaces `create_token` rejects (`is_server_minted_label`) are trusted to name the +/// entity acting, so the label can only have come from a server-side mint. Tokens minted +/// before that guard existed are the remaining hole; closing it needs the token row to record +/// who minted it rather than inferring it from the label. +/// +/// Note that a trigger whose identity is set server-side — the SMTP one builds an `email-*` +/// override directly — does not rely on this at all, so its prefix must not be trusted here. +pub(crate) fn username_override_from_label(label: Option) -> (Option, bool) { match label { + Some(label) if label.starts_with("ephemeral-webhook-") => (Some(label), false), + Some(label) if label.starts_with("ephemeral-script-end-user-") => ( + Some( + label + .trim_start_matches("ephemeral-script-end-user-") + .to_string(), + ), + false, + ), + // User-mintable, so they name nobody in particular — the trigger panels merely + // pre-fill `webhook-`/`http-`, and the editor mints the lsp one. The override keeps + // its value because `require_job_read_access` matches it against the `created_by` of + // jobs launched under it, which these shapes produced while they were trusted. + Some(label) if label == "Ephemeral lsp token" => (Some("lsp".to_string()), true), Some(label) - if label.starts_with("ephemeral-webhook-") - || label.starts_with("webhook-") + if label.starts_with("webhook-") || label.starts_with("http-") || label.starts_with("email-") || label.starts_with("ws-") => { - Some(label) + (Some(label), true) } - Some(label) if label.starts_with("ephemeral-script-end-user-") => Some( - label - .trim_start_matches("ephemeral-script-end-user-") - .to_string(), + Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => ( + Some(format!("{}{label}", crate::GENERIC_TOKEN_LABEL_PREFIX)), + true, ), - Some(label) if label == "Ephemeral lsp token" => Some("lsp".to_string()), - Some(label) if label != "ephemeral-script" && label != "session" && !label.is_empty() => { - Some(format!("label-{label}")) - } - _ => None, + _ => (None, false), } } diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 8120c16605..4161710aaf 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -37,6 +37,11 @@ pub use auth::{ // ------------ ApiAuthed & OptJobAuthed types ------------ +/// Prefix `username_override_from_label` puts on the label of a generic user token. The +/// override keeps this form even though `display_username` skips it: `require_job_read_access` +/// matches it against `created_by` to let a token re-read the jobs it launched. +pub const GENERIC_TOKEN_LABEL_PREFIX: &str = "label-"; + #[derive(Default, Clone, Debug)] pub struct OptJobAuthed { pub job_id: Option, @@ -54,6 +59,11 @@ pub struct ApiAuthed { pub folders: Vec<(String, bool, bool)>, pub scopes: Option>, pub username_override: Option, + /// Whether `username_override` is a generic user-token label rather than a name that + /// identifies the requester. It cannot be recovered from the value: the ephemeral + /// end-user override passes a `created_by` through verbatim, and that may itself be a + /// `label-*` string. Only `username_override_from_label` sets it. + pub username_override_is_token_label: bool, pub token_prefix: Option, pub read_only: bool, } @@ -72,8 +82,23 @@ impl ApiAuthed { } } + /// The name a run triggered by this principal is credited to (`v2_job.created_by`, and + /// the audit `end_user`). A trigger-token override names the entity that fired the + /// request and wins; a generic token label does not, so the token owner is credited and + /// stays traceable even when `permissioned_as` is an on-behalf-of identity. pub fn display_username(&self) -> &str { - self.username_override.as_ref().unwrap_or(&self.username) + match self.username_override.as_deref() { + Some(o) if !self.username_override_is_token_label => o, + _ => &self.username, + } + } + + /// Set an override that names the entity acting, e.g. a trigger. Assigning + /// `username_override` on its own would keep the provenance flag of whatever this authed + /// was built from, and a stale `true` makes `display_username` ignore the new value. + pub fn set_acting_username_override(&mut self, username_override: Option) { + self.username_override = username_override; + self.username_override_is_token_label = false; } } @@ -103,6 +128,7 @@ impl From for ApiAuthed { folders: value.folders, scopes: value.scopes, username_override: None, + username_override_is_token_label: false, token_prefix: value.token_prefix, read_only: false, } @@ -852,6 +878,7 @@ pub async fn fetch_api_authed_from_permissioned_as( folders: authed.folders, scopes: authed.scopes, username_override: None, + username_override_is_token_label: false, token_prefix: authed.token_prefix, read_only: false, }; @@ -869,7 +896,8 @@ pub async fn fetch_api_authed_from_permissioned_as( } }; - api_authed.username_override = username_override; + // Callers pass a trigger or app identity here, never a token label. + api_authed.set_acting_username_override(username_override); Ok(api_authed) } @@ -1194,6 +1222,64 @@ mod tests { } } + /// `display_username` is what `push` credits a run to, so a token label standing in for + /// it erases the caller from `created_by` and from the audit trail — irrecoverably when + /// `permissioned_as` is an on-behalf-of identity that also takes the `username` slot. + #[test] + fn generic_token_label_credits_the_token_owner() { + let owner_of = |label: &str| { + let (username_override, username_override_is_token_label) = + auth::username_override_from_label(Some(label.to_string())); + ApiAuthed { + username: "alice".into(), + username_override, + username_override_is_token_label, + ..Default::default() + } + }; + + // Arbitrary user-chosen labels, and the auto-generated MCP OAuth one. + assert_eq!(owner_of("my-personal-token").display_username(), "alice"); + assert_eq!( + owner_of("mcp-oauth-mcp-client-9f3a1c").display_username(), + "alice" + ); + + // A trigger-*shaped* label is just as user-settable as any other, so it is credited + // the same way. Its value is still kept as the override, for `require_job_read_access`. + let webhookish = owner_of("webhook-f/svc/my_script"); + assert_eq!(webhookish.display_username(), "alice"); + assert_eq!( + webhookish.username_override.as_deref(), + Some("webhook-f/svc/my_script") + ); + + // Only labels `create_token` refuses to mint name the entity that fired the request. + assert_eq!( + owner_of("ephemeral-webhook-google-abc12").display_username(), + "ephemeral-webhook-google-abc12" + ); + + // Minted by the editor through the public handler, so it names no principal either. + assert_eq!(owner_of("Ephemeral lsp token").display_username(), "alice"); + + // The SMTP trigger sets its `email-*` identity server-side rather than through a + // label, so a token carrying that prefix is just a user token. + assert_eq!(owner_of("email-f/team/inbox").display_username(), "alice"); + assert_eq!( + owner_of("ephemeral-script-end-user-enduser42").display_username(), + "enduser42" + ); + + // The end-user token forwards a `created_by` verbatim, and `created_by` is not + // constrained to a username — a job launched before the owner was credited still + // carries `label-*`. That is an end user, not this token's label, so it stands. + assert_eq!( + owner_of("ephemeral-script-end-user-label-alice").display_username(), + "label-alice" + ); + } + // Regression tests for the Preview path traversal: a Preview's path skips the // DB `proper_id` CHECK and reaches the worker, where it builds on-disk module // dirs. Traversal must be rejected even for admins, who otherwise bypass the diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 1d809ae526..6249105ff6 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -50,6 +50,7 @@ fn test_authed() -> ApiAuthed { folders: vec![], scopes: None, username_override: None, + username_override_is_token_label: false, token_prefix: None, read_only: false, } diff --git a/backend/windmill-api-integration-tests/tests/token_label_idor.rs b/backend/windmill-api-integration-tests/tests/token_label_idor.rs index 92dada92c2..ddec1a481a 100644 --- a/backend/windmill-api-integration-tests/tests/token_label_idor.rs +++ b/backend/windmill-api-integration-tests/tests/token_label_idor.rs @@ -179,6 +179,9 @@ async fn test_trigger_token_labels_still_creatable(db: Pool) -> anyhow "http-test-user-2-cd34", "email-test-user-2-ef56", "my-ci-token", + // Minted client-side by the editor (every TypeScript editor load) and the debugger. + "Ephemeral lsp token", + "debugger-token", ] { let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await; assert_eq!( @@ -190,3 +193,31 @@ async fn test_trigger_token_labels_still_creatable(db: Pool) -> anyhow Ok(()) } + +/// The mirror of the above: reserved namespaces must NOT be mintable. `username_override_from_label` +/// trusts these shapes to name the entity acting, so a forged one would stamp an arbitrary +/// name onto `v2_job.created_by` and the audit `end_user` — on an `on_behalf_of` runnable, +/// which also takes the `username`/`email` columns, that leaves no trace of the real caller. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_reserved_token_labels_not_creatable(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for label in [ + "ephemeral-webhook-forged", + "ephemeral-script-end-user-svcaccount", + "ephemeral-script", + "session", + "mcp-oauth-forged", + ] { + let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await; + assert_eq!( + resp.status(), + 400, + "creating a token with reserved label {label:?} must be rejected" + ); + } + + Ok(()) +} diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index f78a9e85ff..bb7e129b28 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -2764,6 +2764,21 @@ async fn create_token( forbid_superadmin_job_token(&db, &authed.email, job_id).await?; check_token_create_rate_limit(&authed.username)?; + // `username_override_from_label` trusts a server-minted label to name the entity acting, + // so a forged one would put an arbitrary name in `created_by` and the audit trail. + // Deliberately narrower than the `is_user_token` guard on relabelling: the editor and the + // debugger mint their own tokens through this handler. Server-side mints bypass it by + // calling `create_token_internal` / `create_token_for_owner` directly. + if token_config + .label + .as_deref() + .is_some_and(windmill_common::auth::is_server_minted_label) + { + return Err(Error::BadRequest( + "label collides with a reserved system-token namespace".to_string(), + )); + } + windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?; let mut tx = db.begin().await?; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 6e5e9cecc0..6a133600a1 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -9746,6 +9746,7 @@ async fn load_workspace_authed( folders: vec![], scopes: base_authed.scopes.clone(), username_override: base_authed.username_override.clone(), + username_override_is_token_label: base_authed.username_override_is_token_label, token_prefix: base_authed.token_prefix.clone(), read_only: base_authed.read_only, }); @@ -9775,6 +9776,7 @@ async fn load_workspace_authed( folders, scopes: base_authed.scopes.clone(), username_override: base_authed.username_override.clone(), + username_override_is_token_label: base_authed.username_override_is_token_label, token_prefix: base_authed.token_prefix.clone(), read_only: base_authed.read_only, }) diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index aa320c4167..ab711ae1d2 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4497,9 +4497,9 @@ async fn build_args( if arg_str.starts_with("\"$ctx:") { let prop = arg_str.trim_start_matches("\"$ctx:").trim_end_matches("\""); let value = match prop { - "username" => authed.as_ref().map(|a| { - serde_json::to_value(a.username_override.as_ref().unwrap_or(&a.username)) - }), + "username" => authed + .as_ref() + .map(|a| serde_json::to_value(a.display_username())), "email" => authed.as_ref().map(|a| serde_json::to_value(&a.email)), "workspace" => Some(serde_json::to_value(&w_id)), "groups" => authed.as_ref().map(|a| serde_json::to_value(&a.groups)), diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 1bd8ec536f..cc37ed097a 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -1103,9 +1103,10 @@ async fn require_job_read_access( // identity, i.e. its `permissioned_as_email` (the token owner's email, never set from // the label) equals `authed.email`. This still admits every legitimate same-owner // re-read (trigger tokens reading their own webhook/http/email jobs, the - // ephemeral-script-end-user worker token, generic labeled tokens) while denying - // cross-principal collisions. The DB hit only happens when an override is present and - // matches, so the common session/token path stays query-free. + // ephemeral-script-end-user worker token, and jobs whose stored `created_by` is a + // `label-*` override) while denying cross-principal collisions. The DB hit only happens + // when an override is present and matches, so the common session/token path stays + // query-free. if authed .username_override .as_deref() @@ -10938,6 +10939,7 @@ mod approval_view_gate_tests { folders: vec![], scopes: None, username_override: None, + username_override_is_token_label: false, token_prefix: None, read_only: false, } diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index f4d30cbd77..f1c7ad4909 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -328,6 +328,7 @@ async fn inject_agent_authed( folders: Vec::new(), scopes: None, username_override: None, + username_override_is_token_label: false, token_prefix: None, read_only: false, }, diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index cbaa392cd4..736190295e 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -43,6 +43,22 @@ pub fn is_user_token(label: Option<&str>) -> bool { } } +/// Whether `label` belongs to a namespace only the server mints, and which therefore must be +/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label +/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token` +/// and `debugger-token` are minted by the editor and the debugger through that same handler, +/// so reserving them would break those features. +/// +/// `username_override_from_label` trusts a label to name the entity acting only if it is in +/// here, so anything added must be unmintable by a member. +pub fn is_server_minted_label(label: &str) -> bool { + label.starts_with("ephemeral-webhook-") + || label.starts_with("ephemeral-script-end-user-") + || label == "ephemeral-script" + || label == "session" + || label.starts_with("mcp-oauth-") +} + /// Hash a raw token using SHA-256 (hex-encoded, 64 chars). /// Used to store and look up tokens without keeping plaintext in the DB. pub fn hash_token(token: &str) -> String { diff --git a/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte b/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte index ed02de2dda..392018750d 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogDetails.svelte @@ -10,6 +10,10 @@ let { logs, selectedId = undefined }: Props = $props() + // `span` holds the caller's token prefix, except for job-minted worker tokens, which + // stamp the job they run for instead. + const JOB_SPAN_PREFIX = 'job-span-' + const ViewFlowOp: AuditLog['operation'][] = ['jobs.run.flow', 'flows.create', 'flows.update'] const ViewAppOp: AuditLog['operation'][] = ['apps.create', 'apps.update'] @@ -24,6 +28,17 @@ ID {log.id} + {#if log.span} + {@const isJobSpan = log.span.startsWith(JOB_SPAN_PREFIX)} +
+ + {isJobSpan ? 'Job' : 'Token prefix'} + + + {isJobSpan ? log.span.slice(JOB_SPAN_PREFIX.length) : log.span} + +
+ {/if}
Parameters
diff --git a/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte b/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte index 5ad2cb2065..b0f5b7256a 100644 --- a/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte +++ b/frontend/src/lib/components/auditLogs/AuditLogsTable.svelte @@ -205,10 +205,19 @@
-
- {logOrDate.log.username} + +
+ + {logOrDate.log.username} + {#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters} - ({logOrDate.log.parameters.end_user}) + + ({logOrDate.log.parameters.end_user}) + {/if}
+
+ updateIncludeType('dataTableMigrations', e.detail)} + options={{ right: 'Data table migrations' }} + /> +
From bd7156682d4d4b78e01fa316580e632152cc5b62 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 31 Jul 2026 23:44:25 +0200 Subject: [PATCH 11/32] fix: keep native triggers attached when a runnable is renamed (#10432) Co-authored-by: Claude Opus 5 (1M context) --- ...9dac27307833fafaa0ea232819aecd4d62773.json | 38 +++ ...2db835776ff3928236dc26033a9f577ddad61.json | 22 ++ ...7056f9a6157c1c448e46f502a17615ec25d87.json | 44 ++++ ...9c3b7cebdbd9be8b5cc9a14b325c4ad1f5b4f.json | 22 ++ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...b63195863245bfb271a946d2aa67c59da7b3c.json | 40 +++ ...dddc63e9fde5b301e7f935def7e1d0d41e672.json | 34 +++ ...608f7f9b84c13d8d906113467c00d440a8fa2.json | 42 +++ ...20977b23f696e74735de1a56da58713e64b50.json | 15 ++ ...cf572fbf901326e80806bb6dcaa953f7b7b60.json | 53 ++++ ...960914f825eaa2b44281a4e147cea6e886e7a.json | 34 +++ ...c6e9c9d36bcf9f7138422414f31f0319e6cfb.json | 23 ++ backend/Cargo.lock | 2 + backend/windmill-api-flows/Cargo.toml | 4 +- backend/windmill-api-flows/src/flows.rs | 42 ++- .../tests/native_triggers.rs | 90 ++++++- .../tests/token_hash.rs | 26 +- backend/windmill-api-scripts/Cargo.toml | 4 +- backend/windmill-api-scripts/src/scripts.rs | 54 +++- backend/windmill-api/Cargo.toml | 2 +- backend/windmill-common/src/triggers.rs | 46 +++- .../src/google/external.rs | 35 ++- .../windmill-native-triggers/src/handler.rs | 101 +++++++- backend/windmill-native-triggers/src/lib.rs | 130 +++++++++- backend/windmill-native-triggers/src/lock.rs | 136 ++++++++++ .../src/nextcloud/external.rs | 35 ++- .../windmill-native-triggers/src/rename.rs | 244 ++++++++++++++++++ backend/windmill-native-triggers/src/sync.rs | 13 +- .../src/workspace_integrations.rs | 22 +- 29 files changed, 1282 insertions(+), 73 deletions(-) create mode 100644 backend/.sqlx/query-14a826d794da720981368c77a4f9dac27307833fafaa0ea232819aecd4d62773.json create mode 100644 backend/.sqlx/query-2c56da59ac2d7b410235a3181cf2db835776ff3928236dc26033a9f577ddad61.json create mode 100644 backend/.sqlx/query-4956c7bb3312520e0ba0ae330037056f9a6157c1c448e46f502a17615ec25d87.json create mode 100644 backend/.sqlx/query-4e3f3202e762aad38707315c05c9c3b7cebdbd9be8b5cc9a14b325c4ad1f5b4f.json create mode 100644 backend/.sqlx/query-72fa27780070363e092a19dbea4b63195863245bfb271a946d2aa67c59da7b3c.json create mode 100644 backend/.sqlx/query-77146f3ebe59007b7608c88ba35dddc63e9fde5b301e7f935def7e1d0d41e672.json create mode 100644 backend/.sqlx/query-86c1c8712587acbacc830fde1be608f7f9b84c13d8d906113467c00d440a8fa2.json create mode 100644 backend/.sqlx/query-90489615820818332e40a46a3d720977b23f696e74735de1a56da58713e64b50.json create mode 100644 backend/.sqlx/query-a0ef88bd0ec4823e4c8a226a790cf572fbf901326e80806bb6dcaa953f7b7b60.json create mode 100644 backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json create mode 100644 backend/.sqlx/query-c13f203478d6bc623c27bba9b6fc6e9c9d36bcf9f7138422414f31f0319e6cfb.json create mode 100644 backend/windmill-native-triggers/src/lock.rs create mode 100644 backend/windmill-native-triggers/src/rename.rs diff --git a/backend/.sqlx/query-14a826d794da720981368c77a4f9dac27307833fafaa0ea232819aecd4d62773.json b/backend/.sqlx/query-14a826d794da720981368c77a4f9dac27307833fafaa0ea232819aecd4d62773.json new file mode 100644 index 0000000000..6604c44c97 --- /dev/null +++ b/backend/.sqlx/query-14a826d794da720981368c77a4f9dac27307833fafaa0ea232819aecd4d62773.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE native_trigger\n SET webhook_token_hash = $1, service_config = $2, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $3\n AND service_name = $4\n AND external_id = $5\n AND updated_at = $6\n RETURNING 1 AS \"applied!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "applied!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Jsonb", + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text", + "Timestamptz" + ] + }, + "nullable": [ + null + ] + }, + "hash": "14a826d794da720981368c77a4f9dac27307833fafaa0ea232819aecd4d62773" +} diff --git a/backend/.sqlx/query-2c56da59ac2d7b410235a3181cf2db835776ff3928236dc26033a9f577ddad61.json b/backend/.sqlx/query-2c56da59ac2d7b410235a3181cf2db835776ff3928236dc26033a9f577ddad61.json new file mode 100644 index 0000000000..76c8a429fc --- /dev/null +++ b/backend/.sqlx/query-2c56da59ac2d7b410235a3181cf2db835776ff3928236dc26033a9f577ddad61.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS \"acquired!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "acquired!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2c56da59ac2d7b410235a3181cf2db835776ff3928236dc26033a9f577ddad61" +} diff --git a/backend/.sqlx/query-4956c7bb3312520e0ba0ae330037056f9a6157c1c448e46f502a17615ec25d87.json b/backend/.sqlx/query-4956c7bb3312520e0ba0ae330037056f9a6157c1c448e46f502a17615ec25d87.json new file mode 100644 index 0000000000..c43bb800bb --- /dev/null +++ b/backend/.sqlx/query-4956c7bb3312520e0ba0ae330037056f9a6157c1c448e46f502a17615ec25d87.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH t1 AS (UPDATE http_trigger SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4), t2 AS (UPDATE email_trigger SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4) UPDATE native_trigger SET script_path = $1, updated_at = NOW(), error = $5 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4 RETURNING service_name::text AS \"service_name!\", external_id, script_path, is_flow", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "service_name!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "external_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_flow", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Bool", + "Text" + ] + }, + "nullable": [ + null, + false, + false, + false + ] + }, + "hash": "4956c7bb3312520e0ba0ae330037056f9a6157c1c448e46f502a17615ec25d87" +} diff --git a/backend/.sqlx/query-4e3f3202e762aad38707315c05c9c3b7cebdbd9be8b5cc9a14b325c4ad1f5b4f.json b/backend/.sqlx/query-4e3f3202e762aad38707315c05c9c3b7cebdbd9be8b5cc9a14b325c4ad1f5b4f.json new file mode 100644 index 0000000000..cf360c9dd5 --- /dev/null +++ b/backend/.sqlx/query-4e3f3202e762aad38707315c05c9c3b7cebdbd9be8b5cc9a14b325c4ad1f5b4f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_lock(hashtextextended($1, 0))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4e3f3202e762aad38707315c05c9c3b7cebdbd9be8b5cc9a14b325c4ad1f5b4f" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-72fa27780070363e092a19dbea4b63195863245bfb271a946d2aa67c59da7b3c.json b/backend/.sqlx/query-72fa27780070363e092a19dbea4b63195863245bfb271a946d2aa67c59da7b3c.json new file mode 100644 index 0000000000..d97f8be4ac --- /dev/null +++ b/backend/.sqlx/query-72fa27780070363e092a19dbea4b63195863245bfb271a946d2aa67c59da7b3c.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, workspace_id, super_admin, owner FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "owner", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true + ] + }, + "hash": "72fa27780070363e092a19dbea4b63195863245bfb271a946d2aa67c59da7b3c" +} diff --git a/backend/.sqlx/query-77146f3ebe59007b7608c88ba35dddc63e9fde5b301e7f935def7e1d0d41e672.json b/backend/.sqlx/query-77146f3ebe59007b7608c88ba35dddc63e9fde5b301e7f935def7e1d0d41e672.json new file mode 100644 index 0000000000..35a2e8deda --- /dev/null +++ b/backend/.sqlx/query-77146f3ebe59007b7608c88ba35dddc63e9fde5b301e7f935def7e1d0d41e672.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, expiration, scopes FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "77146f3ebe59007b7608c88ba35dddc63e9fde5b301e7f935def7e1d0d41e672" +} diff --git a/backend/.sqlx/query-86c1c8712587acbacc830fde1be608f7f9b84c13d8d906113467c00d440a8fa2.json b/backend/.sqlx/query-86c1c8712587acbacc830fde1be608f7f9b84c13d8d906113467c00d440a8fa2.json new file mode 100644 index 0000000000..3c4815172c --- /dev/null +++ b/backend/.sqlx/query-86c1c8712587acbacc830fde1be608f7f9b84c13d8d906113467c00d440a8fa2.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4,\n summary = $5, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $6\n AND service_name = $7\n AND external_id = $8\n AND script_path = $9\n AND is_flow = $10\n RETURNING 1 AS \"applied!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "applied!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Bool", + "Varchar", + "Jsonb", + "Varchar", + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text", + "Text", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "86c1c8712587acbacc830fde1be608f7f9b84c13d8d906113467c00d440a8fa2" +} diff --git a/backend/.sqlx/query-90489615820818332e40a46a3d720977b23f696e74735de1a56da58713e64b50.json b/backend/.sqlx/query-90489615820818332e40a46a3d720977b23f696e74735de1a56da58713e64b50.json new file mode 100644 index 0000000000..ebc76b2fb4 --- /dev/null +++ b/backend/.sqlx/query-90489615820818332e40a46a3d720977b23f696e74735de1a56da58713e64b50.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE script SET path = $1 WHERE workspace_id = 'test-workspace' AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "90489615820818332e40a46a3d720977b23f696e74735de1a56da58713e64b50" +} diff --git a/backend/.sqlx/query-a0ef88bd0ec4823e4c8a226a790cf572fbf901326e80806bb6dcaa953f7b7b60.json b/backend/.sqlx/query-a0ef88bd0ec4823e4c8a226a790cf572fbf901326e80806bb6dcaa953f7b7b60.json new file mode 100644 index 0000000000..6097dab23e --- /dev/null +++ b/backend/.sqlx/query-a0ef88bd0ec4823e4c8a226a790cf572fbf901326e80806bb6dcaa953f7b7b60.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT service_config, webhook_token_hash, script_path, is_flow\n FROM native_trigger\n WHERE workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "service_config", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "webhook_token_hash", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_flow", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + true, + false, + false, + false + ] + }, + "hash": "a0ef88bd0ec4823e4c8a226a790cf572fbf901326e80806bb6dcaa953f7b7b60" +} diff --git a/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json b/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json new file mode 100644 index 0000000000..ebb5cee72c --- /dev/null +++ b/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "webhook_token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a" +} diff --git a/backend/.sqlx/query-c13f203478d6bc623c27bba9b6fc6e9c9d36bcf9f7138422414f31f0319e6cfb.json b/backend/.sqlx/query-c13f203478d6bc623c27bba9b6fc6e9c9d36bcf9f7138422414f31f0319e6cfb.json new file mode 100644 index 0000000000..a1bf434814 --- /dev/null +++ b/backend/.sqlx/query-c13f203478d6bc623c27bba9b6fc6e9c9d36bcf9f7138422414f31f0319e6cfb.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND deleted = false)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c13f203478d6bc623c27bba9b6fc6e9c9d36bcf9f7138422414f31f0319e6cfb" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7dd3c795aa..bd12841ccc 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14927,6 +14927,7 @@ dependencies = [ "windmill-common", "windmill-dep-map", "windmill-git-sync", + "windmill-native-triggers", "windmill-queue", ] @@ -15110,6 +15111,7 @@ dependencies = [ "windmill-common", "windmill-dep-map", "windmill-git-sync", + "windmill-native-triggers", "windmill-object-store", "windmill-parser", "windmill-parser-py", diff --git a/backend/windmill-api-flows/Cargo.toml b/backend/windmill-api-flows/Cargo.toml index 231c00a159..3fc2c7b572 100644 --- a/backend/windmill-api-flows/Cargo.toml +++ b/backend/windmill-api-flows/Cargo.toml @@ -10,8 +10,9 @@ path = "src/lib.rs" [features] default = [] -enterprise = ["windmill-common/enterprise"] +enterprise = ["windmill-common/enterprise", "windmill-native-triggers?/enterprise"] private = ["windmill-common/private", "windmill-dep-map/private"] +native_trigger = ["dep:windmill-native-triggers", "windmill-native-triggers/native_trigger"] [dependencies] windmill-common = { workspace = true, default-features = false } windmill-api-auth.workspace = true @@ -19,6 +20,7 @@ windmill-queue.workspace = true windmill-audit.workspace = true windmill-git-sync.workspace = true windmill-dep-map.workspace = true +windmill-native-triggers = { workspace = true, optional = true } axum.workspace = true hyper.workspace = true diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index da61d08ec0..9c310767ef 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -50,6 +50,7 @@ use windmill_common::{ flows::{EditFlow, Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, + triggers::MovedNativeTrigger, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; @@ -1003,6 +1004,35 @@ async fn update_flow_history( Ok(()) } +/// Re-point the webhooks of the native triggers a rename carried onto the new path. +/// +/// Runs after the deploy transaction commits — repointing a webhook is not undoable — and off the +/// request, because it waits on a third-party service that may be slow or gone, and a deploy that +/// already committed must not look like it failed. The rename itself marked these rows +/// `REREGISTRATION_PENDING`, so nothing is lost silently if this never finishes. +fn reregister_moved_native_triggers( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + moved: Vec, +) { + if moved.is_empty() { + return; + } + #[cfg(feature = "native_trigger")] + { + let (db, authed, w_id) = (db.clone(), authed.clone(), w_id.to_string()); + tokio::spawn(async move { + windmill_native_triggers::rename::reregister_triggers_after_rename( + &db, &authed, &w_id, &moved, + ) + .await; + }); + } + #[cfg(not(feature = "native_trigger"))] + let _ = (db, authed, w_id, moved); +} + async fn update_flow( authed: ApiAuthed, Extension(user_db): Extension, @@ -1022,6 +1052,13 @@ async fn update_flow( // A `<= 0` flow timeout is "unset", not a 0-second limit (see create_flow). nf.timeout = windmill_common::runnable_settings::none_if_non_positive(nf.timeout); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; + // A rename writes the destination as much as the source, so a path-scoped token needs both. + // Checking only the source would let it move a flow onto a path it has no say over — and + // everything that follows the rename, native triggers included, is then acting on a path this + // caller was never authorized for. `create_script` already scopes against its destination. + if nf.path != flow_path { + check_scopes(&authed, || format!("flows:write:{}", nf.path))?; + } if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, @@ -1238,8 +1275,9 @@ async fn update_flow( } } + let mut moved_native_triggers = Vec::new(); if is_new_path { - windmill_common::triggers::update_triggers_script_path( + moved_native_triggers = windmill_common::triggers::update_triggers_script_path( &mut tx, &nf.path, &flow_path, &w_id, true, ) .await @@ -1412,6 +1450,8 @@ async fn update_flow( new_tx.commit().await?; + reregister_moved_native_triggers(&db, &authed, &w_id, moved_native_triggers); + // Trigger CI tests for items that reference this flow { let db2 = db.clone(); diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 8ff515a888..17c6521309 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -17,8 +17,8 @@ use windmill_native_triggers::{ decrypt_oauth_data, delete_native_trigger, delete_workspace_integration, get_workspace_integration, google::{parse_stop_channel_params, should_renew_channel}, - require_native_integration_use, store_native_trigger, store_workspace_integration, - NativeTriggerConfig, OAuthConfig, ServiceName, + list_native_triggers, require_native_integration_use, store_native_trigger, + store_workspace_integration, NativeTriggerConfig, OAuthConfig, ServiceName, }; // ============================================================================ @@ -571,6 +571,92 @@ async fn test_cleanup_preserves_triggers(db: Pool) -> anyhow::Result<( Ok(()) } +// ============================================================================ +// 5. Runnable rename +// ============================================================================ + +/// A rename has to carry the trigger row onto the new path and report it as moved: listings only +/// return rows whose runnable still exists, so one left behind on the old path disappears from the +/// UI for good, and one not reported keeps a webhook aimed at the old path. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result<()> { + insert_test_script(&db, "f/test/before").await?; + store_native_trigger( + &db, + "test-workspace", + ServiceName::Nextcloud, + "ext-1", + &NativeTriggerConfig { + script_path: "f/test/before".to_string(), + is_flow: false, + webhook_token: "abcdefghij1234567890".to_string(), + }, + json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), + None, + ) + .await?; + // An unrelated trigger already sitting on the target path must not be reported as moved. + insert_test_script(&db, "f/test/after").await?; + store_native_trigger( + &db, + "test-workspace", + ServiceName::Nextcloud, + "ext-2", + &NativeTriggerConfig { + script_path: "f/test/after".to_string(), + is_flow: false, + webhook_token: "0987654321jihgfedcba".to_string(), + }, + json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), + None, + ) + .await?; + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE script SET path = $1 WHERE workspace_id = 'test-workspace' AND path = $2", + "f/test/after", + "f/test/before", + ) + .execute(&mut *tx) + .await?; + let moved = windmill_common::triggers::update_triggers_script_path( + &mut tx, + "f/test/after", + "f/test/before", + "test-workspace", + false, + ) + .await?; + tx.commit().await?; + + assert_eq!( + moved + .iter() + .map(|t| (t.service_name.as_str(), t.external_id.as_str())) + .collect::>(), + vec![("nextcloud", "ext-1")] + ); + + let triggers = list_native_triggers( + &db, + "test-workspace", + ServiceName::Nextcloud, + None, + None, + Some("f/test/after"), + Some(false), + ) + .await?; + assert_eq!( + triggers.len(), + 2, + "the moved trigger should be listed under the new path" + ); + + Ok(()) +} + // --- parse_stop_channel_params --- #[test] diff --git a/backend/windmill-api-integration-tests/tests/token_hash.rs b/backend/windmill-api-integration-tests/tests/token_hash.rs index 45d324a3d4..285f15a9c6 100644 --- a/backend/windmill-api-integration-tests/tests/token_hash.rs +++ b/backend/windmill-api-integration-tests/tests/token_hash.rs @@ -343,10 +343,17 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { .execute(&db) .await?; - // Rotate the token - let rotated = rotate_webhook_token(&db, &original_hash, ServiceName::Google) - .await? - .expect("rotate must return Some for existing token"); + // Rotate onto a different runnable than the original token was minted for: a rename moves the + // trigger, and rotation has to follow it rather than carry the old scopes forward. + let renamed_scopes = vec!["jobs:run:flows:f/test/renamed".to_string()]; + let rotated = rotate_webhook_token( + &db, + &original_hash, + ServiceName::Google, + renamed_scopes.clone(), + ) + .await? + .expect("rotate must return Some for existing token"); // New token should be different assert_ne!(rotated.new_token, original_token); @@ -355,7 +362,7 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { // New token's hash should exist in DB with the per-service label and expiration let new_hash = hash_token(&rotated.new_token); let new_row = sqlx::query!( - "SELECT label, expiration FROM token WHERE token_hash = $1", + "SELECT label, expiration, scopes FROM token WHERE token_hash = $1", new_hash ) .fetch_optional(&db) @@ -373,6 +380,13 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { new_row.expiration.is_some(), "rotated Google token must carry an expiration" ); + // Carrying the old token's scopes here is what made every post-rename retry mint a token no + // callback could use, while reporting success. + assert_eq!( + new_row.scopes.as_deref(), + Some(renamed_scopes.as_slice()), + "rotation must scope the new token to the runnable it was rotated for" + ); // Old token should still exist (deletion deferred to caller) let old_exists: bool = sqlx::query_scalar!( @@ -402,7 +416,7 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { assert!(!old_gone, "old token must be gone after explicit deletion"); // Rotating a non-existent hash should return None - let result = rotate_webhook_token(&db, "nonexistent_hash", ServiceName::Google).await?; + let result = rotate_webhook_token(&db, "nonexistent_hash", ServiceName::Google, vec![]).await?; assert!( result.is_none(), "rotating a non-existent token must return None" diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index ecf0ff0b61..8b34512872 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -10,12 +10,14 @@ path = "src/lib.rs" [features] default = [] -enterprise = ["windmill-common/enterprise"] +enterprise = ["windmill-common/enterprise", "windmill-native-triggers?/enterprise"] private = ["windmill-common/private", "windmill-dep-map/private"] python = ["dep:windmill-parser-py", "dep:windmill-parser-py-asset"] prometheus = ["dep:prometheus", "windmill-common/prometheus"] +native_trigger = ["dep:windmill-native-triggers", "windmill-native-triggers/native_trigger"] [dependencies] windmill-common = { workspace = true, default-features = false } +windmill-native-triggers = { workspace = true, optional = true } windmill-object-store.workspace = true windmill-api-auth.workspace = true windmill-queue.workspace = true diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index e82a3f967f..57e2cef3c3 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -71,6 +71,7 @@ use windmill_common::{ ScriptHistory, ScriptHistoryUpdate, ScriptKind, ScriptLang, ScriptModule, ScriptWithStarred, }, + triggers::MovedNativeTrigger, users::username_to_permissioned_as, utils::{not_found_if_none, query_elems_from_hub, require_admin, Pagination, StripPath}, worker::to_raw_value, @@ -499,6 +500,35 @@ async fn get_top_hub_scripts( Ok::<_, Error>((status_code, headers, response)) } +/// Re-point the webhooks of the native triggers a rename carried onto the new path. +/// +/// Runs after the deploy transaction commits — repointing a webhook is not undoable — and off the +/// request, because it waits on a third-party service that may be slow or gone, and a deploy that +/// already committed must not look like it failed. The rename itself marked these rows +/// `REREGISTRATION_PENDING`, so nothing is lost silently if this never finishes. +fn reregister_moved_native_triggers( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + moved: Vec, +) { + if moved.is_empty() { + return; + } + #[cfg(feature = "native_trigger")] + { + let (db, authed, w_id) = (db.clone(), authed.clone(), w_id.to_string()); + tokio::spawn(async move { + windmill_native_triggers::rename::reregister_triggers_after_rename( + &db, &authed, &w_id, &moved, + ) + .await; + }); + } + #[cfg(not(feature = "native_trigger"))] + let _ = (db, authed, w_id, moved); +} + async fn create_snapshot_script( authed: ApiAuthed, Extension(user_db): Extension, @@ -513,6 +543,7 @@ async fn create_snapshot_script( let mut tx = None; let mut uploaded = false; let mut handle_deployment_metadata = None; + let mut moved_native_triggers = Vec::new(); while let Some(field) = multipart.next_field().await.unwrap() { let name = field.name().unwrap().to_string(); let data = field.bytes().await.unwrap(); @@ -520,7 +551,7 @@ async fn create_snapshot_script( let ns: NewScript = Some(serde_json::from_slice(&data).map_err(to_anyhow)?).unwrap(); let is_tar = ns.codebase.as_ref().is_some_and(|x| x.ends_with(".tar")); let use_esm = ns.codebase.as_ref().is_some_and(|x| x.contains(".esm")); - let (new_hash, ntx, hdm) = create_script_internal( + let (new_hash, ntx, hdm, moved) = create_script_internal( ns, w_id.clone(), authed.clone(), @@ -540,6 +571,7 @@ async fn create_snapshot_script( script_hash = Some(nh); tx = Some(ntx); handle_deployment_metadata = hdm; + moved_native_triggers = moved; } if name == "file" { let hash = script_hash.as_ref().ok_or_else(|| { @@ -570,6 +602,7 @@ async fn create_snapshot_script( } tx.unwrap().commit().await?; + reregister_moved_native_triggers(&db, &authed, &w_id, moved_native_triggers); if let Some(hdm) = handle_deployment_metadata { hdm.handle(&db).await?; } @@ -627,7 +660,8 @@ async fn create_script( let script_path = ns.path.clone(); let email = authed.email.clone(); let username = authed.username.clone(); - let (hash, tx, hdm) = create_script_internal( + let authed_for_triggers = authed.clone(); + let (hash, tx, hdm, moved_native_triggers) = create_script_internal( ns, w_id.clone(), authed, @@ -638,6 +672,7 @@ async fn create_script( ) .await?; tx.commit().await?; + reregister_moved_native_triggers(&db, &authed_for_triggers, &w_id, moved_native_triggers); if let Some(hdm) = hdm { // hdm is Some when no lock generation is needed (script is ready immediately). // Trigger CI tests for any items that reference this script. @@ -906,6 +941,7 @@ async fn create_script_internal<'c>( ScriptHash, Transaction<'c, Postgres>, Option, + Vec, )> { if authed.is_operator { return Err(Error::NotAuthorized( @@ -1096,10 +1132,16 @@ async fn create_script_internal<'c>( parent_hash = %p_hash.0, "Skipping no-op script deploy (identical to parent)" ); - return Ok((p_hash.clone(), tx, None)); + return Ok((p_hash.clone(), tx, None, Vec::new())); } if ps.path != ns.path { + // A rename writes the source as much as the destination, and only the destination + // is scope-checked above. `require_owner_of_path` answers whether the *user* owns + // the source, never what their token is scoped to — so without this a path-scoped + // token could move a script it has no say over, taking its native triggers along + // and re-registering them under that token's identity. + check_scopes(&authed, || format!("scripts:write:{}", ps.path))?; require_owner_of_path(&authed, &ps.path)?; } @@ -1807,6 +1849,7 @@ async fn create_script_internal<'c>( } } + let mut moved_native_triggers = Vec::new(); let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { if !skip_draft_deletion { @@ -1883,7 +1926,7 @@ async fn create_script_internal<'c>( .await?; if p_path != &ns.path { - windmill_common::triggers::update_triggers_script_path( + moved_native_triggers = windmill_common::triggers::update_triggers_script_path( &mut tx, &ns.path, p_path, &w_id, false, ) .await @@ -2231,7 +2274,7 @@ async fn create_script_internal<'c>( .execute(&mut *new_tx) .await?; - Ok((hash, new_tx, None)) + Ok((hash, new_tx, None, moved_native_triggers)) } else { if codebase.is_none() { let db2 = db.clone(); @@ -2296,6 +2339,7 @@ async fn create_script_internal<'c>( deployment_message: ns.deployment_message, renamed_from: p_path_opt, }), + moved_native_triggers, )) } } diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index ee789945f5..eeca7213c3 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -39,7 +39,7 @@ static_frontend = ["dep:rust-embed"] postgres_trigger = ["dep:windmill-trigger-postgres", "windmill-store/postgres_trigger"] mqtt_trigger = ["dep:windmill-trigger-mqtt", "windmill-store/mqtt_trigger"] amqp_trigger = ["dep:windmill-trigger-amqp", "windmill-store/amqp_trigger"] -native_trigger = ["dep:windmill-native-triggers", "windmill-native-triggers/native_trigger", "dep:strum", "oauth2"] +native_trigger = ["dep:windmill-native-triggers", "windmill-native-triggers/native_trigger", "windmill-api-flows/native_trigger", "windmill-api-scripts/native_trigger", "dep:strum", "oauth2"] sqs_trigger = ["dep:windmill-trigger-sqs", "windmill-store/sqs_trigger"] gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"] azure_trigger = ["dep:windmill-trigger-azure", "windmill-store/azure_trigger"] diff --git a/backend/windmill-common/src/triggers.rs b/backend/windmill-common/src/triggers.rs index d667363ebb..8fef198ebd 100644 --- a/backend/windmill-common/src/triggers.rs +++ b/backend/windmill-common/src/triggers.rs @@ -8,27 +8,59 @@ lazy_static! { Cache::new(1000); } +/// Marks a moved trigger as not yet re-pointed at its new path. +/// +/// Written in the same statement that moves the row, so it is committed before the rename returns. +/// Re-pointing the webhook happens afterwards and off the request, and its outcome — success or +/// failure — replaces this. Anything that stops it getting that far, a shutdown included, leaves +/// the trigger visibly unfinished rather than quietly pointing at a path nothing serves. +pub const REREGISTRATION_PENDING: &str = + "The runnable was renamed and the webhook registered on the service has not been re-pointed at \ + it yet. If this persists, save the trigger again."; + +/// A `native_trigger` row carried onto the new path by a rename. The webhook registered on +/// the external service embeds the runnable path, so each of these still has to be +/// re-registered — see `windmill_native_triggers::rename`. +#[derive(Debug, Clone)] +pub struct MovedNativeTrigger { + pub service_name: String, + pub external_id: String, + /// Where this rename put the trigger. Re-registration mints a `jobs:run:*` token for the + /// runnable it finds, so it must confirm the row still names this one: anything that moved it + /// afterwards was authorized separately, and its choice is not this rename's to overwrite. + pub script_path: String, + pub is_flow: bool, +} + /// Update `script_path` across all trigger tables when a runnable (script or flow) is renamed. /// For long-running triggers (with `server_id`), also resets `server_id = NULL` to force /// the heartbeat-based restart mechanism to pick up the new config. +/// +/// Returns the `native_trigger` rows that moved. pub async fn update_triggers_script_path( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, new_path: &str, old_path: &str, w_id: &str, is_flow: bool, -) -> Result<(), sqlx::Error> { - // Triggers without server_id (request/response or webhook-based) - sqlx::query!( +) -> Result, sqlx::Error> { + // Triggers without server_id (request/response or webhook-based). + // `native_trigger` rows are only listed when a runnable still exists at `script_path`, + // so a row left behind on the old path is invisible in the UI and unrecoverable. + let moved_native_triggers = sqlx::query_as!( + MovedNativeTrigger, "WITH \ - t1 AS (UPDATE http_trigger SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4) \ - UPDATE email_trigger SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4", + t1 AS (UPDATE http_trigger SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4), \ + t2 AS (UPDATE email_trigger SET script_path = $1 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4) \ + UPDATE native_trigger SET script_path = $1, updated_at = NOW(), error = $5 WHERE script_path = $2 AND workspace_id = $3 AND is_flow = $4 \ + RETURNING service_name::text AS \"service_name!\", external_id, script_path, is_flow", new_path, old_path, w_id, is_flow, + REREGISTRATION_PENDING, ) - .execute(&mut **tx) + .fetch_all(&mut **tx) .await?; // Triggers with server_id (long-running listeners, reset server_id to force restart) @@ -50,5 +82,5 @@ pub async fn update_triggers_script_path( .execute(&mut **tx) .await?; - Ok(()) + Ok(moved_native_triggers) } diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 616f8d3f63..b8728e2f3f 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -11,7 +11,8 @@ use windmill_common::{ use windmill_queue::PushArgsOwned; use crate::{ - generate_webhook_service_url, rotate_webhook_token, + generate_webhook_service_url, + lock::TriggerLock, sync::{SyncAction, SyncError, TriggerSyncInfo}, update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger, NativeTriggerData, ServiceName, @@ -342,10 +343,11 @@ impl Google { .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; - let rotated = match rotate_webhook_token( + let rotated = match crate::rotate_webhook_token( db, &trigger.webhook_token_hash, ServiceName::Google, + crate::webhook_token_scopes(&trigger.script_path, trigger.is_flow), ) .await? { @@ -477,7 +479,7 @@ enum RenewOutcome { Skipped, } -/// Renew one Google watch channel under a row lock. +/// Renew one Google watch channel under `TriggerLock`. /// `sync_all_triggers` runs on every replica with no leader election — without /// the lock, parallel renewals orphan the losers' new tokens and Google channels. async fn try_renew_channel_locked( @@ -486,22 +488,31 @@ async fn try_renew_channel_locked( workspace_id: &str, trigger: &NativeTrigger, ) -> Result { - let mut tx = db.begin().await?; + // Renewal stops the live channel and creates a replacement, exactly like a re-registration + // does — so the two must not overlap, or each stops the channel the other just made and one + // is left orphaned, delivering a second copy of every event. Skipping is fine: the sweep runs + // again. Excluding them through this lock rather than by holding the row itself is what keeps + // a rename's `UPDATE native_trigger` from having to wait on a third party's API. + let Some(lock) = + TriggerLock::try_acquire(db, workspace_id, ServiceName::Google, &trigger.external_id) + .await? + else { + return Ok(RenewOutcome::Skipped); + }; let row = sqlx::query!( r#" - SELECT service_config, webhook_token_hash + SELECT service_config, webhook_token_hash, script_path, is_flow FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 AND external_id = $3 - FOR UPDATE SKIP LOCKED "#, workspace_id, ServiceName::Google as ServiceName, trigger.external_id, ) - .fetch_optional(&mut *tx) + .fetch_optional(db) .await?; let Some(row) = row else { @@ -522,10 +533,14 @@ async fn try_renew_channel_locked( return Ok(RenewOutcome::Skipped); } - // Use freshly-read fields — webhook_token_hash may have rotated since list time. + // Use freshly-read fields — the listing this came from predates the lock, so a rename may + // have moved the runnable since. Renewing against the listed path would build the channel's + // callback URL from a path that no longer resolves. let fresh_trigger = NativeTrigger { service_config: Some(service_config), webhook_token_hash: row.webhook_token_hash, + script_path: row.script_path, + is_flow: row.is_flow, ..trigger.clone() }; @@ -533,6 +548,8 @@ async fn try_renew_channel_locked( .renew_channel(workspace_id, &fresh_trigger, db) .await?; + let mut tx = db.begin().await?; + // Past this point a new Google channel exists. Any failure leaks it. if let Err(e) = update_native_trigger_service_config( &mut *tx, @@ -577,6 +594,8 @@ async fn try_renew_channel_locked( ), } + lock.release().await?; + Ok(RenewOutcome::Renewed) } diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index fda28af407..45d6368fc9 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,8 +1,9 @@ use crate::{ decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger, - list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error, - webhook_token_label, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, - ServiceName, + list_native_triggers, lock::TriggerLock, rotate_webhook_token, store_native_trigger, + sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error, + update_native_trigger_if_runnable_unchanged, webhook_token_label, webhook_token_scopes, + External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName, }; use axum::{ extract::{Path, Query}, @@ -52,6 +53,51 @@ async fn require_is_writer_on_runnable( } } +/// A trigger may only point at a live runnable. +/// +/// The webhook URL is built from this path, so a trigger pointed at a path the runnable has left +/// keeps delivering there: for a flow the row is gone and the trigger vanishes from listings, +/// for a script the abandoned version is still resolvable and fires stale code indefinitely. A +/// client that loaded before a rename submits the old path in good faith, so this needs no +/// concurrency to happen. The writer checks above do not cover it: they return early for admins +/// and path owners without ever looking at the runnable. +async fn require_runnable_exists(db: &DB, w_id: &str, path: &str, is_flow: bool) -> Result<()> { + let exists = if is_flow { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", + path, + w_id + ) + .fetch_one(db) + .await? + } else { + // Renaming a script archives the version at the old path instead of removing it, so a + // plain existence check would still accept a path the script has moved off. Every deploy + // archives its parent, leaving exactly one non-archived version at a live path and none + // at an abandoned one. Soft-delete sets `archived` too, and `deleted` is checked because + // it, not `archived`, is what stops a version from being resolved for execution. + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 \ + AND archived = false AND deleted = false)", + path, + w_id + ) + .fetch_one(db) + .await? + }; + + if exists.unwrap_or(false) { + Ok(()) + } else { + Err(Error::BadRequest(format!( + "There is no {kind} at {path} to trigger. If the {kind} was renamed since this page \ + was loaded, reload and try again; otherwise point this trigger at an existing {kind} \ + or delete it.", + kind = if is_flow { "flow" } else { "script" } + ))) + } +} + #[derive(Debug, Deserialize)] pub struct ListQuery { pub page: Option, @@ -72,7 +118,7 @@ pub struct CreateTriggerResponse { pub external_id: String, } -async fn new_webhook_token( +pub(crate) async fn new_webhook_token( tx: &mut PgConnection, db: &DB, authed: &ApiAuthed, @@ -81,9 +127,7 @@ async fn new_webhook_token( workspace_id: &str, service_name: ServiceName, ) -> Result { - let kind = if is_flow { "flows" } else { "scripts" }; - - let scopes = vec![format!("jobs:run:{kind}:{script_path}")]; + let scopes = webhook_token_scopes(script_path, is_flow); let label = webhook_token_label(service_name); let expiration = service_name .webhook_token_expiration() @@ -121,6 +165,7 @@ async fn create_native_trigger( db.clone(), ) .await?; + require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?; let mut tx = user_db.begin(&authed).await?; @@ -225,11 +270,14 @@ async fn update_native_trigger_handler( db.clone(), ) .await?; + require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?; let integration_service = service_name.integration_service(); let oauth_data: T::OAuthData = decrypt_oauth_data(&db, &workspace_id, integration_service).await?; + let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?; + let mut tx = user_db.clone().begin(&authed).await?; let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id) @@ -260,7 +308,14 @@ async fn update_native_trigger_handler( token } else { // Same runnable — rotate the token (mints a fresh label + expiration) - match rotate_webhook_token(&db, &existing.webhook_token_hash, service_name).await? { + match rotate_webhook_token( + &db, + &existing.webhook_token_hash, + service_name, + webhook_token_scopes(&data.script_path, data.is_flow), + ) + .await? + { Some(rotated) => { old_token_hash_to_delete = Some(rotated.old_token_hash); rotated.new_token @@ -302,7 +357,11 @@ async fn update_native_trigger_handler( webhook_token, }; - store_native_trigger( + // `existing` was read before the network call, and a rename writes `script_path` from the + // deploy transaction, which this lock does not cover. Writing a stale path back would undo the + // rename and drop the trigger out of every listing, so refuse the edit instead. The rename's + // own re-registration is queued behind this lock and puts the service back in step. + let applied = update_native_trigger_if_runnable_unchanged( &mut *tx, &workspace_id, service_name, @@ -310,9 +369,18 @@ async fn update_native_trigger_handler( &config, service_config, data.summary.as_deref(), + &existing.script_path, + existing.is_flow, ) .await?; + if !applied { + return Err(Error::BadRequest(format!( + "The runnable of {external_id} was renamed while this trigger was being saved, so the \ + edit was not applied. Reload and save again." + ))); + } + audit_log( &mut *tx, &authed, @@ -326,6 +394,8 @@ async fn update_native_trigger_handler( tx.commit().await?; + lock.release().await?; + // Everything succeeded — clean up old token (best-effort) if let Some(old_hash) = old_token_hash_to_delete { if let Err(e) = delete_token_by_hash(&db, &old_hash).await { @@ -375,8 +445,10 @@ async fn get_native_trigger_handler( let external_data = match native_trigger { Ok(Some(native_cfg)) => { - // Clear error if it was set - if windmill_trigger.error.is_some() { + // Only the "no longer exists" error is disproven by the trigger being there; other + // paths record failures (e.g. a webhook still aimed at a pre-rename path) that a + // successful fetch says nothing about. + if windmill_trigger.error.as_deref() == Some(EXTERNAL_TRIGGER_MISSING_ERROR) { update_native_trigger_error( &mut *tx, &workspace_id, @@ -390,7 +462,7 @@ async fn get_native_trigger_handler( } Ok(None) => None, Err(Error::NotFound(_)) => { - let error_msg = "Trigger no longer exists on external service".to_string(); + let error_msg = EXTERNAL_TRIGGER_MISSING_ERROR.to_string(); tracing::warn!( "Native trigger no longer exists on external service {}, setting error", service_name @@ -415,6 +487,8 @@ async fn get_native_trigger_handler( Err(e) => return Err(e), }; + tx.commit().await?; + let full_resp = Json(FullTriggerResponse { windmill_data: windmill_trigger, external_data }); Ok(full_resp) @@ -428,6 +502,8 @@ async fn delete_native_trigger_handler( Extension(user_db): Extension, Path((workspace_id, external_id)): Path<(String, String)>, ) -> Result { + let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?; + let mut tx = user_db.begin(&authed).await?; let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id) @@ -482,6 +558,7 @@ async fn delete_native_trigger_handler( .await?; tx.commit().await?; + lock.release().await?; Ok(format!("Native trigger deleted")) } diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index a81217d8d5..df92dcbc17 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -58,9 +58,13 @@ use windmill_oauth::{OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT}; use windmill_api_auth::ApiAuthed; pub mod handler; +pub(crate) mod lock; pub mod sync; pub mod workspace_integrations; +#[cfg(feature = "native_trigger")] +pub mod rename; + // Service modules - add new services here: #[cfg(feature = "native_trigger")] pub mod github; @@ -770,23 +774,37 @@ async fn update_oauth_token_resource( } } +/// The scopes a webhook token must carry to run one runnable, and nothing else. +/// +/// Always derived from the runnable the trigger points at *now*. A token that outlives a rename +/// carries the old path, and a webhook presenting it is refused however correct the URL is. +pub fn webhook_token_scopes(script_path: &str, is_flow: bool) -> Vec { + let kind = if is_flow { "flows" } else { "scripts" }; + vec![format!("jobs:run:{kind}:{script_path}")] +} + /// Create a new webhook token, minting a fresh `ephemeral-webhook-{service}-{rd5}` /// label and the per-service expiration (see `ServiceName::webhook_token_expiration`). /// The old token is **not** deleted — callers must call `delete_token_by_hash` on /// `old_token_hash` after the trigger row has been successfully updated. /// /// Returns `Ok(None)` if the old token no longer exists (e.g. manually deleted by user). +/// +/// `scopes` is applied rather than carried over: the old token's scopes name whatever runnable it +/// was minted for, which a rename may since have moved. Copying them is how a re-save "succeeds" +/// while every callback it authorises is refused. pub async fn rotate_webhook_token( db: &DB, old_token_hash: &str, service_name: ServiceName, + scopes: Vec, ) -> Result> { use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; use windmill_common::utils::rd_string; let old = match sqlx::query!( - "SELECT email, scopes, workspace_id, super_admin, owner FROM token WHERE token_hash = $1", + "SELECT email, workspace_id, super_admin, owner FROM token WHERE token_hash = $1", old_token_hash ) .fetch_optional(db) @@ -825,7 +843,7 @@ pub async fn rotate_webhook_token( old.email, new_label, old.super_admin, - old.scopes.as_deref(), + Some(scopes.as_slice()), old.workspace_id, old.owner, new_expiration, @@ -920,6 +938,114 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> Ok(()) } +/// Record the outcome of re-registering a webhook: the token that now authenticates it and the +/// config the service resolved. +/// +/// Deliberately not `store_native_trigger`: the runnable a trigger points at belongs to whoever +/// renamed or edited it, not to the re-registration, which only ever holds the path as it stood +/// before its network call. Writing that path back would undo a rename that landed in between. +/// +/// Conditional on `updated_at` — the row version — for the same reason. `TriggerLock` does not +/// cover the rename's own `UPDATE`, because deploys must never block on a third party, so another +/// write can land *during* this registration's network call. Recording then would attach a token +/// for state that no longer holds and, worse, clear the `REREGISTRATION_PENDING` a newer rename +/// set to protect itself. Comparing the path alone would not catch it: a save at the same path, or +/// a rename away and back, leaves the path equal while the token has moved on. +/// +/// Returns `false` when the row changed, leaving it untouched for whoever wrote it to finish. +pub(crate) async fn record_reregistration<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, + webhook_token: &str, + service_config: serde_json::Value, + expected_updated_at: DateTime, +) -> Result { + use windmill_common::auth::hash_token; + + let applied = sqlx::query_scalar!( + r#" + UPDATE native_trigger + SET webhook_token_hash = $1, service_config = $2, error = NULL, updated_at = NOW() + WHERE + workspace_id = $3 + AND service_name = $4 + AND external_id = $5 + AND updated_at = $6 + RETURNING 1 AS "applied!" + "#, + hash_token(webhook_token), + sqlx::types::Json(service_config) as _, + workspace_id, + service_name as ServiceName, + external_id, + expected_updated_at, + ) + .fetch_optional(db) + .await? + .is_some(); + + Ok(applied) +} + +/// Apply a trigger edit, unless the runnable moved under it in the meantime. +/// +/// A rename writes `native_trigger.script_path` from inside the deploy transaction, which is not +/// under `TriggerLock` — so an edit holding the lock across its network call can still have the +/// ground shift beneath it. Writing its snapshot's path back would undo the rename and hide the +/// trigger from every listing, which is the bug this whole change exists to fix, so refuse instead. +/// +/// Callers MUST have verified write access to `config.script_path`. +/// +/// Returns `false` when the runnable moved; the edit is then stale and the caller should say so. +pub(crate) async fn update_native_trigger_if_runnable_unchanged< + 'c, + E: sqlx::Executor<'c, Database = Postgres>, +>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, + config: &NativeTriggerConfig, + service_config: serde_json::Value, + summary: Option<&str>, + expected_script_path: &str, + expected_is_flow: bool, +) -> Result { + use windmill_common::auth::hash_token; + + let applied = sqlx::query_scalar!( + r#" + UPDATE native_trigger + SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, + summary = $5, error = NULL, updated_at = NOW() + WHERE + workspace_id = $6 + AND service_name = $7 + AND external_id = $8 + AND script_path = $9 + AND is_flow = $10 + RETURNING 1 AS "applied!" + "#, + config.script_path, + config.is_flow, + hash_token(&config.webhook_token), + sqlx::types::Json(service_config) as _, + summary, + workspace_id, + service_name as ServiceName, + external_id, + expected_script_path, + expected_is_flow, + ) + .fetch_optional(db) + .await? + .is_some(); + + Ok(applied) +} + pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( db: E, workspace_id: &str, diff --git a/backend/windmill-native-triggers/src/lock.rs b/backend/windmill-native-triggers/src/lock.rs new file mode 100644 index 0000000000..577025ada3 --- /dev/null +++ b/backend/windmill-native-triggers/src/lock.rs @@ -0,0 +1,136 @@ +//! Serializing the operations that mutate a trigger's external registration. + +use std::sync::Arc; + +use sqlx::{Connection, PgConnection}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use windmill_common::{ + error::{Error, Result}, + DB, +}; + +use crate::ServiceName; + +/// How long to wait for whoever holds the lock before giving up. +/// +/// A waiter parks on its own connection, and that connection is outside the pool and so outside +/// its limits — enough of them queued on one trigger would eat into what the server has for +/// everything else. Waiting is bounded instead: the holder is doing a network call with its own +/// timeout, so anything longer than this is not worth a connection, and the caller gets a plain +/// "busy, try again" rather than an open-ended stall. +const LOCK_WAIT: &str = "45s"; + +/// Ceiling on how many of these connections can exist at once. +/// +/// Each is opened outside the pool, so nothing else caps them: a burst of renames or repeated +/// requests against one trigger would otherwise eat into what the server has for every other +/// workload. Waiting for a permit costs nothing — the operation was going to queue on the lock +/// anyway — and it is generous enough that ordinary use never reaches it. +const MAX_CONCURRENT_LOCKS: usize = 32; + +lazy_static::lazy_static! { + static ref LOCK_SLOTS: Arc = Arc::new(Semaphore::new(MAX_CONCURRENT_LOCKS)); +} + +/// Held for the whole of a read → register → record cycle on one trigger. +/// +/// Registering a webhook is a read-modify-write spanning a network round-trip, against state held +/// both here and on the external service. Two of them interleaving desynchronises the two: whoever +/// writes the row last wins in the database, whoever calls the service last wins there, and they +/// need not be the same operation — which is how a trigger ends up pointing somewhere the service +/// is not calling, or a deleted trigger keeps firing. +/// +/// A Postgres advisory lock makes them take turns. It is deliberately not a row lock: a rename's +/// own `UPDATE native_trigger` must not block behind a re-registration's network call, and only +/// code that takes the same key here waits. +/// +/// The lock lives on its own connection, opened outside the pool. Holders keep it for as long as +/// the external call takes and go on to need pool connections of their own to finish and release +/// it, so taking it from the pool would let a handful of concurrent renames hold every connection +/// while each waits for one more. Being off-pool also makes the lock impossible to strand: it is +/// session-scoped, and a dropped or crashed connection releases it, whereas a pooled connection +/// would carry it back into the pool. +pub(crate) struct TriggerLock { + conn: Option, + _slot: OwnedSemaphorePermit, +} + +impl TriggerLock { + pub(crate) async fn acquire( + db: &DB, + w_id: &str, + service_name: ServiceName, + external_id: &str, + ) -> Result { + let (mut conn, slot) = Self::open(db).await?; + sqlx::query_scalar!( + "SELECT pg_advisory_lock(hashtextextended($1, 0))", + Self::key(w_id, service_name, external_id) + ) + .fetch_one(&mut conn) + .await + .map_err(|e| { + Error::BadRequest(format!( + "Another operation on {external_id} is still running after {LOCK_WAIT}; try again \ + shortly ({e})" + )) + })?; + Ok(Self { conn: Some(conn), _slot: slot }) + } + + /// Take the lock only if it is free, for callers that would rather come back later than wait + /// out someone else's network call — the background renewal sweep, which runs again shortly. + pub(crate) async fn try_acquire( + db: &DB, + w_id: &str, + service_name: ServiceName, + external_id: &str, + ) -> Result> { + let (mut conn, slot) = Self::open(db).await?; + let acquired = sqlx::query_scalar!( + r#"SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS "acquired!""#, + Self::key(w_id, service_name, external_id) + ) + .fetch_one(&mut conn) + .await?; + Ok(acquired.then_some(Self { conn: Some(conn), _slot: slot })) + } + + /// The lock's own connection, lent to the work it protects. + /// + /// Callers need a connection to hand the external service adapter, and it is held for the + /// whole network call. Taking that from the pool is what starves it: a handful of concurrent + /// renames would each pin one for as long as the remote takes to answer. This one is already + /// dedicated and idle for exactly that window. + pub(crate) fn conn(&mut self) -> &mut PgConnection { + self.conn + .as_mut() + .expect("lock connection is only taken on release") + } + + pub(crate) async fn release(mut self) -> Result<()> { + if let Some(conn) = self.conn.take() { + // Closing the connection is what ends the session and its locks; unlocking first only + // makes that explicit at the call site. + conn.close().await?; + } + Ok(()) + } + + async fn open(db: &DB) -> Result<(PgConnection, OwnedSemaphorePermit)> { + let slot = LOCK_SLOTS + .clone() + .acquire_owned() + .await + .map_err(|e| Error::internal_err(format!("trigger lock slots closed: {e}")))?; + let mut conn = PgConnection::connect_with(&db.connect_options()).await?; + sqlx::query(&format!("SET lock_timeout = '{LOCK_WAIT}'")) + .execute(&mut conn) + .await?; + Ok((conn, slot)) + } + + fn key(w_id: &str, service_name: ServiceName, external_id: &str) -> String { + format!("native_trigger:{w_id}:{service_name}:{external_id}") + } +} diff --git a/backend/windmill-native-triggers/src/nextcloud/external.rs b/backend/windmill-native-triggers/src/nextcloud/external.rs index b296ddb15b..d46c0d4a21 100644 --- a/backend/windmill-native-triggers/src/nextcloud/external.rs +++ b/backend/windmill-native-triggers/src/nextcloud/external.rs @@ -172,19 +172,28 @@ impl External for NextCloud { ) .await?; - // Fetch back the updated state and convert to JSON config - let trigger_data = self - .get(w_id, oauth_data, external_id, db, tx) - .await? - .ok_or_else(|| { - Error::InternalErr(format!( - "Failed to fetch back trigger {} after update", - external_id - )) - })?; - serde_json::to_value(&trigger_data).map_err(|e| { - Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e)) - }) + // The webhook is already updated at this point, so the read-back is an enrichment (it + // picks up whatever Nextcloud resolved server-side), not a second chance to fail. Failing + // here would report a webhook that is in fact installed as un-installed, and callers would + // then unwind a token the service is already using. + let read_back = match self.get(w_id, oauth_data, external_id, db, tx).await { + Ok(Some(trigger_data)) => serde_json::to_value(&trigger_data).ok(), + Ok(None) => None, + Err(e) => { + tracing::warn!( + "Nextcloud webhook {external_id} was updated but could not be read back, \ + storing the requested config: {e:#}" + ); + None + } + }; + + match read_back { + Some(config) => Ok(config), + None => serde_json::to_value(&data.service_config).map_err(|e| { + Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e)) + }), + } } async fn get( diff --git a/backend/windmill-native-triggers/src/rename.rs b/backend/windmill-native-triggers/src/rename.rs new file mode 100644 index 0000000000..a7168f5c7e --- /dev/null +++ b/backend/windmill-native-triggers/src/rename.rs @@ -0,0 +1,244 @@ +//! Keeping native triggers usable when their runnable is renamed. + +use std::collections::HashMap; + +use windmill_api_auth::ApiAuthed; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::{ + error::{Error, Result}, + triggers::MovedNativeTrigger, + DB, +}; + +use crate::{ + decrypt_oauth_data, delete_token_by_hash, get_native_trigger, github::GitHub, google::Google, + handler::new_webhook_token, lock::TriggerLock, nextcloud::NextCloud, record_reregistration, + update_native_trigger_error, External, NativeTriggerData, ServiceName, +}; + +/// Re-register the webhooks of the native triggers a rename moved onto a new runnable path. +/// +/// The URL held by the external service embeds the runnable path and a token scoped to it, so +/// after a rename the registration points at a path that no longer resolves. +/// +/// `moved` comes from `windmill_common::triggers::update_triggers_script_path`. Callers MUST have +/// already committed that rename and verified the caller's write access to the path the rows now +/// carry — this mints fresh `jobs:run:*` tokens for it. Run it *after* the commit: repointing a +/// webhook is not undoable, so doing it while the deploy transaction can still roll back would +/// strand the trigger on a path and token that never existed. +/// +/// The replacement token belongs to `authed`, so the runnable now executes as whoever deployed the +/// rename rather than as the trigger's previous owner — the same identity swap a manual trigger +/// edit performs, since a path-scoped token cannot outlive the path it names. +/// +/// A service that rejects the update gets the failure recorded on its trigger row rather than +/// propagated: the runnable is renamed either way, and the user can retry by re-saving the trigger. +pub async fn reregister_triggers_after_rename( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + moved: &[MovedNativeTrigger], +) { + let mut by_service: HashMap> = HashMap::new(); + for trigger in moved { + match ServiceName::try_from(trigger.service_name.clone()) { + Ok(service) => by_service.entry(service).or_default().push(trigger), + Err(e) => tracing::error!( + "Unknown native trigger service '{}' on trigger '{}' in workspace '{w_id}': {e:#}", + trigger.service_name, + trigger.external_id + ), + } + } + + for (service, of_service) in by_service { + match service { + ServiceName::Nextcloud => { + reregister_service(db, authed, w_id, &of_service, NextCloud).await + } + ServiceName::Google => reregister_service(db, authed, w_id, &of_service, Google).await, + ServiceName::Github => reregister_service(db, authed, w_id, &of_service, GitHub).await, + } + } +} + +async fn reregister_service( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + moved: &[&MovedNativeTrigger], + handler: T, +) { + let oauth_data: T::OAuthData = + match decrypt_oauth_data(db, w_id, T::SERVICE_NAME.integration_service()).await { + Ok(oauth_data) => oauth_data, + Err(e) => { + for trigger in moved { + record_failure::(db, w_id, &trigger.external_id, &e).await; + } + return; + } + }; + + for trigger in moved { + if let Err(e) = reregister_one(db, authed, w_id, &handler, &oauth_data, trigger).await { + record_failure::(db, w_id, &trigger.external_id, &e).await; + } + } +} + +/// Point one trigger's webhook at the runnable the rename moved it to. +/// +/// `TriggerLock` excludes the other operations that touch a registration — edits, deletes, channel +/// renewal, another re-registration — for the whole span including the network call. Renames are +/// not among them: they move the row from inside the runnable's own transaction without taking +/// this lock, so a further rename can still land mid-call. That is why the write-back is +/// additionally conditional on `updated_at` rather than trusting the row read here. +async fn reregister_one( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + handler: &T, + oauth_data: &T::OAuthData, + moved: &MovedNativeTrigger, +) -> Result<()> { + let external_id = moved.external_id.as_str(); + let mut lock = TriggerLock::acquire(db, w_id, T::SERVICE_NAME, external_id).await?; + + let trigger = get_native_trigger(db, w_id, T::SERVICE_NAME, external_id) + .await? + .ok_or_else(|| Error::NotFound(format!("Native trigger not found: {external_id}")))?; + + // Something took the lock first and pointed the trigger elsewhere. That operation authorized + // its own destination and installed its own registration; re-registering here would overwrite + // both with a token minted for a runnable this rename never authorized. + if trigger.script_path != moved.script_path || trigger.is_flow != moved.is_flow { + tracing::info!( + "Skipping re-registration of the {} trigger '{external_id}': it was moved to '{}' \ + after the rename that queued this", + T::SERVICE_NAME, + trigger.script_path + ); + return lock.release().await; + } + + let service_config: T::ServiceConfig = serde_json::from_value( + trigger + .service_config + .clone() + .unwrap_or(serde_json::Value::Null), + ) + .map_err(|e| Error::internal_err(format!("stored trigger config cannot be read back: {e}")))?; + + let data = NativeTriggerData { + script_path: trigger.script_path.clone(), + is_flow: trigger.is_flow, + service_config, + summary: trigger.summary.clone(), + }; + + // The token is scoped to the runnable path and only its hash is kept, so pointing the webhook + // at the new path means minting a replacement rather than reusing the old one. Commit it + // before handing it out: a service may call back the moment it accepts the new URL. + let mut tx = db.begin().await?; + let webhook_token = new_webhook_token( + &mut tx, + db, + authed, + &trigger.script_path, + trigger.is_flow, + w_id, + T::SERVICE_NAME, + ) + .await?; + tx.commit().await?; + + let updated = handler + .update( + w_id, + oauth_data, + external_id, + &webhook_token, + &data, + db, + lock.conn(), + ) + .await; + + // A failed `update` does not mean the service never installed the token — Nextcloud mutates and + // then reads back, and any service can fail on the response after committing the mutation. So + // the token stays valid; deleting one the service did install would turn a webhook that still + // works into one that 401s. + let service_config = updated.inspect_err(|_| { + tracing::warn!( + "The webhook token minted for the {} trigger '{external_id}' was kept even though no \ + row references it, since the service may have installed it", + T::SERVICE_NAME + ) + })?; + + let mut tx = db.begin().await?; + + let applied = record_reregistration( + &mut *tx, + w_id, + T::SERVICE_NAME, + external_id, + &webhook_token, + service_config, + trigger.updated_at, + ) + .await?; + + if !applied { + // Written again while this was on the network — another rename, a save, or a disconnect + // that removed the row. Leave whatever is there now, including a newer rename's pending + // marker, for its own writer to finish, and keep this token: the service may be holding + // it. If the row is gone outright, the disconnect path revokes every token it deletes. + tx.rollback().await?; + tracing::info!( + "Discarding the re-registration of the {} trigger '{external_id}': the row changed \ + while the service was being updated", + T::SERVICE_NAME + ); + return lock.release().await; + } + + delete_token_by_hash(&mut *tx, &trigger.webhook_token_hash).await?; + + audit_log( + &mut *tx, + authed, + &format!("native_triggers.{}.update", T::SERVICE_NAME), + ActionKind::Update, + w_id, + Some(external_id), + Some([("reason", "runnable renamed")].into()), + ) + .await?; + + tx.commit().await?; + lock.release().await?; + + Ok(()) +} + +async fn record_failure(db: &DB, w_id: &str, external_id: &str, err: &Error) { + tracing::error!( + "Failed to re-register the {} trigger '{external_id}' in workspace '{w_id}' after rename: {err:#}", + T::SERVICE_NAME, + ); + let message = format!( + "Could not re-point the webhook registered on {} at the renamed runnable, so it is no \ + longer known to be delivering: {err}. Save the trigger again to retry.", + T::DISPLAY_NAME + ); + if let Err(e) = + update_native_trigger_error(db, w_id, T::SERVICE_NAME, external_id, Some(&message)).await + { + tracing::error!( + "Failed to record the re-registration failure of the {} trigger '{external_id}': {e:#}", + T::SERVICE_NAME, + ); + } +} diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index a544ea1625..bc259f048e 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -12,6 +12,11 @@ use crate::{ update_native_trigger_service_config, External, NativeTrigger, }; +/// The one `native_trigger.error` an existence check owns. Finding the trigger on the service +/// may only clear this value: every other failure recorded there — a webhook left aimed at a +/// pre-rename path, a channel that failed to renew — is one existence cannot disprove. +pub const EXTERNAL_TRIGGER_MISSING_ERROR: &str = "Trigger no longer exists on external service"; + #[derive(Debug, Serialize)] pub struct TriggerSyncInfo { pub external_id: String, @@ -290,7 +295,7 @@ pub async fn reconcile_with_external_state( for trigger in windmill_triggers { if !external_trigger_map.contains_key(&trigger.external_id) { // Trigger no longer exists on external service - set error - let error_msg = "Trigger no longer exists on external service".to_string(); + let error_msg = EXTERNAL_TRIGGER_MISSING_ERROR.to_string(); if trigger.error.as_deref() != Some(&error_msg) { tracing::info!( @@ -336,8 +341,10 @@ pub async fn reconcile_with_external_state( // Trigger exists on external service let external_service_config = external_trigger_map.get(&trigger.external_id).unwrap(); - // Clear error if it was set - if trigger.error.is_some() { + // Only clear the error this reconciler itself sets. Existence says nothing about the + // failures other paths record — a webhook left aimed at a pre-rename path is present + // here yet still broken, and wiping its error would erase the only signal the user has. + if trigger.error.as_deref() == Some(EXTERNAL_TRIGGER_MISSING_ERROR) { tracing::info!( "Trigger (external_id: '{}', script_path: '{}') exists on external service, clearing error", trigger.external_id, diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 18f431812a..eccfa56f19 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -344,24 +344,26 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: } // For Google: skip remote cleanup (watch channels expire naturally) - // Bulk delete all triggers - if let Err(e) = sqlx::query!( - "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + // Revoke the tokens the deleted rows actually named, not the ones listed above: a + // re-registration running concurrently mints a replacement, and leaving that alive would let a + // disconnected integration keep starting jobs. + let deleted_token_hashes = sqlx::query_scalar!( + "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", workspace_id, service_name as ServiceName ) - .execute(db) + .fetch_all(db) .await - { + .unwrap_or_else(|e| { tracing::error!("Failed to delete native triggers for service {service_name:?} in workspace {workspace_id}: {e}"); - } + Vec::new() + }); - // Delete all associated webhook tokens - for trigger in &triggers { - if let Err(e) = delete_token_by_hash(db, &trigger.webhook_token_hash).await { + for webhook_token_hash in &deleted_token_hashes { + if let Err(e) = delete_token_by_hash(db, webhook_token_hash).await { tracing::error!( "Failed to delete webhook token with hash {}: {e}", - trigger.webhook_token_hash + webhook_token_hash ); } } From 2b525d28dbcda4a4b0f626ad40b0c4d79cfbcd53 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 1 Aug 2026 00:29:30 +0200 Subject: [PATCH 12/32] fix: honor on-behalf-of when a workflow step dispatches a script or flow (#10437) * fix: run on-behalf-of scripts under their own identity from workflow steps Co-Authored-By: Claude Opus 5 (1M context) * fix: correct the on-behalf-of provenance comment in the script draft deploy Co-Authored-By: Claude Opus 5 (1M context) * test: pin preserve_on_behalf_of forwarding in the script and flow draft deploys Co-Authored-By: Claude Opus 5 (1M context) * docs: drop the historical comparison from the draft-deploy test comment Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/windmill-store/src/variables.rs | 40 +++- backend/windmill-worker/src/bun_executor.rs | 197 +++++++++++--------- frontend/src/lib/utils_draft_deploy.test.ts | 90 ++++++++- frontend/src/lib/utils_draft_deploy.ts | 8 +- 4 files changed, 241 insertions(+), 94 deletions(-) diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 55047ec0c2..3b70a3e3a7 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -10,7 +10,7 @@ use windmill_api_auth::{ build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; -use windmill_common::db::DB; +use windmill_common::db::{Authable, DB}; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use crate::secret_backend_ext::{ @@ -367,10 +367,10 @@ async fn get_variable( { return Ok(Json(overlay)); } - explain_variable_perm_error(&path, &w_id, &db).await?; + explain_variable_perm_error(&path, &w_id, &db, Some(&authed)).await?; unreachable!() } else { - explain_variable_perm_error(&path, &w_id, &db).await?; + explain_variable_perm_error(&path, &w_id, &db, Some(&authed)).await?; unreachable!() }; @@ -461,10 +461,27 @@ async fn get_value( .map(Json); } +/// The grants alone can't explain a denial: a job started on behalf of another +/// user is authorized as that user, so the error has to name who was actually +/// asking or it reads as a grant bug. +fn describe_authed(authed: Option<&(impl Authable + Sync)>) -> String { + match authed { + Some(authed) => format!( + "username: {}, email: {}, groups: {:?}, folders: {:?}", + authed.username(), + authed.email(), + authed.groups(), + authed.folders() + ), + None => "unauthenticated".to_string(), + } +} + async fn explain_variable_perm_error( path: &str, w_id: &str, db: &sqlx::Pool, + authed: Option<&(impl Authable + Sync)>, ) -> windmill_common::error::Result<()> { let extra_perms = sqlx::query_scalar!( "SELECT extra_perms from variable WHERE path = $1 AND workspace_id = $2", @@ -489,13 +506,14 @@ async fn explain_variable_perm_error( .fetch_optional(db) .await?; return Err(Error::NotAuthorized(format!( - "Variable exists but you don't have access to it:\nvariable perms: {}\nfolder perms: {}", - serde_json::to_string_pretty(&extra_perms).unwrap_or_default(), serde_json::to_string_pretty(&folder_extra_perms).unwrap_or_default() + "Variable exists but you don't have access to it:\nvariable perms: {}\nfolder perms: {}\nauthed as: {}", + serde_json::to_string_pretty(&extra_perms).unwrap_or_default(), serde_json::to_string_pretty(&folder_extra_perms).unwrap_or_default(), describe_authed(authed) ))); } else { return Err(Error::NotAuthorized(format!( - "Variable exists but you don't have access to it:\nvariable perms: {}", - serde_json::to_string_pretty(&extra_perms).unwrap_or_default() + "Variable exists but you don't have access to it:\nvariable perms: {}\nauthed as: {}", + serde_json::to_string_pretty(&extra_perms).unwrap_or_default(), + describe_authed(authed) ))); } } @@ -1498,7 +1516,13 @@ pub async fn get_value_internal<'a>( let variable = if let Some(variable) = variable_o { variable } else { - explain_variable_perm_error(path, w_id, &db_with_opt_authed.db()).await?; + explain_variable_perm_error( + path, + w_id, + &db_with_opt_authed.db(), + db_with_opt_authed.authed(), + ) + .await?; unreachable!() }; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 794514ba11..4f4d33b674 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2512,10 +2512,11 @@ pub async fn handle_wac_v2_output( }; use serde_json::Value; use windmill_common::get_latest_flow_version_info_for_path; - use windmill_common::jobs::{script_path_to_payload, JobKind, JobPayload, RawCode}; + use windmill_common::jobs::{script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode}; use windmill_common::runnable_settings::{ ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, }; + use windmill_common::users::username_to_permissioned_as; use windmill_queue::{push, PushArgs, PushIsolationLevel}; let output = parse_wac_output(&result)?; @@ -2803,86 +2804,101 @@ pub async fn handle_wac_v2_output( let push_result: error::Result<()> = async { for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) { // Resolve job payload based on dispatch_type - let (job_payload, child_args, is_external) = match step.dispatch_type.as_str() { - "script" if step.script.starts_with("./") => { - // Module-relative path: resolve from parent script's modules - let module_key = step.script.strip_prefix("./").unwrap(); - let module = resolve_parent_module(modules, module_key)?; - let payload = JobPayload::Code(RawCode { - content: module.content, - path: job.runnable_path.clone(), - hash: None, - language: module.language, - lock: module.lock, - cache_ttl: job.cache_ttl, - cache_ignore_s3_path: job.cache_ignore_s3_path, - dedicated_worker: None, - concurrency_settings: ConcurrencySettingsWithCustom::default(), - debouncing_settings: DebouncingSettings::default(), - modules: None, - tag: None, - }); - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true) - } - "script" => { - // Resolve script path to job payload (handles hash, lang, etc.) - let (payload, _, _, _, _, _) = script_path_to_payload( - &step.script, - None, // no authed db for background workers - db.clone(), - &job.workspace_id, - Some(true), // skip preprocessor - ) - .await?; - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true) - } - "flow" => { - let flow_info = get_latest_flow_version_info_for_path( - None, - db, - &job.workspace_id, - &step.script, - true, - ) - .await?; - let payload = JobPayload::Flow { - path: step.script.clone(), - dedicated_worker: flow_info.dedicated_worker, - apply_preprocessor: false, - version: flow_info.version, - labels: flow_info.labels.clone(), - }; - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true) - } - _ => { - // "inline" — re-run parent with _executing_key - (job_payload_template.clone(), parent_args.clone(), false) - } - }; + let (job_payload, child_args, is_external, on_behalf_of) = + match step.dispatch_type.as_str() { + "script" if step.script.starts_with("./") => { + // Module-relative path: resolve from parent script's modules + let module_key = step.script.strip_prefix("./").unwrap(); + let module = resolve_parent_module(modules, module_key)?; + let payload = JobPayload::Code(RawCode { + content: module.content, + path: job.runnable_path.clone(), + hash: None, + language: module.language, + lock: module.lock, + cache_ttl: job.cache_ttl, + cache_ignore_s3_path: job.cache_ignore_s3_path, + dedicated_worker: None, + concurrency_settings: ConcurrencySettingsWithCustom::default(), + debouncing_settings: DebouncingSettings::default(), + modules: None, + tag: None, + }); + let step_args: HashMap> = step + .args + .iter() + .map(|(k, v)| { + let raw = serde_json::value::to_raw_value(v).unwrap(); + (k.clone(), raw) + }) + .collect(); + // Inline module code, not a separate runnable: it has no + // identity of its own and runs as the parent. + (payload, step_args, true, None) + } + "script" => { + // Resolve script path to job payload (handles hash, lang, etc.) + let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload( + &step.script, + None, // no authed db for background workers + db.clone(), + &job.workspace_id, + Some(true), // skip preprocessor + ) + .await?; + let step_args: HashMap> = step + .args + .iter() + .map(|(k, v)| { + let raw = serde_json::value::to_raw_value(v).unwrap(); + (k.clone(), raw) + }) + .collect(); + (payload, step_args, true, on_behalf_of) + } + "flow" => { + let flow_info = get_latest_flow_version_info_for_path( + None, + db, + &job.workspace_id, + &step.script, + true, + ) + .await?; + let payload = JobPayload::Flow { + path: step.script.clone(), + dedicated_worker: flow_info.dedicated_worker, + apply_preprocessor: false, + version: flow_info.version, + labels: flow_info.labels.clone(), + }; + let on_behalf_of = + flow_info.on_behalf_of_email.map(|email| OnBehalfOf { + email, + permissioned_as: username_to_permissioned_as( + &flow_info.edited_by, + ), + }); + let step_args: HashMap> = step + .args + .iter() + .map(|(k, v)| { + let raw = serde_json::value::to_raw_value(v).unwrap(); + (k.clone(), raw) + }) + .collect(); + (payload, step_args, true, on_behalf_of) + } + _ => { + // "inline" — re-run parent with _executing_key + ( + job_payload_template.clone(), + parent_args.clone(), + false, + None, + ) + } + }; let push_args = PushArgs { args: &child_args, extra: None }; @@ -2930,6 +2946,21 @@ pub async fn handle_wac_v2_output( } } + // A target runnable that opts into on-behalf-of runs under its own + // identity, never the caller's, so a step that reaches it through a + // workflow cannot widen or narrow its permissions. `created_by` still + // credits the caller, matching how the run API pushes these jobs. + let (child_email, child_permissioned_as) = match on_behalf_of.as_ref() { + Some(on_behalf_of) => ( + on_behalf_of.email.as_str(), + on_behalf_of.permissioned_as.clone(), + ), + None => ( + job.permissioned_as_email.as_str(), + job.permissioned_as.clone(), + ), + }; + let (_, mut tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -2937,8 +2968,8 @@ pub async fn handle_wac_v2_output( job_payload, push_args, &job.created_by, - &job.permissioned_as_email, - job.permissioned_as.clone(), + child_email, + child_permissioned_as, None, None, None, diff --git a/frontend/src/lib/utils_draft_deploy.test.ts b/frontend/src/lib/utils_draft_deploy.test.ts index bae50691cc..9bed95917b 100644 --- a/frontend/src/lib/utils_draft_deploy.test.ts +++ b/frontend/src/lib/utils_draft_deploy.test.ts @@ -1,5 +1,36 @@ -import { describe, it, expect } from 'vitest' -import { draftBaseIsStale } from './utils_draft_deploy' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { draftBaseIsStale, deployDraft } from './utils_draft_deploy' + +vi.mock('$lib/gen', () => ({ + ScriptService: { getScriptByPath: vi.fn(), createScript: vi.fn() }, + FlowService: { getFlowByPath: vi.fn(), createFlow: vi.fn(), updateFlow: vi.fn() }, + DraftService: { deleteDraft: vi.fn() }, + AppService: {}, + VariableService: {}, + ResourceService: {}, + ScheduleService: {}, + HttpTriggerService: {}, + WebsocketTriggerService: {}, + PostgresTriggerService: {}, + KafkaTriggerService: {}, + NatsTriggerService: {}, + MqttTriggerService: {}, + AmqpTriggerService: {}, + SqsTriggerService: {}, + GcpTriggerService: {}, + AzureTriggerService: {}, + EmailTriggerService: {} +})) +vi.mock('$lib/userDraftDbSyncer.svelte', () => ({ UserDraftDbSyncer: { save: vi.fn() } })) +vi.mock('$lib/workspaceDrafts.svelte', () => ({ invalidateWorkspaceDrafts: vi.fn() })) +vi.mock('$lib/workspaceComparison', () => ({ invalidateWorkspaceComparison: vi.fn() })) +vi.mock('$lib/localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() })) +vi.mock('$lib/rawAppDeploy', () => ({ deployRawAppDraft: vi.fn() })) +vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi.fn() })) +vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() })) +vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] })) + +import { ScriptService, FlowService } from '$lib/gen' // draftBaseIsStale compares a draft's base pointer against the deployed head // of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts @@ -38,3 +69,58 @@ describe('draftBaseIsStale', () => { expect(draftBaseIsStale('script', undefined)).toBe(false) }) }) + +// Without preserve_on_behalf_of the backend rewrites on_behalf_of_email to the +// deploying user, so deploying a draft silently re-points the runnable's +// identity. + +describe('deployDraft preserves on_behalf_of', () => { + beforeEach(() => vi.clearAllMocks()) + + it('script: forwards the flag when the draft carries an on_behalf_of_email', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + hash: 'v1', + draft: { path: 'f/admin/send_email', on_behalf_of_email: 'alice@windmill.dev' } + } as any) + + expect(await deployDraft('script', 'f/admin/send_email', 'ws')).toEqual({ success: true }) + expect(ScriptService.createScript).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + on_behalf_of_email: 'alice@windmill.dev', + preserve_on_behalf_of: true + }) + }) + ) + }) + + it('script: omits the flag when the draft has no on_behalf_of_email', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + hash: 'v1', + draft: { path: 'f/admin/send_email' } + } as any) + + await deployDraft('script', 'f/admin/send_email', 'ws') + expect(ScriptService.createScript).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ preserve_on_behalf_of: undefined }) + }) + ) + }) + + it('flow: forwards the flag when the draft carries an on_behalf_of_email', async () => { + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + draft: { path: 'f/admin/notify', value: {}, on_behalf_of_email: 'alice@windmill.dev' } + } as any) + + expect(await deployDraft('flow', 'f/admin/notify', 'ws')).toEqual({ success: true }) + expect(FlowService.updateFlow).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + on_behalf_of_email: 'alice@windmill.dev', + preserve_on_behalf_of: true + }) + }) + ) + }) +}) diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 87baf2e5c7..a9e1f3ad99 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -459,7 +459,10 @@ export async function deployDraft( ...rest, path: scriptPath, parent_hash: r.hash, - deployment_message: deploymentMessage + deployment_message: deploymentMessage, + // Deploy the draft's on-behalf-of as-is; the backend resets it to the + // deploying user without this flag, gated by can_preserve_on_behalf_of. + preserve_on_behalf_of: rest.on_behalf_of_email ? true : undefined } }) // Then deploy any draft trigger edits, so they aren't dropped with the draft. @@ -482,6 +485,9 @@ export async function deployDraft( ws_error_handler_muted: d.ws_error_handler_muted, visible_to_runner_only: d.visible_to_runner_only, on_behalf_of_email: d.on_behalf_of_email, + // Same as scripts and apps: the backend resets on_behalf_of_email to the + // deploying user without this flag, gated by can_preserve_on_behalf_of. + preserve_on_behalf_of: d.on_behalf_of_email ? true : undefined, labels: d.labels, deployment_message: deploymentMessage } From 7a70d6aa3bdcdf3fe0455e5159a23578280187c8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 1 Aug 2026 10:41:37 +0200 Subject: [PATCH 13/32] refactor: derive the azure trigger address from its principal (#10439) * refactor: derive the azure trigger address from its principal Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to 564ad8932e1488dfc4b2694d9b4e50c580d1b8ed This commit updates the EE repository reference after PR #706 was merged in windmill-ee-private. Previous ee-repo-ref: 74655906a7936c6e9e7c984f6b7af111f121404b New ee-repo-ref: 564ad8932e1488dfc4b2694d9b4e50c580d1b8ed Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...1ffba1511715ac1f9270939d79fb82f8e88d0.json | 15 ------------ ...70b506677681394606426964491488d64c62c.json | 15 ------------ ...afbeeea28b1d17dfacecb30dde65304cd9799.json | 15 ++++++++++++ ...3e8d12a191ca97954c499ae5dc022410a240.json} | 5 ++-- backend/ee-repo-ref.txt | 2 +- ...01060114_drop_azure_trigger_email.down.sql | 23 +++++++++++++++++++ ...0801060114_drop_azure_trigger_email.up.sql | 4 ++++ backend/windmill-api-users/src/users.rs | 8 ------- .../windmill-api-workspaces/src/workspaces.rs | 4 ++-- 9 files changed, 47 insertions(+), 44 deletions(-) delete mode 100644 backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json delete mode 100644 backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json create mode 100644 backend/.sqlx/query-617c19043d445ab1366630633e6afbeeea28b1d17dfacecb30dde65304cd9799.json rename backend/.sqlx/{query-ad25201d0eea65972234cade87a95d8cd99fc26e5bd466942423cbd09efcebe4.json => query-eaad94dd1b419f814af4cb54f2843e8d12a191ca97954c499ae5dc022410a240.json} (74%) create mode 100644 backend/migrations/20260801060114_drop_azure_trigger_email.down.sql create mode 100644 backend/migrations/20260801060114_drop_azure_trigger_email.up.sql diff --git a/backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json b/backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json deleted file mode 100644 index 36e9ac3e04..0000000000 --- a/backend/.sqlx/query-0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE azure_trigger SET email = $1 WHERE email = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "0da2425ff8ee737104cd9f2650f1ffba1511715ac1f9270939d79fb82f8e88d0" -} diff --git a/backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json b/backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json deleted file mode 100644 index 06a3ee417e..0000000000 --- a/backend/.sqlx/query-16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO azure_trigger (\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters, push_auth_config, path, script_path,\n is_flow, workspace_id, edited_by, email, edited_at, extra_perms, server_id,\n last_server_ping, error, mode, permissioned_as, error_handler_path,\n error_handler_args, retry, labels\n )\n SELECT\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters, push_auth_config, path, script_path,\n is_flow, $1, edited_by, email, edited_at, extra_perms, NULL,\n NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path,\n error_handler_args, retry, labels\n FROM azure_trigger WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "16e8d3f75ce4b5b18cefc25ebb670b506677681394606426964491488d64c62c" -} diff --git a/backend/.sqlx/query-617c19043d445ab1366630633e6afbeeea28b1d17dfacecb30dde65304cd9799.json b/backend/.sqlx/query-617c19043d445ab1366630633e6afbeeea28b1d17dfacecb30dde65304cd9799.json new file mode 100644 index 0000000000..9f41bf73e0 --- /dev/null +++ b/backend/.sqlx/query-617c19043d445ab1366630633e6afbeeea28b1d17dfacecb30dde65304cd9799.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO azure_trigger (\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters, push_auth_config, path, script_path,\n is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id,\n last_server_ping, error, mode, permissioned_as, error_handler_path,\n error_handler_args, retry, labels\n )\n SELECT\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters, push_auth_config, path, script_path,\n is_flow, $1, edited_by, edited_at, extra_perms, NULL,\n NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path,\n error_handler_args, retry, labels\n FROM azure_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "617c19043d445ab1366630633e6afbeeea28b1d17dfacecb30dde65304cd9799" +} diff --git a/backend/.sqlx/query-ad25201d0eea65972234cade87a95d8cd99fc26e5bd466942423cbd09efcebe4.json b/backend/.sqlx/query-eaad94dd1b419f814af4cb54f2843e8d12a191ca97954c499ae5dc022410a240.json similarity index 74% rename from backend/.sqlx/query-ad25201d0eea65972234cade87a95d8cd99fc26e5bd466942423cbd09efcebe4.json rename to backend/.sqlx/query-eaad94dd1b419f814af4cb54f2843e8d12a191ca97954c499ae5dc022410a240.json index 7b989b68f5..29aa952292 100644 --- a/backend/.sqlx/query-ad25201d0eea65972234cade87a95d8cd99fc26e5bd466942423cbd09efcebe4.json +++ b/backend/.sqlx/query-eaad94dd1b419f814af4cb54f2843e8d12a191ca97954c499ae5dc022410a240.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO azure_trigger (\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters,\n push_auth_config, workspace_id, path, script_path, is_flow,\n permissioned_as, mode, edited_by, email,\n error_handler_path, error_handler_args, retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7,\n $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18\n )\n ", + "query": "\n INSERT INTO azure_trigger (\n azure_resource_path, azure_mode, scope_resource_id, topic_name,\n subscription_name, event_type_filters,\n push_auth_config, workspace_id, path, script_path, is_flow,\n permissioned_as, mode, edited_by,\n error_handler_path, error_handler_args, retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7,\n $8, $9, $10, $11, $12, $13, $14, $15, $16, $17\n )\n ", "describe": { "columns": [], "parameters": { @@ -42,12 +42,11 @@ }, "Varchar", "Varchar", - "Varchar", "Jsonb", "Jsonb" ] }, "nullable": [] }, - "hash": "ad25201d0eea65972234cade87a95d8cd99fc26e5bd466942423cbd09efcebe4" + "hash": "eaad94dd1b419f814af4cb54f2843e8d12a191ca97954c499ae5dc022410a240" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ce38709dac..108c17635c 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a786cd42b5aaf0aa6789fbb723d956560f93b1b3 +564ad8932e1488dfc4b2694d9b4e50c580d1b8ed diff --git a/backend/migrations/20260801060114_drop_azure_trigger_email.down.sql b/backend/migrations/20260801060114_drop_azure_trigger_email.down.sql new file mode 100644 index 0000000000..cf51c37487 --- /dev/null +++ b/backend/migrations/20260801060114_drop_azure_trigger_email.down.sql @@ -0,0 +1,23 @@ +-- Rebuild email from permissioned_as, mirroring +-- windmill_common::users::get_email_from_permissioned_as. + +ALTER TABLE azure_trigger ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT ''; + +UPDATE azure_trigger t SET email = CASE + WHEN t.permissioned_as LIKE 'u/%' THEN COALESCE( + (SELECT u.email FROM usr u + WHERE u.workspace_id = t.workspace_id + AND u.username = SUBSTRING(t.permissioned_as FROM 3)), + (SELECT p.email FROM password p + WHERE p.super_admin + AND (p.username = SUBSTRING(t.permissioned_as FROM 3) + OR p.email = SUBSTRING(t.permissioned_as FROM 3)) + LIMIT 1), + SUBSTRING(t.permissioned_as FROM 3) || '@unknown.windmill.dev' + ) + WHEN t.permissioned_as LIKE 'g/%' + THEN 'group-' || SUBSTRING(t.permissioned_as FROM 3) || '@windmill.dev' + ELSE t.permissioned_as +END; + +ALTER TABLE azure_trigger ALTER COLUMN email DROP DEFAULT; diff --git a/backend/migrations/20260801060114_drop_azure_trigger_email.up.sql b/backend/migrations/20260801060114_drop_azure_trigger_email.up.sql new file mode 100644 index 0000000000..293822e25c --- /dev/null +++ b/backend/migrations/20260801060114_drop_azure_trigger_email.up.sql @@ -0,0 +1,4 @@ +-- azure_trigger.email duplicated an address that permissioned_as already +-- determines, and which every other trigger table derives at fire time. + +ALTER TABLE azure_trigger DROP COLUMN email; diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index bb7e129b28..d680d4306f 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1956,14 +1956,6 @@ async fn change_user_email( .execute(&mut *tx) .await?; - sqlx::query!( - "UPDATE azure_trigger SET email = $1 WHERE email = $2", - &new_email, - &old_email - ) - .execute(&mut *tx) - .await?; - sqlx::query!( "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2", &new_email, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 6ec1a1598d..1577cf18a8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5470,14 +5470,14 @@ async fn clone_triggers_and_schedules( r#"INSERT INTO azure_trigger ( azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, event_type_filters, push_auth_config, path, script_path, - is_flow, workspace_id, edited_by, email, edited_at, extra_perms, server_id, + is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, mode, permissioned_as, error_handler_path, error_handler_args, retry, labels ) SELECT azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, event_type_filters, push_auth_config, path, script_path, - is_flow, $1, edited_by, email, edited_at, extra_perms, NULL, + is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, 'disabled'::TRIGGER_MODE, permissioned_as, error_handler_path, error_handler_args, retry, labels FROM azure_trigger WHERE workspace_id = $2"#, From 25084170d7449dfa06dd758bc3783580916cfa95 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 1 Aug 2026 13:50:47 +0200 Subject: [PATCH 14/32] feat: make job subprocess oom_score_adj configurable (#10443) * feat: make job subprocess oom_score_adj configurable via JOB_OOM_SCORE_ADJ Co-Authored-By: Claude Opus 5 (1M context) * fix: warn when JOB_OOM_SCORE_ADJ leaves no gap over the worker's own score Co-Authored-By: Claude Opus 5 (1M context) * style: drop em dash from JOB_OOM_SCORE_ADJ doc comment Co-Authored-By: Claude Opus 5 (1M context) * fix: warn on any oom_score_adj gap too small to steer the OOM killer Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/src/main.rs | 76 +++++++++++-------- .../windmill-common/src/global_settings.rs | 1 + backend/windmill-common/src/worker.rs | 56 ++++++++++++++ backend/windmill-worker/src/handle_child.rs | 8 +- .../windmill-worker/src/python_executor.rs | 6 +- 5 files changed, 114 insertions(+), 33 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 969fd4e94d..51febad6f0 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -932,46 +932,62 @@ async fn windmill_main() -> anyhow::Result<()> { } // Lower the worker's oom_score_adj so the OOM killer strongly prefers killing - // job subprocesses (oom_score_adj=1000) over the worker itself. + // job subprocesses (oom_score_adj=JOB_OOM_SCORE_ADJ) over the worker itself. // Kubernetes sets it high for burstable QoS (e.g. 937), leaving a tiny gap vs jobs. // Requires CAP_SYS_RESOURCE to lower it; if missing, we just warn. #[cfg(any(target_os = "linux"))] - match std::fs::read_to_string("/proc/self/oom_score_adj") { - Ok(current) => { - let current = current.trim().to_string(); - let current_val = match current.parse::() { - Ok(v) => v, - Err(e) => { - tracing::warn!("Could not parse oom_score_adj '{current}': {e}"); - 0 - } - }; - if current_val > 0 { - match std::fs::write("/proc/self/oom_score_adj", "0") { - Ok(_) => { - tracing::info!( - "Lowered worker oom_score_adj from {current} to 0 \ - (jobs get 1000, gap=1000)" - ); + { + // Badness is (memory used, in permille of host RAM) + oom_score_adj, so the gap + // must exceed the worker's own footprint in permille to actually steer the kill. + // 100 covers a worker holding up to ~10% of host RAM. + const MIN_OOM_SCORE_GAP: i32 = 100; + + let job_adj = *windmill_common::worker::JOB_OOM_SCORE_ADJ; + match std::fs::read_to_string("/proc/self/oom_score_adj") { + Ok(current) => { + let current = current.trim().to_string(); + match current.parse::() { + Ok(mut worker_adj) => { + if worker_adj > 0 { + match std::fs::write("/proc/self/oom_score_adj", "0") { + Ok(_) => { + tracing::info!( + "Lowered worker oom_score_adj from {worker_adj} to 0" + ); + worker_adj = 0; + } + Err(e) => { + tracing::warn!( + "Could not lower worker oom_score_adj from {worker_adj} to 0: {e}. \ + Add CAP_SYS_RESOURCE to the container to fix this" + ); + } + } + } + let gap = job_adj - worker_adj; + if gap >= MIN_OOM_SCORE_GAP { + tracing::info!( + "Worker oom_score_adj={worker_adj}, jobs get {job_adj} (gap={gap})" + ); + } else { + tracing::warn!( + "Worker oom_score_adj={worker_adj}, jobs get {job_adj} (gap={gap}): \ + too small to reliably steer the OOM killer to the job. \ + Raise JOB_OOM_SCORE_ADJ or lower the worker's own score" + ); + } } Err(e) => { tracing::warn!( - "Could not lower worker oom_score_adj from {current} to 0: {e}. \ - Gap to jobs is only {} — OOM killer may target the worker instead. \ - Add CAP_SYS_RESOURCE to the container to fix this", - 1000 - current_val + "Could not parse worker oom_score_adj '{current}': {e}. \ + Cannot tell whether jobs (oom_score_adj={job_adj}) outrank the worker" ); } } - } else { - tracing::info!( - "Worker oom_score_adj={current} (jobs get 1000, gap={})", - 1000 - current_val - ); } - } - Err(e) => { - tracing::warn!("Could not read worker oom_score_adj: {e}"); + Err(e) => { + tracing::warn!("Could not read worker oom_score_adj: {e}"); + } } } } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 427077fdb9..6fb435cd71 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -298,6 +298,7 @@ pub const ENV_SETTINGS: &[&str] = &[ "GLOBAL_ERROR_HANDLER_PATH_IN_ADMINS_WORKSPACE", "MAX_WAIT_FOR_SIGINT", "MAX_WAIT_FOR_SIGTERM", + "JOB_OOM_SCORE_ADJ", "WORKER_GROUP", "SAML_METADATA", "INSTANCE_IS_DEV", diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index b9dd29c00a..226d74cf47 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -217,6 +217,33 @@ pub const CONCURRENCY_KEY_MAX_QUEUED_DEFAULT: u32 = 10_000; /// the setting is cleared or malformed. A workspace spans many keys, so this sits well above /// the per-key cap. pub const WORKSPACE_MAX_QUEUED_JOBS_DEFAULT: u32 = 20_000; +/// Default for [`JOB_OOM_SCORE_ADJ`]; also the value used when the env var is out of range or +/// unparseable. +pub const JOB_OOM_SCORE_ADJ_DEFAULT: i32 = 1000; + +/// procfs accepts -1000..=1000, but a job must never be *less* killable than the worker that +/// supervises it, so negative adjustments are rejected rather than clamped. +fn parse_job_oom_score_adj(raw: Option<&str>) -> i32 { + let Some(raw) = raw else { + return JOB_OOM_SCORE_ADJ_DEFAULT; + }; + match raw.trim().parse::() { + Ok(v) if (0..=1000).contains(&v) => v, + Ok(v) => { + tracing::warn!( + "JOB_OOM_SCORE_ADJ={v} is outside the accepted 0..=1000 range, \ + using {JOB_OOM_SCORE_ADJ_DEFAULT}" + ); + JOB_OOM_SCORE_ADJ_DEFAULT + } + Err(e) => { + tracing::warn!( + "Could not parse JOB_OOM_SCORE_ADJ='{raw}': {e}, using {JOB_OOM_SCORE_ADJ_DEFAULT}" + ); + JOB_OOM_SCORE_ADJ_DEFAULT + } + } +} lazy_static::lazy_static! { pub static ref WORKER_GROUP: String = std::env::var("WORKER_GROUP").unwrap_or_else(|_| { #[cfg(not(feature = "enterprise"))] @@ -240,6 +267,15 @@ lazy_static::lazy_static! { pub static ref LIMIT_WINDOWS_TO_1CU: bool = std::env::var("LIMIT_WINDOWS_TO_1CU").ok().is_some_and(|x| x == "1" || x == "true"); + /// `oom_score_adj` applied to job subprocesses. The kernel adds it to the process's memory + /// use expressed in permille of host RAM, so the job only reliably outranks the worker once + /// the gap between their two adjustments exceeds the worker's own footprint in permille; the + /// default maximizes that margin. Userspace OOM daemons (earlyoom, systemd-oomd, nohang) rank + /// every process on the host by the same score, so at 1000 a tiny job outranks multi-GB + /// processes and gets killed first. Lowering this trades margin over the worker for a fairer + /// ranking against everything else on the host. + pub static ref JOB_OOM_SCORE_ADJ: i32 = parse_job_oom_score_adj(std::env::var("JOB_OOM_SCORE_ADJ").ok().as_deref()); + pub static ref CGROUP_V2_PATH_RE: Regex = Regex::new(r#"(?m)^0::(/.*)$"#).unwrap(); pub static ref CGROUP_V2_CPU_RE: Regex = Regex::new(r#"(?m)^(\d+) \S+$"#).unwrap(); pub static ref CGROUP_V1_INACTIVE_FILE_RE: Regex = Regex::new(r#"(?m)^total_inactive_file (\d+)$"#).unwrap(); @@ -2441,6 +2477,26 @@ mod tests { ids.iter().map(|s| s.to_string()).collect() } + #[test] + fn test_parse_job_oom_score_adj() { + assert_eq!(parse_job_oom_score_adj(Some("300")), 300); + assert_eq!(parse_job_oom_score_adj(Some(" 0\n")), 0); + assert_eq!(parse_job_oom_score_adj(None), JOB_OOM_SCORE_ADJ_DEFAULT); + // Out of range and unparseable both fall back rather than weaken the worker's protection. + assert_eq!( + parse_job_oom_score_adj(Some("-500")), + JOB_OOM_SCORE_ADJ_DEFAULT + ); + assert_eq!( + parse_job_oom_score_adj(Some("1001")), + JOB_OOM_SCORE_ADJ_DEFAULT + ); + assert_eq!( + parse_job_oom_score_adj(Some("high")), + JOB_OOM_SCORE_ADJ_DEFAULT + ); + } + #[test] fn test_bash_sandbox_image_annotation() { // `# sandbox ` selects the container runtime and returns the image. diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index b7ac2d54e2..439a4a09a2 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -125,13 +125,17 @@ pub async fn handle_child( let pid = child.id(); #[cfg(target_os = "linux")] if let Some(pid) = pid { + let oom_score_adj = *windmill_common::worker::JOB_OOM_SCORE_ADJ; // procfs handles writes synchronously in-kernel; no fsync (it returns // EINVAL on procfs files). - match std::fs::write(format!("/proc/{pid}/oom_score_adj"), b"1000") { + match std::fs::write( + format!("/proc/{pid}/oom_score_adj"), + oom_score_adj.to_string(), + ) { Ok(()) => {} Err(e) => { tracing::error!( - "Failed to set oom_score_adj=1000 for pid {pid}: {e:#}. \ + "Failed to set oom_score_adj={oom_score_adj} for pid {pid}: {e:#}. \ OOM killer may target the worker instead of this job" ); } diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 13e3749fbd..397367742a 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -3026,7 +3026,11 @@ pub async fn handle_python_reqs( "failed to get PID for python installation process: {}", &req ))) - .and_then(|pid| write_file(&format!("/proc/{pid}"), "oom_score_adj", "1000")) + .and_then(|pid| write_file( + &format!("/proc/{pid}"), + "oom_score_adj", + &windmill_common::worker::JOB_OOM_SCORE_ADJ.to_string(), + )) { tracing::error!( req = %req, From 032300e28eba9f8e790f16e894bb00fff22eb296 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 1 Aug 2026 13:58:05 +0200 Subject: [PATCH 15/32] feat: run dbt projects as a first-class Windmill runtime (#10326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: mount only the engine in the dbt jail, reject shadowed and malformed args Review round 42. The jail mounted the whole dbt cache directory, whose siblings of the engine are `repos/` and `packages/` — other workspaces' private checkouts and package trees, kept apart by cache key rather than by permissions. A jailed project could read them. It now mounts the engine's own directory, which the provisioner names; verified from inside the jail that `repos/`, `packages/` and `state/` are invisible while the engine stays usable. A `{{ placeholder }}` may no longer take the name of a run argument this runtime defines. It was silently dropped from the signature, so a descriptor like `value: "{{ select }}"` deployed and then could not be run at all: the built-in `select` is an array and the interpolation needs a scalar. Refused at parse, so the deploy says so. A `vars` override that is not an object is refused rather than ignored. Argument-schema validation is opt-in, so a string or an array silently ran the descriptor's own vars — against a different schema or alias than the caller asked for. `select` and `exclude` already refused theirs. * feat(dbt): the project is the script's module bundle, not a git checkout A dbt script now carries its whole dbt project as its module bundle. The descriptor is the script content; ` @@ -712,6 +721,10 @@ {/if} +{#if dbtRun} + +{/if} + {#if result_stream && result == undefined}
diff --git a/frontend/src/lib/components/HighlightCode.svelte b/frontend/src/lib/components/HighlightCode.svelte index 73c9a08c4a..bb51851003 100644 --- a/frontend/src/lib/components/HighlightCode.svelte +++ b/frontend/src/lib/components/HighlightCode.svelte @@ -26,7 +26,10 @@ interface Props { code?: string - language: Script['language'] | 'bunnative' | 'frontend' | 'json' | undefined + // `sql` is the dialect-agnostic option: a dbt model's SQL is compiled by + // whichever adapter the project targets, so naming one dialect would be a + // guess. Every dialect below highlights through the same grammar anyway. + language: Script['language'] | 'bunnative' | 'frontend' | 'json' | 'sql' | undefined highlightLanguage?: LanguageType | undefined lines?: boolean className?: string @@ -56,7 +59,9 @@ ? 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150' : '' - function getLang(lang: Script['language'] | 'bunnative' | 'frontend' | 'json' | undefined) { + function getLang( + lang: Script['language'] | 'bunnative' | 'frontend' | 'json' | 'sql' | undefined + ) { switch (lang) { case 'python3': return python @@ -76,6 +81,8 @@ return javascript case 'graphql': return graphql + case 'sql': + return sql case 'mysql': return sql case 'postgresql': diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index bd30cd6909..f209907537 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -454,6 +454,8 @@ language: 'bun' } } + } else if (script.language === 'dbt') { + seedDbtProject() } const restarter = scheduleRestartSync(userDraftPath, { waitForContent: true }) initContent(script.language, script.kind, template).finally(() => restarter.markContentReady()) @@ -971,6 +973,34 @@ function handleDeployTrigger(_trigger: Trigger) {} + // A dbt script's modules ARE its dbt project, and the runtime refuses one + // without a `dbt_project.yml`. Seeded from BOTH entry points — the empty-script + // bootstrap and the language picker — because reaching dbt by switching an + // existing draft otherwise produces a script that cannot deploy or run. + // Existing modules are left alone: switching away and back must not discard a + // project the user has already grown. + function seedDbtProject() { + // Keyed on the project file rather than on "has any modules at all": a + // draft that grew modules under another language carries none of what dbt + // needs, and the worker refuses a bundle with no `dbt_project.yml` — so + // that draft reached dbt in a state it could neither deploy nor run. + if (script.modules?.['dbt_project.yml']) return + script.modules = { + 'dbt_project.yml': { + content: + 'name: my_dbt_project\nversion: "1.0"\nprofile: my_dbt_project\nmodels:\n my_dbt_project:\n +materialized: view\n', + language: 'dbt' + }, + 'models/example.sql': { + content: 'select 1 as id\n', + language: 'dbt' + }, + // Last, so anything already written wins: the previous language's + // helper files are inert to dbt and are the user's to remove. + ...(script.modules ?? {}) + } + } + function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) { if (lang == 'docker') { template = 'docker' @@ -983,6 +1013,9 @@ // initContent(language, script.kind, template) script.language = language + if (language === 'dbt') { + seedDbtProject() + } } function onSummaryChange(value: string) { diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9a65fc9d2b..e112f92b3b 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -19,6 +19,10 @@ import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' import WacDiagram from '$lib/components/graph/WacDiagram.svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' + import DbtProjectPanel, { + dbtFileLang, + dbtModelSelector + } from '$lib/components/dbt/DbtProjectPanel.svelte' import SchemaForm from './SchemaForm.svelte' import PowerShellCommonParams from './PowerShellCommonParams.svelte' import LogPanel from './scriptEditor/LogPanel.svelte' @@ -354,9 +358,32 @@ editor?.setCode(editorCode) } + // Whether the open file is tested as a runnable of its own. A `__mod` helper + // is; a dbt project's files are not — the run is always the project's, so the + // arguments shown, edited and logged must be the descriptor's, not an empty + // per-module set the request would ignore. + let onModuleArgs = $derived(activeModuleTab !== null && lang !== 'dbt') + + // The selector a Test would build with, when the open file is a model. Macros, + // analyses and singular tests are `.sql` too and none is selectable by name, + // so those fall back to running the project. + let dbtSelected = $derived.by(() => { + const open = activeModuleTab + if (lang !== 'dbt' || !open) return undefined + const selector = dbtModelSelector(modules ?? {}, open) + // The label drops whichever extension the selector matched, so a Python + // model reads `Build my_model` rather than `Build my_model.py`. + const name = open.split('/').pop()!.replace(/\.(sql|py)$/, '') + return selector ? { selector, name } : undefined + }) + let effectiveLang = $derived( activeModuleTab && modules?.[activeModuleTab] - ? (modules[activeModuleTab].language as Preview['language']) + ? lang === 'dbt' + ? // Every dbt module is stored as `dbt`; the extension is what says + // whether this file is SQL, YAML or a seed. + dbtFileLang(activeModuleTab) + : (modules[activeModuleTab].language as Preview['language']) : lang ) @@ -373,7 +400,15 @@ return isTsWac || isPyWac }) let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2) - let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang))) + // A dbt script's content is the descriptor and its modules are the project. + // A tree rather than the module tab strip: a project has folders and dozens + // of files, which a strip cannot show. + let isDbt = $derived(lang === 'dbt') + let mainFileName = $derived( + isDbt + ? 'wm_dbt.yaml' + : 'script.' + langToExt(scriptLangToEditorLang(lang)) + ) let modulePathInput = $state('') let showAddModulePopover = $state(false) @@ -428,13 +463,28 @@ bunnative: ['.ts'] } + // A dbt project's files are dbt's own, not Windmill modules: models and tests + // are `.sql`, schemas and the project file `.yml`, seeds `.csv`, docs `.md`. + // `.py` because dbt Python models are first-class on Snowflake, BigQuery and + // Databricks, and the CLI already bundles one; refusing to CREATE one here + // was the only place that restriction existed. + const DBT_MODULE_EXTENSIONS = ['.sql', '.py', '.yml', '.yaml', '.csv', '.md'] let allowedModuleExtensions = $derived( - lang - ? (LANG_MODULE_EXTENSIONS[lang] ?? Object.keys(ALL_MODULE_EXTENSIONS)) - : Object.keys(ALL_MODULE_EXTENSIONS) + lang === 'dbt' + ? DBT_MODULE_EXTENSIONS + : lang + ? (LANG_MODULE_EXTENSIONS[lang] ?? Object.keys(ALL_MODULE_EXTENSIONS)) + : Object.keys(ALL_MODULE_EXTENSIONS) ) function inferModuleLang(filePath: string): ScriptModule['language'] | undefined { + // Every file of a dbt project is stored as `dbt`, whatever its extension: + // they are the project's, and dbt is what reads them. + if (lang === 'dbt') { + return DBT_MODULE_EXTENSIONS.some((e) => filePath.endsWith(e)) + ? ('dbt' as ScriptModule['language']) + : undefined + } for (const [ext, moduleLang] of Object.entries(ALL_MODULE_EXTENSIONS)) { if (filePath.endsWith(ext)) return moduleLang } @@ -442,6 +492,12 @@ } function getModuleDefaultContent(filePath: string): string { + if (lang === 'dbt') { + // A model that compiles on its own, so a new file is runnable before it + // is edited; anything else starts empty rather than with a guess at + // which dbt schema it is. + return filePath.endsWith('.sql') ? `select 1 as id\n` : '' + } if (filePath.endsWith('.py')) { return `def hello() -> str:\n return "world"\n` } else if (filePath.endsWith('.ts')) { @@ -474,8 +530,19 @@ return '' } + /// The descriptor is the script's CONTENT, not a module. A module at that same + /// path would be a second, independent value for one file: the export writes + /// the content there, and the bundle would emit over it. + function reservedDbtPath(path: string): string | undefined { + return lang === 'dbt' && path.trim() === 'wm_dbt.yaml' + ? `wm_dbt.yaml is the descriptor, edited from the tree — it cannot also be a file` + : undefined + } + function validateModulePath(path: string): string { if (!path.trim()) return '' + const reserved = reservedDbtPath(path) + if (reserved) return reserved const moduleLang = inferModuleLang(path) if (!moduleLang) { const exts = allowedModuleExtensions.join(', ') @@ -525,6 +592,8 @@ function validateRenameModulePath(newPath: string, oldPath: string): string { if (!newPath.trim()) return '' + const reserved = reservedDbtPath(newPath) + if (reserved) return reserved const moduleLang = inferModuleLang(newPath) if (!moduleLang) { const exts = allowedModuleExtensions.join(', ') @@ -838,16 +907,30 @@ // Flush module edits back to modules map before running preview flushModuleContent() - const testCode = activeModuleTab !== null ? editorCode : code - const testLang = activeModuleTab !== null ? effectiveLang : lang - const rawTestArgs = - activeModuleTab !== null - ? testPanelArgs - : selectedTab === 'preprocessor' || kind === 'preprocessor' - ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) } - : (args ?? {}) - const testSchema = activeModuleTab !== null ? testPanelSchema : schema + // A dbt run is always the project's, whichever file is open: `dbt build` + // takes the whole bundle, and testing one model in isolation is not a + // thing dbt does. + const onModule = onModuleArgs + const testCode = onModule ? editorCode : code + const testLang = onModule ? effectiveLang : lang + const rawTestArgs = onModule + ? testPanelArgs + : selectedTab === 'preprocessor' || kind === 'preprocessor' + ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) } + : (args ?? {}) + const testSchema = onModule ? testPanelSchema : schema const testArgs = await processSecretArgs(rawTestArgs, testSchema, opWs) + // Testing with a model open builds THAT model: `dbt build --select ` + // is dbt's own inner loop, and running the whole project to check one file + // is the thing a dbt developer never does. Its tests come along, because + // `build` interleaves them. + if (dbtSelected) { + testArgs.command = { + ...((testArgs.command as object) ?? {}), + label: 'build', + select: [dbtSelected.selector] + } + } if (showPsCommonParams) { for (const [k, v] of Object.entries(psCommonParams)) { if (v !== undefined && v !== false && v !== '') { @@ -891,7 +974,10 @@ } }, undefined, - activeModuleTab !== null ? undefined : modules, + // A `__mod` helper is tested alone, so its siblings are left out. A dbt + // project cannot be: the bundle IS the project, and without it the run + // finds no `dbt_project.yml` whichever file happens to be open. + onModule ? undefined : modules, undefined, timeout ) @@ -1041,6 +1127,11 @@ async function inferModuleSchema() { if (activeModuleTab === null) return + // A dbt project's files are not independently runnable: a model is SQL dbt + // compiles, not a script with arguments. Inferring some would put another + // language's parameters (a `.sql` model reads as Postgres) in the run form + // beside the descriptor's own. + if (lang === 'dbt') return try { await inferArgs(effectiveLang, editorCode, testPanelSchema) injectPartitionArg(testPanelSchema, testPanelArgs, effectiveLang, editorCode) @@ -2241,7 +2332,7 @@ { if (e.detail) { - if (activeModuleTab !== null) { + if (onModuleArgs) { testPanelArgs = e.detail } else { args = e.detail @@ -2259,7 +2350,7 @@ bind:clientHeight={schemaHeight} > {#key argsRender} - {#if activeModuleTab !== null} + {#if onModuleArgs} - Test + + {dbtSelected ? `Build ${dbtSelected.name}` : 'Test'} {/snippet} @@ -2371,7 +2464,7 @@ previewIsLoading={debugMode ? $debugState.running && !$debugState.stopped : testIsLoading} {editor} {diffEditor} - args={activeModuleTab !== null ? testPanelArgs : args} + args={onModuleArgs ? testPanelArgs : args} {showCaptures} customUi={customUi?.previewPanel} showCustomResultPanel={showDebugPanel} @@ -2505,7 +2598,34 @@ {/snippet} {#snippet editorContent()} -
+
+ {#if isDbt} + (p === null ? switchToMain() : switchToModule(p))} + onDelete={removeModule} + > + {#snippet addFile()} + + {#snippet trigger()} +
+ +
+ {/snippet} + {#snippet content({ close })} + {@render addModuleForm(close)} + {/snippet} +
+ {/snippet} +
+ {/if} {#if supportsModules}
{/if} -
+
{#if assets?.length} diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte index edaefe947a..214ab3e31d 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte @@ -7,7 +7,7 @@ import { inferArgs } from '$lib/infer' import { initialCode } from '$lib/script_helpers' import { emptySchema } from '$lib/utils' - import { defaultScriptLanguages, getScriptByPath, processLangs } from '$lib/scripts' + import { defaultScriptLanguages, getScriptByPath, processInlineLangs } from '$lib/scripts' import { Building, GitFork, Globe2 } from 'lucide-svelte' import { createEventDispatcher } from 'svelte' @@ -88,7 +88,7 @@ } let langs = $derived( - processLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages)) + processInlineLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages)) .map((l) => [defaultScriptLanguages[l], l]) .filter( (x) => diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index 3e5e9d8cef..8a893e3e84 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -18,10 +18,15 @@ import PanToNode from './PanToNode.svelte' import InitialFitView from './InitialFitView.svelte' import { layoutAssetGraph } from './assetGraphLayout' - import { computeMutedReadKeys } from './resolveGraph' + import { computeMutedReadKeys, dbtAssociations } from './resolveGraph' import { buildDownstreamMap } from './graphTraversal' import { buildLineageDownstreamMap } from './boundedCascade' - import type { AssetGraphResponse, AssetGraphSelection, NativeTriggerKind } from './types' + import type { + AssetGraphResponse, + AssetGraphSelection, + AssetRunState, + NativeTriggerKind + } from './types' import type { RunnableRunState } from './activeRunnables.svelte' import type { AssetKind } from '$lib/gen' import { NODE } from '$lib/components/graph/util' @@ -179,6 +184,11 @@ * When a node's nonce changes, it flashes a fading green background — its * producer just recomputed it. Driven by the replay player frame-by-frame. */ recomputedAssetIds?: ReadonlyMap + /** What a run is currently doing to each relation, keyed `asset::` + * like every other per-asset map here. + * Distinct from `recomputedAssetIds`, which is a one-shot pulse: this is + * the state a node holds until the run moves it. */ + assetRunStatus?: ReadonlyMap /** Let the wheel zoom the canvas (and swallow the page scroll while doing * so). Default true for the full-height editor/player. Set false when the * canvas is embedded inline inside a scrollable container, so a wheel @@ -215,6 +225,7 @@ viewportFitKey = '', highlightActiveRun = false, recomputedAssetIds, + assetRunStatus, scrollZoom = true }: Props = $props() @@ -244,6 +255,7 @@ | 'data-test' | 'macro' | 'test-dependency' + | 'dbt-ref' unsaved?: boolean // Muted read edge: a ducklake/s3 input read every run whose (default) // auto cascade trigger is suppressed by `// mute` / `// mute all`. @@ -287,6 +299,26 @@ // by node id across producers). const addedTestNodes = new Set() + // A dbt script owns every relation its project materializes. Drawing that + // as one edge per model buries the lineage that matters — `ref()` between + // models, and native consumers — under a fan-out that grows with the + // project, so the association is carried by the model's badge and its + // hover/click highlight instead. Only the DRAWING is dropped: the + // producer rows still drive cascade dispatch and "who produced this". + const dbtRunnableIds = new Set( + g.runnables.filter((r) => r.dbt).map((r) => `${r.usage_kind}:${r.path}`) + ) + // Association only — the canvas deliberately draws no edge for it. + const { ownerByAsset: dbtOwnerByAsset, writesByOwner: dbtWritesByOwner } = dbtAssociations( + g.runnables, + g.edges + ) + // Per-relation dbt description, to tell a project's own declared source + // from a relation another script materializes. + const dbtAssetProvenance = new Map( + g.assets.filter((a) => a.dbt).map((a) => [`asset:${a.kind}:${a.path}`, a.dbt!]) + ) + const hasAddNode = onAddPipelineScript != null if (hasAddNode) { nodes.push({ @@ -395,6 +427,7 @@ path: a.path, fork_materialization: a.fork_materialization, derived_from: a.derived_from, + dbt: a.dbt, onAddScript: onAddScriptForAsset, pathPrefix, defaultPathSuffix, @@ -404,7 +437,32 @@ producerFailed, // Bumped by the replay player when this asset's producer just // recomputed it — the node flashes green and fades. - recomputePulse: recomputedAssetIds?.get(assetId) + recomputePulse: recomputedAssetIds?.get(assetId), + // What the run in view is doing to this relation right now. + runStatus: assetRunStatus?.get(assetId)?.status, + runRowCount: assetRunStatus?.get(assetId)?.rowCount, + // The dbt project that materializes this relation, related by + // badge rather than by an edge — so only when that node is on + // this graph. The run page and the pipeline page both hide it, + // and passing handlers anyway makes the chip advertise a click + // that resolves to nothing. + ...(dbtOwnerByAsset.has(assetId) + ? { + onDbtHover: (on: boolean) => (dbtHoverId = on ? assetId : undefined), + onDbtSelect: () => { + // `runnable::` — the id shape `build` uses. + const owner = model.dbtOwnerByAsset.get(assetId) + const [kind, ...rest] = owner?.split(':') ?? [] + if (kind && rest.length) { + onselect?.({ + kind: 'runnable', + runnable_kind: kind as 'script' | 'flow', + path: rest.join(':') + }) + } + } + } + : {}) } }) } @@ -475,6 +533,8 @@ tag: r.tag, retry: r.retry, macros: r.macros, + dbt: r.dbt, + onDbtHover: (on: boolean) => (dbtHoverId = on ? rid : undefined), unsaved: r.unsaved ?? false, // Same dispatch the asset node uses, only routed when the // runnable is a script (the page handler short-circuits @@ -522,10 +582,28 @@ // (`// mute` / `// mute all` opted the default auto trigger out). Gated // on pipeline scripts inside the helper (non-pipeline reads never derive). const mutedReadKeys = computeMutedReadKeys(g.edges, g.triggers, g.runnables) + // A dbt script owns every relation of its project. Drawing that as one + // edge per model buries the lineage that matters (`ref()` between models, + // and native consumers) under a fan-out that grows with the project — so + // the association is carried by the node badge and its hover/click + // highlight instead. Only the DRAWING is dropped: the producer rows still + // drive cascade dispatch and "who produced this". for (const e of g.edges) { const runnableId = `${e.runnable_kind}:${e.runnable_path}` const assetId = `asset:${e.asset_kind}:${e.asset_path}` const access = e.access_type ?? 'r' + // A dbt project's own relations are related by badge, not by edges: its + // writes are the fan-out, and its declared sources already reach its + // models through the `ref()` edges, so both would be noise. + // + // A read of a relation ANOTHER script builds is different — that is how + // two selections of one project compose (decision 6), it carries the + // cascade, and no `ref()` edge survives the split to stand in for it. + // Kept, or the two halves render as disconnected islands. + if (dbtRunnableIds.has(runnableId)) { + const isOwnSource = dbtAssetProvenance.get(assetId)?.resource_type === 'source' + if (access === 'w' || access === 'rw' || isOwnSource) continue + } if (access === 'w' || access === 'rw') { // Data tests assert on the `// materialize` target, which is always // a ducklake asset (v1 enforces this), so only the ducklake @@ -621,6 +699,17 @@ }) } + // dbt `ref()` lineage: model → model inside one project. The dbt script + // writes every one of them, so without these the canvas shows a flat + // fan-out from the script and loses the project's actual shape. + const assetNodeIds = new Set(g.assets.map((a) => `asset:${a.kind}:${a.path}`)) + for (const de of g.dbt_edges ?? []) { + const from = `asset:dbt:${de.from_asset_path}` + const to = `asset:dbt:${de.to_asset_path}` + if (!assetNodeIds.has(from) || !assetNodeIds.has(to)) continue + edges.push({ id: `dbtref:${from}->${to}`, source: from, target: to, kind: 'dbt-ref' }) + } + // Non-asset triggers (schedule + native) are rendered as source nodes // above the pipeline script. Real (non-missing) nodes are // deduplicated per (kind, ref) tuple so a single schedule shared @@ -787,11 +876,24 @@ } } - return { nodes, edges } + return { nodes, edges, dbtOwnerByAsset, dbtWritesByOwner } } let model = $derived(build(graph)) + // dbt association, surfaced by emphasis instead of edges. Hovering a model's + // dbt badge lights up the project node that materializes it; hovering the + // project node lights up every model it owns. Clicking the badge selects the + // project node, so the association survives the pointer leaving. + let dbtHoverId = $state(undefined) + let dbtEmphasisIds = $derived.by(() => { + if (!dbtHoverId) return new Set() + const owned = model.dbtWritesByOwner.get(dbtHoverId) + if (owned) return new Set([dbtHoverId, ...owned]) + const owner = model.dbtOwnerByAsset.get(dbtHoverId) + return owner ? new Set([dbtHoverId, owner]) : new Set() + }) + let selectedId = $derived.by(() => { if (!selection) return undefined return selection.kind === 'asset' @@ -895,12 +997,13 @@ else if (boundPick.bounded.has(n.id)) boundClass = 'wm-bound-in' else if (!boundPick.eligible.has(n.id)) boundClass = 'wm-bound-dim' } + const dbtClass = dbtEmphasisIds.has(n.id) ? 'wm-dbt-linked' : undefined return { id: n.id, type: n.type, position: { x: p.x + xCenter + xShift, y: p.y + 40 }, data: n.data, - class: boundClass ?? runClass ?? assetClass, + class: boundClass ?? dbtClass ?? runClass ?? assetClass, selected: n.id === selectedId, // All nodes non-draggable: the layout is sugiyama-computed, // dragging would fight the reactive re-layout. Selection is @@ -1064,6 +1167,21 @@ label = 'test needs' labelStyle = 'fill: rgb(217 119 6); font-size: 10px; font-weight: 600;' break + case 'dbt-ref': + // model → model inside one dbt project. Orange, matching the + // dbt badges, and dashed because the edge is dbt's own lineage + // rather than a Windmill read/write the cascade acts on. + style = 'stroke: rgb(234 88 12); stroke-width: 1.25px;' + strokeDasharray = '4 3' + markerColor = 'rgb(234 88 12)' + label = 'ref' + labelStyle = 'fill: rgb(234 88 12); font-size: 10px; font-weight: 600;' + // Same rule the pipeline uses for a running script: the edges + // touching what is happening animate. Here the unit of work is + // the model, so the edges feeding the one dbt is building move, + // and the flow reads in DAG order as it advances. + animated = assetRunStatus?.get(e.target)?.status === 'running' + break default: style = '' } @@ -1254,6 +1372,11 @@ /* Activity-panel emphasis — soft, monochromatic, less prominent than the blue details selection above. Hover is a thin neutral ring (transient); pinning an expanded run is a soft-blue ring. */ + /* A dbt project node and the models it materializes, related by badge + rather than by edges — hovering either lights up the whole set. */ + :global(.svelte-flow__node.wm-dbt-linked .drop-shadow-sm) { + @apply outline outline-2 outline-orange-400/80; + } :global(.svelte-flow__node.wm-run-hover .drop-shadow-sm) { @apply outline outline-1 outline-gray-400 dark:outline-gray-500; } diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index d48ecbe09e..2c5e5fd97e 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -27,7 +27,9 @@ import { inferArgs } from '$lib/infer' import { emptySchema, sendUserToast } from '$lib/utils' import type { Schema } from '$lib/common' - import type { AssetGraphSelection, PipelineMode } from './types' + import type { AssetGraphSelection, DbtAssetProvenance, PipelineMode } from './types' + import HighlightCode from '$lib/components/HighlightCode.svelte' + import DbtIcon from '$lib/components/icons/DbtIcon.svelte' import PipelineScriptView from './PipelineScriptView.svelte' import { parsePipelineAnnotations, @@ -154,6 +156,9 @@ // resolved graph). Drives the transitive column-lineage trace shown for a // selected materialized asset. selectionColumnGraph?: ColumnLineageGraph + /** dbt provenance of the selected relation, when a dbt project + * materializes it — carries the model's own SQL. */ + selectionDbt?: DbtAssetProvenance // Whether the selected ducklake asset's schema can evolve (whole-table // `replace` producer). Forwarded to the Schema tab: version history when // true, a single fixed-schema view when false. Defaults to true (unknown). @@ -284,6 +289,7 @@ onScriptRemoved, selectionProducers = [], selectionColumnGraph, + selectionDbt, schemaCanEvolve = true, selectionForkMaterialization = undefined, schemaContractContext = undefined, @@ -426,6 +432,20 @@ ) ) + // Where the selected model's file sits on disk: the producing script's + // module folder holds the dbt project verbatim, so this is the path a + // `wmill sync pull` writes and the one to edit. + let dbtBundlePath = $derived.by(() => { + const file = selectionDbt?.original_file_path + if (!file) return undefined + // A relation may have several script producers, and nothing here says which + // of them is the dbt project this model came from. Prefixing the wrong one + // names a `__dbt` folder that does not exist, so an ambiguous relation shows + // the path inside the project alone. + const scripts = selectionProducers.filter((p) => p.kind === 'script') + return scripts.length === 1 ? `${scripts[0].path}__dbt/${file}` : file + }) + // Bound from ScriptEditor — populated by inferAssets on every code // change. Forwarded to the page so the canvas can re-derive write // edges as the user edits the body (e.g. renaming a CREATE TABLE @@ -1216,6 +1236,25 @@
{/key} + {:else if selectionDbt?.raw_code} + +
+
+ + {dbtBundlePath ?? selectionDbt.unique_id} + read-only · edit locally +
+
+ +
+
{:else}
No inline preview yet for {selection.asset_kind}. Use the producer/consumer arrows diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index 999559a0c7..815db36b18 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -14,13 +14,17 @@ Loader2, Plus, ShieldCheck, - ShieldAlert + ShieldAlert, + CheckCircle2, + XCircle } from 'lucide-svelte' import type { ScriptLang } from '$lib/gen' import { enterpriseLicense, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/utils' import { PIPELINE_LANGUAGES } from './pipelineLanguages' import type { PipelineOutputKind } from './pipelineTemplates' + import type { DbtAssetProvenance } from './types' + import DbtIcon from '$lib/components/icons/DbtIcon.svelte' // Shape used for both the data prop and the run callback. Drafts carry // `content` / `language` so the page-level run handler can dispatch to @@ -44,6 +48,15 @@ // "current view of " marker so it reads as a derived node, not an // unrelated table. derived_from?: string + // dbt provenance when this warehouse table is a dbt node: which model + // it is, how dbt materializes it, its tags and its generic tests. + dbt?: DbtAssetProvenance + /** Hovering the dbt chip emphasizes the project node that + * materializes this model — the association the graph deliberately + * does not draw as an edge. */ + onDbtHover?: (on: boolean) => void + /** Clicking it selects that project node. */ + onDbtSelect?: () => void onAddScript?: ( asset: { kind: AssetKind; path: string }, language: ScriptLang, @@ -78,6 +91,11 @@ // producer just recomputed it. A change triggers a one-shot green // fade so a freshly-written table stands out as the run progresses. recomputePulse?: number + // What the run being viewed is doing to this relation. dbt records it + // per model as it walks the DAG, so the graph moves with the run + // instead of only settling once the job ends. + runStatus?: 'running' | 'materialized' | 'failed' + runRowCount?: number | null } // SvelteFlow injects this on the node component when the user clicks // the node. Combined with our own `hovered` state to drive the @@ -133,6 +151,40 @@ let showAdd = $derived(data.onAddScript != undefined) + // dbt badge. `materialized` is dbt's own word rather than the Windmill + // strategy because `view` and `ephemeral` have no strategy, and showing the + // dbt word keeps the node legible to someone reading their own project. + let dbtLabel = $derived(data.dbt?.materialized ?? data.dbt?.resource_type) + let dbtTitle = $derived.by(() => { + const d = data.dbt + if (!d) return '' + const lines = [`dbt ${d.resource_type}: ${d.unique_id}`] + if (d.materialized) { + const strategy = d.materialize_strategy ? ` -> ${d.materialize_strategy}` : '' + lines.push(`materialized: ${d.materialized}${strategy}`) + } + if (d.tags?.length) lines.push(`tags: ${d.tags.join(', ')}`) + for (const t of d.data_tests ?? []) { + const col = t.column ? ` on ${t.column}` : '' + lines.push(`test ${t.kind}${col}${t.severity ? ` [${t.severity}]` : ''}`) + } + const cols = Object.entries(d.columns ?? {}) + if (cols.length) { + lines.push( + `columns: ${cols.map(([c, desc]) => (desc ? `${c} (${desc})` : c)).join(', ')}` + ) + } + if (d.freshness) { + const f = d.freshness as Record + const window = (k: string) => + f[k]?.count != null ? `${k.replace('_after', '')} after ${f[k].count}${f[k].period?.[0] ?? ''}` : '' + const windows = ['warn_after', 'error_after'].map(window).filter(Boolean) + if (windows.length) lines.push(`freshness: ${windows.join(', ')}`) + } + if (d.description) lines.push(d.description) + return lines.join('\n') + }) + // Data-test outcome badge. Only guarded assets show it. The write's fate on a // failing test differs by edition — surface which one applies so a shared // parent/fork table name can't hide a silently-published bad version. @@ -200,6 +252,39 @@ class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-blue-600 dark:text-blue-400'}`} size="14px" /> + {#if data.runStatus} + + + {#if data.runStatus === 'running'} + + {:else if data.runStatus === 'failed'} + + {:else} + + {/if} + + + {#if data.runStatus === 'materialized' && data.runRowCount != undefined} + + {Intl.NumberFormat().format(data.runRowCount)} + + {/if} + {/if} {formatShortAssetPath(asset)} @@ -225,6 +310,34 @@ fork {/if} + + {#if data.dbt} + + {/if} diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte index 7db35c2353..ec28b7c784 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineGraphEditor.svelte @@ -16,8 +16,7 @@ AssetGraphResponse, AssetGraphSelection, NativeTriggerKind, - PipelineMode - } from './types' + PipelineMode, DbtAssetProvenance } from './types' import type { AssetKind, Script, ScriptLang } from '$lib/gen' import type { RunnableRunState, PipelineEvent } from './activeRunnables.svelte' import type { PipelineOutputKind } from './pipelineTemplates' @@ -76,6 +75,7 @@ localScriptsVersion, selectionProducers = [], selectionColumnGraph, + selectionDbt, schemaCanEvolve = true, selectionForkMaterialization = undefined, schemaContractContext = undefined, @@ -181,6 +181,8 @@ selectionProducers?: Array<{ kind: 'script' | 'flow'; path: string; unsaved?: boolean }> /** Transitive column-lineage trace for a selected ducklake asset (route page). */ selectionColumnGraph?: ColumnLineageGraph + /** dbt provenance of the selected relation — carries its SQL. */ + selectionDbt?: DbtAssetProvenance schemaCanEvolve?: boolean /** Fork workspaces: data-environment state of the selected ducklake asset (route page). */ selectionForkMaterialization?: 'fork' | 'deferred' @@ -512,6 +514,7 @@ selection={activeDraft ? undefined : editor.selection} selectionProducers={activeDraft ? [] : selectionProducers} {selectionColumnGraph} + {selectionDbt} {schemaCanEvolve} {selectionForkMaterialization} {schemaContractContext} diff --git a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte index d8a977539b..c260bc2e5b 100644 --- a/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/RunnableNode.svelte @@ -18,6 +18,7 @@ XCircle, Zap } from 'lucide-svelte' + import DbtIcon from '$lib/components/icons/DbtIcon.svelte' import { twMerge } from 'tailwind-merge' import { preventDefault, stopPropagation } from 'svelte/legacy' import type { GraphUsageKind } from './types' @@ -45,6 +46,13 @@ // Macros this script provides (deployed/drafted `// macros` library). // Non-empty renders the ƒ chip marking the node as a macro library. macros?: { name: string; params: string; is_table: boolean }[] + // Set on a dbt script: the number of models the project materializes. + // One runnable node stands for the whole project, so the count is what + // tells it apart from a single-output script. + dbt?: { model_count: number } + /** Hovering the project badge emphasizes every model it + * materializes — the fan-out the graph deliberately omits. */ + onDbtHover?: (on: boolean) => void // Last-run status + run count observed this session (from the // folder queue poll). Undefined until the first observed run. runState?: RunnableRunState @@ -188,7 +196,19 @@ -
(hover = true)} onmouseleave={() => (hover = false)}> + +
{ + hover = true + data.onDbtHover?.(true) + }} + onmouseleave={() => { + hover = false + data.onDbtHover?.(false) + }} +> + {#if onDelete && node.path !== 'dbt_project.yml'} + + {/if} +
+ {/if} + {/each} +{/snippet} + +
+
+ {scriptPath}__dbt/ +
+ {fileCount + 1} + {@render addFile?.()} +
+
+
+ + + {@render branch(tree, 0)} +
+ {#if fileCount === 0} +
+ No project yet. Copy one in and push it: +
cp -r my-dbt-project/. {scriptPath}__dbt/
+wmill sync push
+
+ {/if} +
diff --git a/frontend/src/lib/components/dbt/DbtRunGraph.svelte b/frontend/src/lib/components/dbt/DbtRunGraph.svelte new file mode 100644 index 0000000000..7b9cbee07b --- /dev/null +++ b/frontend/src/lib/components/dbt/DbtRunGraph.svelte @@ -0,0 +1,769 @@ + + +{#snippet sqlPane()} + {#if selectedIsForeign} +
+ Another dbt project in this workspace also materializes this relation, and the graph keeps one + project's model per relation — so the SQL shown here would not be this run's. Open that + project's own run to see it. +
+ {:else if selectedDbt?.raw_code} +
+
+ {selectedDbt.original_file_path ?? selectedDbt.unique_id} + {#if selectedDbt.materialized} + {selectedDbt.materialized} + {/if} + + {#if selectedDbt.resource_type === 'model'} + {#if showRows && preview && !('error' in preview)} + + {:else} + + {/if} + {/if} + {#if selectedRelation} + + {/if} + + + read-only · edit in the script + +
+
+ {#if showRows && preview} + {#if 'error' in preview} +
{preview.error}
+ + {:else if 'pending' in preview} +
+ + Running `dbt show` — this is a job, so it waits on a worker and the engine. +
+ {:else} + {@const cols = Object.keys(preview.rows[0] ?? {})} + {#if cols.length === 0} +
The model returned no rows.
+ {:else} + + + + {#each cols as c (c)} + + {/each} + + + + {#each preview.rows as row, i (i)} + + {#each cols as c (c)} + + {/each} + + {/each} + +
{c}
{cellText(row[c])}
+
+ {preview.rows.length} rows in {(preview.tookMs / 1000).toFixed(1)}s{preview.node + ? ` · ${preview.node}` + : ''} +
+ {/if} + {/if} + {:else} + + {/if} +
+
+ {/if} +{/snippet} + +{#if resumable} +
+ {(run?.totals?.error ?? 0) > 0 ? `${run?.totals?.error} failed` : ''}{(run?.totals?.error ?? + 0) > 0 && (run?.totals?.skipped ?? 0) > 0 + ? ', ' + : ''}{(run?.totals?.skipped ?? 0) > 0 ? `${run?.totals?.skipped} skipped` : ''}. Rebuild only + those with dbt retry, instead of the whole project. + + + +
+{/if} + +{#if loading} +
+ Loading the model graph +
+{:else if failed} +
Could not load the model graph.
+{:else if !graph} +
+ {#if ranTestsOnly} + This run selected tests alone, so it built no models. A dbt test is an assertion rather than a + relation, so it has no node here — the models it asserts against belong to the runs that build + them. Its results are in the table below. + {:else} + This dbt script has no models in the asset graph. A project that brings its own + profiles.yml without naming a + profile.warehouse has no warehouse identity to key them on. + {/if} +
+{:else} +
+ {#if relationDrift > 0} +
+ {relationDrift} + {relationDrift === 1 ? 'model has' : 'models have'} been renamed or moved since this run — + {relationDrift === 1 ? 'its node shows' : 'their nodes show'} today's relation, not the one this + run wrote. +
+ {/if} + {#if goneSinceRun > 0} +
+ {goneSinceRun} + {goneSinceRun === 1 ? 'model' : 'models'} this run built {goneSinceRun === 1 ? 'is' : 'are'} + no longer in the project — renamed or removed since, so + {goneSinceRun === 1 ? 'it is' : 'they are'} not drawn. +
+ {/if} +
+ (selection = s)} + showMinimap={false} + scrollZoom={false} + /> +
+ {@render sqlPane()} +
+{/if} diff --git a/frontend/src/lib/components/dbt/DbtRunResult.svelte b/frontend/src/lib/components/dbt/DbtRunResult.svelte new file mode 100644 index 0000000000..34d7886b30 --- /dev/null +++ b/frontend/src/lib/components/dbt/DbtRunResult.svelte @@ -0,0 +1,146 @@ + + +
+
+ {#each [{ k: 'success', label: 'passed', cls: 'text-green-600 dark:text-green-400' }, { k: 'warn', label: 'warned', cls: 'text-yellow-600 dark:text-yellow-400' }, { k: 'error', label: 'failed', cls: 'text-red-600 dark:text-red-400' }, { k: 'skipped', label: 'skipped', cls: 'text-secondary' }] as t (t.k)} + {@const n = (totals as Record)[t.k] ?? 0} + {#if n > 0} + {n} {t.label} + {/if} + {/each} + of {totals.total ?? nodes.length} nodes + + {run.command ?? 'build'} · {run.engine ?? ''} + {run.engine_version ?? ''} + +
+ + {#if nodes.length > 0} +
+ + + + + + + + + + + + {#each nodes as node (node.unique_id)} + {@const s = split(node.unique_id)} + {@const r = rank(node.status, node.outcome)} + + + + + + + + {/each} + +
NodeKindRelationRowsTime
+
+ + {#if r === 0} + + {:else if r === 1} + + {:else if r === 2} + + {:else} + + {/if} + + {s.name} + {#if node.message && r < 2} + {node.message} + {/if} +
+ {#if node.message && r < 2} +
+ {node.message} +
+ {/if} +
+ + {#if s.kind === 'test'} + + {/if} + {s.kind} + + + {fmtRelation(node.relation_name) ?? ''} + + {node.rows_affected ?? ''} + + {fmtTime(node.execution_time)} +
+
+ {#if hasTests} +
+ A test's severity decides the outcome: dbt's own warn surfaces + without failing the job. +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/dbt/parseDbtRun.test.ts b/frontend/src/lib/components/dbt/parseDbtRun.test.ts new file mode 100644 index 0000000000..47d6073ef7 --- /dev/null +++ b/frontend/src/lib/components/dbt/parseDbtRun.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest' +import { + parseDbtRun, + relationOutcome, + splitRelation, + statusRank, + splitUniqueId, + nodeSelector +} from './parseDbtRun' + +const run = { + engine: 'dbt-core-1x', + engine_version: '1.12.0', + command: 'build', + totals: { total: 1, success: 1, error: 0, warn: 0, skipped: 0 }, + nodes: [{ unique_id: 'model.p.customers', status: 'success' }] +} + +describe('parseDbtRun', () => { + it('takes a successful run as-is', () => { + expect(parseDbtRun(run)?.engine).toBe('dbt-core-1x') + }) + + // The worker puts the same JSON in the error message after the exit-status + // line, and this is the case worth rendering: the failing node is what the + // user came for. + it('recovers the run from a failed job’s error message', () => { + const failed = { + error: { + name: 'ExecutionErr', + message: `execution error:\nNon-zero exit status for dbt build: 1\n\n${JSON.stringify(run)}` + } + } + expect(parseDbtRun(failed)?.totals?.total).toBe(1) + }) + + // The failures worth reading are the ones whose message carries braces of its + // own — a Jinja template, the compiled SQL, an adapter's own JSON — and the + // payload is appended after all of it. + it('finds the run past braces in the error text', () => { + const failed = { + error: { + name: 'ExecutionErr', + message: + 'execution error:\nCompilation Error in model x\n {{ ref("missing") }} depends on {"a": 1}\n' + + `Non-zero exit status for dbt build: 1\n\n${JSON.stringify(run)}` + } + } + expect(parseDbtRun(failed)?.totals?.total).toBe(1) + }) + + // `{nodes, totals}` alone is a shape an ordinary script can return, and it + // would then be rendered as somebody's dbt run. + it('does not claim an ordinary result that happens to have nodes and totals', () => { + expect(parseDbtRun({ nodes: [], totals: {} })).toBeUndefined() + expect(parseDbtRun({ engine: 'v8', nodes: [], totals: {} })).toBeUndefined() + }) + + it('accepts every engine the worker stamps', () => { + for (const engine of ['dbt-core-1x', 'dbt-core-2x', 'fusion']) { + expect(parseDbtRun({ ...run, engine })?.engine).toBe(engine) + } + }) + + // The payload carries one object per node, so a scan bounded by brace COUNT + // gives up on an ordinary project — a few hundred nodes, tests included — and + // silently loses the per-model table on exactly the runs it exists for. + it('finds the run in a payload with hundreds of nodes', () => { + const big = { + ...run, + totals: { total: 400, success: 399, error: 1, warn: 0, skipped: 0 }, + nodes: Array.from({ length: 400 }, (_, i) => ({ + unique_id: `model.p.m${i}`, + status: i === 0 ? 'error' : 'success' + })) + } + const failed = { + error: { + name: 'ExecutionErr', + message: + 'execution error:\nCompilation Error {{ ref("x") }} {"a": 1}\n\n' + + JSON.stringify(big, null, 2) + } + } + expect(parseDbtRun(failed)?.nodes?.length).toBe(400) + }) + + it('is undefined for anything unparseable', () => { + expect(parseDbtRun(undefined)).toBeUndefined() + expect(parseDbtRun('a string')).toBeUndefined() + expect(parseDbtRun({ error: { message: 'failed with {not json' } })).toBeUndefined() + }) +}) + +describe('statusRank', () => { + // dbt counts `partial success` in totals.error and a retry redoes it, so + // ranking it as a pass would contradict the job's own outcome. + it('ranks partial success with the failures', () => { + expect(statusRank('partial success')).toBe(statusRank('error')) + expect(statusRank('PARTIAL SUCCESS')).toBe(0) + }) + + // The worker publishes `unknown` for a status it does not recognise and counts + // it in `totals.error`; ranking it as a pass drew a green check on a node the + // same result called an error. + // The worker counts `no_op` in totals.skipped — dbt built nothing for that + // node — so a green check would claim a run that never happened. + it('ranks a no-op with the skips, not with the passes', () => { + expect(statusRank('no-op', 'no_op')).toBe(statusRank('skipped', 'skipped')) + expect(statusRank('success', 'no_op')).toBe(2) + }) + + it('ranks an unknown outcome with the failures, not with the passes', () => { + expect(statusRank('some-future-dbt-status', 'unknown')).toBe(0) + expect(statusRank('success', 'unknown')).toBe(0) + }) + + it('orders failed before warned before skipped before passed', () => { + expect( + ['success', 'skipped', 'warn', 'error'].sort((a, b) => statusRank(a) - statusRank(b)) + ).toEqual(['error', 'warn', 'skipped', 'success']) + }) +}) + +describe('splitUniqueId', () => { + it('splits kind from name and drops a generic test’s uniqueness hash', () => { + expect(splitUniqueId('model.jaffle.stg_orders')).toEqual({ + kind: 'model', + name: 'stg_orders' + }) + expect(splitUniqueId('test.jaffle.not_null_orders_id.4e687af8d0')).toEqual({ + kind: 'test', + name: 'not_null_orders_id' + }) + // A model whose name contains a dot keeps it: only tests carry the hash. + expect(splitUniqueId('model.jaffle.a.b').name).toBe('a.b') + }) +}) + +describe('relationOutcome', () => { + it('agrees with the worker classifier on every status it names', () => { + expect(relationOutcome('started')).toBe('running') + for (const s of ['success', 'pass', 'PASS', ' Success ']) { + expect(relationOutcome(s)).toBe('materialized') + } + // `partial success` built the relation and then failed its tests; the + // worker records it failed, so the colour must agree. + for (const s of ['error', 'fail', 'runtime error', 'partial success', 'PARTIAL SUCCESS']) { + expect(relationOutcome(s)).toBe('failed') + } + // Nothing was built, so nothing is coloured. + for (const s of ['warn', 'skipped', 'no-op', 'something new']) { + expect(relationOutcome(s)).toBeUndefined() + } + }) +}) + +describe('splitRelation', () => { + it('keeps a period that lives inside a quoted identifier', () => { + // The backend supports it, so rendering it as `v2.orders` names a + // relation that does not exist. + expect(splitRelation('"wh"."analytics.v2"."orders"')).toEqual(['wh', 'analytics.v2', 'orders']) + expect(splitRelation('"db"."schema"."name"')).toEqual(['db', 'schema', 'name']) + expect(splitRelation('db.schema.name')).toEqual(['db', 'schema', 'name']) + // BigQuery backticks and T-SQL brackets quote too. + expect(splitRelation('`proj`.`data.set`.`t`')).toEqual(['proj', 'data.set', 't']) + expect(splitRelation('[db].[my.schema].[t]')).toEqual(['db', 'my.schema', 't']) + }) + + // Every one of these dialects escapes its delimiter by doubling it. Dropping + // the pair renames the relation, and the manifest keeps the real spelling — + // so the run's status would be recorded against a key no graph node has. + it('keeps a delimiter the identifier escaped by doubling', () => { + expect(splitRelation('"wh"."schema"."a""b"')).toEqual(['wh', 'schema', 'a"b']) + expect(splitRelation('`proj`.`da``ta`.`t`')).toEqual(['proj', 'da`ta', 't']) + expect(splitRelation('[db].[my]]schema].[t]')).toEqual(['db', 'my]schema', 't']) + }) +}) + +describe('nodeSelector', () => { + // Verified against dbt-core 1.12, dbt-core 2.0.0-alpha.5 and fusion + // 2.0.0-preview.202: the intersection resolves to the one node whatever the + // project's `model-paths` is, while a path-derived FQN resolves to nothing + // as soon as that root is more than one segment deep. + it('intersects the name with its package, wherever the model sits', () => { + expect(nodeSelector('model.jaffle_shop.fct_orders')).toBe('fct_orders,package:jaffle_shop') + }) + + // Ambiguous across packages, but a selector dbt resolves rather than rejects. + it('falls back to the bare name without a package', () => { + expect(nodeSelector('fct_orders')).toBe('fct_orders') + }) +}) diff --git a/frontend/src/lib/components/dbt/parseDbtRun.ts b/frontend/src/lib/components/dbt/parseDbtRun.ts new file mode 100644 index 0000000000..aecd3dcf03 --- /dev/null +++ b/frontend/src/lib/components/dbt/parseDbtRun.ts @@ -0,0 +1,247 @@ +export type DbtNode = { + unique_id: string + status: string + /** Windmill's stable word for the same result, published beside dbt's own. + * Preferred wherever a decision is made: `status` is dbt's vocabulary and + * dbt may rename it. */ + outcome?: DbtOutcome + execution_time?: number + rows_affected?: number + relation_name?: string + message?: string +} + +export type DbtRun = { + engine?: string + engine_version?: string + command?: string + totals?: { total?: number; success?: number; error?: number; warn?: number; skipped?: number } + nodes?: DbtNode[] + /** The arguments the run actually used, as submitted. A `dbt retry` restores + * the failed run's arguments inside the worker, so the retry job's own args + * name only the run it resumed — this is the sole way to recover what it + * really ran with. */ + invocation_args?: Record +} + +/** The engines the worker stamps on a result. This is the discriminator: a + * `{nodes, totals}` shape alone is one an ordinary script can return, and it + * would then be rendered as somebody's dbt run. */ +const ENGINES = ['dbt-core-1x', 'dbt-core-2x', 'fusion'] + +function asDbtRun(v: unknown): DbtRun | undefined { + if (!v || typeof v !== 'object') return undefined + const o = v as Record + return ENGINES.includes(o.engine as string) && + Array.isArray(o.nodes) && + o.totals != undefined && + typeof o.totals === 'object' + ? (o as DbtRun) + : undefined +} + +/** + * The dbt invocation a job result describes, if it describes one. + * + * On success the result IS the run. On failure the worker puts the same JSON in + * the error message after the exit-status line, and that is the case worth + * rendering: the failing node is what the user came for. + */ +export function parseDbtRun(result: any): DbtRun | undefined { + const direct = asDbtRun(result) + if (direct) return direct + const msg = result?.error?.message + if (typeof msg !== 'string') return undefined + // The payload is appended pretty-printed, so its `{` is the only one at COLUMN + // ZERO — everything nested is indented, and dbt's own error text carries its + // braces mid-line. Counting braces instead needs a cap that a real project + // blows: forwards on an error full of them, backwards on one `{` per node. + for (const line of lineStarts(msg)) { + if (msg[line] !== '{') continue + try { + const run = asDbtRun(JSON.parse(msg.slice(line))) + if (run) return run + } catch { + // A `{` alone on a line inside the error text; the payload is later. + } + } + return undefined +} + +/** Index of the first character of each line, the payload's own `{` among them. */ +function* lineStarts(s: string): Generator { + let at = 0 + while (at !== -1 && at < s.length) { + yield at + const next = s.indexOf('\n', at) + at = next === -1 ? -1 : next + 1 + } +} + +/** Ordering rank of a node's status: 0 failed, 1 warned, 2 skipped, 3 passed. + * + * Both `unknown` and `no_op` rank where the RESULT counts them, not where the + * default would put them: the worker counts `unknown` in `totals.error` and + * `no_op` in `totals.skipped`, so falling through to 3 drew a green check on a + * node the same result called an error, and on one it never built. */ +export function statusRank(status: string, outcome?: DbtOutcome): number { + switch (outcome ?? classifyStatus(status)) { + case 'failed': + case 'unknown': + return 0 + case 'warned': + return 1 + case 'skipped': + case 'no_op': + return 2 + default: + return 3 + } +} + +/** The worker's stable vocabulary for a node result, published as `outcome`. */ +export type DbtOutcome = + | 'started' + | 'passed' + | 'failed' + | 'warned' + | 'skipped' + | 'no_op' + | 'unknown' + +/** + * dbt's node status, reduced to the outcomes the UI distinguishes. + * + * Only for results that predate `outcome`, or for the live event stream, which + * carries dbt's word alone. Anything holding a node from a job result should + * read `outcome` instead — that is the field the worker publishes precisely so + * this mapping is not the contract. + */ +function classifyStatus( + status: string +): 'started' | 'passed' | 'failed' | 'warned' | 'skipped' | 'other' { + // `partial success` is dbt's word for a node that built but whose tests + // failed. The worker counts it in `totals.error` and a retry redoes it, so + // showing it green would contradict the job's own outcome. + switch (status.trim().toLowerCase()) { + case 'started': + return 'started' + case 'success': + case 'pass': + return 'passed' + case 'error': + case 'fail': + case 'runtime error': + case 'partial success': + return 'failed' + case 'warn': + return 'warned' + case 'skipped': + return 'skipped' + default: + return 'other' + } +} + +/** + * The kind and name behind a dbt `unique_id`, which dbt builds as + * `..`. A generic test's name carries a trailing + * hash dbt adds for uniqueness; it is noise in a run summary. + */ +export function splitUniqueId(uniqueId: string): { kind: string; name: string } { + const parts = uniqueId.split('.') + const kind = parts[0] ?? '' + let name = parts.slice(2).join('.') + if (kind === 'test') name = name.replace(/\.[0-9a-f]{6,}$/, '') + return { kind, name: name || uniqueId } +} + +/** + * What a node's status says happened to the relation it builds, or `undefined` + * when it says nothing. + * + * Mirrors the worker's `classify_status`, and must keep mirroring it: the two + * decide the same thing about the same string, one for the record it writes and + * one for the colour drawn over it. `warn`, `skipped` and `no-op` leave the + * relation untouched, so they get no colour rather than a misleading one. + */ +export function relationOutcome( + status: string, + outcome?: DbtOutcome +): 'running' | 'materialized' | 'failed' | undefined { + switch (outcome ?? classifyStatus(status)) { + case 'started': + return 'running' + case 'passed': + return 'materialized' + case 'failed': + return 'failed' + // `warn`, `skipped` and `no-op` say nothing about the relation: nothing + // was written, so its state is whatever the last run left. + default: + return undefined + } +} + +/** + * dbt's `relation_name` split into its parts, honouring quoting. + * + * Mirrors the worker's `split_relation`: `"`, `` ` `` and `[` open a quoted + * identifier, and a `.` inside one is part of the name. Splitting on every `.` + * turns `"wh"."analytics.v2"."orders"` into a relation called `orders` in a + * schema called `v2` — a table that does not exist. + */ +export function splitRelation(relation: string): string[] { + const parts: string[] = [] + let current = '' + let quote: string | undefined + for (let i = 0; i < relation.length; i++) { + const c = relation[i] + if (quote !== undefined) { + const close = quote === '[' ? ']' : quote + if (c === close) { + // Doubled, which is how each of these dialects escapes its own + // delimiter: one literal character, not the end of the identifier. + if (relation[i + 1] === close) { + current += close + i++ + } else { + quote = undefined + } + } else current += c + } else if (c === '"' || c === '`' || c === '[') { + quote = c + } else if (c === '.') { + parts.push(current) + current = '' + } else { + current += c + } + } + parts.push(current) + return parts.map((p) => p.trim()) +} + +/** + * A selector naming exactly one node: `,package:`. + * + * The comma is dbt's intersection operator, so this reads "the node whose name + * is `` and whose package is ``" — one node, since dbt refuses + * two models of one name inside a package. A bare name would match the leaf of + * every package's FQN, and a package can ship a model whose name the project + * also uses. + * + * Not the FQN (`..`), which cannot be rebuilt from + * `original_file_path`: how many leading segments are the resource root is + * `model-paths`, and dropping exactly one turns `src/models/marts/orders.sql` + * into `pkg.models.marts.orders`, which dbt's matcher — equal lengths, from the + * front — resolves to nothing at all. + * + * Without a package, the bare name — ambiguous across packages, but a selector + * dbt resolves rather than one it rejects. + */ +export function nodeSelector(uniqueId: string): string { + const { name } = splitUniqueId(uniqueId) + const pkg = uniqueId.split('.')[1] + return pkg ? `${name},package:${pkg}` : name +} diff --git a/frontend/src/lib/components/dbt/previewRows.ts b/frontend/src/lib/components/dbt/previewRows.ts new file mode 100644 index 0000000000..48ff56ec4c --- /dev/null +++ b/frontend/src/lib/components/dbt/previewRows.ts @@ -0,0 +1,68 @@ +import { JobService } from '$lib/gen' + +/** A model's rows, as `dbt show` returns them. */ +export type DbtPreview = + | { pending: true } + | { rows: Record[]; node?: string; tookMs: number } + | { error: string } + +/** + * Preview one model's rows by running its own project's `dbt show`. + * + * A job, not a query: the rows come from the warehouse through the project's + * profile, with its vars and its adapter, which is the only place that knows how + * to resolve `ref()` and where the relation actually lives. `show` is therefore + * not a run-form command — it is what a table's preview is made of, here and on + * the run page's graph. + * + * `stillWanted` is asked before each poll and before the result is used, so a + * preview outlives neither the page that asked for it nor a navigation. + */ +export async function previewDbtRows(opts: { + workspace: string + scriptPath: string + /** Pins the preview to a deployed version, for a graph showing that version. */ + scriptHash?: string | number + /** One node: a model name, or `package.model` where two packages share one. */ + model: string + /** The run's own vars, so a descriptor with a required `{{ }}` var resolves. */ + vars?: Record + limit?: number + /** Extra top-level arguments — a run's `{{ placeholder }}` values. */ + args?: Record + stillWanted?: () => boolean +}): Promise { + const { workspace, scriptPath, scriptHash, model, vars, limit, args, stillWanted } = opts + const startedAt = Date.now() + const requestBody = { + ...(args ?? {}), + command: { label: 'show', vars: vars ?? {}, model, limit: limit ?? 25 } + } + try { + // By HASH whenever the caller pins one: the SQL on screen is that version's, + // and running the deployed one would show today's rows under it — or fail + // outright for a model since removed. + const id = scriptHash + ? await JobService.runScriptByHash({ + workspace, + hash: String(scriptHash), + requestBody + }) + : await JobService.runScriptByPath({ workspace, path: scriptPath, requestBody }) + // Polled rather than awaited: a preview is a job, and its engine may need + // provisioning on a cold worker. + for (let i = 0; i < 90; i++) { + await new Promise((r) => setTimeout(r, 1000)) + if (stillWanted && !stillWanted()) return undefined + const done = await JobService.getCompletedJobResultMaybe({ workspace, id }) + if (!done.completed) continue + const res = done.result as { node?: string; show?: Record[] } | undefined + return done.success && res?.show + ? { rows: res.show, node: res.node, tookMs: Date.now() - startedAt } + : { error: 'The preview job failed — open it from Runs for the detail.' } + } + return { error: 'The preview is still running; open it from Runs.' } + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) } + } +} diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index 335a37b0c7..e275df3976 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -12,7 +12,7 @@ import { Check, Code, Zap } from 'lucide-svelte' import SuspendDrawer from './SuspendDrawer.svelte' import { defaultScripts } from '$lib/stores' - import { defaultScriptLanguages, processLangs } from '$lib/scripts' + import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts' import type { SupportedLanguage } from '$lib/common' import DefaultScripts from '$lib/components/DefaultScripts.svelte' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' @@ -48,7 +48,7 @@ let filter = $state('') let langs = $derived( - processLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages)) + processInlineLangs(undefined, $defaultScripts?.order ?? Object.keys(defaultScriptLanguages)) .map((l) => [defaultScriptLanguages[l], l]) .filter( (x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x[1]) diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index dc2cba93d4..7a55ca92fc 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -6,7 +6,7 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/worker_group.ts b/frontend/src/lib/components/worker_group.ts index d0391b17c2..31d0cee7db 100644 --- a/frontend/src/lib/components/worker_group.ts +++ b/frontend/src/lib/components/worker_group.ts @@ -59,7 +59,8 @@ export const defaultTags = [ 'java', 'ruby', 'rlang', - 'duckdb' + 'duckdb', + 'dbt' // for related places search: ADD_NEW_LANG ] /** Strip cache_clear, null/undefined values, empty arrays and empty objects from a worker group config. */ diff --git a/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte b/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte new file mode 100644 index 0000000000..d472ce6d08 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DbtSettings.svelte @@ -0,0 +1,173 @@ + + + + + + Where dbt projects in this workspace run. A project names a warehouse by name in its descriptor (profile.warehouse) and reaches + {DEFAULT_WAREHOUSE} when it names none, so a project carries no + connection of its own. The name is also what its tables are keyed on in the asset graph (dbt://{DEFAULT_WAREHOUSE}/schema/table), so two projects on one warehouse share their nodes. Each entry points at a resource, and + configuring one here is what makes it available: anyone who may run a dbt script builds with it + and reads its models, without being granted the resource, the same bargain workspace object + storage makes. + + + + + + Name + Resource + Target + + + + + {#each dbtSettings.warehouses as warehouse, i (i)} + + + + + + + + + + + + + + + + + + onDiscard?.()} + saveLabel="Save dbt warehouses" +/> diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 1e0a36af50..db32bf0274 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -42,7 +42,8 @@ import initRustParser, { parse_rust } from 'windmill-parser-wasm-rust' import initYamlParser, { parse_assets_ansible, parse_ansible, - parse_ansible_delegate + parse_ansible_delegate, + parse_dbt } from 'windmill-parser-wasm-yaml' import initCSharpParser, { parse_csharp } from 'windmill-parser-wasm-csharp' import initNuParser, { parse_nu } from 'windmill-parser-wasm-nu' @@ -521,6 +522,9 @@ export async function inferArgs( } catch { inferedSchema = parseRSignatureFallback(code) } + } else if (language == 'dbt') { + await initWasmYaml() + inferedSchema = JSON.parse(parse_dbt(code)) // for related places search: ADD_NEW_LANG } else { return null diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 70dabb01f6..f67c0415f0 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -709,7 +709,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "language": { "type": "string", - "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative" + "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" }, "kind": { "type": "string", @@ -1218,7 +1218,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "language": { "type": "string", - "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative" + "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" }, "tag": { "type": "string" @@ -1250,7 +1250,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "language": { "type": "string", - "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative" + "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" }, "lock": { "type": "string", diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index 91e310f5c9..178f4474e7 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -1345,6 +1345,43 @@ main <- function( return(toJSON(result, auto_unbox = TRUE)) } ` + +// A dbt script is a whole dbt project: the descriptor below is the script's +// content, and the project's own files live in its module bundle (the +// ` + +{#if onBehalfOf} + + {#snippet text()} + Every run of this {kind} is permissioned as {detailed}, whoever starts it. + {/snippet} + + On behalf of {onBehalfOf} + + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index fd9483f5d2..1dec7c4873 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -21,6 +21,7 @@ import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable' import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte' + import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte' import { goto } from '$lib/navigation' import { base } from '$lib/base' import { Badge as HeaderBadge, Alert } from '$lib/components/common' @@ -600,6 +601,11 @@ {#if $workspaceStore && flow} {/if} + {#if flow?.value?.priority != undefined}