Merge branch 'main' into change-057d82f3

This commit is contained in:
Diego Imbert
2026-06-03 09:23:39 +02:00
committed by GitHub
40 changed files with 1918 additions and 276 deletions
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [1.714.1](https://github.com/windmill-labs/windmill/compare/v1.714.0...v1.714.1) (2026-06-02)
### Bug Fixes
* **backend:** route //native TypeScript previews to native workers (WIN-2007) ([#9407](https://github.com/windmill-labs/windmill/issues/9407)) ([73edebc](https://github.com/windmill-labs/windmill/commit/73edebc833a981488a8ea116f4f13c020a011a6f))
* **nsjail:** raise python download fd limit for --compile-bytecode (WIN-2009) ([#9414](https://github.com/windmill-labs/windmill/issues/9414)) ([9e6559a](https://github.com/windmill-labs/windmill/commit/9e6559a6f688cc8d982277b19920219ea6d0fd8e))
* **triggers:** prevent Zoom challenge handler from being used as a signing oracle ([#9413](https://github.com/windmill-labs/windmill/issues/9413)) ([ab2a15b](https://github.com/windmill-labs/windmill/commit/ab2a15b2a859096eabde718bf6e60289ae187118))
## [1.714.0](https://github.com/windmill-labs/windmill/compare/v1.713.1...v1.714.0) (2026-06-02)
+1 -1
View File
@@ -300,7 +300,7 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
ENV LD_LIBRARY_PATH="."
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT id AS \"id!\" FROM chain",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50"
}
+118 -118
View File
@@ -1521,7 +1521,7 @@ version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -1541,7 +1541,7 @@ version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -1584,9 +1584,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
dependencies = [
"serde_core",
]
@@ -4585,7 +4585,7 @@ version = "25.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"rustc_version 0.4.1",
]
@@ -6099,7 +6099,7 @@ version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cfg-if",
"libc",
]
@@ -6587,7 +6587,7 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e668df13f2e97f3eed52d9301f6b1c4c1ccfccc30eab9e6628e4a8c1fc3546"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"bytes",
"lazy_static",
"libgssapi-sys",
@@ -6636,7 +6636,7 @@ version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"libc",
"plain",
"redox_syscall 0.8.1",
@@ -6720,9 +6720,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.30"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
[[package]]
name = "loom"
@@ -7240,7 +7240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b42ced54aa8ac97226486337973f9bc3956e24f03a23e88a6e18f640959d6e2"
dependencies = [
"base64 0.22.1",
"bitflags 2.11.1",
"bitflags 2.12.1",
"btoi",
"byteorder",
"bytes",
@@ -7299,7 +7299,7 @@ version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cfg-if",
"libc",
]
@@ -7310,7 +7310,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -7322,7 +7322,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -7334,7 +7334,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -7784,7 +7784,7 @@ version = "6.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"libc",
"once_cell",
"onig_sys",
@@ -7843,7 +7843,7 @@ version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"cfg-if",
"foreign-types",
"libc",
@@ -8792,7 +8792,7 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"chrono",
"flate2",
"hex",
@@ -8806,7 +8806,7 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"chrono",
"hex",
]
@@ -8893,7 +8893,7 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"getopts",
"memchr",
"unicase",
@@ -9177,7 +9177,7 @@ version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
]
[[package]]
@@ -9291,7 +9291,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
]
[[package]]
@@ -9300,7 +9300,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
]
[[package]]
@@ -9867,7 +9867,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -9880,7 +9880,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"errno",
"libc",
"linux-raw-sys 0.12.1",
@@ -10333,7 +10333,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
@@ -10346,7 +10346,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -11062,7 +11062,7 @@ dependencies = [
"atoi",
"base64 0.22.1",
"bigdecimal",
"bitflags 2.11.1",
"bitflags 2.12.1",
"byteorder",
"bytes",
"chrono",
@@ -11107,7 +11107,7 @@ dependencies = [
"atoi",
"base64 0.22.1",
"bigdecimal",
"bitflags 2.11.1",
"bitflags 2.12.1",
"byteorder",
"chrono",
"crc",
@@ -11382,7 +11382,7 @@ version = "15.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"is-macro",
"num-bigint",
"once_cell",
@@ -11438,7 +11438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "017d06ea85008234aa9fb34d805c7dc563f2ea6e03869ed5ac5a2dc27d561e4d"
dependencies = [
"arrayvec",
"bitflags 2.11.1",
"bitflags 2.12.1",
"either",
"num-bigint",
"phf 0.11.3",
@@ -11760,7 +11760,7 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"byteorder",
"enum-as-inner",
"libc",
@@ -11788,7 +11788,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -12703,7 +12703,7 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
"base64 0.22.1",
"bitflags 2.11.1",
"bitflags 2.12.1",
"bytes",
"futures-core",
"futures-util",
@@ -13343,7 +13343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595"
dependencies = [
"bindgen 0.71.1",
"bitflags 2.11.1",
"bitflags 2.12.1",
"fslock",
"gzip-header",
"home",
@@ -13598,7 +13598,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"semver 1.0.28",
@@ -13764,7 +13764,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-nats",
@@ -13845,7 +13845,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"async-stream",
"async-trait",
@@ -13878,7 +13878,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13891,7 +13891,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"argon2",
@@ -14029,7 +14029,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14052,7 +14052,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14065,7 +14065,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14091,7 +14091,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14101,7 +14101,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14118,7 +14118,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14140,7 +14140,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14163,7 +14163,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14179,7 +14179,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14200,7 +14200,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14221,7 +14221,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14235,7 +14235,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-nats",
@@ -14267,7 +14267,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14292,7 +14292,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14310,7 +14310,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14332,7 +14332,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14352,7 +14352,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14382,7 +14382,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14410,7 +14410,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"lazy_static",
"serde",
@@ -14422,7 +14422,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14447,7 +14447,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14461,7 +14461,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14494,7 +14494,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"chrono",
"lazy_static",
@@ -14508,7 +14508,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14527,7 +14527,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14544,7 +14544,7 @@ dependencies = [
"axum 0.8.9",
"backon",
"base64 0.22.1",
"bitflags 2.11.1",
"bitflags 2.12.1",
"bytes",
"chrono",
"chrono-tz",
@@ -14628,7 +14628,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14647,7 +14647,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"regex",
"serde",
@@ -14662,7 +14662,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14686,7 +14686,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"futures",
@@ -14703,7 +14703,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14719,7 +14719,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -14740,7 +14740,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -14771,7 +14771,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"arc-swap",
@@ -14796,7 +14796,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-stream",
@@ -14830,7 +14830,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"futures",
@@ -14848,7 +14848,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14857,7 +14857,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -14869,7 +14869,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -14881,7 +14881,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"gosyn",
@@ -14893,7 +14893,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -14905,7 +14905,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -14917,7 +14917,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14939,7 +14939,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14951,7 +14951,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14962,7 +14962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -14984,7 +14984,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -14996,7 +14996,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15010,7 +15010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15027,7 +15027,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15040,7 +15040,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde",
@@ -15052,7 +15052,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15070,7 +15070,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15086,7 +15086,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15102,7 +15102,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde",
@@ -15113,7 +15113,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15151,7 +15151,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"const_format",
@@ -15189,7 +15189,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15200,7 +15200,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15230,7 +15230,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15254,7 +15254,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15287,7 +15287,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15320,7 +15320,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15340,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15374,7 +15374,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15410,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15433,7 +15433,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15457,7 +15457,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15481,7 +15481,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15516,7 +15516,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15544,7 +15544,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15569,10 +15569,10 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"bitflags 2.12.1",
"chrono",
"hex",
"itertools 0.14.0",
@@ -15588,7 +15588,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15698,7 +15698,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"bytes",
"futures",
@@ -15949,7 +15949,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.12.1",
"widestring",
"windows-sys 0.52.0",
]
@@ -16375,7 +16375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"bitflags 2.12.1",
"indexmap 2.14.0",
"log",
"serde",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.714.0"
version = "1.714.1"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.714.0"
version = "1.714.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.714.0"
version = "1.714.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.714.0"
version = "1.714.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+192
View File
@@ -0,0 +1,192 @@
-- Fixture for the single-job read authorization regression test
-- (see tests/jobs_read_auth.rs).
--
-- Users available from `base`:
-- test-user (admin, token SECRET_TOKEN)
-- test-user-2 (User, token SECRET_TOKEN_2) -- owner of the secret script
-- test-user-3 (User, token SECRET_TOKEN_3) -- the unprivileged "viewer"
--
-- test-user-3 is NOT a member of any folder/group granting access to
-- `u/test-user-2/...`, so under the same RLS as `jobs/list` they cannot see any
-- of these jobs unless they created them.
-- A tag-scoped token for test-user-2 (who can read both VICTIM (tag 'deno') and
-- the flow (tag 'flow')). The `if_jobs:filter_tags:deno` modifier restricts it to
-- the 'deno' tag, so it must NOT be able to mint a share token for the 'flow' job.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('SCOPED_DENO_TOKEN'::bytea), 'hex'), 'SCOPED_DEN', 'SCOPED_DENO_TOKEN',
'test2@windmill.dev', 'scoped deno token', false,
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
);
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
-- running-state to a non-reader.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'77777777-7777-7777-7777-777777777777', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/running_secret', 'deno', true
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
('77777777-7777-7777-7777-777777777777', 'test-workspace', '2023-01-01 00:00:00', true, 'deno');
-- 1. VICTIM job: a completed run of test-user-2's private script, e.g. produced
-- by a public HTTP trigger. `created_by` is the route identity (test-user-2),
-- NOT the viewer; `permissioned_as`/`runnable_path` sit in test-user-2's
-- namespace; `visible_to_owner` is true. Its args + result carry secrets.
-- Pre-fix, test-user-3 could read all of these by UUID.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/secret_script', 'deno', true,
'{"secret": "LEAK_TEST_ARGS"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 1000,
'success'::job_status, '{"secret": "RESULT_SECRET"}'
);
INSERT INTO public.job_logs (job_id, workspace_id, logs) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'secret logs LEAK_TEST_LOGS');
-- 2. APP-style job: run by the viewer (test-user-3) on behalf of an app whose
-- policy executes as test-user-2. `created_by` is the launching viewer, but
-- `permissioned_as`/`runnable_path` are the app owner's and
-- `visible_to_owner` is false (apps hide their component runs from the runs
-- list). This is the case that must KEEP working after the fix: the viewer
-- polls their own component result by UUID.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 'test-user-3',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
'{"app_arg": "ok"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 1000,
'success'::job_status, '{"app_result": "visible_to_launcher"}'
);
-- 3. ANONYMOUS job: a public-trigger run whose creator is `anonymous`. Reading
-- it without authentication must keep working (unchanged behavior).
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 'anonymous',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/public_trigger', 'deno', true,
'{"public": "arg"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 1000,
'success'::job_status, '{"public": "result"}'
);
-- 4. FLOW + STEP: test-user-3 has *read* access to folder `shared` (extra_perms),
-- so they can see flow `f/shared/flow1` (run by test-user-2) even though they
-- did not launch it. The flow's STEP job runs the inner script
-- `u/test-user-2/inner_secret` (test-user-3 has NO direct ACL on it) and is
-- not in their list. Visibility must be INHERITED from the flow root: being
-- able to see the flow means being able to inspect its steps (the flow-run UI
-- fetches each step by id). This guards against the fix over-blocking.
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', 'shared', 'Shared Folder', '{"u/test-user-2"}',
'{"u/test-user-3": false}', 'test-user-2');
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/flow1', 'flow', true
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 1000,
'success'::job_status, '{"flow": "done"}'
);
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job, args
) VALUES (
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/inner_secret', 'deno', true,
'dddddddd-dddd-dddd-dddd-dddddddddddd', 'dddddddd-dddd-dddd-dddd-dddddddddddd',
'dddddddd-dddd-dddd-dddd-dddddddddddd', '{"step_arg": "x"}'
);
INSERT INTO public.v2_job_completed (
id, workspace_id, duration_ms, status, result
) VALUES (
'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 1000,
'success'::job_status, '{"step": "STEP_RESULT_INHERITED"}'
);
-- 5. DEEP NESTING / MIDDLE-LAYER VISIBILITY: top flow `f/secret/top` is NOT
-- visible to test-user-3; it has a sub-flow step `f/shared/mid` that IS visible
-- (folder `shared`); and that sub-flow has its own leaf step running
-- `u/test-user-2/deep_secret` (not visible). The leaf's `root_job` points at the
-- *outermost* top (not visible), so visibility must come from the *intermediate*
-- sub-flow the user can see — which requires walking the full parent chain, not
-- just [self, root].
INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by)
VALUES ('test-workspace', 'secret', 'Secret Folder', '{"u/test-user-2"}', '{}', 'test-user-2');
-- top flow (not visible to test-user-3)
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/secret/top', 'flow', true
);
-- intermediate sub-flow (visible via folder `shared`), child of top
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'99999999-9999-9999-9999-999999999999', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'flow', 'deno', 'f/shared/mid', 'flow', true,
'ffffffff-ffff-ffff-ffff-ffffffffffff', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'ffffffff-ffff-ffff-ffff-ffffffffffff'
);
-- leaf step of the sub-flow; runnable not visible, root_job = outermost top (not visible)
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner,
parent_job, root_job, flow_innermost_root_job
) VALUES (
'88888888-8888-8888-8888-888888888888', 'test-workspace', 'test-user-2',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/deep_secret', 'deno', true,
'99999999-9999-9999-9999-999999999999', 'ffffffff-ffff-ffff-ffff-ffffffffffff',
'99999999-9999-9999-9999-999999999999'
);
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
('ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 1000, 'success'::job_status,
'{"top": "TOP_SECRET_RESULT"}'),
('99999999-9999-9999-9999-999999999999', 'test-workspace', 1000, 'success'::job_status,
'{"mid": "MID_RESULT"}'),
('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status,
'{"deep": "DEEP_STEP_INHERITED"}');
+512
View File
@@ -0,0 +1,512 @@
//! Regression test for the single-job read authorization bypass.
//!
//! The single-job read endpoints (`/jobs_u/get`, `/completed/get`,
//! `/completed/get_result`, `/get_args`, `/get_logs`, `/getupdate`, ...) fetch a
//! job through the root DB handle, filtered only by job id + workspace. That is
//! required for the unauthenticated approval / public-trigger / anonymous-job
//! flows, but for a *logged-in* user it meant any workspace member — including a
//! plain viewer with no ACL on the runnable — could read another user's job
//! args/result/logs simply by obtaining the job UUID, even though the same job is
//! hidden from them in `jobs/list` (RLS-filtered) and the underlying script
//! returns 404.
//!
//! The fix (`require_job_read_access`) gates the authenticated case: a caller may
//! read a job they created (covers app components / webhooks / their own runs)
//! or one visible to them under the same RLS as `jobs/list` (admins bypass);
//! otherwise 404. Unauthenticated access is unchanged (anonymous jobs only).
//!
//! This test pins down, against the `jobs_read_auth` fixture:
//! - a viewer is denied the victim job's full record / result / result_maybe /
//! args / logs / live update by UUID, and the secret never appears in the
//! body (the core fix; pre-fix these returned 200 with the secret),
//! - the job's owner and an admin can still read it (no over-blocking),
//! - the "app component" affordance survives: a viewer who *launched* a job
//! (created_by) running as someone else's identity can still read its result,
//! - unauthenticated behavior is unchanged: anonymous jobs readable, the
//! non-anonymous victim job rejected.
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const VICTIM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const APP_JOB: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const ANON_JOB: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc";
const FLOW_JOB: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd";
const STEP_JOB: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
// Deep nesting: top (not visible) -> mid (visible via folder) -> deep leaf.
const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
// A queued/running job (no completed row) owned by test-user-2.
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
const ARGS_SECRET: &str = "LEAK_TEST_ARGS";
const LOGS_SECRET: &str = "LEAK_TEST_LOGS";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
let mut req = client().get(format!("{base}/{path}"));
if let Some(token) = token {
req = req.header("Authorization", format!("Bearer {token}"));
}
let resp = req.send().await.expect("request");
let status = resp.status();
let body = resp.text().await.expect("body");
(status, body)
}
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u");
// result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`.
let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
// The endpoints that return the victim job's sensitive data by UUID.
let endpoints = [
("get", format!("get/{VICTIM}")),
("completed/get", format!("completed/get/{VICTIM}")),
(
"completed/get_result",
format!("completed/get_result/{VICTIM}"),
),
(
"completed/get_result_maybe",
format!("completed/get_result_maybe/{VICTIM}"),
),
("get_args", format!("get_args/{VICTIM}")),
("get_logs", format!("get_logs/{VICTIM}")),
(
"get_completed_logs_tail",
format!("get_completed_logs_tail/{VICTIM}"),
),
("get_flow_all_logs", format!("get_flow_all_logs/{VICTIM}")),
(
"completed/get_timing",
format!("completed/get_timing/{VICTIM}"),
),
("getupdate", format!("getupdate/{VICTIM}?only_result=true")),
];
// ---- CORE REGRESSION: the viewer (test-user-3) is denied on every endpoint
// and no secret ever appears in the body. Pre-fix these returned 200
// and leaked the secret.
for (name, path) in &endpoints {
let (status, body) = get(&base, path, Some("SECRET_TOKEN_3")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on {name} (got {status}): {body}"
);
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
assert!(
!body.contains(secret),
"viewer response for {name} leaked `{secret}`: {body}"
);
}
}
// The 403 for an existing-but-forbidden job carries actionable guidance
// (request a share link), distinguishing it from a plain not-found.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
assert!(
body.to_lowercase().contains("share"),
"403 body should guide the user to request a share link: {body}"
);
// A genuinely non-existent job is a 404, not a 403 — existence is only disclosed
// for jobs that actually exist in the workspace.
let missing = "00000000-0000-4000-8000-000000000000";
let (status, _) = get(
&base,
&format!("completed/get_result/{missing}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"a non-existent job must be 404, not 403 (got {status})"
);
// ---- NO OVER-BLOCKING: the job's owner (test-user-2) can read its result.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must still read their own job result (got {status}): {body}"
);
assert!(
body.contains(RESULT_SECRET),
"owner result must contain the value: {body}"
);
// ---- ADMIN BYPASS: an admin (test-user) can read any job in the workspace.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}"),
Some("SECRET_TOKEN"),
)
.await;
assert!(
status.is_success(),
"admin must read any job (got {status}): {body}"
);
assert!(body.contains(RESULT_SECRET), "admin result body: {body}");
// ---- APP AFFORDANCE: a viewer who LAUNCHED a job (created_by = viewer) that
// runs as another identity (permissioned_as = test-user-2,
// visible_to_owner = false) can still read its result. This is the app
// component-polling path; the fix must not break it.
let (status, body) = get(
&base,
&format!("completed/get_result/{APP_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"launcher must read a job they created even without ACL on the runnable (got {status}): {body}"
);
assert!(
body.contains("visible_to_launcher"),
"launcher should get the result they polled: {body}"
);
// ---- AUTHED `/jobs` endpoints in the same class: result_by_id (flow node
// result) and get_otel_traces (job telemetry). The viewer must be denied
// the victim by UUID. The auth gate runs before result/trace resolution,
// so 404 here is the gate, not incidental resolution failure.
let (status, body) = get(
&authed_base,
&format!("result_by_id/{VICTIM}/somenode"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on result_by_id (got {status}): {body}"
);
assert!(!body.contains(RESULT_SECRET), "result_by_id leaked: {body}");
let (status, body) = get(
&authed_base,
&format!("get_otel_traces/{VICTIM}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must get 403 on get_otel_traces (got {status}): {body}"
);
// ---- FLOW VISIBILITY INHERITANCE: test-user-3 has folder ACL on the flow
// `f/shared/flow1` (run by test-user-2) but did NOT launch it, and has no
// ACL on the step's inner runnable `u/test-user-2/inner_secret`. They must
// still be able to (a) read the flow they can see, and (b) inspect its
// step result — visibility is inherited from the flow root. A naive
// "same as list" gate would 404 the step and break the flow-run UI.
let (status, body) = get(
&base,
&format!("completed/get_result/{FLOW_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer with folder ACL must read the flow they can see (got {status}): {body}"
);
let (status, body) = get(
&base,
&format!("completed/get_result/{STEP_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"viewer must inspect a step of a flow they can see, even without ACL on the step's runnable (got {status}): {body}"
);
assert!(
body.contains("STEP_RESULT_INHERITED"),
"step result should be returned via flow-root inheritance: {body}"
);
// ---- DEEP NESTING / MIDDLE-LAYER VISIBILITY: the deep leaf's root_job is the
// top flow (NOT visible to test-user-3), but an intermediate sub-flow
// (f/shared/mid) IS visible. Reading the leaf must succeed via that middle
// ancestor — i.e. the full parent chain is walked, not just [self, root].
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"deep leaf must be readable via a visible intermediate sub-flow (got {status}): {body}"
);
assert!(
body.contains("DEEP_STEP_INHERITED"),
"deep leaf result should be returned via mid-ancestor visibility: {body}"
);
// ...but the top flow itself, in a folder the viewer cannot read, stays denied.
let (status, body) = get(
&base,
&format!("completed/get_result/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"top flow in an unreadable folder must stay denied (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
// without a token (public trigger / public app result polling).
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
assert!(
status.is_success(),
"anonymous job must remain readable unauthenticated (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: the non-anonymous victim job is rejected
// for an unauthenticated caller (400, the pre-existing guard).
let (status, body) = get(&base, &format!("completed/get_result/{VICTIM}"), None).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"unauthenticated access to a non-anonymous job must stay rejected (got {status}): {body}"
);
assert!(
!body.contains(RESULT_SECRET),
"unauth body must not leak: {body}"
);
// ---- SHARE READ LINK (view_token) ----
// The owner (test-user-2) mints a share token for the victim job.
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{VICTIM}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner must be able to mint a share token (got {status}): {mint_body}"
);
let token = mint_body.trim().trim_matches('"').to_string();
assert!(
token.starts_with(VICTIM),
"token must encode the job id: {token}"
);
// The viewer (no ACL) can now read the victim job via the share link.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"view_token must grant the viewer read of the shared job (got {status}): {body}"
);
assert!(
body.contains(RESULT_SECRET),
"shared job result must be returned with a valid view_token: {body}"
);
// ...and its args/logs too (whole detail page).
let (status, _) = get(
&base,
&format!("get_args/{VICTIM}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"view_token must also grant args (got {status})"
);
// The token is scoped: it does NOT authorize an unrelated job.
let (status, _) = get(
&base,
&format!("completed/get_result/{ANON_JOB}?view_token={token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"a victim-scoped token must not authorize a different job (got {status})"
);
// A garbage token is rejected (falls through to the normal 404).
let (status, _) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={VICTIM}.deadbeef"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"an invalid view_token must not grant access (got {status})"
);
// A share token authorizes the shared job's whole flow subtree: the owner mints
// for the top secret flow, and the viewer can then read its deep leaf.
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner mints token for top flow (got {status}): {mint_body}"
);
let top_token = mint_body.trim().trim_matches('"').to_string();
let (status, body) = get(
&base,
&format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={top_token}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert!(
status.is_success(),
"a flow's share token must authorize its deep descendants (got {status}): {body}"
);
// A viewer who cannot read a job cannot mint a share token for it.
let (status, _) = get(
&authed_base,
&format!("job_view_token/{TOP_SECRET_FLOW}"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"a non-reader must not be able to mint a share token (got {status})"
);
// ---- TAG-SCOPED token must not mint a token outside its allowed tags ----
// SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both
// VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the
// tag scope: allowed for the deno job, denied for the flow job.
let (status, body) = get(
&authed_base,
&format!("job_view_token/{VICTIM}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert!(
status.is_success(),
"tag-scoped token may mint for an in-scope (deno) job (got {status}): {body}"
);
let (status, _) = get(
&authed_base,
&format!("job_view_token/{FLOW_JOB}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"tag-scoped token must NOT mint for an out-of-scope (flow) job (got {status})"
);
// ---- USE side: a tag-scoped token must not use someone else's valid view_token
// to read an out-of-scope job, even via handlers that don't tag-filter their
// data query (result_by_id, get_otel_traces, get_flow_debug_info). ----
// An unscoped owner mints a valid token for the flow (tag 'flow').
let (status, mint_body) = get(
&authed_base,
&format!("job_view_token/{FLOW_JOB}"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success(),
"owner mints flow token (got {status}): {mint_body}"
);
let flow_token = mint_body.trim().trim_matches('"').to_string();
// The deno-scoped token presents that valid flow token to the non-tag-filtered
// endpoints — must still be denied (flow tag is out of its scope).
for path in [
format!("get_otel_traces/{FLOW_JOB}?view_token={flow_token}"),
format!("result_by_id/{FLOW_JOB}/somenode?view_token={flow_token}"),
] {
let (status, _) = get(&authed_base, &path, Some("SCOPED_DENO_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"tag-scoped token must not use a view_token to read an out-of-scope job ({path}, got {status})"
);
}
// ...but the deno-scoped token CAN use an in-scope (deno) view_token.
let (status, body) = get(
&base,
&format!("completed/get_result/{VICTIM}?view_token={token}"),
Some("SCOPED_DENO_TOKEN"),
)
.await;
assert!(
status.is_success(),
"tag-scoped token may use a view_token for an in-scope (deno) job (got {status}): {body}"
);
// ---- get_result_maybe?get_started=true must authorize before disclosing the
// running-state of a queued (not-yet-completed) private job. ----
// Viewer (no ACL) must be denied rather than told the job is started.
let (status, body) = get(
&base,
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
Some("SECRET_TOKEN_3"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must be denied the running-state of a private queued job (got {status}): {body}"
);
assert!(
!body.contains("\"started\""),
"denied response must not disclose started-state: {body}"
);
// The owner still gets the in-progress response.
let (status, body) = get(
&base,
&format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"),
Some("SECRET_TOKEN_2"),
)
.await;
assert!(
status.is_success() && body.contains("\"started\":true"),
"owner must see the running job as started (got {status}): {body}"
);
Ok(())
}
+28 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.714.0
version: 1.714.1
title: Windmill API
contact:
@@ -9318,6 +9318,33 @@ paths:
application/json:
schema: {}
/w/{workspace}/jobs/job_view_token/{id}:
get:
summary: mint a read-only share token for a job
description: >
Returns a stateless `{job_id}.{hmac}` token that grants an authenticated
workspace member read access to this job (and its flow subtree) via a
`view_token` query param or `X-View-Token` header. Only callable by a user
who can already read the job.
operationId: getJobViewToken
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: the share read token
content:
text/plain:
schema:
type: string
/w/{workspace}/flows/list_paths:
get:
summary: list all flow paths
+632 -37
View File
@@ -17,6 +17,7 @@ use itertools::Itertools;
use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
@@ -344,6 +345,10 @@ pub fn workspaced_service() -> Router {
"/result_by_id/{job_id}/{node_id}",
get(get_result_by_id).layer(cors.clone()),
)
.route(
"/job_view_token/{id}",
get(get_job_view_token).layer(cors.clone()),
)
.route("/run/dependencies", post(run_dependencies_job))
.route("/run/dependencies_async", post(run_dependencies_job_async))
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
@@ -426,12 +431,27 @@ struct JsonPath {
pub approver: Option<String>,
}
async fn get_result_by_id(
OptViewToken(view_token): OptViewToken,
authed: ApiAuthed,
tokened: Tokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
// Reading a node's result requires being able to read the flow itself (the node
// belongs to it). Gate on the flow's visibility (created_by / RLS / root
// inheritance) before resolving via the root DB.
require_job_update_read_access(
&db,
&user_db,
&authed,
&w_id,
&flow_id,
view_token.as_deref(),
)
.await?;
let res =
windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path)
.await?;
@@ -441,6 +461,25 @@ async fn get_result_by_id(
Ok(Json(res))
}
/// Mint a stateless "share read link" token for a job. Only a caller who can already
/// read the job (creator / RLS / flow ancestor / admin) may mint it. The returned
/// `{job_id}.{hmac}` is passed back as the `view_token` query param on the run page's
/// reads, granting an authenticated member read of this job and its flow subtree.
async fn get_job_view_token(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<String> {
// No `view_token` here: minting requires the caller's own read access, so a share
// link cannot be used to mint further links. `require_job_read_access` also
// enforces the caller's `if_jobs:filter_tags` scope, so a tag-scoped token can't
// mint a transferable link for a job outside its allowed tags.
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
let hmac = generate_view_token(&w_id, id, &db).await?;
Ok(format!("{id}.{hmac}"))
}
async fn get_root_job(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
@@ -690,9 +729,11 @@ async fn get_scheduled_for(
}
async fn get_flow_job_debug_info(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
tokened_o: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let job = GetQuery::new()
@@ -700,6 +741,18 @@ async fn get_flow_job_debug_info(
.fetch_queued((&db).into(), &id, &w_id)
.await?;
if let Some(job) = job {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&job.created_by,
view_token.as_deref(),
)
.await?;
}
let is_flow = job.is_flow();
if job.is_flow_step || !is_flow {
return Err(error::Error::BadRequest(
@@ -857,10 +910,313 @@ struct GetJobQuery {
pub approval_token: Option<String>,
}
/// Authorize an *authenticated* caller to read a single job's data
/// (full job / args / result / logs / live updates).
///
/// Single-job read endpoints query through the root `DB` (RLS-bypassing), filtered
/// only by job id + workspace (+ token scope tags). That is required for the
/// unauthenticated approval / public-trigger / anonymous-job flows, but for a
/// logged-in user it meant any workspace member — e.g. a viewer with no ACL on the
/// runnable — could read another user's job args/result/logs simply by obtaining the
/// job UUID, even though the same job is hidden from them in `jobs/list`
/// (RLS-filtered) and the underlying script returns 404. (WIN-2026-jobs-read)
///
/// Unauthenticated callers are still handled by each handler's anonymous-job check;
/// this gate applies only when a user is authenticated. Access is granted when:
/// - the caller created the job (`created_by`) — covers app components, webhooks and
/// the caller's own runs, whose `permissioned_as` is the policy identity rather
/// than the caller, so they would otherwise fail the RLS probe; or
/// - the job is visible to the caller under the same RLS as `jobs/list`, probed on
/// `v2_job` via `user_db` (admins BYPASSRLS).
///
/// Optional share-read-link token (validated by [`validate_view_token`]). Read from
/// the `view_token` query parameter — needed for `EventSource`/SSE and direct links,
/// which can't set headers — falling back to the `X-View-Token` header, which lets the
/// frontend attach it to every generated-client request via a single interceptor
/// instead of threading it through each call. Read independently of each handler's own
/// `Query<T>` extractor (axum allows only one typed `Query`).
pub struct OptViewToken(pub Option<String>);
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for OptViewToken {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let from_query = parts.uri.query().and_then(|q| {
serde_urlencoded::from_str::<Vec<(String, String)>>(q)
.ok()
.and_then(|pairs| {
pairs
.into_iter()
.find(|(k, _)| k == "view_token")
.map(|(_, v)| v)
})
});
let token = from_query.or_else(|| {
parts
.headers
.get("x-view-token")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
Ok(OptViewToken(token))
}
}
/// Otherwise returns 404 — matching `scripts/get` and avoiding existence disclosure.
async fn require_job_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
) -> error::Result<()> {
// Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a
// scoped token: it must never read a job outside its allowed tags, regardless of
// how authorization is otherwise satisfied (created_by / view token / RLS). Most
// read handlers also tag-filter their data query, but some (result_by_id,
// get_flow_job_debug_info, get_otel_traces) do not, so enforce it here — before
// the grants below — so a share token can't be used to escape the tag scope.
// `get_scope_tags` is `None` for unscoped callers (the common case), so this adds
// no query for normal sessions/tokens.
if let Some(tags) = get_scope_tags(authed) {
let in_scope = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))",
job_id,
w_id,
&tags.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
)
.fetch_one(db)
.await?
== Some(true);
if !in_scope {
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
}
// Fast path: you can always read a job you launched. This is also load-bearing
// for apps — a component job runs as the app policy's `permissioned_as`, but its
// `created_by` is the launching viewer, so the RLS probe below would hide it.
if created_by == authed.username
|| authed
.username_override
.as_deref()
.is_some_and(|u| u == created_by)
{
return Ok(());
}
// Share read link: a valid view token minted by someone with read access grants
// this authenticated member read of the shared job and its flow subtree.
if let Some(token) = view_token {
if validate_view_token(db, w_id, job_id, token).await? {
return Ok(());
}
}
// The probe below (chain walk + an RLS-scoped transaction) is comparatively
// expensive and the same (caller, job) is hit repeatedly — e.g. `getupdate`
// polling of a run you can see but did not launch, or an admin watching many
// runs. Cache the boolean outcome. All job-side inputs to the decision
// (created_by, runnable_path, permissioned_as, visible_to_owner, flow lineage)
// are immutable after creation, and every mutable caller-side input
// (is_admin / username / username_override / groups / folders) is folded into
// the key — so a permission change yields a new key rather than a stale hit, and
// no TTL is needed (size-bounded LRU; mirrors apps' PERMIT_CACHE).
let cache_key = job_read_access_cache_key(authed, w_id, job_id);
let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) {
visible
} else {
// Visibility is inherited along the flow hierarchy: if you can read ANY flow
// that (transitively) contains this job, you can read the job. A step runs as
// its flow's `permissioned_as` but its `runnable_path` is the inner runnable's
// — which the caller may have no direct ACL on — and the flow-run UI fetches
// each step by id, so gating purely on the step's own RLS visibility would
// break inspecting a flow you can see but did not launch. We therefore probe
// RLS visibility of the job OR any of its `parent_job` ancestors (admins
// BYPASSRLS) — the same visibility as `jobs/list`.
let chain_ids = job_ancestor_chain_ids(db, w_id, job_id).await?;
let mut tx = user_db.clone().begin(authed).await?;
let visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&chain_ids[..],
w_id,
)
.fetch_one(&mut *tx)
.await?
== Some(true);
tx.commit().await?;
JOB_READ_ACCESS_CACHE.insert(cache_key, visible);
visible
};
if visible {
return Ok(());
}
// Denied. Distinguish "the run exists but you lack access" (actionable: ask a
// colleague for a share link) from "no such run", so the UI can guide the user.
// Only authenticated members reach this point and job UUIDs are non-enumerable,
// so disclosing mere existence to a member is an acceptable trade-off for the UX.
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)",
&[*job_id][..],
w_id,
)
.fetch_one(db)
.await?
== Some(true);
if exists {
Err(Error::PermissionDenied(format!(
"You do not have access to run {job_id}. Ask a user who can see it to open the run and \
share a read-only link with you (the \"Share\" button on the run page)."
)))
} else {
Err(Error::NotFound(format!("Job {job_id} not found")))
}
}
/// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level
/// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive).
/// Falls back to `[job_id]` if the row is absent so callers still run their probe.
async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Result<Vec<Uuid>> {
let chain_ids = sqlx::query_scalar!(
r#"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
SELECT j.id, j.parent_job FROM v2_job j
JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2
)
SELECT id AS "id!" FROM chain"#,
job_id,
w_id,
)
.fetch_all(db)
.await?;
Ok(if chain_ids.is_empty() {
vec![*job_id]
} else {
chain_ids
})
}
/// A share read link token has the form `{shared_job_id}.{hmac}` where `hmac` is
/// [`windmill_common::variables::generate_view_token`] for `shared_job_id`. It grants
/// read of that job and its whole flow subtree, so the run page can present a single
/// link that also renders the flow's steps. Returns true iff the signature is valid
/// AND `accessed_job_id` is the shared job or one of its descendants.
async fn validate_view_token(
db: &DB,
w_id: &str,
accessed_job_id: &Uuid,
token: &str,
) -> error::Result<bool> {
let Some((shared_id_str, provided_hmac)) = token.split_once('.') else {
return Ok(false);
};
let Ok(shared_id) = Uuid::parse_str(shared_id_str) else {
return Ok(false);
};
let Ok(provided_bytes) = hex::decode(provided_hmac) else {
return Ok(false);
};
// Constant-time verification (same domain as `generate_view_token`, mirroring
// `verify_suspended_secret`); avoids the timing side-channel of comparing the
// hex strings with `!=`.
let key = get_workspace_key(w_id, db).await?;
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?;
mac.update(shared_id.as_bytes());
mac.update(b"view_token");
if mac.verify_slice(&provided_bytes).is_err() {
return Ok(false);
}
if accessed_job_id == &shared_id {
return Ok(true);
}
// The token authorizes the shared job's subtree: accessed must descend from it,
// i.e. the shared job is among accessed's ancestors.
let chain = job_ancestor_chain_ids(db, w_id, accessed_job_id).await?;
Ok(chain.contains(&shared_id))
}
lazy_static::lazy_static! {
/// Caches the result of the `require_job_read_access` RLS visibility probe,
/// keyed by the caller's authorization-relevant identity plus the job id (see
/// [`job_read_access_cache_key`]). No TTL: the cached decision is a pure function
/// of immutable job-side state and the caller-side state encoded in the key, so a
/// permission change re-keys rather than going stale. Size-bounded LRU.
static ref JOB_READ_ACCESS_CACHE: Cache<[u8; 32], bool> = Cache::new(50_000);
}
/// Key for [`JOB_READ_ACCESS_CACHE`]: a SHA-256 over every caller-side input that
/// affects job-read visibility (admin flag, username, username override, the sorted
/// group set, and the sorted folder set the caller has any grant on — RLS reads from
/// all of them) plus the workspace and job id. Sorting makes the key order-independent;
/// each variable-length field is length-prefixed so no choice of input values can make
/// two distinct identities hash equal (e.g. `["a","bc"]` vs `["ab","c"]`).
fn job_read_access_cache_key(authed: &ApiAuthed, w_id: &str, job_id: &Uuid) -> [u8; 32] {
let mut hasher = Sha256::new();
// Length-prefix every variable-length field (u32 BE) to make the encoding injective.
let field = |hasher: &mut Sha256, bytes: &[u8]| {
hasher.update((bytes.len() as u32).to_be_bytes());
hasher.update(bytes);
};
hasher.update([authed.is_admin as u8]);
field(&mut hasher, authed.username.as_bytes());
field(
&mut hasher,
authed.username_override.as_deref().unwrap_or("").as_bytes(),
);
let mut groups: Vec<&str> = authed.groups.iter().map(String::as_str).collect();
groups.sort_unstable();
hasher.update((groups.len() as u32).to_be_bytes());
for g in groups {
field(&mut hasher, g.as_bytes());
}
let mut folders: Vec<&str> = authed.folders.iter().map(|f| f.0.as_str()).collect();
folders.sort_unstable();
hasher.update((folders.len() as u32).to_be_bytes());
for f in folders {
field(&mut hasher, f.as_bytes());
}
field(&mut hasher, w_id.as_bytes());
hasher.update(job_id.as_bytes());
hasher.finalize().into()
}
/// [`require_job_read_access`] for callers (job-update poll / SSE) that haven't
/// already loaded `created_by` — fetches it (root DB, by id+workspace) first.
async fn require_job_update_read_access(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
view_token: Option<&str>,
) -> error::Result<()> {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await
}
async fn get_job(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetJobQuery { no_logs, no_code, approval_token }): Query<GetJobQuery>,
) -> error::Result<Response> {
@@ -903,6 +1259,23 @@ async fn get_job(
let mut job = get.fetch(&db, &id, &w_id).await?;
job.fetch_outstanding_wait_time(&db).await?;
// A valid approval token is itself the capability; otherwise an authenticated
// caller must pass the same visibility as `jobs/list` (see `require_job_read_access`).
if !has_valid_approval_token {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
job.created_by(),
view_token.as_deref(),
)
.await?;
}
}
log_job_view(
&db,
opt_authed.as_ref(),
@@ -1477,8 +1850,10 @@ async fn get_logs_from_disk(
}
async fn get_completed_job_logs_tail(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<String> {
let tags = opt_authed
@@ -1501,7 +1876,18 @@ async fn get_completed_job_logs_tail(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1519,9 +1905,11 @@ struct QueryJobLogs {
}
async fn get_job_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(query_job_logs): Query<QueryJobLogs>,
) -> error::Result<Response> {
@@ -1552,7 +1940,18 @@ async fn get_job_logs(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1680,9 +2079,11 @@ async fn resolve_logs_to_string(
}
async fn get_flow_all_logs(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
@@ -1702,7 +2103,18 @@ async fn get_flow_all_logs(
let root_job = not_found_if_none(root_job, "Job", id.to_string())?;
if opt_authed.is_none() && root_job.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&root_job.created_by,
view_token.as_deref(),
)
.await?;
} else if root_job.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1858,9 +2270,11 @@ async fn get_flow_all_logs(
}
async fn get_args(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<Box<RawValue>> {
let tags = opt_authed
@@ -1879,7 +2293,18 @@ async fn get_args(
.await?;
if let Some(record) = record {
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -1907,7 +2332,18 @@ async fn get_args(
.fetch_optional(&db)
.await?;
let record = not_found_if_none(record, "Job Args", id.to_string())?;
if opt_authed.is_none() && record.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&record.created_by,
view_token.as_deref(),
)
.await?;
} else if record.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -2453,7 +2889,7 @@ pub async fn resume_suspended_flow_as_owner(
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
use windmill_common::variables::{generate_approval_token, generate_view_token};
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
@@ -7107,8 +7543,10 @@ pub async fn run_job_by_hash_inner(
}
async fn get_log_file(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, file_p)): Path<(String, String)>,
) -> error::Result<Response> {
if file_p.contains("..") {
@@ -7147,7 +7585,18 @@ async fn get_log_file(
.fetch_optional(&db)
.await?
.ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?;
if opt_authed.is_none() && created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(error::Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -7217,9 +7666,11 @@ async fn get_log_file(
}
async fn get_job_update(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
@@ -7232,6 +7683,17 @@ async fn get_job_update(
..
}): Query<JobUpdateQuery>,
) -> JsonResult<JobUpdate> {
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
Ok(Json(
get_job_update_data(
&opt_authed,
@@ -7259,9 +7721,11 @@ async fn get_job_update(
}
async fn get_job_update_sse(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery {
log_offset,
@@ -7275,6 +7739,20 @@ async fn get_job_update_sse(
poll_delay_ms,
}): Query<JobUpdateQuery>,
) -> error::Result<Response> {
// Authorize once at connection time; `created_by` cannot change for a given job,
// mirroring the per-stream `anonymous_verified` latch in the streaming loop.
if let Some(authed) = opt_authed.as_ref() {
require_job_update_read_access(
&db,
&user_db,
authed,
&w_id,
&job_id,
view_token.as_deref(),
)
.await?;
}
let (tx, rx) = tokio::sync::mpsc::channel(32);
start_job_update_sse_stream(
@@ -8034,9 +8512,11 @@ async fn list_completed_jobs(
}
async fn get_completed_job<'a>(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let tags = opt_authed
@@ -8051,6 +8531,20 @@ async fn get_completed_job<'a>(
.await?;
let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?;
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&cj.created_by,
view_token.as_deref(),
)
.await?;
}
let response = Json(cj).into_response();
// let extra_log = query_scalar!(
// "SELECT substr(logs, $1) as logs FROM large_logs WHERE workspace_id = $2 AND job_id = $3",
@@ -8081,9 +8575,11 @@ pub struct RawResult {
}
async fn get_completed_job_result(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query<JsonPath>,
) -> error::Result<Response> {
@@ -8128,26 +8624,40 @@ async fn get_completed_job_result(
let mut raw_result = not_found_if_none(result_o, "Completed Job", id.to_string())?;
if opt_authed.is_none() && raw_result.created_by.unwrap_or_default() != "anonymous" {
match (suspended_job, resume_id, approver, secret) {
(Some(suspended_job), Some(resume_id), approver, Some(secret)) => {
let mut parent_job = id;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
if let Some(p_job) = p_job {
parent_job = p_job;
} else {
return Err(Error::BadRequest("Approval secret of suspended job is not a parent of the job whose id's is being searched not found".to_string()));
let created_by = raw_result.created_by.take().unwrap_or_default();
// A valid approval secret for the suspended parent flow grants access to this
// node's result for ANY caller — logged in or not — since the approval page
// renders its form from this result. Try it first. If the secret triple is absent,
// or present but invalid, fall through to normal authorization: an authenticated
// reader with ACL must NOT be blocked just because a stale/garbage secret was
// attached (pre-fix the secret branch was skipped entirely for authed callers),
// while an unauthenticated caller, for whom the secret is the only credential,
// still ends up rejected below.
let approval_secret_ok = match (suspended_job, resume_id, secret) {
(Some(suspended_job), Some(resume_id), Some(secret)) => {
// Walk from `id` up to the claimed suspended parent.
let mut parent_job = id;
let mut reached = true;
while parent_job != suspended_job {
let p_job = sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2",
parent_job,
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
match p_job {
Some(p_job) => parent_job = p_job,
None => {
reached = false;
break;
}
}
verify_suspended_secret(
}
reached
&& verify_suspended_secret(
&w_id,
&db,
suspended_job,
@@ -8155,14 +8665,28 @@ async fn get_completed_job_result(
&QueryApprover { approver, flow_level: None },
secret,
)
.await?
}
_ => {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
))
}
.await
.is_ok()
}
_ => false,
};
if !approval_secret_ok {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
}
@@ -8235,9 +8759,11 @@ struct GetCompletedJobQuery {
}
async fn get_completed_job_result_maybe(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetCompletedJobQuery { get_started }): Query<GetCompletedJobQuery>,
) -> error::Result<Response> {
@@ -8263,7 +8789,18 @@ async fn get_completed_job_result_maybe(
if let Some(mut res) = result_o {
format_result(res.result_columns.as_ref(), res.result.as_mut());
if opt_authed.is_none() && res.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&res.created_by,
view_token.as_deref(),
)
.await?;
} else if res.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -8286,6 +8823,36 @@ async fn get_completed_job_result_maybe(
})
.into_response())
} else if get_started.is_some_and(|x| x) {
// No completed row yet — the job may be queued/running. Returning its
// running-state still discloses information about a (possibly private) job, so
// authorize first when the job exists. If it doesn't exist, fall through to a
// `started: false` response (which leaks nothing).
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
if let Some(created_by) = created_by {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
));
}
}
let started = sqlx::query_scalar!(
"SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2",
id,
@@ -8320,8 +8887,10 @@ struct JobTiming {
}
async fn get_completed_job_timing(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::JsonResult<JobTiming> {
let tags = opt_authed
@@ -8347,7 +8916,18 @@ async fn get_completed_job_timing(
let result = not_found_if_none(result, "Completed Job", id.to_string())?;
if opt_authed.is_none() && result.created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&result.created_by,
view_token.as_deref(),
)
.await?;
} else if result.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
@@ -8367,7 +8947,7 @@ async fn delete_completed_job<'a>(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let mut tx = user_db.begin(&authed).await?;
let mut tx = user_db.clone().begin(&authed).await?;
require_admin(authed.is_admin, &authed.username)?;
let tags = get_scope_tags(&authed);
@@ -8411,17 +8991,21 @@ async fn delete_completed_job<'a>(
tx.commit().await?;
return get_completed_job(
OptViewToken(None),
OptAuthed(Some(authed)),
OptTokened { token: Some(token) },
Extension(db),
Extension(user_db),
Path((w_id, id)),
)
.await;
}
async fn get_otel_traces(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Json<Vec<serde_json::Value>>> {
// Check job exists and user has permission to view it
@@ -8435,7 +9019,18 @@ async fn get_otel_traces(
match job {
Some(created_by) => {
if opt_authed.is_none() && created_by != "anonymous" {
if let Some(authed) = opt_authed.as_ref() {
require_job_read_access(
&db,
&user_db,
authed,
&w_id,
&id,
&created_by,
view_token.as_deref(),
)
.await?;
} else if created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
+20
View File
@@ -174,6 +174,26 @@ pub async fn generate_approval_token(
Ok(hex::encode(mac.finalize().into_bytes()))
}
/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || "view_token")`.
/// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can
/// never be used as a view token (or vice-versa). Used to build a "share read link" that
/// grants an authenticated workspace member read access to a job (and its flow subtree)
/// they otherwise lack ACL on. No expiry/revocation (stateless), like the approval token.
pub async fn generate_view_token(
w_id: &str,
job_id: uuid::Uuid,
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let key = get_workspace_key(w_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job_id.as_bytes());
mac.update(b"view_token");
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub async fn get_secret_value_as_admin(
db: &DB,
w_id: &str,
@@ -337,6 +337,20 @@ mod zoom {
return Ok(None);
}
// Prevent this challenge endpoint from being used as a signing oracle.
// Legitimate Zoom validation tokens are short random hex strings that
// never contain colons. The exploit requires crafting a plainToken in the
// `v0:{timestamp}:{body}` webhook-signing format (always containing colons)
// to obtain a valid signature for an arbitrary body. Reject any token that
// does not look like a legitimate Zoom validation token.
if zoom_request_body.payload.plain_token.contains(':')
|| zoom_request_body.payload.plain_token.len() > 128
{
return Err(AuthenticationError::InvalidChallengeResponse(
"Zoom: invalid plainToken format".to_string(),
));
}
let hmac_signature = calculate_hmac_signature(
HmacAlgorithm::Sha256,
&signature_config_data.secret_key,
@@ -1540,6 +1554,52 @@ mod tests {
assert!(response.is_none());
}
#[test]
fn test_zoom_challenge_normal_token_succeeds() {
// A legitimate Zoom validation token is a short random alphanumeric string.
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#;
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
let response = handler
.handle_challenge_request(&HeaderMap::new(), &config_data, payload)
.unwrap();
assert!(response.is_some());
}
#[test]
fn test_zoom_challenge_token_with_colons_rejected() {
// Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format
// would let an attacker obtain a valid webhook signature for an arbitrary body.
let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#;
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload);
assert!(matches!(
result,
Err(AuthenticationError::InvalidChallengeResponse(_))
));
}
#[test]
fn test_zoom_challenge_token_too_long_rejected() {
// A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token.
let long_token = "a".repeat(129);
let payload = format!(
r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#,
long_token
);
let handler = WebhookType::Zoom.get_webhook_handler().unwrap();
let config_data = SignatureConfigData { secret_key: "zoom_secret" };
let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload);
assert!(matches!(
result,
Err(AuthenticationError::InvalidChallengeResponse(_))
));
}
// --- Custom webhook end-to-end ---
#[test]
@@ -8,7 +8,11 @@ time_limit: 900
rlimit_as: 2048
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
# uv's --compile-bytecode spawns a Python interpreter that compiles .py files
# with parallelism scaling to the host's CPU count, opening many fds at once.
# A low cap (was 64) is exhausted on high-core machines -> "Too many open files".
# Matches the runtime configs (run.python3/run.ansible) which already use 10000.
rlimit_nofile: 10000
envar: "HOME=/user"
envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH"
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.714.0";
export const VERSION = "v1.714.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -89,7 +89,7 @@ export {
token,
};
export const VERSION = "1.714.0";
export const VERSION = "1.714.1";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+1 -1
View File
@@ -83,7 +83,7 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+1 -1
View File
@@ -83,7 +83,7 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.714.0",
"version": "1.714.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.714.0",
"version": "1.714.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.714.0",
"version": "1.714.1",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -6,6 +6,7 @@
import { copyToClipboard, parseS3Object, roughSizeOfObject } from '$lib/utils'
import { base } from '$lib/base'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { Button, Drawer, DrawerContent } from './common'
import {
ClipboardCopy,
@@ -176,9 +177,11 @@
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
? appendViewToken(
nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
)
: undefined
)
let resultDownloadHref = $derived(
@@ -1016,9 +1019,7 @@
{#if largeObject}
<div class="text-xs text-emphasis"
>{#if resultApiPath && shouldDownloadViaClient()}
<button
onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}
>
<button onclick={() => downloadViaClient(resultApiPath!, resultDownloadName)}>
Download {filename ? '' : 'as JSON'}
</button>
{:else}
@@ -4,6 +4,7 @@
import Popover from './Popover.svelte'
import { copyToClipboard } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import type { DisplayResultUi } from './custom_ui'
import { createEventDispatcher } from 'svelte'
@@ -41,9 +42,11 @@
let resultApiPath = $derived(
workspaceId && jobId
? nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
? appendViewToken(
nodeId
? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}`
: `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}`
)
: undefined
)
let downloadName = $derived(`${filename ?? 'result'}.json`)
@@ -28,6 +28,7 @@
import ModuleStatus from './ModuleStatus.svelte'
import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import JobArgs from './JobArgs.svelte'
import { ChevronDown, Download, ExternalLink, Hourglass } from 'lucide-svelte'
import { deepEqual } from 'fast-equals'
@@ -1839,7 +1840,9 @@
style="min-height: {minTabHeight}px"
>
{#if !hideDownloadLogs && !isReplay && job?.id}
{@const logsApiPath = `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`}
{@const logsApiPath = appendViewToken(
`/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`
)}
{@const logsName = `windmill_flow_logs_${job.id}.txt`}
<div class="flex justify-end p-1">
{#if shouldDownloadViaClient()}
+4 -1
View File
@@ -14,6 +14,7 @@
import { deepEqual } from 'fast-equals'
import { isWindmillTooBigObject } from './job_args'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
interface Props {
id?: string | undefined
@@ -29,7 +30,9 @@
let jsonStr = $state('')
const argsDownloadName = 'windmill-args.json'
let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined)
let argsApiPath = $derived(
id && workspace ? appendViewToken(`/w/${workspace}/jobs_u/get_args/${id}`) : undefined
)
let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`)
function pythonCode() {
+18 -1
View File
@@ -15,6 +15,7 @@
type OpenFlow
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getViewToken } from '$lib/viewToken'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { getContext, onDestroy, tick, untrack } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
@@ -47,6 +48,9 @@
noLogs?: boolean
workspaceOverride?: string | undefined
notfound?: boolean
/** Status/body of the last load failure, so callers can distinguish e.g. a
* 403 (job exists but no access — offer a share link) from a 404. */
loadError?: { status?: number; message?: string } | undefined
allowConcurentRequests?: boolean
jobUpdateLastFetch?: Date | undefined
toastError?: boolean
@@ -65,6 +69,7 @@
allowConcurentRequests = false,
workspaceOverride = undefined,
notfound = $bindable(false),
loadError = $bindable(undefined),
jobUpdateLastFetch = $bindable(undefined),
toastError = false,
onlyResult = false,
@@ -600,9 +605,14 @@
}
}
notfound = false
loadError = undefined
} catch (err) {
const status = (err as any)?.status
loadError = { status, message: (err as any)?.body ?? (err as any)?.message }
errorIteration += 1
if (errorIteration == 5) {
// Auth failures won't resolve by retrying: surface them immediately so
// the caller can show the right message (e.g. 403 -> request a share link).
if (status === 403 || status === 404 || errorIteration == 5) {
notfound = true
job = undefined
clearCurrentId()
@@ -754,6 +764,13 @@
params.set('token', token.token)
}
// Share read link: SSE/EventSource can't set the X-View-Token header,
// so carry the token as a query param instead.
const viewToken = getViewToken()
if (viewToken) {
params.set('view_token', viewToken)
}
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}`
currentEventSource = new EventSource(sseUrl)
+2 -1
View File
@@ -17,6 +17,7 @@
import { base } from '$lib/base'
import { withExternalDomain } from '$lib/externalDomain'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { workspaceStore } from '$lib/stores'
import { AnsiUp } from 'ansi_up'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
@@ -241,7 +242,7 @@
fetchedSkippedJobId = undefined
}
})
let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)
let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`))
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
let truncatedContent = $derived(
@@ -55,6 +55,7 @@
Bug,
CheckCircle,
Code,
DiffIcon,
EllipsisVertical,
Plus,
Rocket,
@@ -101,7 +102,6 @@
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { ScriptBuilderProps } from './script_builder'
import type { DiffDrawerI } from './diff_drawer'
import WorkerTagSelect from './WorkerTagSelect.svelte'
import type { ButtonType } from './common/button/model'
import DebounceLimit from './flows/DebounceLimit.svelte'
@@ -804,13 +804,34 @@
// Inside an AI session pane (which injects an aiChatManager via context) the
// extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace
// fork, Exit & See details, Export — don't make sense: the session always
// stays put and is already scoped to a fork. Only "Show diff" is kept.
// stays put and is already scoped to a fork. Diff is exposed as a standalone
// top-bar button (rendered independently of the session pane), not here.
const inSessionPane = !!getContext('aiChatManager')
async function openDiffDrawer() {
if (!savedScript) {
return
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const deployed = deployedValue ?? savedScript
const current = { ...script, draft_triggers: currentDraftTriggers }
if (current.assets && !current.assets.length) delete current.assets
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed,
draft: savedScript['draft'],
current
})
}
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
diffDrawer: DiffDrawerI | undefined
savedScript: NewScriptWithDraftAndDraftTriggers | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
@@ -841,35 +862,6 @@
: [])
]
: []),
...(customUi?.topBar?.diff !== false && savedScript && diffDrawer
? [
{
label: 'Show diff',
onClick: async () => {
if (!savedScript) {
return
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
const deployed = deployedValue ?? savedScript
const current = { ...script, draft_triggers: currentDraftTriggers }
if (current.assets && !current.assets.length) delete current.assets
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed,
draft: savedScript['draft'],
current
})
}
}
]
: []),
...(!inSessionPane &&
!script.draft_only &&
script.kind === 'script' &&
@@ -2035,6 +2027,21 @@
</Button>
{/if}
{/snippet}
{#snippet diffButton()}
{#if customUi?.topBar?.diff != false}
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedScript || !diffDrawer}
iconOnly={compactTopbar}
title="Diff"
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
@@ -2048,8 +2055,10 @@
/>
{/snippet}
</DropdownV2>
{@render diffButton()}
{@render settingsButton()}
{:else}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if $workerTags}
{#if $workerTags?.length ?? 0 > 0}
@@ -2080,7 +2089,7 @@
<DeployButton
loading={!fullyLoaded}
{loadingSave}
dropdownItems={computeDropdownItems(initialPath, savedScript, diffDrawer)}
dropdownItems={computeDropdownItems(initialPath, savedScript)}
on:save={({ detail }) => handleEditScript(false, detail)}
/>
</div>
@@ -364,6 +364,29 @@
})
}
async function openDiffDrawer() {
if (!savedApp) {
return
}
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
policy,
custom_path: customPath
}
})
}
async function updateApp(npath: string) {
if (!app) {
sendUserToast(`App hasn't been loaded yet`, true)
@@ -682,33 +705,6 @@
action: () => {
publishToHubDrawerOpen = true
}
},
{
displayName: 'Diff',
icon: DiffIcon,
action: async () => {
if (!savedApp) {
return
}
// deployedValue should be syncronized when we open Diff
await syncWithDeployed()
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedApp,
draft: savedApp.draft,
current: {
summary: summary,
value: app,
path: newEditedPath || savedApp.draft?.path || savedApp.path,
policy,
custom_path: customPath
}
})
},
disabled: !savedApp
}
])
@@ -965,6 +961,18 @@
{/snippet}
</DropdownV2>
<Button
variant="default"
unifiedSize="md"
on:click={() => openDiffDrawer()}
disabled={!savedApp}
iconOnly={compactTopbar}
title="Diff"
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
<div class="{compactTopbar ? 'hidden' : 'hidden md:inline'} relative overflow-visible">
<Button
on:click={() => {
@@ -184,7 +184,7 @@
ScriptBuilder behaves exactly like /scripts/add — Save draft is enabled and
creates it on first save. On that save ScriptBuilder writes savedScript back
through the bind and sets its own initialPath to the path, flipping us into
edit mode (Save draft + Show diff) without navigating away.
edit mode (Save draft + Diff) without navigating away.
-->
<ScriptBuilder
bind:script={runtime.scriptStore.val}
+44
View File
@@ -0,0 +1,44 @@
import { OpenAPI } from '$lib/gen'
/**
* Share-read-link support. When viewing a run via a share link
* (`/run/{id}?view_token=...`), the token grants the current authenticated member
* read access to that job and its flow subtree on the backend.
*
* The token is attached to every generated-client request via the `X-View-Token`
* header (registered once below) so we don't have to thread it through every
* `JobService` call. `EventSource`/SSE can't set headers, so those URLs read
* `getViewToken()` and append it as a `view_token` query param instead.
*/
let currentViewToken: string | undefined = undefined
export function setViewToken(token: string | undefined): void {
currentViewToken = token || undefined
}
export function getViewToken(): string | undefined {
return currentViewToken
}
/**
* Append the current view token as a `view_token` query param to a URL/path.
* Used for download links (plain `<a href>` and `downloadViaClient`), which don't
* go through the request interceptor that adds the `X-View-Token` header.
* Returns the url unchanged when no share link is active.
*/
export function appendViewToken(url: string): string {
if (!currentViewToken) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}view_token=${encodeURIComponent(currentViewToken)}`
}
// Register the request interceptor exactly once. It is a no-op unless a view token
// is currently set, so it is safe to keep installed for the whole session.
OpenAPI.interceptors.request.use((options) => {
if (currentViewToken) {
const headers = new Headers(options.headers)
headers.set('X-View-Token', currentViewToken)
options.headers = headers
}
return options
})
@@ -36,7 +36,8 @@
ClipboardCopy,
GitBranch,
GitFork,
EllipsisVertical
EllipsisVertical,
Share2
} from 'lucide-svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
@@ -85,6 +86,7 @@
} from '$lib/components/flows/FlowAssetsHandler.svelte'
import JobAssetsViewer from '$lib/components/assets/JobAssetsViewer.svelte'
import { page } from '$app/state'
import { setViewToken } from '$lib/viewToken'
import { twMerge } from 'tailwind-merge'
import FlowRestartButton from '$lib/components/FlowRestartButton.svelte'
import { useNestedRestartState } from '$lib/components/useNestedRestartState.svelte'
@@ -120,6 +122,7 @@
let testIsLoading = $state(false)
let jobLoader: JobLoader | undefined = $state(undefined)
let loadError: { status?: number; message?: string } | undefined = $state(undefined)
// Flow execution status state
let suspendStatus: import('$lib/utils').StateStore<Record<string, { job: Job; nb: number }>> =
@@ -146,6 +149,34 @@
concurrencyKey = await ConcurrencyGroupsService.getConcurrencyKey({ id: job.id })
}
// Share read link: if the URL carries a `view_token`, install it so every job
// read on this page (incl. flow steps, args, logs, SSE) is authorized by it.
// Set eagerly at init (before JobLoader mounts and fires its first fetch), and
// reactively keep it in sync across client-side navigation.
setViewToken(page.url.searchParams.get('view_token') ?? undefined)
$effect(() => {
setViewToken(page.url.searchParams.get('view_token') ?? undefined)
})
onDestroy(() => setViewToken(undefined))
async function shareReadLink(id: string): Promise<void> {
try {
const workspace = $workspaceStore!
const token = (await JobService.getJobViewToken({ workspace, id })).trim()
// Pin the workspace in the link: the token is signed with this workspace's
// key, and the logged layout only switches `$workspaceStore` when the URL
// carries `workspace=`. Without it a recipient whose active workspace
// differs would open the run (and validate the token) against the wrong one.
const url = `${window.location.origin}${base}/run/${id}?workspace=${encodeURIComponent(
workspace
)}&view_token=${encodeURIComponent(token)}`
copyToClipboard(url)
sendUserToast('Read-only share link copied to clipboard')
} catch (e) {
sendUserToast(`Failed to create share link: ${e}`, true)
}
}
async function deleteCompletedJob(id: string): Promise<void> {
await JobService.deleteCompletedJob({ workspace: $workspaceStore!, id })
getJob()
@@ -447,6 +478,7 @@
bind:jobUpdateLastFetch
workspaceOverride={$workspaceStore}
bind:notfound
bind:loadError
/>
{/if}
@@ -454,7 +486,28 @@
<PersistentScriptDrawer bind:this={persistentScriptDrawer} />
</Portal>
{#if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)}
{#if loadError?.status === 403}
<div class="max-w-3xl px-4 mx-auto w-full">
<div class="mt-6">
<Alert type="warning" title="You don't have access to this run">
<div class="flex flex-col gap-2">
<p>
This run exists in <span class="font-semibold">{$workspaceStore}</span>, but you don't
have permission to view it.
</p>
<p>
Ask a colleague who can see it to open the run and use the
<span class="font-semibold">Share</span> button to send you a read-only link. Opening that
link will grant you access to this run (and its steps).
</p>
</div>
</Alert>
<div class="mt-4">
<Button href="{base}/runs" unifiedSize="md" variant="accent">Go to runs page</Button>
</div>
</div>
</div>
{:else if notfound || (job?.workspace_id != undefined && $workspaceStore != undefined && job?.workspace_id != $workspaceStore)}
<div class="max-w-7xl px-4 mx-auto w-full">
<div class="flex flex-col gap-6">
<h1 class="text-red-400 mt-6 text-2xl font-semibold"
@@ -535,6 +588,17 @@
</Button>
{/if}
{/if}
{#if job}
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Share2 }}
title="Copy a read-only share link to this run for another workspace member"
onclick={() => job && shareReadLink(job.id)}
>
Share
</Button>
{/if}
{@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`}
{@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}?workspace=${job?.workspace_id ?? $workspaceStore}`}
{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.714.0"
wmill = ">=1.714.1"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.714.0
version: 1.714.1
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.714.0'
ModuleVersion = '1.714.1'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.714.0"
version = "1.714.1"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.714.0",
"version": "1.714.1",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.714.0",
"version": "1.714.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,
+1 -1
View File
@@ -1 +1 @@
1.714.0
1.714.1