Merge remote-tracking branch 'origin/main' into ai-agents-dynamic-tools-memory-input

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
hugocasa
2026-09-14 11:08:14 +02:00
86 changed files with 2881 additions and 655 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.810.0"
".": "1.811.1"
}
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## [1.811.1](https://github.com/windmill-labs/windmill/compare/v1.811.0...v1.811.1) (2026-09-13)
### Bug Fixes
* check kafka trigger topics against a set, not a one-pass iterator ([#11108](https://github.com/windmill-labs/windmill/issues/11108)) ([bf4fa2b](https://github.com/windmill-labs/windmill/commit/bf4fa2b174b8d4a5897b2aa0d108480442bd0e18))
* let the hub_sync job read the uid and hub_base_url settings ([#11106](https://github.com/windmill-labs/windmill/issues/11106)) ([45102c8](https://github.com/windmill-labs/windmill/commit/45102c82659d86ca5ec6fd3aee57232f2348736c))
## [1.811.0](https://github.com/windmill-labs/windmill/compare/v1.810.0...v1.811.0) (2026-09-12)
### Features
* make snowflake_oauth work as a dbt warehouse on every engine ([#11095](https://github.com/windmill-labs/windmill/issues/11095)) ([9fc50a2](https://github.com/windmill-labs/windmill/commit/9fc50a23fb75cb541c481247b7b25c206fb06d36))
### Bug Fixes
* accept any hub version of the git sync script in the token check ([#11099](https://github.com/windmill-labs/windmill/issues/11099)) ([670628b](https://github.com/windmill-labs/windmill/commit/670628b300ab119363adb5496ebfe3c6ccd80063))
* bring back Publish to Hub for scripts ([#11097](https://github.com/windmill-labs/windmill/issues/11097)) ([864e5f0](https://github.com/windmill-labs/windmill/commit/864e5f02ec1c2dd16c74524f551f44485df10a20))
* bundle deployed bun scripts whose only pin is on a dynamic import ([#11096](https://github.com/windmill-labs/windmill/issues/11096)) ([4afb9aa](https://github.com/windmill-labs/windmill/commit/4afb9aa677ac11d22e22e54bb6fd7881378b7005))
* clear a stale git auto-pull failure and show the status time ([#11100](https://github.com/windmill-labs/windmill/issues/11100)) ([e877b5f](https://github.com/windmill-labs/windmill/commit/e877b5f2e81b1741aee90e843c8cccc22f9eef24))
* stop a resource delete from taking variables it does not own ([#11102](https://github.com/windmill-labs/windmill/issues/11102)) ([2a21efa](https://github.com/windmill-labs/windmill/commit/2a21efa11b8307b331a8c20720028444dd3c62ff))
## [1.810.0](https://github.com/windmill-labs/windmill/compare/v1.809.0...v1.810.0) (2026-09-11)
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "32b4d1fe69fd219a5931fdd63cb2b0cb7770950e5c7fa6128bc9d2b2abcc7f2f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of_email' = $2 AND (value->'policy'->>'on_behalf_of' IS NULL OR value->'policy'->>'on_behalf_of' NOT LIKE 'g/%')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "3721bd6524ea48a1068ee8013bcc1aeca1b9fe784336fabb71ce13bdb58839da"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ws_specific\n WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "61caaa5c4ae62f618ba18f36330bbf58ff58e8c448874ffc9ca466165e545878"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text))\n WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "79d3bbd278c34734ce5babc3be852898bb83047f3563f789753e8fec030d301f"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb('u/' || $1))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "98cb765a480dac8a27ecad5df26c26f3dd6ff9aa87293ca671be32fd1305a73a"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "99194b850cb30d174c8d99303f6ed693e011a89aefb3a0e239630e6950dca5cd"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "WITH survivors AS (\n SELECT value::text AS rendered FROM resource\n WHERE workspace_id = $1 AND NOT (path = ANY($2::text[]))\n )\n SELECT v.path FROM unnest($3::text[]) AS v(path)\n WHERE EXISTS (\n SELECT 1 FROM survivors s\n WHERE strpos(s.rendered, '\"$var:' || v.path || '\"') > 0\n OR strpos(s.rendered, '\"$jsonvar:' || v.path || '\"') > 0\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "a7b6731427d51eb34744ca6a39e30d02949f149901ec95323b5725911944df4d"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET policy = jsonb_set(\n jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)),\n ARRAY['on_behalf_of_email'], to_jsonb($4::text)\n ) WHERE policy->>'on_behalf_of' = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "bff72874d1fdee7d572e2677aea1dede87b6793f92af884a023253a0ffc3a905"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)))\n WHERE typ IN ('app', 'raw_app')\n AND value->'policy'->>'on_behalf_of' = $2\n AND value->'policy'->>'on_behalf_of_email' = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d599e8058e96f3708cf6af2cc1a2ea3d912ec703687f6ce52d2419a410b1599e"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "d88b1c445acc5375e9c543dcec81059d0e7018a6cea79ba743693098d223edf5"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_admin, operator, email FROM usr where username = $1 AND workspace_id = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "dada076fe622e9902606bf95f9e6df004f8cb7091588309a1f484b91fe7183fc"
}
+96 -95
View File
@@ -2095,9 +2095,9 @@ dependencies = [
[[package]]
name = "byte-unit"
version = "5.2.5"
version = "5.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0"
checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b"
dependencies = [
"rust_decimal",
"schemars 1.2.2",
@@ -2138,9 +2138,9 @@ dependencies = [
[[package]]
name = "bytemuck_derive"
version = "1.12.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
@@ -2311,9 +2311,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.5"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2733,9 +2733,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
@@ -6790,9 +6790,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
@@ -6807,18 +6807,19 @@ dependencies = [
[[package]]
name = "jiff-core"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
@@ -7307,9 +7308,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.23"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
dependencies = [
"bitflags 2.13.2",
"libc",
@@ -14791,7 +14792,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-nats",
@@ -14879,7 +14880,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"async-stream",
"async-trait",
@@ -14912,7 +14913,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14925,7 +14926,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"argon2",
@@ -15065,7 +15066,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15088,7 +15089,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15105,7 +15106,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15131,7 +15132,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15141,7 +15142,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15158,7 +15159,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15180,7 +15181,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15203,7 +15204,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15219,7 +15220,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15241,7 +15242,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15262,7 +15263,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15276,7 +15277,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15311,7 +15312,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15336,7 +15337,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15364,7 +15365,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15386,7 +15387,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15406,7 +15407,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15444,7 +15445,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15472,7 +15473,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"lazy_static",
"serde",
@@ -15484,7 +15485,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15508,7 +15509,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15522,7 +15523,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15557,7 +15558,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"chrono",
"lazy_static",
@@ -15571,7 +15572,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15590,7 +15591,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15696,7 +15697,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"chrono",
"futures",
@@ -15716,7 +15717,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"regex",
"serde",
@@ -15731,7 +15732,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15758,7 +15759,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"futures",
@@ -15775,7 +15776,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15791,7 +15792,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15812,7 +15813,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -15843,7 +15844,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"arc-swap",
@@ -15868,7 +15869,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-stream",
@@ -15903,7 +15904,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"futures",
@@ -15921,7 +15922,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15930,7 +15931,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15942,7 +15943,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15954,7 +15955,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"gosyn",
@@ -15966,7 +15967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15978,7 +15979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15990,7 +15991,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16001,7 +16002,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16012,7 +16013,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16024,7 +16025,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16035,7 +16036,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16057,7 +16058,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16069,7 +16070,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16083,7 +16084,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16100,7 +16101,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16113,7 +16114,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde",
@@ -16125,7 +16126,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16143,7 +16144,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16159,7 +16160,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16175,7 +16176,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16189,7 +16190,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16228,7 +16229,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"const_format",
@@ -16268,7 +16269,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16279,7 +16280,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16314,7 +16315,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16338,7 +16339,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16371,7 +16372,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16398,7 +16399,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16431,7 +16432,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16451,7 +16452,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16485,7 +16486,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16521,7 +16522,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16544,7 +16545,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16568,7 +16569,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16592,7 +16593,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16627,7 +16628,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16655,7 +16656,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16680,7 +16681,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16699,7 +16700,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16817,7 +16818,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.810.0"
version = "1.811.1"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.810.0"
version = "1.811.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
f889c5e81b72a7e94e7ce8a3b5b7686303634555
080a42ca190246aa4ef49bdddf128c07d59c91ad
@@ -0,0 +1,6 @@
-- Add down migration script here
-- Nothing to undo. The up migration gives a policy that only ever carried the address the
-- principal it runs as, and rewrites an address that disagreed with its principal. The previous
-- version reads both halves, so both results are correct for it too, and the addresses replaced
-- named an account other than the one the app runs as.
SELECT 1;
@@ -0,0 +1,100 @@
-- Add up migration script here
-- `policy.on_behalf_of` becomes the authority for an app's identity: the address beside it is
-- written through from it on every save, so the two can no longer name different accounts.
--
-- The address key is deliberately NOT removed here, and is still written: a replica predating
-- the derive-when-absent fallback errors outright when it is missing, which would 400 every
-- anonymous, publisher and guest app served by one that has not yet rolled over. Removing the
-- key is a follow-up, per docs/app-policy-email-removal.md.
--
-- What is left is the data written before that rule. A policy that only ever had the address has
-- no principal to run as, so give it one. A policy whose halves disagree was stored as a client
-- sent it; reads return that pair and a redeploy that keeps the identity sends it back, where the
-- pair check rejects it. So once every policy has a principal, rewrite its address from it.
-- Mirrors `users::username_to_permissioned_as`: an email-shaped username is its own principal
-- unless it contains a slash, which a reader would split on, and a legacy `group-*` username is
-- the group it names.
CREATE OR REPLACE FUNCTION pg_temp.username_to_permissioned_as(name VARCHAR)
RETURNS VARCHAR AS $$
SELECT CASE
WHEN $1 LIKE '%@%' AND $1 LIKE '%/%' THEN 'u/' || $1
WHEN $1 LIKE '%@%' THEN $1
WHEN $1 LIKE 'group-%' THEN 'g/' || substr($1, 7)
ELSE 'u/' || $1
END;
$$ LANGUAGE SQL IMMUTABLE;
-- Mirrors `users::permissioned_as_from_email`: a real account wins over the synthetic group
-- namespace, which is not reserved and may be a user's own address. `pg_temp` lives for the
-- whole session and migrations share one connection, so an identically-named helper from an
-- earlier migration is still in scope: replace it, and drop this one at the end.
CREATE OR REPLACE FUNCTION pg_temp.permissioned_as_from_email(w_id VARCHAR, email VARCHAR)
RETURNS VARCHAR AS $$
SELECT COALESCE(
(SELECT pg_temp.username_to_permissioned_as(u.username)
FROM usr u WHERE u.workspace_id = $1 AND u.email = $2),
-- A superadmin acting outside their workspaces has no usr row.
(SELECT pg_temp.username_to_permissioned_as(COALESCE(p.username, p.email))
FROM password p WHERE p.email = $2 AND p.super_admin),
(SELECT 'g/' || g.name FROM group_ g
WHERE g.workspace_id = $1
AND $2 = 'group-' || g.name || '@windmill.dev')
);
$$ LANGUAGE SQL STABLE;
-- Mirrors `users::get_email_from_permissioned_as`, except that a `u/` principal naming nobody
-- yields NULL rather than the synthetic `@unknown.windmill.dev` address, so that row is left as
-- it is instead of losing the one address it had.
CREATE OR REPLACE FUNCTION pg_temp.email_from_permissioned_as(w_id VARCHAR, principal VARCHAR)
RETURNS VARCHAR AS $$
SELECT CASE
WHEN $2 LIKE 'u/%' THEN COALESCE(
(SELECT u.email FROM usr u WHERE u.workspace_id = $1 AND u.username = substr($2, 3)),
(SELECT p.email FROM password p
WHERE (p.username = substr($2, 3) OR p.email = substr($2, 3)) AND p.super_admin
ORDER BY p.email LIMIT 1))
WHEN $2 LIKE 'g/%' THEN 'group-' || substr($2, 3) || '@windmill.dev'
ELSE $2
END;
$$ LANGUAGE SQL STABLE;
-- A policy naming only the address predates the principal being written to it.
-- A principal wider than `v2_job.permissioned_as` could not be enqueued, so it is not recorded
-- at all — the app falls back to erroring on anonymous execution until someone picks an identity
-- the deploy path accepts. Same cap and reason as the sibling migration 20260801043001.
UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'],
to_jsonb(pg_temp.permissioned_as_from_email(workspace_id, policy->>'on_behalf_of_email')))
WHERE policy->>'on_behalf_of' IS NULL
AND pg_temp.permissioned_as_from_email(workspace_id, policy->>'on_behalf_of_email') IS NOT NULL
AND length(pg_temp.permissioned_as_from_email(workspace_id, policy->>'on_behalf_of_email')) <= 55;
-- App drafts carry a copy of the policy and are deployed from it, so they need the same.
UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'],
to_jsonb(pg_temp.permissioned_as_from_email(workspace_id, value->'policy'->>'on_behalf_of_email'))))
WHERE typ IN ('app', 'raw_app')
AND value->'policy'->>'on_behalf_of' IS NULL
AND pg_temp.permissioned_as_from_email(workspace_id, value->'policy'->>'on_behalf_of_email') IS NOT NULL
AND length(pg_temp.permissioned_as_from_email(workspace_id, value->'policy'->>'on_behalf_of_email')) <= 55;
-- The address a save now writes, applied to the rows saved before. Execution already takes a `u/`
-- principal's own address, so this only changes what runs for a `g/` or bare principal, whose
-- stored address decided the superadmin flag and instance groups: those now follow the principal.
UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of_email'],
to_jsonb(pg_temp.email_from_permissioned_as(workspace_id, policy->>'on_behalf_of')))
WHERE policy->>'on_behalf_of' IS NOT NULL
AND pg_temp.email_from_permissioned_as(workspace_id, policy->>'on_behalf_of') IS NOT NULL
AND policy->>'on_behalf_of_email'
IS DISTINCT FROM pg_temp.email_from_permissioned_as(workspace_id, policy->>'on_behalf_of');
UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of_email'],
to_jsonb(pg_temp.email_from_permissioned_as(workspace_id, value->'policy'->>'on_behalf_of'))))
WHERE typ IN ('app', 'raw_app')
AND value->'policy'->>'on_behalf_of' IS NOT NULL
AND pg_temp.email_from_permissioned_as(workspace_id, value->'policy'->>'on_behalf_of') IS NOT NULL
AND value->'policy'->>'on_behalf_of_email'
IS DISTINCT FROM pg_temp.email_from_permissioned_as(workspace_id, value->'policy'->>'on_behalf_of');
DROP FUNCTION pg_temp.permissioned_as_from_email(VARCHAR, VARCHAR);
DROP FUNCTION pg_temp.email_from_permissioned_as(VARCHAR, VARCHAR);
DROP FUNCTION pg_temp.username_to_permissioned_as(VARCHAR);
@@ -0,0 +1,8 @@
-- Add down migration script here
DROP TRIGGER IF EXISTS password_superadmin_delete_trigger ON password;
DROP TRIGGER IF EXISTS password_superadmin_insert_trigger ON password;
DROP TRIGGER IF EXISTS password_superadmin_update_trigger ON password;
DROP TRIGGER IF EXISTS usr_email_update_trigger ON usr;
DROP TRIGGER IF EXISTS usr_email_change_trigger ON usr;
DROP FUNCTION IF EXISTS notify_superadmin_identity_change();
DROP FUNCTION IF EXISTS notify_usr_email_change();
@@ -0,0 +1,85 @@
-- Add up migration script here
-- Emit a notify_event so every process evicts its cached `permissioned_as` -> address mapping
-- (windmill-common EMAIL_CACHE) at its next notify-event poll, rather than serving the old
-- address for the rest of the TTL. Authorization does not rest on this:
-- `fetch_authed_from_permissioned_as` re-resolves the address from the principal's live binding.
-- SECURITY DEFINER so the INSERT runs as the function owner rather than the invoking
-- windmill_user/windmill_admin role, matching the other notify_* triggers.
CREATE OR REPLACE FUNCTION notify_usr_email_change()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO notify_event (channel, payload)
VALUES (
'notify_user_email_change',
COALESCE(NEW.workspace_id, OLD.workspace_id) || ':' || COALESCE(NEW.username, OLD.username)
);
-- A rename leaves the OLD username cached against this account's address; evict both keys.
IF TG_OP = 'UPDATE' AND NEW.username IS DISTINCT FROM OLD.username THEN
INSERT INTO notify_event (channel, payload)
VALUES ('notify_user_email_change', OLD.workspace_id || ':' || OLD.username);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- INSERT matters too: a lookup that resolved to nobody is cached as the synthetic
-- `{username}@unknown.windmill.dev`, so creating the row has to drop that entry.
CREATE TRIGGER usr_email_change_trigger
AFTER INSERT OR DELETE ON usr
FOR EACH ROW
EXECUTE FUNCTION notify_usr_email_change();
CREATE TRIGGER usr_email_update_trigger
AFTER UPDATE OF email, username ON usr
FOR EACH ROW
WHEN (OLD.email IS DISTINCT FROM NEW.email OR OLD.username IS DISTINCT FROM NEW.username)
EXECUTE FUNCTION notify_usr_email_change();
-- A superadmin acting outside their workspaces resolves through `password` instead, and that row
-- names no workspace of its own. The `*:` payload says so: the reader drops that name's entry in
-- every workspace rather than the whole cache, which would undo the caching on an instance that
-- rewrites these rows in bulk. Confined to superadmins because they are the only accounts the
-- `usr` triggers above cannot cover.
CREATE OR REPLACE FUNCTION notify_superadmin_identity_change()
RETURNS TRIGGER AS $$
DECLARE
names TEXT[] := '{}';
BEGIN
-- Every alias the principal can be spelled as: `resolve_username_to_email` matches a `u/`
-- principal against `username` OR `email`, and whichever string the caller passed is the key
-- it cached under, so one account can hold a live entry under either. Old and new of each,
-- because a change to one leaves the other's entry behind.
IF TG_OP <> 'DELETE' THEN names := names || ARRAY[NEW.username, NEW.email]; END IF;
IF TG_OP <> 'INSERT' THEN names := names || ARRAY[OLD.username, OLD.email]; END IF;
INSERT INTO notify_event (channel, payload)
SELECT DISTINCT 'notify_user_email_change', '*:' || n
FROM unnest(names) AS n
WHERE n IS NOT NULL;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- `super_admin` is half of what the fallback matches on, so gaining or losing it moves the
-- mapping as surely as the address does: a demotion leaves the real address cached where the
-- truth is now synthetic, and a promotion leaves that synthetic one cached in place of a real
-- account. `OLD.super_admin OR NEW.super_admin` is what catches both directions.
CREATE TRIGGER password_superadmin_update_trigger
AFTER UPDATE OF email, username, super_admin ON password
FOR EACH ROW
WHEN ((OLD.super_admin OR NEW.super_admin)
AND (OLD.email IS DISTINCT FROM NEW.email
OR OLD.username IS DISTINCT FROM NEW.username
OR OLD.super_admin IS DISTINCT FROM NEW.super_admin))
EXECUTE FUNCTION notify_superadmin_identity_change();
CREATE TRIGGER password_superadmin_insert_trigger
AFTER INSERT ON password
FOR EACH ROW
WHEN (NEW.super_admin)
EXECUTE FUNCTION notify_superadmin_identity_change();
CREATE TRIGGER password_superadmin_delete_trigger
AFTER DELETE ON password
FOR EACH ROW
WHEN (OLD.super_admin)
EXECUTE FUNCTION notify_superadmin_identity_change();
+1
View File
@@ -241,6 +241,7 @@
}
},
"snowflake_oauth": {
"resource_fields": ["database", "warehouse", "role", "schema"],
"connect_config_template": {
"display_name": "Snowflake",
"label": "Snowflake Account Identifier",
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.810.0"
version = "1.811.1"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.810.0"
version = "1.811.1"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+11
View File
@@ -1913,6 +1913,17 @@ async fn process_notify_event(
);
windmill_api::auth::invalidate_token_from_cache(payload);
}
"notify_user_email_change" => {
// `<workspace_id>:<username>`, or `*:<username>` from a `password` change, which
// knows the name but no workspace. Workspace ids can't contain ':'.
if let Some(username) = payload.strip_prefix("*:") {
tracing::info!("Superadmin identity change detected, invalidating: {username}");
windmill_common::users::invalidate_email_cache_for_username(username);
} else if let Some((workspace_id, username)) = payload.split_once(':') {
tracing::info!("User email change detected, invalidating cache: {payload}");
windmill_common::users::invalidate_email_cache(workspace_id, username);
}
}
"notify_app_policy_change" => {
// payload is `<workspace_id>:<path>`; workspace ids can't contain ':'.
if server_mode {
+70
View File
@@ -990,6 +990,76 @@ export function main() { return [ns === isNumber, label, local()]; }"#
Ok(())
}
async fn bun_dependency_lock(db: &Pool<Postgres>, port: u16, path: &str, content: &str) -> String {
let deps = RunJob::from(JobPayload::RawScriptDependencies {
script_path: path.into(),
content: content.into(),
language: ScriptLang::Bun,
})
.run_until_complete(db, false, port)
.await
.json_result()
.unwrap();
let Some(lock) = deps["lock"].as_str() else {
panic!("the dependency job returned no lock: {deps}");
};
lock.to_string()
}
/// Bundling a locked script resolves a pinned dynamic `import()` as written. Where that fails, both
/// the dependency job and a run that finds no cached bundle must still build it, from the version
/// the lock pins; where bun tolerates the failure, the bundle must stay as written.
#[sqlx::test(fixtures("base"))]
async fn test_bun_bundles_pinned_dynamic_import(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The dependency job saves the script's bundle here; the server binary creates it at startup.
std::fs::create_dir_all(&*windmill_worker::BUN_BUNDLE_CACHE_DIR)?;
const PATH: &str = "f/pinned_dynamic_import/main";
// Every `script` call draws its own nonce, so each job below misses every bundle cached before
// it, this test's included, and has to build one: a cached bundle skips the build under test.
// 4.17.20 is not npm's `latest`, so a bundle that lost the pin cannot match by accident.
let script = |body: &str| {
format!(
"export async function main() {{\n {body}\n}}\n// {}",
Uuid::new_v4()
)
};
let import = r#"const m = await import("lodash@4.17.20"); return (m.default ?? m).VERSION;"#;
let lock = bun_dependency_lock(&db, port, PATH, &script(import)).await;
let result = RunJob::from(JobPayload::Code(RawCode {
content: script(import),
path: Some(PATH.into()),
language: ScriptLang::Bun,
lock: Some(lock),
..RawCode::default()
}))
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("4.17.20"));
let tolerated = script(&format!("try {{ {import} }} catch {{ return null; }}"));
let lock = bun_dependency_lock(&db, port, PATH, &tolerated).await;
let (bundle, _) = windmill_worker::compute_bundle_local_and_remote_path(
&tolerated,
&lock,
PATH,
Some(&db),
"test-workspace",
&None,
None,
)
.await;
assert!(std::fs::read_to_string(bundle)?.contains("lodash@4.17.20"));
Ok(())
}
#[sqlx::test(fixtures("base", "bun_edge_cases"))]
async fn test_bun_shared_imports_both_styles(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
+10
View File
@@ -0,0 +1,10 @@
-- A workspace whose auto-pulled repository last recorded a head-check failure while
-- already synced to head "aaa": the state a recovery write is decided on.
INSERT INTO workspace (id, name, owner) VALUES ('ap-ws', 'ap-ws', 'test-user');
INSERT INTO workspace_settings (workspace_id, git_sync) VALUES
('ap-ws', '{"repositories":[{"git_repo_resource_path":"$res:u/admin/repo",
"auto_pull":{"enabled":true,"mode":"polling",
"last_synced_sha":{"main":"aaa"},
"last_pull_status":{"success":false,"at":1,"error":"head check failed: x"}}}]}');
@@ -630,7 +630,7 @@ async fn test_folder_default_permissioned_as(db: Pool<Postgres>) -> anyhow::Resu
);
assert_eq!(
policy["on_behalf_of_email"], "group-wm_deployers@windmill.dev",
"app policy.on_behalf_of_email gets folder default email"
"the stored address is derived from that principal"
);
// 7b. Admin, non-matching path — acting user
+143
View File
@@ -0,0 +1,143 @@
//! A recorded auto-pull failure is cleared once the tracked head is observed again
//! at the already-synced sha, and only then: the decision is taken on a snapshot,
//! so the write must re-check the stored row rather than overwrite it.
#![cfg(all(feature = "enterprise", feature = "private"))]
use sqlx::{Pool, Postgres};
use std::collections::HashMap;
use uuid::Uuid;
use windmill_common::workspaces::AutoPullStatus;
use windmill_git_sync::{clear_auto_pull_failure, persist_auto_pull_state};
const WS: &str = "ap-ws";
const REPO: &str = "$res:u/admin/repo";
/// The failure the fixture records, as the poller would have read it.
fn fixture_failure() -> AutoPullStatus {
AutoPullStatus {
synced_sha: None,
at: 1,
job_id: None,
success: false,
error: Some("head check failed: x".to_string()),
}
}
fn recovered(head: &str) -> AutoPullStatus {
AutoPullStatus {
synced_sha: Some(head.to_string()),
at: 2,
job_id: None,
success: true,
error: None,
}
}
async fn stored_auto_pull(db: &Pool<Postgres>) -> anyhow::Result<serde_json::Value> {
let git_sync: serde_json::Value =
sqlx::query_scalar("SELECT git_sync FROM workspace_settings WHERE workspace_id = $1")
.bind(WS)
.fetch_one(db)
.await?;
Ok(git_sync["repositories"][0]["auto_pull"].clone())
}
/// The recovery every test below runs, decided on the fixture's failure at head
/// "aaa": live in the first test, stale in the two that move the stored state
/// first.
async fn recovery_for_the_fixture_failure(db: &Pool<Postgres>) -> anyhow::Result<()> {
clear_auto_pull_failure(
db,
WS,
REPO,
"main",
"aaa",
&fixture_failure(),
&recovered("aaa"),
)
.await?;
Ok(())
}
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
async fn recovery_clears_the_failure_at_the_synced_head(db: Pool<Postgres>) -> anyhow::Result<()> {
recovery_for_the_fixture_failure(&db).await?;
let auto_pull = stored_auto_pull(&db).await?;
assert_eq!(auto_pull["last_pull_status"]["success"], true);
assert!(auto_pull["last_pull_status"].get("error").is_none());
assert_eq!(auto_pull["last_pull_status"]["synced_sha"], "aaa");
assert_eq!(
auto_pull["last_synced_sha"]["main"], "aaa",
"the sha map is not part of a recovery write"
);
Ok(())
}
/// Between the poller observing head "aaa" unchanged and its recovery write, a
/// webhook may have enqueued newer head "bbb". The stale recovery must leave that
/// optimistic state (sha, success, job id) in place; the job's completion hook
/// relies on it, and rolling the sha back would re-enqueue "bbb" on the next tick.
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
async fn stale_recovery_leaves_a_newer_state_alone(db: Pool<Postgres>) -> anyhow::Result<()> {
let job_id = Uuid::new_v4();
let advanced = AutoPullStatus {
synced_sha: Some("bbb".to_string()),
at: 3,
job_id: Some(job_id),
success: true,
error: None,
};
persist_auto_pull_state(
&db,
WS,
REPO,
&HashMap::from([("main".to_string(), "bbb".to_string())]),
&advanced,
)
.await?;
recovery_for_the_fixture_failure(&db).await?;
let auto_pull = stored_auto_pull(&db).await?;
assert_eq!(auto_pull["last_synced_sha"]["main"], "bbb");
assert_eq!(auto_pull["last_pull_status"]["synced_sha"], "bbb");
assert_eq!(auto_pull["last_pull_status"]["job_id"], job_id.to_string());
assert_eq!(auto_pull["last_pull_status"]["at"], 3);
Ok(())
}
/// The head can stay at "aaa" while a newer failure is recorded (a later head
/// check, a pull job that failed). A recovery decided on the older failure must
/// not paper over the newer one, whether it differs by timestamp or, within the
/// same second, only by its error.
#[sqlx::test(fixtures("git_sync_autopull_recovery"))]
async fn stale_recovery_keeps_a_newer_failure_at_the_same_head(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let same_sha = HashMap::from([("main".to_string(), "aaa".to_string())]);
for newer in [
AutoPullStatus {
at: 5,
error: Some("head check failed: later".to_string()),
..fixture_failure()
},
AutoPullStatus {
error: Some("head check failed: same second".to_string()),
..fixture_failure()
},
] {
persist_auto_pull_state(&db, WS, REPO, &same_sha, &newer).await?;
recovery_for_the_fixture_failure(&db).await?;
let auto_pull = stored_auto_pull(&db).await?;
assert_eq!(auto_pull["last_pull_status"]["success"], false);
assert_eq!(auto_pull["last_pull_status"]["at"], newer.at);
assert_eq!(
auto_pull["last_pull_status"]["error"],
newer.error.as_deref().unwrap()
);
}
Ok(())
}
+98 -30
View File
@@ -405,11 +405,12 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
// 7. App: Admin preserves on_behalf_of
// ========================================
// Principal only, so the stored address can only have come from deriving it.
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
.json(&new_app_with_on_behalf_of(
"u/test-user/app_admin_preserve",
Some("u/original-user"),
Some("original@windmill.dev"),
None,
true,
))
.send()
@@ -434,10 +435,24 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
Some("u/original-user"),
"Admin should preserve app on_behalf_of"
);
// The address is written through from the principal, never taken from the request, so the
// stored copy can only agree with it.
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Admin should preserve app on_behalf_of_email"
"the stored address is derived from the principal, not the one the client sent"
);
let resp = authed(
client().get(format!("{base}/apps/get/p/u/test-user/app_admin_preserve")),
"SECRET_TOKEN",
)
.send()
.await?;
let returned: serde_json::Value = resp.json().await?;
assert_eq!(
returned["policy"]["on_behalf_of_email"].as_str(),
Some("original@windmill.dev"),
"the response returns the address written through from the principal"
);
// ========================================
@@ -476,11 +491,6 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
Some("u/original-user"),
"Deployer should preserve app on_behalf_of"
);
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Deployer should preserve app on_behalf_of_email"
);
// ========================================
// 9. App: Non-admin cannot preserve
@@ -518,10 +528,26 @@ async fn test_preserve_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
Some("u/test-user-2"),
"Non-admin should have their own permissioned_as as app on_behalf_of"
);
// ========================================
// 9b. App: a policy naming two different accounts is rejected
// ========================================
// This is the shape a workspace deploy produces when it carries the source
// workspace's principal beside the target's address.
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
.json(&new_app_with_on_behalf_of(
"u/test-user/app_mismatched_pair",
Some("u/original-user"),
Some("test2@windmill.dev"),
true,
))
.send()
.await?;
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("test2@windmill.dev"),
"Non-admin should have their own email as app on_behalf_of_email"
resp.status(),
400,
"a policy whose two halves name different accounts must be rejected"
);
// ========================================
@@ -1238,11 +1264,6 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
Some("u/original-user"),
"Admin update should preserve app on_behalf_of"
);
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Admin update should preserve app on_behalf_of_email"
);
// ========================================
// Deployer updates with preserve flag
@@ -1304,11 +1325,6 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
Some("u/original-user"),
"Deployer update should preserve app on_behalf_of"
);
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("original@windmill.dev"),
"Deployer update should preserve app on_behalf_of_email"
);
// ========================================
// Non-admin cannot preserve on update
@@ -1370,10 +1386,62 @@ async fn test_app_update_preserves_on_behalf_of(db: Pool<Postgres>) -> anyhow::R
Some("u/test-user-2"),
"Non-admin update should overwrite app on_behalf_of with their own"
);
Ok(())
}
/// A superadmin acting outside their workspaces has no `usr` row, so the per-workspace rename
/// sweep never reaches the apps that name them. Their principal is their instance username, so
/// without a global sweep a rename leaves those apps naming an account that resolves to nobody.
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
async fn test_rename_sweeps_external_superadmin_app_identity(
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");
let path = "u/original-user/app_run_by_external_superadmin";
let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN")
.json(&new_app_with_on_behalf_of(
path,
Some("u/superadmin-external"),
Some("superadmin-external@windmill.dev"),
true,
))
.send()
.await?;
assert_eq!(
policy.get("on_behalf_of_email").and_then(|v| v.as_str()),
Some("test2@windmill.dev"),
"Non-admin update should overwrite app on_behalf_of_email with their own"
resp.status(),
201,
"Should create app: {}",
resp.text().await?
);
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/users/rename/superadmin-external@windmill.dev"
)),
"SECRET_TOKEN",
)
.json(&json!({ "new_username": "superadmin_renamed" }))
.send()
.await?;
assert_eq!(resp.status(), 200, "Should rename: {}", resp.text().await?);
let app = sqlx::query!(
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2",
path,
"test-workspace"
)
.fetch_one(&db)
.await?;
assert_eq!(
app.policy.get("on_behalf_of").and_then(|v| v.as_str()),
Some("u/superadmin_renamed"),
"the rename should follow the principal an app names"
);
Ok(())
@@ -2855,10 +2923,8 @@ async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyho
resp.text().await?
);
// App: a real superadmin on_behalf_of is *allowed* at deploy (deployers may
// deploy on behalf of any real user). The escalation is closed at execution
// by the job-token cap, not by restricting what can be stored, so even a
// superadmin email pinned onto an unrelated principal deploys fine here.
// App: a real superadmin's address pinned onto an unrelated principal is a pair naming two
// accounts, which the principal-authoritative policy refuses.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
@@ -2873,12 +2939,14 @@ async fn test_reject_reserved_sentinel_on_behalf_of(db: Pool<Postgres>) -> anyho
.await?;
assert_eq!(
resp.status(),
201,
"a real superadmin on_behalf_of is allowed at deploy (capped at execution): {}",
400,
"a superadmin address beside an unrelated principal must be refused: {}",
resp.text().await?
);
// App: a consistently named real superadmin identity is likewise allowed.
// App: a real superadmin on_behalf_of is allowed at deploy when consistently named (deployers
// may deploy on behalf of any real user); the escalation is closed at execution by the
// job-token cap, not by restricting what can be stored.
let resp = authed(
client().post(format!("{base}/apps/create")),
"DEPLOYER_TOKEN",
+25 -9
View File
@@ -318,19 +318,35 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool<Postgres>) -> anyho
resp.text().await?
);
}
// ...and the one `settings/global` key on the allowlist, which the CLI reads before
// creating a user on a git-sync push. `ws_base_url` is the control: the handler leaves
// it as ungated as `automate_username_creation`, so only the allowlist stops it.
// ...and the `settings/global` keys on the allowlist, which the CLI reads from a job: on a
// git-sync push, and in `u/admin/hub_sync`. `ws_base_url` is the control: the handler
// leaves it as ungated as these, so only the allowlist stops it.
for key in ["automate_username_creation", "uid", "hub_base_url"] {
let resp = authed(
client().get(format!("{api}/settings/global/{key}")),
&user_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still read {key}: {}",
resp.text().await?
);
}
// The same hub pull reads `hub_api_secret` for a private Hub, but a secret stays out of
// a job's reach even when the token borrows a superadmin.
let resp = authed(
client().get(format!("{api}/settings/global/automate_username_creation")),
&user_wm,
client().get(format!("{api}/settings/global/hub_api_secret")),
&sa_wm,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"WM_TOKEN must still read automate_username_creation: {}",
let status = resp.status().as_u16();
assert!(
status == 401 || status == 403,
"superadmin WM_TOKEN must not read hub_api_secret ({status}): {}",
resp.text().await?
);
let resp = authed(
+1 -1
View File
@@ -5483,7 +5483,7 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
let result =
RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None, restarted_from: None })
.email("test2@windmill.dev")
.as_user("test-user-2", "test2@windmill.dev")
.run_until_complete(&db, false, server.addr.port())
.await;
+202 -4
View File
@@ -391,7 +391,8 @@ async fn test_create_resource_upsert_clears_ws_specific(db: Pool<Postgres>) -> a
/// Regression for GHSA-xmr2-98m6-cjf7: a token scoped only to `resources:write:<r>`
/// must NOT use the resource-delete cascade to delete a linked secret variable it has
/// no `variables:write` scope for.
/// no `variables:write` scope for. The victim sits at a path the resource owns, which is
/// the only kind the cascade reaches at all.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_scoped_token_cannot_cascade_delete_linked_variable(
db: Pool<Postgres>,
@@ -406,7 +407,7 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/victim_secret",
"path": "u/test-user/db_victim_secret",
"value": "hunter2",
"is_secret": true,
"description": ""
@@ -421,7 +422,7 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
)
.json(&json!({
"path": "u/test-user/db",
"value": { "password": "$var:u/test-user/victim_secret" },
"value": { "password": "$var:u/test-user/db_victim_secret" },
"resource_type": "object"
}))
.send()
@@ -443,9 +444,206 @@ async fn test_scoped_token_cannot_cascade_delete_linked_variable(
resp.text().await?
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/victim_secret").await?,
variable_exists(&db, "test-workspace", "u/test-user/db_victim_secret").await?,
"victim variable must survive the denied cascade"
);
// The scope check runs after the resource DELETE, so only the rollback keeps the resource
// alive — moving the check out of the transaction would silently delete it on a 403.
let resource_left: Option<i64> =
sqlx::query_scalar("SELECT COUNT(*) FROM resource WHERE workspace_id = $1 AND path = $2")
.bind("test-workspace")
.bind("u/test-user/db")
.fetch_one(&db)
.await?;
assert_eq!(
resource_left.unwrap_or(0),
1,
"the denied delete must roll the resource back too"
);
Ok(())
}
/// Deleting a resource must not take a variable other things still need. Two gates, each
/// with a way past the other: a variable outside the resource's own path is never its to
/// delete, and even one it owns stays if another resource points at it.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_resource_delete_spares_variables_it_does_not_own(
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");
let create_var = |path: &'static str| {
authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
.send()
};
let create_res = |path: &'static str, var: &'static str| {
authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": { "password": format!("$var:{var}") },
"resource_type": "object"
}))
.send()
};
// A shared secret at a path of its own, and two resources reading it.
assert_eq!(create_var("u/test-user/shared_canary").await?.status(), 201);
assert_eq!(
create_res("u/test-user/probe_a", "u/test-user/shared_canary")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/probe_b", "u/test-user/shared_canary")
.await?
.status(),
201
);
// A secret the resource at the same path owns, which a second resource also reads.
assert_eq!(create_var("u/test-user/owned").await?.status(), 201);
assert_eq!(
create_res("u/test-user/owned", "u/test-user/owned")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/borrower", "u/test-user/owned")
.await?
.status(),
201
);
for resource in ["u/test-user/probe_a", "u/test-user/owned"] {
let resp = authed(
client().delete(format!("{base}/resources/delete/{resource}")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"delete {resource}: {}",
resp.text().await?
);
}
assert!(
variable_exists(&db, "test-workspace", "u/test-user/shared_canary").await?,
"a variable the deleted resource only referenced must survive"
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/owned").await?,
"an owned variable another resource still references must survive"
);
Ok(())
}
/// The bulk cascade follows what RLS actually deleted, not what the caller asked for: a
/// resource the request names but leaves standing neither cascades nor stops counting as a
/// referrer. Both halves matter, and neither covers the other.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_bulk_delete_follows_what_rls_deleted(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");
let create_var = |path: &'static str| {
authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
.send()
};
// ws_specific so the flag assertion at the end has something to check.
let create_res = |path: &'static str, var: &'static str| {
authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": { "password": format!("$var:{var}") },
"resource_type": "object",
"ws_specific": true
}))
.send()
};
// Private to test-user: a resource and the secret it owns.
assert_eq!(create_var("u/test-user/hidden_pwd").await?.status(), 201);
assert_eq!(
create_res("u/test-user/hidden", "u/test-user/hidden_pwd")
.await?
.status(),
201
);
// test-user-2's own resource and secret, which the private resource above also reads.
assert_eq!(create_var("u/test-user-2/own_pwd").await?.status(), 201);
assert_eq!(
create_res("u/test-user-2/own", "u/test-user-2/own_pwd")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/reader", "u/test-user-2/own_pwd")
.await?
.status(),
201
);
// test-user-2 may write the private secret but has no access to its resource at all.
sqlx::query(
"UPDATE variable SET extra_perms = '{\"u/test-user-2\": true}'::jsonb
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/hidden_pwd'",
)
.execute(&db)
.await?;
let resp = authed(
client().delete(format!("{base}/resources/delete_bulk")),
"SECRET_TOKEN_2",
)
.json(&json!({
"paths": ["u/test-user/hidden", "u/test-user-2/own", "u/test-user/reader"]
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "bulk delete: {}", resp.text().await?);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/hidden_pwd").await?,
"the variable of a resource RLS refused to delete must survive"
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user-2/own_pwd").await?,
"a requested resource RLS left standing still counts as a referrer"
);
// ws_specific has no RLS policy of its own, so clearing it by requested path rather than
// by deleted path would quietly turn a surviving resource workspace-generic.
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/hidden").await?,
1,
"a resource RLS refused to delete must keep its ws_specific flag"
);
Ok(())
}
+8 -1
View File
@@ -1053,6 +1053,10 @@ pub async fn fetch_api_authed_from_permissioned_as(
db: &DB,
username_override: Option<String>,
) -> error::Result<ApiAuthed> {
// Keyed by the supplied address, so an entry built for a principal's previous holder is reused
// while that address is still supplied, until its 120s expiry: a cached dispatch address is
// evicted sooner, an app's stored one (a username deleted then reused) may not be. Accepted;
// the rebuild after expiry is the current holder's.
let key = (w_id.to_string(), permissioned_as.clone(), email.clone());
let mut api_authed = match API_AUTHED_CACHE.get(&key) {
@@ -1068,7 +1072,10 @@ pub async fn fetch_api_authed_from_permissioned_as(
let api_authed = ApiAuthed {
username: authed.username,
email,
// The resolved one, not the address we were handed: that is the point of
// `fetch_authed_from_permissioned_as` validating it against the principal's live
// binding, and this value goes on to the job row, `job_perms` and the JWT.
email: authed.email,
is_admin: authed.is_admin,
is_operator: authed.is_operator,
groups: authed.groups,
+11 -6
View File
@@ -1073,12 +1073,15 @@ fn scope_grants_access(
/// the caller's own row; `email` and `allowed_domain_auto_invite` are derived from the
/// token itself and touch no table.
///
/// `settings/global/automate_username_creation` is the one instance setting on the list.
/// `get_global_setting` exempts a handful of keys from its own super-admin gate, that one
/// among them, so the boolean is already readable by every authenticated user; it is here
/// because the CLI reads it before creating a user during a git-sync push, which runs as a
/// job. The other ungated keys have no such caller, so they stay confined — being ungated
/// earns a key nothing on its own.
/// Three instance settings are on the list. `get_global_setting` exempts a handful of keys
/// from its own super-admin gate, these among them, so each is already readable by every
/// authenticated user; each is here because the CLI reads it from a job:
/// `automate_username_creation` before creating a user during a git-sync push, `uid` and
/// `hub_base_url` when `u/admin/hub_sync` pulls resource types from the Hub. The other
/// ungated keys have no such caller, so they stay confined — being ungated earns a key
/// nothing on its own. Listing a gated key earns it nothing either: `require_super_admin`
/// refuses every job token, so `hub_api_secret`, which that pull reads for a private Hub,
/// stays out of a job's reach whatever this list says.
///
/// Deliberately absent, as each crosses that line: `users/list_invites` (returns the
/// workspace ids the identity was invited to), `users/tokens/list` (credential metadata
@@ -1096,6 +1099,8 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool {
| "/api/users/tutorial_progress"
| "/api/workspaces/allowed_domain_auto_invite"
| "/api/settings/global/automate_username_creation"
| "/api/settings/global/uid"
| "/api/settings/global/hub_base_url"
| "/api/docs/search"
| "/api/docs/page"
| "/api/integrations/hub/list"
+1 -1
View File
@@ -922,7 +922,7 @@ async fn derived_on_behalf_of_email(
let Some(permissioned_as) = flow.on_behalf_of.as_deref() else {
return Ok(None);
};
// Uncached, for the reason given on `prefetch_cached_script`: this pair is round-tripped.
// Uncached: this pair is round-tripped by the client and stored again on redeploy.
Ok(Some(
windmill_common::users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db)
.await?,
@@ -599,3 +599,59 @@ async fn test_offboard_invalid_target(db: Pool<Postgres>) -> anyhow::Result<()>
Ok(())
}
/// A legacy member named `group-ops` canonicalizes to `g/ops`, the principal the real `ops` group
/// runs as. Offboarding the member must not hand the group's runnables to the replacement.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_offboard_group_prefixed_member_keeps_group_identities(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
sqlx::raw_sql(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('ops-bot@windmill.dev', 'x', 'password', false, true, 'Ops bot', 'group-ops');
INSERT INTO usr(workspace_id, email, username, is_admin, role)
VALUES ('test-workspace', 'ops-bot@windmill.dev', 'group-ops', false, 'User');
INSERT INTO group_(workspace_id, name, summary) VALUES ('test-workspace', 'ops', '');
INSERT INTO app(workspace_id, path, summary, policy, versions, extra_perms)
VALUES ('test-workspace', 'f/shared/ops_app', '',
'{\"execution_mode\": \"publisher\", \"on_behalf_of\": \"g/ops\",
\"on_behalf_of_email\": \"group-ops@windmill.dev\"}', '{}', '{}');",
)
.execute(&db)
.await?;
let preview: serde_json::Value =
authed(client().get(ws_url(port, "offboard_preview/group-ops")))
.send()
.await?
.json()
.await?;
assert!(
preview["executing_on_behalf"]["apps"].is_null(),
"the group's apps are not the member's to reassign: {preview}"
);
let resp = authed(client().post(ws_url(port, "offboard/group-ops")))
.json(&json!({
"reassign_to": "u/test-user",
"new_on_behalf_of_user": "test-user",
"delete_user": false
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let principal: Option<String> = sqlx::query_scalar(
"SELECT policy->>'on_behalf_of' FROM app
WHERE workspace_id = 'test-workspace' AND path = 'f/shared/ops_app'",
)
.fetch_one(&db)
.await?;
assert_eq!(principal.as_deref(), Some("g/ops"));
Ok(())
}
+11 -8
View File
@@ -332,7 +332,7 @@ async fn create_schedule(
)
.await?;
// email is still written for backwards compat with old workers that don't know about permissioned_as
let resolved_email = windmill_common::users::get_email_from_permissioned_as(
let resolved_email = windmill_common::users::get_email_from_permissioned_as_uncached(
&resolved_permissioned_as,
&w_id,
&db,
@@ -545,18 +545,14 @@ async fn edit_schedule(
reject_reserved_schedule_path(path)?;
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
let mut tx = user_db.begin(&authed).await?;
// Check schedule for error
ScheduleType::from_str(&es.schedule, es.cron_version.as_deref(), true)?;
// Validate dynamic_skip if provided
if let Some(handler_path) = &es.dynamic_skip {
validate_dynamic_skip(&mut tx, &w_id, handler_path).await?;
}
let resolved_edited_by = resolve_edited_by(&authed);
// Resolved on the (non-RLS) pool before the RLS transaction opens: the lookup mid-transaction
// would hold a second connection while `tx` is checked out.
let resolved_permissioned_as = resolve_permissioned_as(
es.permissioned_as.as_ref(),
es.preserve_permissioned_as,
@@ -568,7 +564,7 @@ async fn edit_schedule(
let resolved_email = if resolved_permissioned_as
!= windmill_common::users::username_to_permissioned_as(&authed.username)
{
windmill_common::users::get_email_from_permissioned_as(
windmill_common::users::get_email_from_permissioned_as_uncached(
&resolved_permissioned_as,
&w_id,
&db,
@@ -585,6 +581,13 @@ async fn edit_schedule(
Some(&resolved_email),
)?;
let mut tx = user_db.begin(&authed).await?;
// Validate dynamic_skip if provided
if let Some(handler_path) = &es.dynamic_skip {
validate_dynamic_skip(&mut tx, &w_id, handler_path).await?;
}
let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
let schedule = sqlx::query_as!(
+4 -1
View File
@@ -61,7 +61,7 @@ use windmill_common::{
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING,
MAX_RETENTION_OVERRIDE_WORKSPACES, RETENTION_PERIOD_SECS_OVERRIDES_SETTING,
RUFF_CONFIG_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING,
},
@@ -1321,6 +1321,9 @@ pub async fn get_global_setting(
&& key != AUTOMATE_USERNAME_CREATION_SETTING
&& key != DEFAULT_TAGS_WORKSPACES_SETTING
&& key != HUB_BASE_URL_SETTING
// `wmill hub pull` reads it from a job, and no job token clears the gate. It binds an
// offline license only together with `license_key`, which stays gated.
&& key != UNIQUE_ID_SETTING
&& key != HUB_ACCESSIBLE_URL_SETTING
&& key != DISABLE_HUB_SETTING
&& key != EMAIL_DOMAIN_SETTING
+25 -3
View File
@@ -2149,6 +2149,28 @@ async fn change_user_email(
.execute(&mut *tx)
.await?;
// An app draft carries a copy of the deployed policy, principal included.
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2"#,
&new_principal,
&old_principal
)
.execute(&mut *tx)
.await?;
// A raw-app draft persists the address the client read back too. The deploy sends it beside
// the principal, where an address naming somebody else is rejected — and unlike a live read
// it never refreshes on its own. Same group guard as the deployed policy above, plus the
// `IS NULL` arm: without it the predicate is `NULL` for a draft with no principal, which is
// neither true nor false, so those rows would be skipped.
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($1::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of_email' = $2 AND (value->'policy'->>'on_behalf_of' IS NULL OR value->'policy'->>'on_behalf_of' NOT LIKE 'g/%')"#,
&new_email,
&old_email
)
.execute(&mut *tx)
.await?;
// A folder's default rules are an ordered array, first match wins, so the rewrite has to
// preserve their order. A rule left on the old address makes `ensure_permissioned_as_exists`
// reject the creation of every runnable the rule matches.
@@ -2326,9 +2348,9 @@ async fn change_user_email(
)
.await?;
// Read back inside the transaction: the address is derived at dispatch through a cache
// that nothing else evicts, so without this a job pushed in the next 60s would resolve
// the old address and with it the wrong superadmin flag and instance groups.
// Read back inside the transaction so this process can evict its own keys immediately.
// `notify_user_email_change` reaches every replica for the same change, but asynchronously,
// and this one is the replica that just served the request.
let memberships =
sqlx::query_scalar!("SELECT workspace_id FROM usr WHERE email = $1", &new_email)
.fetch_all(&mut *tx)
+3 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.810.0
version: 1.811.1
title: Windmill API
contact:
@@ -33946,8 +33946,10 @@ components:
checked when the session is minted and again on every guest request
on_behalf_of:
type: string
description: The user or group the app runs as in anonymous or publisher mode (e.g. 'u/admin' or 'g/mygroup'). The authority for the app's identity.
on_behalf_of_email:
type: string
description: Address of `on_behalf_of`, written through from it on every save and returned as stored. Optional; when absent it is derived from `on_behalf_of`. Sending it is optional too; it must name the same account as `on_behalf_of`, and a pair that disagrees is rejected.
sandbox:
type: boolean
description: >
+137 -75
View File
@@ -491,6 +491,13 @@ pub struct S3Key {
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Policy {
pub on_behalf_of: Option<String>,
/// The address `on_behalf_of` resolves to. Every write stores what the principal resolves
/// to, so it is not taken from the request except when a client names only the address —
/// which is how a cross-workspace deploy carries an identity — and it is rejected when the
/// two disagree. Optional: a policy without it executes by deriving from the principal, so
/// removing it is a change of default rather than of behavior — see
/// `docs/app-policy-email-removal.md`.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
//paths:
// - script/<path>
@@ -2461,31 +2468,37 @@ async fn create_app_internal<'a>(
}
// Resolve the on-behalf-of defaults on the (non-RLS) pool *before* opening
// the RLS transaction below: doing these lookups mid-transaction would hold
// a second simultaneous connection while `tx` is still checked out.
// a second simultaneous connection while `tx` is still checked out. The race this
// leaves with a concurrent rename or removal, including a freed username later
// rebinding the stored principal, is known and accepted: see `resolve_on_behalf_of`.
let should_preserve = app.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& app.policy.on_behalf_of.is_some();
&& (app.policy.on_behalf_of.is_some() || app.policy.on_behalf_of_email.is_some());
if !should_preserve {
let mut preserved_on_behalf_of: Option<String> = None;
if should_preserve {
app.policy.on_behalf_of = windmill_common::resolve_on_behalf_of(
app.policy.on_behalf_of_email.as_deref(),
app.policy.on_behalf_of.as_deref(),
true,
&authed,
w_id,
&db,
)
.await?;
} else {
let folder_default = if windmill_common::can_preserve_on_behalf_of(&authed) {
windmill_common::folders::resolve_folder_default_permissioned_as(&db, w_id, &app.path)
.await?
} else {
None
};
if let Some(default_permissioned_as) = folder_default {
let default_email = windmill_common::users::get_email_from_permissioned_as(
&default_permissioned_as,
w_id,
&db,
)
.await?;
app.policy.on_behalf_of = Some(default_permissioned_as);
app.policy.on_behalf_of_email = Some(default_email);
} else {
app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
app.policy.on_behalf_of_email = Some(authed.email.clone());
}
app.policy.on_behalf_of =
Some(folder_default.unwrap_or_else(|| username_to_permissioned_as(&authed.username)));
}
app.policy.on_behalf_of_email = stored_on_behalf_of_email(&app.policy, w_id, &db).await?;
if should_preserve {
preserved_on_behalf_of = audited_on_behalf_of(&app.policy, &authed);
}
// Reject a forged superadmin run identity in the (possibly preserved) policy.
@@ -2621,21 +2634,17 @@ async fn create_app_internal<'a>(
None,
)
.await?;
if should_preserve {
if let Some(ref obo_email) = app.policy.on_behalf_of_email {
if obo_email != &authed.email {
audit_log(
&mut *tx,
&authed,
"apps.on_behalf_of",
ActionKind::Create,
w_id,
Some(&app.path),
Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()),
)
.await?;
}
}
if let Some(ref obo_email) = preserved_on_behalf_of {
audit_log(
&mut *tx,
&authed,
"apps.on_behalf_of",
ActionKind::Create,
w_id,
Some(&app.path),
Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()),
)
.await?;
}
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
if let Some(dm) = &app.deployment_message {
@@ -3392,19 +3401,33 @@ async fn update_app_internal<'a>(
}
}
// Reject a forged superadmin run identity in a preserved policy. Mirror the
// `should_preserve` gate below (only a preserved value is caller-controlled;
// otherwise the policy is rewritten to the deployer's own identity) and run
// it on the non-RLS pool before the transaction to avoid a second connection.
if let Some(npolicy) = ns.policy.as_ref() {
// Resolved on the (non-RLS) pool before the RLS transaction opens, for the reason
// `create_app` states, with the same known, accepted rename race (see
// `resolve_on_behalf_of`). Submitting a policy is how a deployer claims the app's execution
// identity; a source deploy that sent none claims nothing, so whoever the app already runs as
// stays.
let mut preserved_on_behalf_of: Option<String> = None;
if let Some(npolicy) = ns.policy.as_mut() {
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& npolicy.on_behalf_of.is_some();
&& (npolicy.on_behalf_of.is_some() || npolicy.on_behalf_of_email.is_some());
if should_preserve {
windmill_common::auth::validate_on_behalf_of(
npolicy.on_behalf_of.as_deref(),
npolicy.on_behalf_of = windmill_common::resolve_on_behalf_of(
npolicy.on_behalf_of_email.as_deref(),
)?;
npolicy.on_behalf_of.as_deref(),
true,
&authed,
w_id,
&db,
)
.await?;
} else {
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
}
npolicy.on_behalf_of_email = stored_on_behalf_of_email(npolicy, w_id, &db).await?;
if should_preserve {
preserved_on_behalf_of = audited_on_behalf_of(npolicy, &authed);
}
}
@@ -3437,7 +3460,6 @@ async fn update_app_internal<'a>(
reject_kind_change(path, raw_app, deployed_raw_app)?;
}
let mut preserved_on_behalf_of: Option<String> = None;
let npath = if ns.policy.is_some()
|| ns.path.is_some()
|| ns.summary.is_some()
@@ -3623,23 +3645,6 @@ async fn update_app_internal<'a>(
}
}
}
let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false)
&& windmill_common::can_preserve_on_behalf_of(&authed)
&& npolicy.on_behalf_of.is_some();
if should_preserve {
if let Some(ref obo_email) = npolicy.on_behalf_of_email {
if obo_email != &authed.email {
preserved_on_behalf_of = Some(obo_email.clone());
}
}
} else if caller_sent_policy {
// Submitting a policy is how a deployer claims the app's
// execution identity. A source deploy that sent none is not
// claiming anything, so whoever the app already runs as stays.
npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username));
npolicy.on_behalf_of_email = Some(authed.email.clone());
}
sqlb.set(
"policy",
quote(serde_json::to_string(&json!(npolicy)).map_err(|e| {
@@ -3849,6 +3854,8 @@ fn digest(code: &str) -> String {
async fn get_on_behalf_details_from_policy_and_authed(
policy: &Policy,
opt_authed: &Option<ApiAuthed>,
w_id: &str,
db: &DB,
) -> Result<(String, String, String)> {
// A guest acts only through an app open to guests — or to everyone. A members-only
// mode means the policy changed after the session was issued. Decided here, in the
@@ -3871,7 +3878,7 @@ async fn get_on_behalf_details_from_policy_and_authed(
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
let (permissioned_as, email) = get_on_behalf_of(&policy, w_id, db).await?;
(username, permissioned_as, email)
}
// Guest runs as the publisher exactly as Publisher does; the two differ only
@@ -3885,7 +3892,7 @@ async fn get_on_behalf_details_from_policy_and_authed(
"publisher execution mode requires authentication".to_string(),
)
})?;
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
let (permissioned_as, email) = get_on_behalf_of(&policy, w_id, db).await?;
(username, permissioned_as, email)
}
ExecutionMode::Viewer => {
@@ -4243,7 +4250,7 @@ async fn execute_component(
}
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let resolved_delete_secs =
resolve_delete_after_secs(None, policy_triggerables.delete_after_secs);
@@ -4613,7 +4620,7 @@ async fn upload_s3_file_from_app(
let s3_inputs = policy.s3_inputs.as_ref().unwrap();
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let on_behalf_authed = fetch_api_authed_from_permissioned_as(
permissioned_as.clone(),
@@ -5024,7 +5031,7 @@ async fn get_on_behalf_authed_from_app(
let opt_authed = guest_caller_for_mode(opt_authed.clone(), policy.execution_mode(), path)?;
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let on_behalf_authed =
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, Some(username))
@@ -5535,7 +5542,45 @@ async fn app_load_csv_preview() -> Result<()> {
))
}
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
/// The address to store beside the principal. Derived from it, never taken from the request, so
/// the stored copy can only ever agree with the principal — the drift it used to allow is what
/// this replaces.
///
/// Written unconditionally, including for the versions that could derive it instead: a replica
/// predating that fallback fails outright when the key is absent, which would 400 every
/// anonymous, publisher and guest app for the length of a rolling deploy. The write is what
/// holds the key in place — see `docs/app-policy-email-removal.md`.
async fn stored_on_behalf_of_email(policy: &Policy, w_id: &str, db: &DB) -> Result<Option<String>> {
let Some(permissioned_as) = policy.on_behalf_of.as_deref() else {
return Ok(None);
};
Ok(Some(
windmill_common::users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db)
.await?,
))
}
/// The address to record in the `apps.on_behalf_of` audit entry: the one the app will run as,
/// when it is not the deployer's own. `None` when they match — a deployer handing an app their
/// own identity is not an on-behalf-of deploy.
///
/// Reads the address `stored_on_behalf_of_email` just resolved rather than looking it up again,
/// so the audit row and the policy row can only ever name the same account.
fn audited_on_behalf_of(policy: &Policy, authed: &ApiAuthed) -> Option<String> {
policy
.on_behalf_of_email
.as_deref()
.filter(|email| *email != authed.email)
.map(str::to_string)
}
/// The identity an anonymous, publisher or guest execution runs as.
///
/// `on_behalf_of_email` is optional: every write stores it, so it is present on anything this
/// release deployed, and it is only derived for a policy that predates that. Deriving is the
/// fallback rather than the rule so that removing the key later is a change of default, not a
/// change of behavior — see `docs/app-policy-email-removal.md`.
async fn get_on_behalf_of(policy: &Policy, w_id: &str, db: &DB) -> Result<(String, String)> {
let permissioned_as = policy
.on_behalf_of
.as_ref()
@@ -5546,16 +5591,15 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
)
})?
.to_string();
let email = policy
.on_behalf_of_email
.as_ref()
.ok_or_else(|| {
Error::BadRequest(
"on_behalf_of_email is missing in the app policy and is required for anonymous execution"
.to_string(),
)
})?
.to_string();
let email = match policy.on_behalf_of_email.as_deref() {
Some(email) => email.to_string(),
// Cached on purpose, up to one notify poll stale: the accepted dispatch case
// `get_email_from_permissioned_as` documents.
None => {
windmill_common::users::get_email_from_permissioned_as(&permissioned_as, w_id, db)
.await?
}
};
// Defence in depth against a policy that already carries a forged superadmin
// sentinel (deployed before validation existed, or copied verbatim by a
// workspace fork): the sentinels are internal-only and never a legitimate app
@@ -5702,7 +5746,25 @@ async fn build_args(
"email" => authed.as_ref().map(|a| serde_json::to_value(&a.email)),
"workspace" => Some(serde_json::to_value(&w_id)),
"groups" => authed.as_ref().map(|a| serde_json::to_value(&a.groups)),
"author" => Some(serde_json::to_value(&policy.on_behalf_of_email)),
// Same rule as `get_on_behalf_of`: the stored address, derived only when absent.
"author" => {
let author = match (
policy.on_behalf_of_email.as_deref(),
policy.on_behalf_of.as_deref(),
) {
(Some(email), _) => Some(email.to_string()),
(None, Some(permissioned_as)) => Some(
windmill_common::users::get_email_from_permissioned_as(
permissioned_as,
w_id,
db,
)
.await?,
),
(None, None) => None,
};
Some(serde_json::to_value(&author))
}
_ => {
return Err(Error::BadRequest(format!(
"context variable {} not allowed",
+34 -13
View File
@@ -128,6 +128,17 @@ struct WorkspaceReassignment {
// ---- Preview helpers ----
/// The principal a departing member's runnables run as, or `None` when none of them are theirs
/// to hand over. `usr.username` is constrained to `[\w-]+`, so a member is `u/{username}`, except
/// a legacy `group-*` username, which canonicalizes to the group it names: what runs under that
/// principal runs as the group, which outlives the member. `None` binds NULL, which the
/// `on_behalf_of = $n` queries then match nowhere.
fn departing_principal(username: &str) -> Option<String> {
use windmill_common::users::{username_to_permissioned_as, PERMISSIONED_AS_GROUP_PREFIX};
let principal = username_to_permissioned_as(username);
(!principal.starts_with(PERMISSIONED_AS_GROUP_PREFIX)).then_some(principal)
}
async fn get_offboard_preview(
db: impl sqlx::PgExecutor<'_> + Copy,
w_id: &str,
@@ -136,10 +147,8 @@ async fn get_offboard_preview(
) -> Result<OffboardPreview> {
let user_prefix = format!("u/{}/%", username);
let user_owner = format!("u/{}", username);
// Same form the mutation reassigns, so preview and execution cannot disagree. `usr.username`
// is constrained to `[\w-]+`, so a member is always named `u/{username}` — the address form a
// principal can also take names an account with no `usr` row, which is nobody offboardable.
let departing = windmill_common::users::username_to_permissioned_as(username);
// Same form the mutation reassigns, so preview and execution cannot disagree.
let departing = departing_principal(username);
// ---- Owned objects (under u/{username}/) ----
let scripts = sqlx::query_scalar!(
@@ -244,17 +253,17 @@ async fn get_offboard_preview(
// ---- Operator references (not under user's path) ----
let obo_scripts = sqlx::query_scalar!(
"SELECT path FROM script WHERE on_behalf_of = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted",
&departing, &user_prefix, w_id
departing.as_deref(), &user_prefix, w_id
).fetch_all(db).await?;
let obo_flows = sqlx::query_scalar!(
"SELECT path FROM flow WHERE on_behalf_of = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived",
&departing, &user_prefix, w_id
departing.as_deref(), &user_prefix, w_id
).fetch_all(db).await?;
let obo_apps = sqlx::query_scalar!(
"SELECT path FROM app WHERE policy->>'on_behalf_of' = $1 AND NOT path LIKE $2 AND workspace_id = $3",
&user_owner, &user_prefix, w_id
departing.as_deref(), &user_prefix, w_id
).fetch_all(db).await?;
let obo_schedules = sqlx::query_scalar!(
@@ -831,7 +840,7 @@ async fn offboard_user_from_workspace<'c>(
new_permissioned_as: &str,
) -> Result<OffboardSummary> {
let new_prefix = reassign_to.to_string();
let departing = windmill_common::users::username_to_permissioned_as(username);
let departing = departing_principal(username);
// The app policy stores an address beside its principal, and script/flow keep one for the
// workers that still read it, so the replacement's is resolved here.
@@ -871,7 +880,7 @@ async fn offboard_user_from_workspace<'c>(
sqlx::query!(
"UPDATE script SET on_behalf_of = $1, on_behalf_of_email = $4 WHERE on_behalf_of = $2 AND workspace_id = $3",
new_permissioned_as,
&departing,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
@@ -912,7 +921,7 @@ async fn offboard_user_from_workspace<'c>(
sqlx::query!(
"UPDATE flow SET on_behalf_of = $1, on_behalf_of_email = $4 WHERE on_behalf_of = $2 AND workspace_id = $3",
new_permissioned_as,
&departing,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
@@ -925,7 +934,7 @@ async fn offboard_user_from_workspace<'c>(
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['on_behalf_of'], to_jsonb($1::text)), ARRAY['on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('script', 'flow') AND value->>'on_behalf_of' = $2 AND workspace_id = $3"#,
new_permissioned_as,
&departing,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
@@ -951,9 +960,21 @@ async fn offboard_user_from_workspace<'c>(
"UPDATE app SET policy = jsonb_set(
jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)),
ARRAY['on_behalf_of_email'], to_jsonb($4::text)
) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3",
) WHERE policy->>'on_behalf_of' = $2 AND workspace_id = $3",
&new_permissioned_as,
username,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
.execute(&mut **tx)
.await?;
// An app draft carries a copy of the deployed policy and is deployed from it, so it needs
// the same pair rewritten — the draft sweep above only covers scripts and flows.
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)), ARRAY['policy', 'on_behalf_of_email'], to_jsonb($4::text))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = $2 AND workspace_id = $3"#,
new_permissioned_as,
departing.as_deref(),
w_id,
new_on_behalf_of_user_email
)
+47
View File
@@ -261,6 +261,12 @@ async fn rename_user(
)));
}
let old_instance_username =
sqlx::query_scalar!("SELECT username FROM password WHERE email = $1", user_email)
.fetch_optional(&mut *tx)
.await?
.flatten();
sqlx::query!(
"UPDATE password SET username = $1 WHERE email = $2",
ru.new_username,
@@ -269,6 +275,36 @@ async fn rename_user(
.execute(&mut *tx)
.await?;
// The per-workspace sweep below only reaches accounts with a `usr` row. A superadmin acting
// outside their workspaces has none, yet an app can name them: their principal is
// `u/{password.username}`, which this rename just moved. Matching on the address as well
// keeps a like-named member of some other workspace out of it.
if let Some(old_username) = old_instance_username.filter(|u| *u != ru.new_username) {
let old_principal = windmill_common::users::username_to_permissioned_as(&old_username);
let new_principal =
windmill_common::users::username_to_permissioned_as(&ru.new_username);
sqlx::query!(
"UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text))
WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3",
&new_principal,
&old_principal,
user_email
)
.execute(&mut *tx)
.await?;
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb($1::text)))
WHERE typ IN ('app', 'raw_app')
AND value->'policy'->>'on_behalf_of' = $2
AND value->'policy'->>'on_behalf_of_email' = $3"#,
&new_principal,
&old_principal,
user_email
)
.execute(&mut *tx)
.await?;
}
let workspace_usernames = sqlx::query!(
"SELECT workspace_id, username FROM usr WHERE email = $1",
&user_email
@@ -754,6 +790,17 @@ async fn update_username_in_workpsace<'c>(
.execute(&mut **tx)
.await?;
// An app draft carries a copy of the deployed policy, so the rename must reach it
// there too — same reason as the script/flow draft sweep above.
sqlx::query!(
r#"UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), ARRAY['policy', 'on_behalf_of'], to_jsonb('u/' || $1))) WHERE typ IN ('app', 'raw_app') AND value->'policy'->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3"#,
new_username,
old_username,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"UPDATE app SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3",
new_username,
@@ -149,9 +149,7 @@ async fn derive_email(
if let Some(hit) = cache.get(permissioned_as) {
return Ok(Some(hit.clone()));
}
// Uncached: the address goes into an archive a client redeploys from, and the write path
// validates the pair it sends back against an uncached lookup. The memo above still holds
// this to one query per distinct principal per export.
// The memo above holds this to one query per distinct principal per export.
let email =
windmill_common::users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db)
.await?;
+41 -15
View File
@@ -452,6 +452,42 @@ async fn fetch_authed_from_permissioned_as_inner(
w_id: &str,
conn: &mut sqlx::PgConnection,
) -> Result<Authed> {
// The `usr` row is the live binding between a `u/` principal and an address, and it is read
// here anyway for the workspace role. Callers may hand us a cached address, so read it before
// anything is granted: `super_admin` and `email_to_igroup` below are keyed on the address
// while the role is keyed on the principal, and an address that no longer belongs to this
// principal — a username freed and reassigned while its previous holder keeps a privileged
// account — would mix one account's role with another's instance privileges.
let member = match permissioned_as.split_once('/') {
Some(("u", name)) => sqlx::query!(
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id
)
.fetch_optional(&mut *conn)
.await?,
_ => None,
};
let resolved_email;
let email = match member.as_ref() {
Some(m) => m.email.as_str(),
// No enabled `usr` row. Resolve as `resolve_username_to_email` does: a disabled member's
// own row still wins over the `password` superadmin fallback, so it can never resolve to
// an unrelated superadmin who shares the username (workspace usernames are only unique per
// workspace). Off the member path, which is why it is worth a query that path skips.
None => match permissioned_as.split_once('/') {
Some(("u", name)) => {
resolved_email =
crate::users::resolve_username_to_email(w_id, name, &mut *conn).await?;
// No live binding at all: the supplied address stands. A cached one is at most one
// notify poll stale; accepted, see `users::get_email_from_permissioned_as`.
resolved_email.as_deref().unwrap_or(email)
}
_ => email,
},
};
let is_super_admin = permissioned_as == SUPERADMIN_SYNC_EMAIL
|| email == SUPERADMIN_SECRET_EMAIL
|| email == SUPERADMIN_NOTIFICATION_EMAIL
@@ -465,22 +501,12 @@ async fn fetch_authed_from_permissioned_as_inner(
if prefix == "u" {
let (is_admin, is_operator) = if is_super_admin {
(true, false)
} else if let Some(m) = member.as_ref() {
(m.is_admin, m.operator)
} else {
let r = sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id
)
.fetch_optional(&mut *conn)
.await?;
if let Some(r) = r {
(r.is_admin, r.operator)
} else {
return Err(Error::NotFound(format!(
"user {name} not found in workspace {w_id}"
)));
}
return Err(Error::NotFound(format!(
"user {name} not found in workspace {w_id}"
)));
};
let groups = get_groups_for_user(w_id, &name, email, &mut *conn).await?;
-2
View File
@@ -78,8 +78,6 @@ pub async fn resolve_folder_default_on_behalf_of(
else {
return Ok(None);
};
// Uncached: this pair is written straight onto the runnable, where a stale address would
// contradict the principal it is stored beside.
let email =
crate::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db).await?;
Ok(Some((email, permissioned_as)))
+24 -12
View File
@@ -282,14 +282,16 @@ pub fn check_on_behalf_of_preservation(
None
}
/// Resolves the identity to store when creating/updating a flow or script.
/// Resolves the identity to store when creating/updating a flow, script or app.
///
/// The permissioned_as is the only stored identity it decides what the job may access,
/// and the address is derived from it at read time — so the two can never name different
/// accounts. Callers may supply either: a bare email (every client written before the
/// principal existed) is resolved to the principal it names, and an email that names
/// nobody is rejected rather than recorded, since it could only produce a runnable that
/// cannot authenticate.
/// The permissioned_as is the identity: it decides what the job may access, and the address is
/// a function of it, so the two can never name different accounts. For a script or flow the
/// address is derived at read time; an app still stores it, as a compatibility copy written
/// through from the principal on every save and returned verbatim by the app reads (see
/// `docs/app-policy-email-removal.md`). Callers may supply either: a bare email (every client
/// written before the principal existed) is resolved to the principal it names, and an email
/// that names nobody is rejected rather than recorded, since it could only produce a runnable
/// that cannot authenticate.
///
/// Returns `None` when the runnable has no on-behalf-of identity, and the caller's own
/// identity when they are not allowed to preserve someone else's.
@@ -297,6 +299,18 @@ pub fn check_on_behalf_of_preservation(
/// Resolves through the non-RLS pool and authorizes nothing itself — `authed` decides only
/// whether preservation is allowed, and its role flags are not re-checked against `w_id`.
/// Callers must already be authorized for the workspace they pass.
///
/// Known, accepted race. The lookup runs on the pool, outside the caller's write transaction, so
/// an account renamed or removed between the two has its sweep run before the write is visible,
/// and the write stores the old principal. The runnable then fails to authenticate until it is
/// deployed with a current identity, with two exceptions: an app naming an external superadmin
/// keeps running as that account through its stored address, and if the freed username is later
/// given to another account, the stale principal binds to that account and runs as it. Every
/// caller shares this (scripts, flows and apps, address-only inputs included), and it needs a
/// rename or removal of the exact account inside the lookup-to-commit gap. Closing it means
/// serializing every identity write against every identity mutation, across all runnable kinds
/// (a `usr` row lock in each write, with each sweep ordered after the account change), which no
/// single caller can do on its own; it is left open deliberately.
pub async fn resolve_on_behalf_of(
on_behalf_of_email: Option<&str>,
on_behalf_of: Option<&str>,
@@ -1869,11 +1883,9 @@ pub async fn on_behalf_of_from_permissioned_as(
let Some(permissioned_as) = permissioned_as else {
return Ok(None);
};
// Uncached: the address is copied onto the job row, where it stays for the life of the run
// and decides the superadmin flag and the instance groups. Nothing evicts the cache across
// processes, so a cached read would keep minting jobs under an address the account no longer
// holds for up to a minute after it moves.
let email = users::get_email_from_permissioned_as_uncached(permissioned_as, w_id, db).await?;
// Cached on purpose, up to one notify poll stale: the accepted dispatch case
// `get_email_from_permissioned_as` documents.
let email = users::get_email_from_permissioned_as(permissioned_as, w_id, db).await?;
Ok(Some(jobs::OnBehalfOf {
email,
permissioned_as: permissioned_as.to_string(),
-3
View File
@@ -91,9 +91,6 @@ async fn prefetch_cached_script_inner(
derive_email: bool,
) -> crate::error::Result<Script<ScriptRunnableSettingsInline>> {
let derived_email = match script.on_behalf_of.as_deref().filter(|_| derive_email) {
// Uncached: the client preserves this pair and sends it back, where the write path
// validates it against an uncached lookup. A cached address would pair a live principal
// with an address the account no longer holds, and the redeploy would be rejected.
Some(permissioned_as) => Some(
crate::users::get_email_from_permissioned_as_uncached(
permissioned_as,
+58 -9
View File
@@ -175,13 +175,19 @@ pub async fn permissioned_as_exists(
/// Drop a cached address so a transactional email change is visible immediately.
///
/// The address is derived at dispatch and feeds the instance-superadmin check and
/// `email_to_igroup`, so serving a stale one would run jobs with the wrong authorization
/// for up to the cache TTL.
/// Not the thing that keeps authorization correct — `fetch_authed_from_permissioned_as`
/// re-resolves the address before granting anything. This keeps the cache from serving an
/// address that is merely wrong for the TTL, on reads and on what is shown.
pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
EMAIL_CACHE.remove(&(workspace_id.to_string(), username.to_string()));
}
/// Drop this name's entry in every workspace, for the changes that know the name but not the
/// workspace: a superadmin resolves through `password`, whose row names no workspace of its own.
pub fn invalidate_email_cache_for_username(username: &str) {
EMAIL_CACHE.retain(|(_workspace_id, cached_username), _| cached_username != username);
}
/// Inverse of [`get_email_from_permissioned_as`]: the principal an on-behalf-of email
/// names in this workspace, for callers that supply the email alone.
///
@@ -194,6 +200,14 @@ pub fn invalidate_email_cache(workspace_id: &str, username: &str) {
/// not a superadmin's, or a group that no longer exists. Callers then leave the identity
/// unrecorded rather than storing a principal that cannot authenticate.
///
/// Known, accepted consequence of a real account winning the synthetic `group-*@windmill.dev`
/// namespace: a group identity sent as its address alone, as a "keep target identity" workspace
/// deploy sends it for scripts, flows and apps, comes back as the account holding that address
/// when one exists, not as `g/*`. Such an account takes an admin to exist: a superadmin or an
/// admin-configured identity provider to create it (the public OAuth providers only assert a
/// `@windmill.dev` address to that domain's owner) and an admin of the target workspace to admit
/// it, so no member can steer a group's runnables to themselves this way.
///
/// Reads through the non-RLS pool and authorizes nothing: callers must already be authorized
/// for `workspace_id`.
pub async fn permissioned_as_from_email(
@@ -242,6 +256,33 @@ pub async fn permissioned_as_from_email(
/// - "u/{username}" → resolve via [`resolve_username_to_email`] (cached)
/// - "g/{group}" → "group-{group}@windmill.dev"
/// - raw email → return as-is
///
/// `notify_user_email_change` evicts the key on every process for each change that can move it,
/// at that process's next notify-event poll (`LISTEN_NEW_EVENTS_INTERVAL_SEC`, 10s by default),
/// so a hit can still be the old address for up to one poll. The TTL caps it if an eviction is
/// ever missed.
///
/// Which of the two to use is a question of how long a wrong answer lives, not of whether it is
/// stored — both of these get stored and read back. A config row (an app policy, a schedule, a
/// runnable) is the authority for every run that follows it, so a stale address there is
/// permanent and invisible: those use [`get_email_from_permissioned_as_uncached`]. Job dispatch
/// also stores its answer, and the worker reads it back to build that run's authed, but it
/// governs one job and dies with it, so it stays here.
///
/// The job's own authorization does not trust the address as given:
/// `fetch_authed_from_permissioned_as` re-resolves it from the principal's live binding, and that
/// corrected address is what the job row and its token carry. Route an address into an `Authed`,
/// a job row or a token without going through that function, and this cache stops being safe to
/// read at dispatch.
///
/// What reads the dispatch address before that re-resolution (the quota and superadmin-exemption
/// checks at the top of `push_inner`, a flow step's tag check) or when the principal has no live
/// binding can act on the old address for up to one poll after a username reuse, an email change
/// or a superadmin change. That window is accepted as the cost of keeping dispatch off the
/// database; a consumer that cannot tolerate it must re-resolve first.
///
/// Reads through the non-RLS pool and authorizes nothing — callers must already be authorized
/// for `workspace_id`.
pub async fn get_email_from_permissioned_as<'c>(
permissioned_as: &str,
workspace_id: &str,
@@ -250,13 +291,21 @@ pub async fn get_email_from_permissioned_as<'c>(
get_email_from_permissioned_as_inner(permissioned_as, workspace_id, db, true).await
}
/// [`get_email_from_permissioned_as`] without the address cache. Nothing evicts that cache
/// across processes, so for a minute after an email change it still serves the old address —
/// fine where the address only labels something on screen, wrong where it decides whether a
/// write is accepted or is copied onto a job row that outlives the window.
/// [`get_email_from_permissioned_as`] for a value about to be **persisted**.
///
/// Reads through the non-RLS pool and authorizes nothing, like the cached one: callers must
/// already be authorized for `workspace_id`.
/// The eviction is delivered by the `notify_event` poller, not synchronously, so for a few
/// seconds after a change a replica can still serve the old address. In a config row that is
/// permanent: the row outlives the eviction, every later run trusts it, and nothing re-derives
/// it, so a principal and an address that name different accounts stay that way.
///
/// Use this for three cases, all of which end in a stored pair:
/// - writing the address into a row;
/// - the lookup that validates a pair before it is stored;
/// - **reads whose result the client sends back** — a script or a workspace export hands over a
/// principal and address together, and a redeploy validates that pair against a fresh
/// resolution, so a stale one comes back as a rejected deploy rather than a stale display.
///
/// See [`get_email_from_permissioned_as`] for the dispatch case that deliberately does not.
pub async fn get_email_from_permissioned_as_uncached<'c>(
permissioned_as: &str,
workspace_id: &str,
@@ -360,6 +360,77 @@ async fn test_trigger_notify_workspace_key_change(db: Pool<Postgres>) {
);
}
/// The address a job runs as is served from a process-local cache, so every change that can move
/// a `(workspace, username)` -> email mapping has to reach the other replicas as an eviction.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_trigger_notify_user_email_change(db: Pool<Postgres>) {
let before_id = get_latest_event_id(&db).await.unwrap();
sqlx::query("UPDATE usr SET email = 'renamed@windmill.dev' WHERE workspace_id = 'test-workspace' AND username = 'test-user'")
.execute(&db)
.await
.expect("Failed to change email");
let events = poll_notify_events(&db, before_id)
.await
.expect("Should poll events");
assert!(
events.iter().any(|e| e.channel == "notify_user_email_change"
&& e.payload == "test-workspace:test-user"),
"email change should evict the key it moved"
);
// A superadmin outside their workspaces resolves through `password`, which names no
// workspace: the wildcard is the only way to reach that key. `super_admin` is half of what
// that fallback matches on, so losing it moves the mapping just as the address does.
for (label, stmt, expected_aliases) in [
(
"email change",
"UPDATE password SET email = 'sa2@windmill.dev' WHERE email = 'test@windmill.dev'",
// old address, new address, and the username that outlives both
vec!["test@windmill.dev", "sa2@windmill.dev", "test-user"],
),
(
"demotion",
"UPDATE password SET super_admin = false WHERE email = 'sa2@windmill.dev'",
vec!["sa2@windmill.dev", "test-user"],
),
(
"promotion",
"UPDATE password SET super_admin = true WHERE email = 'sa2@windmill.dev'",
vec!["sa2@windmill.dev", "test-user"],
),
(
"deletion",
"DELETE FROM password WHERE email = 'sa2@windmill.dev'",
vec!["sa2@windmill.dev", "test-user"],
),
] {
let before_id = get_latest_event_id(&db).await.unwrap();
sqlx::query(stmt)
.execute(&db)
.await
.unwrap_or_else(|e| panic!("Failed to apply superadmin {label}: {e}"));
let events = poll_notify_events(&db, before_id)
.await
.expect("Should poll events");
// Every alias the principal can be spelled as, since `resolve_username_to_email`
// matches a `u/` principal against the username or the address.
let evicted: Vec<&str> = events
.iter()
.filter(|e| e.channel == "notify_user_email_change")
.filter_map(|e| e.payload.strip_prefix("*:"))
.collect();
for alias in expected_aliases {
assert!(
evicted.contains(&alias),
"superadmin {label} should evict {alias}, got {evicted:?}"
);
}
}
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_trigger_notify_token_invalidation(db: Pool<Postgres>) {
// First insert a session token with token_hash and token_prefix
@@ -0,0 +1,81 @@
use sqlx::{Pool, Postgres};
use windmill_common::auth::fetch_authed_from_permissioned_as;
/// The address handed to `fetch_authed_from_permissioned_as` may come from a cache that a
/// username reassignment has outrun. It must not be believed: the workspace role is keyed on the
/// principal while `super_admin` and `email_to_igroup` are keyed on the address, so trusting a
/// stale one would run the new holder's job with the previous holder's instance privileges.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_stale_address_cannot_carry_the_previous_holders_privileges(db: Pool<Postgres>) {
// `test-user` in the fixture is a superadmin with the address `test@windmill.dev`. Free the
// username and hand it to somebody who is not, exactly as an offboard-then-onboard would.
sqlx::query("DELETE FROM usr WHERE workspace_id = 'test-workspace' AND username = 'test-user'")
.execute(&db)
.await
.expect("free the username");
sqlx::query(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('newcomer@windmill.dev', 'x', 'password', false, true, 'Newcomer')",
)
.execute(&db)
.await
.expect("create the new account");
sqlx::query(
"INSERT INTO usr(workspace_id, email, username, is_admin, role)
VALUES ('test-workspace', 'newcomer@windmill.dev', 'test-user', false, 'User')",
)
.execute(&db)
.await
.expect("reassign the username");
// What a replica that has not yet consumed the eviction would pass: the principal is the
// reassigned username, the address is the one it cached for the previous holder.
let authed = fetch_authed_from_permissioned_as(
"u/test-user",
"test@windmill.dev",
"test-workspace",
&db,
)
.await
.expect("should authenticate the current holder");
assert_eq!(
authed.email, "newcomer@windmill.dev",
"the principal's live address must win over the one supplied"
);
assert!(
!authed.is_admin,
"the new holder must not inherit the previous holder's superadmin"
);
}
/// A disabled member still holds its username in the workspace. Workspace usernames are only
/// unique per workspace, so an unrelated instance superadmin can share it, and falling through to
/// the `password` fallback would run the disabled member's jobs as that superadmin.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_disabled_member_never_resolves_to_a_same_named_superadmin(db: Pool<Postgres>) {
sqlx::query(
"UPDATE usr SET disabled = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-2'",
)
.execute(&db)
.await
.expect("disable the member");
sqlx::query(
"INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('other-superadmin@windmill.dev', 'x', 'password', true, true, 'Other', 'test-user-2')",
)
.execute(&db)
.await
.expect("create the same-named superadmin");
for supplied in ["test2@windmill.dev", "other-superadmin@windmill.dev"] {
let authed =
fetch_authed_from_permissioned_as("u/test-user-2", supplied, "test-workspace", &db)
.await;
assert!(
authed.is_err(),
"a disabled member must not authenticate (supplied {supplied}): {:?}",
authed.map(|a| (a.email, a.is_admin))
);
}
}
+4 -4
View File
@@ -14,10 +14,10 @@ pub mod git_sync_oss;
#[cfg(feature = "private")]
pub use git_sync_ee::{
enqueue_git_pull_dry_run, enqueue_git_pull_job, handle_deployment_metadata,
handle_deployment_metadata_batch, handle_fork_branch_creation, persist_auto_pull_state,
reconcile_and_enqueue_pull, reconcile_fork_branch_pull, record_auto_pull_failure,
tally_deployed_object_changes,
clear_auto_pull_failure, enqueue_git_pull_dry_run, enqueue_git_pull_job,
handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation,
persist_auto_pull_state, reconcile_and_enqueue_pull, reconcile_fork_branch_pull,
record_auto_pull_failure, tally_deployed_object_changes,
};
#[cfg(not(feature = "private"))]
+4 -2
View File
@@ -88,8 +88,10 @@ pub struct OAuthConfig {
#[serde(default = "empty_string")]
pub token_url: String,
pub userinfo_url: Option<String>,
/// The registry JSON may also carry `scope_options`, a frontend-only pick
/// list for the connect dialog; it is deliberately not modelled here.
/// The registry JSON may also carry two frontend-only keys for the connect
/// dialog, deliberately not modelled here: `scope_options`, a scope pick
/// list, and `resource_fields`, the fields of the resource type the dialog
/// asks for once the token is in (Snowflake's database and warehouse).
pub scopes: Option<Vec<String>>,
/// Default scopes for the client-credentials (2-legged) flow. These differ
/// from the authorization-code `scopes` for most providers (member/consent
+9 -2
View File
@@ -5505,6 +5505,8 @@ async fn push_inner<'c, 'd>(
) {
// Check current usage with SELECT (fast, no row locks)
// Only check user usage for non-premium workspaces
// `email` here and in the per-user checks below can be a cached dispatch address, up
// to one notify poll stale; accepted, see `get_email_from_permissioned_as`.
let (current_workspace_usage, current_user_usage) =
check_usage_limits(db, &billing_w_id, email, !team_plan_status.premium).await?;
@@ -6894,7 +6896,11 @@ async fn push_inner<'c, 'd>(
language as Option<ScriptLang>,
same_worker,
pre_run_error.map(|e| e.to_string()),
email,
// `job_authed`'s, not the handed-in `email`: unless the caller's own authed already names
// this identity, it came through `fetch_authed_from_permissioned_as`, which re-resolves the
// address from the principal's live binding. The same statement writes it to
// `job_perms.email`, and the two columns naming different accounts is what this prevents.
job_authed.email,
visible_to_owner,
flow_innermost_root_job,
guarded_concurrent_limit,
@@ -7011,7 +7017,8 @@ async fn push_inner<'c, 'd>(
hm.insert("created_by", user);
}
let audit_author = AuditAuthor {
email: email.to_string(),
// `job_authed`'s address, matching `v2_job` and `job_perms` above.
email: job_authed.email.clone(),
username: if runs_on_behalf {
windmill_common::auth::permissioned_as_to_username(&permissioned_as)
} else {
+345 -193
View File
@@ -7,7 +7,7 @@
*/
use dashmap::DashMap;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::sync::LazyLock;
@@ -1339,53 +1339,21 @@ async fn delete_resource(
return Err(Error::PermissionDenied(msg));
}
let cascade = plan_linked_var_cascade(&db, &w_id, &[path.to_string()]).await?;
let mut tx = user_db.begin(&authed).await?;
// Capture resource data for trashbin before deleting
let trash_resource: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2",
// The whole row comes back out of the delete, so the trashbin entry below is built from
// what RLS actually removed, and the cascade runs only once RLS has allowed the delete.
let deleted: Option<(String, serde_json::Value)> = sqlx::query_as(
"DELETE FROM resource AS t WHERE t.path = $1 AND t.workspace_id = $2
RETURNING t.path, to_jsonb(t)",
)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
// Fetch the resource value before deleting, so we can find linked $var: references
let resource_value: Option<Option<serde_json::Value>> =
sqlx::query_scalar("SELECT value FROM resource WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
// Collect all $var: paths referenced in the resource value
let mut linked_var_paths: Vec<String> = Vec::new();
if let Some(Some(ref value)) = resource_value {
collect_var_refs(value, &mut linked_var_paths);
}
// A scoped token must not delete linked variables it lacks variables:write for.
check_linked_var_delete_scopes(&authed, &linked_var_paths)?;
// Capture linked variables for trashbin before deleting them
let trash_linked_vars: Vec<serde_json::Value> = if linked_var_paths.is_empty() {
Vec::new()
} else {
let placeholders: Vec<String> = linked_var_paths
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 2))
.collect();
let query = format!(
"SELECT to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path IN ({})",
placeholders.join(", ")
);
let mut q = sqlx::query_scalar::<_, serde_json::Value>(&query).bind(&w_id);
for var_path in &linked_var_paths {
q = q.bind(var_path);
}
q.fetch_all(&mut *tx).await?
};
let (deleted_path, res_data) = not_found_if_none(deleted, "Resource", &path)?;
sqlx::query!(
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = $2",
@@ -1395,64 +1363,63 @@ async fn delete_resource(
.execute(&mut *tx)
.await?;
let deleted_path = sqlx::query_scalar!(
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
let linked_var_paths = cascade.resolve(std::slice::from_ref(&deleted_path));
// A scoped token must not delete linked variables it lacks variables:write for. Erroring
// here rolls the resource delete back with it, so nothing is deleted either way.
check_linked_var_delete_scopes(&authed, &linked_var_paths)?;
// Capture linked variables for trashbin before deleting them
let trash_linked_vars: Vec<(String, serde_json::Value)> = sqlx::query_as(
"SELECT path, to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path = ANY($2)",
)
.fetch_optional(&mut *tx)
.bind(&w_id)
.bind(&linked_var_paths)
.fetch_all(&mut *tx)
.await?;
not_found_if_none(deleted_path, "Resource", &path)?;
// Delete linked variables that are actually referenced in the resource value
let deleted_linked_variables: Vec<String> = if linked_var_paths.is_empty() {
Vec::new()
} else {
// Clean up any ws_specific rows for these variables first
// (mark_linked_variables_ws_specific may have auto-inserted them) so
// they don't survive the variable deletion as orphans — a variable
// later recreated at the same path would otherwise inherit the stale
// ws_specific flag.
sqlx::query!(
"DELETE FROM ws_specific
WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
w_id,
&linked_var_paths
)
.execute(&mut *tx)
.await?;
let deleted_linked_variables = sqlx::query_scalar!(
"DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
w_id,
&linked_var_paths
)
.fetch_all(&mut *tx)
.await?;
let placeholders: Vec<String> = linked_var_paths
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 2))
.collect();
let query = format!(
"DELETE FROM variable WHERE workspace_id = $1 AND path IN ({}) RETURNING path",
placeholders.join(", ")
);
let mut q = sqlx::query_scalar::<_, String>(&query).bind(&w_id);
for var_path in &linked_var_paths {
q = q.bind(var_path);
}
q.fetch_all(&mut *tx).await?
};
// ws_specific has no FK to variable, so a row mark_linked_variables_ws_specific inserted
// would survive as an orphan and a variable later recreated at that path would inherit
// the stale flag.
sqlx::query!(
"DELETE FROM ws_specific
WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
w_id,
&deleted_linked_variables
)
.execute(&mut *tx)
.await?;
if let Some(res_data) = trash_resource {
let mut trash_data = serde_json::json!({"row": res_data});
if !trash_linked_vars.is_empty() {
trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"resource",
path,
trash_data,
&authed.username,
)
.await?;
// Only the rows that actually went: the snapshot above is what the caller could read,
// which is not necessarily what RLS let it delete, and the trashbin must not hold a copy
// of a secret that is still live.
let trash_linked_vars: Vec<serde_json::Value> = trash_linked_vars
.into_iter()
.filter(|(var_path, _)| deleted_linked_variables.contains(var_path))
.map(|(_, row)| row)
.collect();
let mut trash_data = serde_json::json!({"row": res_data});
if !trash_linked_vars.is_empty() {
trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"resource",
path,
trash_data,
&authed.username,
)
.await?;
audit_log(
&mut *tx,
@@ -1464,6 +1431,24 @@ async fn delete_resource(
None,
)
.await?;
// The cascade is the one way a variable dies without a variables/delete request of its
// own, so give each one the audit row it would have had, stamped with what took it.
for var_path in &deleted_linked_variables {
let mut params = HashMap::new();
params.insert("via_resource", path);
audit_log(
&mut *tx,
&authed,
"variables.delete",
ActionKind::Delete,
&w_id,
Some(var_path),
Some(params),
)
.await?;
}
tx.commit().await?;
// Resource gone for everyone: wipe ALL users' drafts at this path (and any linked
@@ -1515,35 +1500,205 @@ async fn delete_resource(
);
}
Ok(format!("resource {} deleted", path))
// Name what else went: the cascade is silent from the caller's side otherwise, and a
// secret it took is not something to discover later from a failing job.
if deleted_linked_variables.is_empty() {
Ok(format!("resource {} deleted", path))
} else {
Ok(format!(
"resource {} deleted, along with its linked variables: {}",
path,
deleted_linked_variables.join(", ")
))
}
}
/// Recursively collect all `$var:path` references from a JSON value.
fn collect_var_refs(value: &serde_json::Value, out: &mut Vec<String>) {
/// The forms that resolve a variable path against `variable`, so a value carrying any of them
/// breaks when that variable goes. Only `$var:` is minted by the resource editor, which is why
/// `collect_var_refs` stays narrower than this.
const REFERRER_PREFIXES: [&str; 2] = ["$var:", "$jsonvar:"];
/// Recursively collect the variable paths a JSON value references through any of `prefixes`.
fn collect_refs_with_prefixes(value: &serde_json::Value, prefixes: &[&str], out: &mut Vec<String>) {
match value {
serde_json::Value::String(s) => {
if let Some(var_path) = s.strip_prefix("$var:") {
if let Some(var_path) = prefixes.iter().find_map(|p| s.strip_prefix(p)) {
out.push(var_path.to_string());
}
}
serde_json::Value::Object(m) => {
for v in m.values() {
collect_var_refs(v, out);
collect_refs_with_prefixes(v, prefixes, out);
}
}
serde_json::Value::Array(arr) => {
for v in arr {
collect_var_refs(v, out);
collect_refs_with_prefixes(v, prefixes, out);
}
}
_ => {}
}
}
/// Deleting a resource cascades into the `$var:` variables its value references. A
/// scoped token must not use that cascade to delete variables it could not delete
/// directly via `delete_variable` (which gates on `variables:write:<path>`), so require
/// `variables:write` for EVERY linked variable and fail the whole delete otherwise.
/// Recursively collect all `$var:path` references from a JSON value.
fn collect_var_refs(value: &serde_json::Value, out: &mut Vec<String>) {
collect_refs_with_prefixes(value, &["$var:"], out)
}
/// Whether the variable at `var_path` is the resource's own secret rather than one its value
/// merely points at: the same-path twin `delete_variable` and the `update_resource` rename
/// already act on, or `<resource path>_<field>`, which the connect form mints for a resource
/// type with several secret fields. Anything else is a standalone workspace variable, and
/// deleting one destroys a secret its other referrers still need.
///
/// A rename moves only the twin, so `<old path>_<field>` secrets stop matching and are left
/// behind instead. An orphaned secret can be deleted by hand; a destroyed one cannot.
fn is_owned_linked_var(resource_path: &str, var_path: &str) -> bool {
var_path == resource_path
|| var_path
.strip_prefix(resource_path)
.is_some_and(|suffix| suffix.starts_with('_'))
}
/// Which of `var_paths` a resource outside `excluded_resource_paths` still references.
///
/// Must run off the non-RLS pool: a referrer in a folder the caller cannot read is precisely
/// the one whose variable has to survive. Nothing from those rows reaches the response.
async fn linked_vars_referenced_elsewhere(
db: &DB,
w_id: &str,
var_paths: &[String],
excluded_resource_paths: &[String],
) -> Result<HashSet<String>> {
if var_paths.is_empty() {
return Ok(HashSet::new());
}
// The quotes around the pattern are what make it a whole-JSON-string match rather than a
// prefix one, so `f/db` does not match `"$var:f/db_replica"`. Paths are `proper_id`
// segments, so no JSON escaping or LIKE wildcard can reach this.
let referenced = sqlx::query_scalar!(
"WITH survivors AS (
SELECT value::text AS rendered FROM resource
WHERE workspace_id = $1 AND NOT (path = ANY($2::text[]))
)
SELECT v.path FROM unnest($3::text[]) AS v(path)
WHERE EXISTS (
SELECT 1 FROM survivors s
WHERE strpos(s.rendered, '\"$var:' || v.path || '\"') > 0
OR strpos(s.rendered, '\"$jsonvar:' || v.path || '\"') > 0
)",
w_id,
excluded_resource_paths,
var_paths,
)
.fetch_all(db)
.await?;
Ok(referenced.into_iter().flatten().collect())
}
/// A resource delete's candidate cascade, gathered before the caller's transaction opens: the
/// referrer scan runs on `db` because it has to see resources RLS hides, and a second acquire
/// from that same pool under an open `user_db` transaction stalls to the acquire timeout when
/// `DATABASE_CONNECTIONS` is small. `resolve` then decides without touching the database.
///
/// So a resource that starts referencing a candidate between the scan and the delete keeps a
/// `$var:` pointing at nothing. Narrowing that window means running the scan on the
/// transaction's own connection under a tightly scoped `SET LOCAL ROLE NONE` (the elevation
/// `windmill-queue/src/schedule.rs` uses); closing it needs a lock on every resource write.
struct LinkedVarCascade {
/// Each owned `$var:` path with the requested resource whose value carries it.
candidates: Vec<(String, String)>,
/// Requested resource paths, each with every variable path its value references.
requested: Vec<(String, Vec<String>)>,
/// Candidate paths a resource outside the requested set still references.
referenced_outside: HashSet<String>,
}
impl LinkedVarCascade {
/// The variables to delete, now that RLS has settled which resources went.
///
/// A requested resource left standing is the one referrer the scan could not account for,
/// having had to exclude every requested path before RLS had ruled.
fn resolve(&self, deleted_paths: &[String]) -> Vec<String> {
let referenced_by_survivors: HashSet<&str> = self
.requested
.iter()
.filter(|(path, _)| !deleted_paths.contains(path))
.flat_map(|(_, refs)| refs.iter().map(String::as_str))
.collect();
let mut resolved: Vec<String> = self
.candidates
.iter()
.filter(|(var_path, owner)| {
deleted_paths.contains(owner)
&& !self.referenced_outside.contains(var_path.as_str())
&& !referenced_by_survivors.contains(var_path.as_str())
})
.map(|(var_path, _)| var_path.clone())
.collect();
resolved.sort();
resolved.dedup();
resolved
}
/// Which deleted resource the cascade took `var_path` for, to stamp on its audit row.
fn owner_of<'a>(&'a self, var_path: &str, deleted_paths: &[String]) -> Option<&'a str> {
self.candidates
.iter()
.find(|(candidate, owner)| candidate == var_path && deleted_paths.contains(owner))
.map(|(_, owner)| owner.as_str())
}
}
/// Gather what `LinkedVarCascade::resolve` needs for a delete of `paths`.
async fn plan_linked_var_cascade(
db: &DB,
w_id: &str,
paths: &[String],
) -> Result<LinkedVarCascade> {
let rows: Vec<(String, Option<serde_json::Value>)> = sqlx::query_as(
"SELECT path, value FROM resource WHERE workspace_id = $1 AND path = ANY($2)",
)
.bind(w_id)
.bind(paths)
.fetch_all(db)
.await?;
let mut candidates: Vec<(String, String)> = Vec::new();
let mut requested: Vec<(String, Vec<String>)> = Vec::new();
for (path, value) in rows {
let mut owned: Vec<String> = Vec::new();
let mut refs: Vec<String> = Vec::new();
if let Some(value) = &value {
collect_var_refs(value, &mut owned);
collect_refs_with_prefixes(value, &REFERRER_PREFIXES, &mut refs);
}
candidates.extend(
owned
.into_iter()
.filter(|var_path| is_owned_linked_var(&path, var_path))
.map(|var_path| (var_path, path.clone())),
);
requested.push((path, refs));
}
candidates.sort();
candidates.dedup();
let mut candidate_paths: Vec<String> = candidates
.iter()
.map(|(var_path, _)| var_path.clone())
.collect();
candidate_paths.dedup();
let referenced_outside =
linked_vars_referenced_elsewhere(db, w_id, &candidate_paths, paths).await?;
Ok(LinkedVarCascade { candidates, requested, referenced_outside })
}
/// Deleting a resource cascades into the `$var:` variables it owns. A scoped token must not
/// use that cascade to delete variables it could not delete directly via `delete_variable`
/// (which gates on `variables:write:<path>`), so require `variables:write` for EVERY cascaded
/// variable and fail the whole delete otherwise.
///
/// No co-located-path exemption: a resource and a variable may share a path, and a
/// resource-write token can create a resource over an existing standalone variable and
@@ -1646,111 +1801,91 @@ async fn delete_resources_bulk(
return Err(Error::PermissionDenied(msg));
}
let cascade = plan_linked_var_cascade(&db, &w_id, &request.paths).await?;
let mut tx = user_db.begin(&authed).await?;
// Capture resources for trashbin per path before bulk delete, and
// collect $var: references so we can cascade-delete the linked variables
// (matching single-resource delete semantics).
let mut linked_var_paths: Vec<String> = Vec::new();
for path in &request.paths {
let trash_resource: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
if let Some(res_data) = trash_resource {
// Per-resource linked vars so each resource's trash entry carries
// exactly the variables that vanished with it (matching the
// single-delete shape: trash_data["linked_variables"]).
let mut this_linked: Vec<String> = Vec::new();
if let Some(value) = res_data.get("value") {
collect_var_refs(value, &mut this_linked);
}
this_linked.sort();
this_linked.dedup();
let trash_linked_vars: Vec<serde_json::Value> = if this_linked.is_empty() {
Vec::new()
} else {
let placeholders: Vec<String> = this_linked
.iter()
.enumerate()
.map(|(i, _)| format!("${}", i + 2))
.collect();
let query = format!(
"SELECT to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path IN ({})",
placeholders.join(", ")
);
let mut q = sqlx::query_scalar::<_, serde_json::Value>(&query).bind(&w_id);
for var_path in &this_linked {
q = q.bind(var_path);
}
q.fetch_all(&mut *tx).await?
};
let mut trash_data = serde_json::json!({"row": res_data});
if !trash_linked_vars.is_empty() {
trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"resource",
path,
trash_data,
&authed.username,
)
.await?;
linked_var_paths.extend(this_linked);
}
}
linked_var_paths.sort();
linked_var_paths.dedup();
// A scoped token must not delete linked variables it lacks variables:write for.
check_linked_var_delete_scopes(&authed, &linked_var_paths)?;
// Whole rows out of the delete; see delete_resource. RLS can leave a requested resource
// standing, so everything below is driven by this list rather than by `request.paths`.
let deleted: Vec<(String, serde_json::Value)> = sqlx::query_as(
"DELETE FROM resource AS t WHERE t.path = ANY($1) AND t.workspace_id = $2
RETURNING t.path, to_jsonb(t)",
)
.bind(&request.paths)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let deleted_paths: Vec<String> = deleted.iter().map(|(path, _)| path.clone()).collect();
sqlx::query!(
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = ANY($2)",
w_id,
&request.paths
&deleted_paths
)
.execute(&mut *tx)
.await?;
let deleted_paths = sqlx::query_scalar!(
"DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path",
&request.paths,
w_id
let linked_var_paths = cascade.resolve(&deleted_paths);
// A scoped token must not delete linked variables it lacks variables:write for. Erroring
// here rolls the resource deletes back with it.
check_linked_var_delete_scopes(&authed, &linked_var_paths)?;
// Snapshot before the delete below: the trashbin entries need the rows.
let trash_linked_vars: Vec<(String, serde_json::Value)> = sqlx::query_as(
"SELECT path, to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path = ANY($2)",
)
.bind(&w_id)
.bind(&linked_var_paths)
.fetch_all(&mut *tx)
.await?;
let deleted_linked_variables = sqlx::query_scalar!(
"DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
w_id,
&linked_var_paths
)
.fetch_all(&mut *tx)
.await?;
// Cascade-clean linked variables: delete any ws_specific 'variable' rows
// (typically auto-inserted by mark_linked_variables_ws_specific when the
// resource was ws_specific) BEFORE deleting the variable rows themselves
// — otherwise those ws_specific rows survive as orphans and a later
// variable created at the same path would inherit a stale flag.
if !linked_var_paths.is_empty() {
sqlx::query!(
"DELETE FROM ws_specific
WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
w_id,
&linked_var_paths
)
.execute(&mut *tx)
.await?;
// See delete_resource: ws_specific has no FK, so the rows would orphan.
sqlx::query!(
"DELETE FROM ws_specific
WHERE workspace_id = $1 AND item_kind = 'variable' AND path = ANY($2)",
w_id,
&deleted_linked_variables
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM variable WHERE workspace_id = $1 AND path = ANY($2)",
w_id,
&linked_var_paths
for (path, res_data) in &deleted {
// Every cascaded variable this resource's value points at, ownership aside: restoring
// it on its own must bring back each secret it needs, and the one it borrowed from a
// sibling in the same batch is gone too.
let mut refs: Vec<String> = Vec::new();
if let Some(value) = res_data.get("value") {
collect_var_refs(value, &mut refs);
}
let this_linked: Vec<serde_json::Value> = trash_linked_vars
.iter()
.filter(|(var_path, _)| {
refs.contains(var_path) && deleted_linked_variables.contains(var_path)
})
.map(|(_, row)| row.clone())
.collect();
let mut trash_data = serde_json::json!({"row": res_data});
if !this_linked.is_empty() {
trash_data["linked_variables"] = serde_json::Value::Array(this_linked);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"resource",
path,
trash_data,
&authed.username,
)
.execute(&mut *tx)
.await?;
}
@@ -1765,13 +1900,30 @@ async fn delete_resources_bulk(
)
.await?;
// See delete_resource: a cascaded variable gets the audit row it would have had.
for var_path in &deleted_linked_variables {
let params = cascade
.owner_of(var_path, &deleted_paths)
.map(|resource_path| HashMap::from([("via_resource", resource_path)]));
audit_log(
&mut *tx,
&authed,
"variables.delete",
ActionKind::Delete,
&w_id,
Some(var_path),
params,
)
.await?;
}
tx.commit().await?;
// Wipe ALL users' drafts at these paths (and linked variables); see delete_resource.
for path in &deleted_paths {
delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?;
}
for var_path in &linked_var_paths {
for var_path in &deleted_linked_variables {
delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?;
}
+10 -4
View File
@@ -158,6 +158,7 @@ pub struct RunJob {
pub payload: JobPayload,
pub args: serde_json::Map<String, serde_json::Value>,
pub scheduled_for_o: Option<chrono::DateTime<chrono::Utc>>,
pub username: String,
pub email: String,
pub job_id: Option<Uuid>,
pub workspace_id: String,
@@ -169,6 +170,7 @@ impl From<JobPayload> for RunJob {
payload,
args: Default::default(),
scheduled_for_o: None,
username: "test-user".to_string(),
email: "test@windmill.dev".to_string(),
job_id: None,
workspace_id: "test-workspace".to_string(),
@@ -190,7 +192,11 @@ impl RunJob {
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
/// Run as this workspace member. Both halves together, because the job's identity is the
/// principal: an address paired with another member's username is re-resolved at push to
/// the address that username holds.
pub fn as_user(mut self, username: impl Into<String>, email: impl Into<String>) -> Self {
self.username = username.into();
self.email = email.into();
self
}
@@ -206,7 +212,7 @@ impl RunJob {
}
pub async fn push(self, db: &Pool<Postgres>) -> Uuid {
let RunJob { payload, args, scheduled_for_o, email, job_id, workspace_id } = self;
let RunJob { payload, args, scheduled_for_o, username, email, job_id, workspace_id } = self;
let mut hm_args = std::collections::HashMap::new();
for (k, v) in args {
hm_args.insert(k, windmill_common::worker::to_raw_value(&v));
@@ -219,9 +225,9 @@ impl RunJob {
&workspace_id,
payload,
windmill_queue::PushArgs::from(&hm_args),
/* user */ "test-user",
/* user */ &username,
/* email */ &email,
/* permissioned_as */ "u/test-user".to_string(),
/* permissioned_as */ format!("u/{username}"),
/* token_prefix */ None,
/* audit_end_user */ None,
scheduled_for_o,
+77 -2
View File
@@ -1146,6 +1146,81 @@ pub async fn generate_bun_bundle(
Ok(())
}
/// [`generate_bun_bundle`], built once more with the version pins dropped from the import
/// specifiers of `main.ts` if it fails. The lockfile pins those versions, but bun fails on a
/// pinned specifier except where it tolerates a failed import (in a `try`, under a `.catch`, in
/// dead code). Such a script builds as written and must keep that bundle, so only failures retry.
async fn generate_bun_bundle_unpinning_imports(
job_dir: &str,
w_id: &str,
job_id: &Uuid,
worker_name: &str,
db: Option<&Connection>,
timeout: Option<i32>,
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
common_bun_proc_envs: &HashMap<String, String>,
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
) -> Result<()> {
let built = generate_bun_bundle(
job_dir,
w_id,
job_id,
worker_name,
db,
timeout,
mem_peak,
canceled_by,
common_bun_proc_envs,
occupancy_metrics,
)
.await;
// Without a job, a failed build comes back as an `ExecutionErr`; with one, that variant is a
// cancellation or timeout, which must not be retried.
let build_failed = match &built {
Err(error::Error::ExitStatus(..)) => true,
Err(_) => db.is_none(),
Ok(()) => false,
};
if !build_failed {
return built;
}
let Some(unpinned) = read_file_content(&format!("{job_dir}/main.ts"))
.await
.ok()
.and_then(|main| {
remove_pinned_import_specifiers(&main)
.ok()
.filter(|u| *u != main)
})
else {
return built;
};
write_file(job_dir, "main.ts", &unpinned)?;
if let Some(db) = db {
append_logs(
job_id,
w_id,
"\nbundling again with the imports' versions taken from the lockfile\n",
db,
)
.await;
}
generate_bun_bundle(
job_dir,
w_id,
job_id,
worker_name,
db,
timeout,
mem_peak,
canceled_by,
common_bun_proc_envs,
occupancy_metrics,
)
.await
}
struct PulledCodebase {
is_esm: bool,
}
@@ -1305,7 +1380,7 @@ pub async fn prebundle_bun_script(
let common_bun_proc_envs: HashMap<String, String> = get_common_bun_proc_envs(None).await;
generate_bun_bundle(
generate_bun_bundle_unpinning_imports(
job_dir,
w_id,
job_id,
@@ -2202,7 +2277,7 @@ try {{
if !codebase.is_some() && !has_bundle_cache {
if build_cache {
generate_bun_bundle(
generate_bun_bundle_unpinning_imports(
job_dir,
&job.workspace_id,
&job.id,
@@ -259,6 +259,19 @@ async fn compile_index(
.await
.ok();
// Static analysis can log in to read schemas, so it gets live credentials like
// every other dbt process.
if let Err(e) = p.refresh_profile(descriptor, job_id, w_id, conn).await {
append_logs(
job_id,
w_id,
format!("\nColumn lineage: skipped, {e}\n"),
conn,
)
.await;
return Ok(None);
}
let mut cmd = dbt_command(
p,
&[
+119 -13
View File
@@ -1121,15 +1121,72 @@ pub struct PreparedProject {
/// Written nsjail profile for this job, when the worker sandboxes jobs.
/// `None` means the phases run unsandboxed, exactly as before.
pub sandbox_config: Option<SandboxProfile>,
/// One-way digest of the rendered profile — the resolved connection, not
/// just the names it exposes. A resource repointed from one warehouse to
/// another that happens to use the same database and schema names is
/// invisible to `relation_root`, and a retry would then execute the saved
/// failures against a warehouse where the successful nodes do not exist.
/// One-way digest of the rendered profile, credentials masked: the resolved
/// connection, not just the names it exposes. A resource repointed from one
/// warehouse to another that happens to use the same database and schema
/// names is invisible to `relation_root`, and a retry would then execute the
/// saved failures against a warehouse where the successful nodes do not exist.
pub profile_digest: String,
/// The job's client, which `refresh_profile` re-resolves the warehouse with.
client: AuthedClient,
}
impl PreparedProject {
/// Re-resolve the warehouse and rewrite `profiles.yml` just before a dbt
/// process that logs in. What preparation wrote can have expired by then: a
/// Snowflake OAuth token lasts ten minutes, and the build follows `dbt deps`
/// and a parse, the `after_all` tests follow the build, a node retry follows
/// its backoff.
pub(crate) async fn refresh_profile(
&self,
descriptor: &DbtDescriptor,
job_id: &Uuid,
w_id: &str,
conn: &Connection,
) -> error::Result<()> {
// A project-owned `profiles.yml` is rendered by dbt itself, from an
// environment resolved once.
if descriptor.profile.profiles_yml.is_some() {
return Ok(());
}
let fresh = match write_profiles(
descriptor,
&self.project_dir,
&self.project_dir.to_string_lossy(),
&self.client,
&self.template_env(),
)
.await
{
Ok(fresh) => fresh,
// The profile on disk is still whole: a credential that does not
// expire connects with it exactly as before.
Err(e) => {
append_logs(
job_id,
w_id,
format!(
"\nCould not re-resolve the warehouse, so this dbt process uses the \
credentials resolved earlier in the job: {e}\n"
),
conn,
)
.await;
return Ok(());
}
};
// Credentials are masked out of the digest, so a mismatch is the warehouse
// itself moving mid-run, and this process would build somewhere else.
if fresh.digest != self.profile_digest {
return Err(Error::BadRequest(format!(
"the `{}` warehouse was repointed while this run was in progress; run the \
script again",
self.warehouse.as_deref().unwrap_or(DBT_DEFAULT_WAREHOUSE)
)));
}
Ok(())
}
/// Where this run's relations live: the resolved schema and database. Drift
/// here since the deploy means the stored graph names relations that no
/// longer exist.
@@ -1388,6 +1445,7 @@ pub(crate) async fn prepare_project(
},
sandbox_config,
profile_digest: profile.digest,
client: client.clone(),
project_dir,
profiles_dir: profile.dir,
engine,
@@ -1883,9 +1941,6 @@ async fn write_profiles(
.or(workspace_target.as_deref())
.unwrap_or("default");
let dir = PathBuf::from(job_dir).join("dbt_profiles");
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| Error::internal_err(format!("creating the profiles dir: {e}")))?;
let rendered = if is_dbt_profile {
let block = value.as_object().ok_or_else(|| {
Error::BadRequest(
@@ -1906,6 +1961,7 @@ async fn write_profiles(
} else {
render_profile(
&adapter,
descriptor.engine(),
&value,
&profile_name,
target,
@@ -1914,6 +1970,10 @@ async fn write_profiles(
&dir,
)?
};
// After the render, so a render that fails leaves the previous profile whole.
fresh_dir(&dir)
.await
.map_err(|e| Error::internal_err(format!("creating the profiles dir: {e}")))?;
write_file(dir.to_str().unwrap(), "profiles.yml", &rendered.yaml)?;
if let Some(pem) = rendered.root_certificate_pem.as_deref() {
write_file(
@@ -1923,7 +1983,7 @@ async fn write_profiles(
)?;
}
let profile_digest = profile_identity_digest(
&rendered.yaml,
&rendered.identity,
&dir,
rendered.root_certificate_pem.as_deref(),
&client.token,
@@ -1946,6 +2006,20 @@ async fn write_profiles(
})
}
/// An empty directory at `dir`, whatever was there. `refresh_profile` writes into
/// the job directory after project code has run in a jail that can write it, and a
/// symlink left at `dir` or inside it would carry the worker's write out of the
/// sandbox. An entry that is not a real directory is unlinked, never followed.
async fn fresh_dir(dir: &Path) -> std::io::Result<()> {
match tokio::fs::symlink_metadata(dir).await {
Ok(m) if m.is_dir() => tokio::fs::remove_dir_all(dir).await?,
Ok(_) => tokio::fs::remove_file(dir).await?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
tokio::fs::create_dir_all(dir).await
}
/// Where a workspace warehouse name points: its resource path and, if the
/// workspace names one, its target.
async fn resolve_warehouse(
@@ -1965,7 +2039,9 @@ async fn resolve_warehouse(
.map_err(|e| Error::BadRequest(format!("resolving the dbt warehouse `{warehouse}`: {e}")))
}
/// Identifies the connection a rendered profile describes, for run identity.
/// Identifies the connection a rendered profile describes, for run identity,
/// from the rendering whose credentials are already masked
/// (`RenderedProfile::identity`).
///
/// Two things in the rendered text belong to the ATTEMPT rather than the
/// connection, and hashing either as-is makes a retry reject its own
@@ -2494,6 +2570,8 @@ async fn run_dbt(
ctx: &mut JobCtx<'_>,
with_selection: bool,
) -> error::Result<()> {
p.refresh_profile(descriptor, &job.id, &job.workspace_id, conn)
.await?;
let mut cmd = dbt_command(p, &[command]);
// The console stays human-readable and goes straight to the job log; the
// machine-readable copy goes to a file the progress reporter tails, so
@@ -3157,6 +3235,7 @@ async fn run_show(
`@`) or wildcard (`*`), resolves to a set: run `build` with it instead"
)));
}
p.refresh_profile(descriptor, job_id, w_id, conn).await?;
let mut cmd = dbt_command(p, &["show"]);
if inv.deferral.is_some() {
cmd.args(defer_flags("show", p.engine.engine));
@@ -5716,20 +5795,47 @@ mod tests {
// and without normalizing it the saved run is never recognized as its own.
#[test]
fn profile_identity_ignores_the_attempts_token() {
let yaml = |tok: &str| format!("host: \"wh\"\npassword: \"{tok}\"\n");
let yaml = |v: &str| format!("host: \"{v}\"\nuser: \"u\"\n");
let dir = Path::new("/tmp/windmill/w/job-1/profiles");
assert_eq!(
profile_identity_digest(&yaml("tok-first"), dir, None, "tok-first"),
profile_identity_digest(&yaml("tok-retry"), dir, None, "tok-retry")
);
// A password that is NOT the job's token is the connection, and changing
// it must still read as a different warehouse.
// A value that is NOT the job's token is the connection, and changing it
// must still read as a different warehouse.
assert_ne!(
profile_identity_digest(&yaml("static-a"), dir, None, "tok-first"),
profile_identity_digest(&yaml("static-b"), dir, None, "tok-retry")
);
}
// Project code can leave a symlink where the worker later writes the profile:
// either the directory or the file in it. Neither may be followed.
#[cfg(unix)]
#[tokio::test]
async fn fresh_dir_never_follows_what_the_jail_left() {
let root = tempfile::tempdir().unwrap();
let host = root.path().join("host");
std::fs::create_dir(&host).unwrap();
std::fs::write(host.join("profiles.yml"), "host file").unwrap();
let dir = root.path().join("dbt_profiles");
std::os::unix::fs::symlink(&host, &dir).unwrap();
fresh_dir(&dir).await.unwrap();
assert!(!std::fs::symlink_metadata(&dir).unwrap().is_symlink());
std::fs::write(dir.join("profiles.yml"), "rendered").unwrap();
fresh_dir(&dir).await.unwrap();
std::os::unix::fs::symlink(host.join("profiles.yml"), dir.join("profiles.yml")).unwrap();
fresh_dir(&dir).await.unwrap();
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
assert_eq!(
std::fs::read_to_string(host.join("profiles.yml")).unwrap(),
"host file"
);
}
// The jail profile is protobuf text format, and the project path and the
// descriptor's environment land inside string literals. An unescaped quote or
// newline closes the literal and lets the rest be read as further directives —
+204 -34
View File
@@ -7,12 +7,42 @@
use serde_json::Value;
use windmill_common::error::{self, Error};
use windmill_parser_yaml::dbt::DbtEngine;
/// Written beside `profiles.yml`, and named absolutely in `sslrootcert`: dbt
/// runs with the project as its working directory and hands the path to the
/// driver unchanged.
pub const ROOT_CERT_FILENAME: &str = "server-ca.pem";
/// What a credential's value becomes in [`RenderedProfile::identity`].
const MASKED_CREDENTIAL: &str = "$CREDENTIAL";
/// Whether a target key holds a credential rather than part of the address. By
/// name, because a `dbt_profile` block's keys are its adapter's own: `password`,
/// dbt-postgres's `pass`, `token`, `private_key_passphrase`, `client_secret`,
/// `aws_secret_access_key` and the `key_id` rotated with it, … An endpoint is
/// address even when its name says otherwise: BigQuery's `token_uri` is where the
/// token comes from.
fn is_credential_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
if key.ends_with("_uri") || key.ends_with("_url") || key.contains("endpoint") {
return false;
}
key == "pass"
|| [
"password",
"passphrase",
"secret",
"token",
"private_key",
"api_key",
"access_key",
"key_id",
]
.iter()
.any(|c| key.contains(c))
}
/// The per-adapter facts, so each adapter states them together and a new one
/// cannot inherit another's by omission. `PG` is the base every arm spreads
/// from: most adapters differ from Postgres only in their name and package.
@@ -469,6 +499,10 @@ fn port_of(resource: &Value, default: i64) -> error::Result<i64> {
#[derive(Debug)]
pub struct RenderedProfile {
pub yaml: String,
/// `yaml` with every credential's value masked: what run identity hashes. A
/// rotated token or password is still the same connection, and an OAuth token
/// rotates every few minutes, so hashing it would refuse nearly every retry.
pub identity: String,
pub schema: Option<String>,
pub database: Option<String>,
/// A private CA the caller must write next to `profiles.yml`, under the
@@ -483,8 +517,11 @@ pub struct RenderedProfile {
/// from the descriptor when set, else from the resource, else from dbt's own
/// per-adapter default — dbt errors out clearly when it ends up missing, which
/// is a better failure than a Windmill-invented default.
#[allow(clippy::too_many_arguments)]
pub fn render_profile(
adapter: &DbtAdapter,
// The engines read some keys differently; see the Snowflake token below.
engine: DbtEngine,
resource: &Value,
profile_name: &str,
target: &str,
@@ -593,11 +630,18 @@ pub fn render_profile(
out.push(("user".into(), quoted(&u)));
}
// Key-pair is Windmill's own snowflake resource shape; the
// `snowflake_oauth` type carries a token instead, which dbt only
// accepts alongside `authenticator: oauth` — without both, the
// profile renders with no credential at all and cannot connect.
// `snowflake_oauth` type carries an access token instead, which needs
// an `authenticator` saying so. dbt-core 1.x reads `oauth` + `token`
// as one. The Rust engines read `oauth` as the refresh-token flow and
// refuse a profile without client credentials, and send the same
// login for `jwt`, which dbt-snowflake has only from 1.9, while the
// 1.x engine still resolves 1.8.
if let Some(t) = s(resource, "token").or_else(|| s(resource, "access_token")) {
out.push(("authenticator".into(), quoted("oauth")));
let authenticator = match engine {
DbtEngine::DbtCore1x => "oauth",
DbtEngine::DbtCore2x | DbtEngine::Fusion => "jwt",
};
out.push(("authenticator".into(), quoted(authenticator)));
out.push(("token".into(), quoted(&t)));
} else if let Some(k) = s(resource, "private_key") {
out.push(("private_key".into(), quoted(&k)));
@@ -612,10 +656,17 @@ pub fn render_profile(
out.push((k.into(), quoted(&v)));
}
}
database = s(resource, "database");
if let Some(d) = database.clone() {
out.push(("database".into(), quoted(&d)));
}
// dbt-snowflake requires one, and a resource from the OAuth connect flow
// starts without it: naming the field beats dbt's schema error.
let db = s(resource, "database").ok_or_else(|| {
Error::BadRequest(
"a Snowflake target needs a database; add `database` to the warehouse's \
resource"
.to_string(),
)
})?;
out.push(("database".into(), quoted(&db)));
database = Some(db);
schema = schema.or_else(|| s(resource, "schema"));
}
KnownAdapter::Bigquery => {
@@ -692,24 +743,43 @@ pub fn render_profile(
// a newline in one opens a sibling key of the caller's choosing.
let (qp, qt) = (yaml_scalar(profile_name), yaml_scalar(target));
let mut yaml = format!("{qp}:\n target: {qt}\n outputs:\n {qt}:\n");
let mut identity = yaml.clone();
for (k, v) in &out {
yaml.push_str(&format!(" {k}: {}\n", v.render()));
let shown = if is_credential_key(k) {
yaml_scalar(MASKED_CREDENTIAL)
} else {
v.render()
};
identity.push_str(&format!(" {k}: {shown}\n"));
}
// The service-account document is a nested mapping, not a scalar.
if adapter == KnownAdapter::Bigquery {
yaml.push_str(" keyfile_json:\n");
identity.push_str(" keyfile_json:\n");
let obj = resource
.as_object()
.ok_or_else(|| Error::BadRequest("bigquery resource is not an object".to_string()))?;
for (k, v) in obj {
if let Some(v) = v.as_str() {
yaml.push_str(&format!(" {}: {}\n", yaml_scalar(k), yaml_scalar(v)));
let shown = if is_credential_key(k) {
MASKED_CREDENTIAL
} else {
v
};
identity.push_str(&format!(
" {}: {}\n",
yaml_scalar(k),
yaml_scalar(shown)
));
}
}
}
Ok(RenderedProfile {
yaml,
identity,
schema,
database,
root_certificate_pem: matches!(adapter, KnownAdapter::Postgres)
@@ -746,6 +816,7 @@ pub fn render_dbt_profile(
" \"type\": {}\n",
yaml_scalar(adapter.dbt_type())
));
let mut identity = yaml.clone();
for (k, v) in block {
// A null is an optional field the resource form left unset, and dbt
// validates several keys against a schema that rejects one.
@@ -762,28 +833,33 @@ pub fn render_dbt_profile(
if (k == schema_key && schema_override.is_some()) || (k == "threads" && threads.is_some()) {
continue;
}
emit_entry(&mut yaml, 6, k, v);
emit_entry(&mut yaml, 6, k, v, false);
emit_entry(&mut identity, 6, k, v, true);
}
let mut tail = String::new();
if root_certificate_pem.is_some() {
yaml.push_str(&format!(
tail.push_str(&format!(
" \"sslrootcert\": {}\n",
yaml_scalar(&profiles_dir.join(ROOT_CERT_FILENAME).to_string_lossy())
));
}
if let Some(sc) = schema_override {
yaml.push_str(&format!(
tail.push_str(&format!(
" {}: {}\n",
yaml_scalar(schema_key),
yaml_scalar(sc)
));
}
if let Some(t) = threads {
yaml.push_str(&format!(" \"threads\": {t}\n"));
tail.push_str(&format!(" \"threads\": {t}\n"));
}
yaml.push_str(&tail);
identity.push_str(&tail);
let str_key = |k: &str| block.get(k).and_then(|v| v.as_str()).map(|v| v.to_string());
Ok(RenderedProfile {
yaml,
identity,
schema: schema_override
.map(|x| x.to_string())
.or_else(|| str_key(schema_key)),
@@ -794,16 +870,21 @@ pub fn render_dbt_profile(
/// Emit one target key, nesting as deep as the value goes — an adapter's credential can be
/// a mapping (bigquery's `keyfile_json`) or a list. Keys are quoted like values: one nothing
/// here enumerates is as free-form as a password.
fn emit_entry(out: &mut String, indent: usize, key: &str, v: &Value) {
/// here enumerates is as free-form as a password. `mask` writes credentials as
/// [`MASKED_CREDENTIAL`], at any depth, for [`RenderedProfile::identity`].
fn emit_entry(out: &mut String, indent: usize, key: &str, v: &Value, mask: bool) {
out.push_str(&format!("{}{}:", " ".repeat(indent), yaml_scalar(key)));
emit_value(out, indent, v);
emit_value(out, indent, v, mask, mask && is_credential_key(key));
}
/// The value half, after `key:`. An empty collection is emitted INLINE: a block with no
/// children reads back as `null`, so `extensions: []` would reach the adapter as a missing
/// value rather than the empty list dbt was handed.
fn emit_value(out: &mut String, indent: usize, v: &Value) {
///
/// `credential` masks scalars only. A collection under a credential-named key is still
/// walked: dbt-duckdb's `secrets:` list holds the endpoint and scope that say which
/// connection it is, each entry judged by its own key.
fn emit_value(out: &mut String, indent: usize, v: &Value, mask: bool, credential: bool) {
match v {
Value::Object(m) => {
// A null is an optional field the resource form left unset, and dbt validates
@@ -815,7 +896,7 @@ fn emit_value(out: &mut String, indent: usize, v: &Value) {
}
out.push('\n');
for (k, v) in kept {
emit_entry(out, indent + 2, k, v);
emit_entry(out, indent + 2, k, v, mask);
}
}
Value::Array(items) => {
@@ -828,9 +909,10 @@ fn emit_value(out: &mut String, indent: usize, v: &Value) {
for item in items {
out.push_str(&pad);
out.push('-');
emit_value(out, indent + 2, item);
emit_value(out, indent + 2, item, mask, credential);
}
}
_ if credential => out.push_str(&format!(" {}\n", yaml_scalar(MASKED_CREDENTIAL))),
_ => out.push_str(&format!(" {}\n", yaml_value(v))),
}
}
@@ -904,6 +986,7 @@ mod tests {
"dbname": "warehouse", "sslmode": "require"});
let p = render_profile(
&KnownAdapter::Postgres.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"prod",
@@ -936,6 +1019,7 @@ mod tests {
"password": "p", "dbname": "warehouse"});
let p = render_profile(
&KnownAdapter::Redshift.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"prod",
@@ -1136,6 +1220,7 @@ mod tests {
"http_path": "/sql/1.0/warehouses/x", "token": "t"});
let p = render_profile(
&KnownAdapter::Databricks.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"prod",
@@ -1161,6 +1246,7 @@ mod tests {
"root_certificate_pem": "-----BEGIN CERTIFICATE-----\nx\n"});
let p = render_profile(
&KnownAdapter::Postgres.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"prod",
@@ -1188,6 +1274,7 @@ mod tests {
let plain = json!({"host": "h", "dbname": "d", "sslmode": "require"});
let p = render_profile(
&KnownAdapter::Postgres.into(),
DbtEngine::DbtCore1x,
&plain,
"wm",
"prod",
@@ -1200,29 +1287,107 @@ mod tests {
assert_eq!(p.root_certificate_pem, None);
}
// `snowflake_oauth` maps to the Snowflake adapter, but its credential is a
// token, which dbt honors only with `authenticator: oauth`. Forwarding neither
// renders a profile with no credential at all.
#[test]
fn snowflake_oauth_renders_its_token() {
let r = json!({"account_identifier": "acc", "username": "u", "token": "tok",
"database": "db", "warehouse": "wh"});
let p = render_profile(
fn snowflake(engine: DbtEngine, r: &Value) -> error::Result<RenderedProfile> {
render_profile(
&KnownAdapter::Snowflake.into(),
&r,
engine,
r,
"wm",
"prod",
None,
None,
std::path::Path::new("/tmp/p"),
)
.unwrap();
assert!(
p.yaml.contains(" authenticator: \"oauth\"\n"),
"{}",
p.yaml
);
assert!(p.yaml.contains(" token: \"tok\"\n"));
}
// `snowflake_oauth` carries an access token, which only one `authenticator`
// per engine accepts: the Rust engines refuse `oauth` without client
// credentials, and dbt-snowflake before 1.9 has no `jwt`.
#[test]
fn snowflake_oauth_names_its_token_per_engine() {
let r = json!({"account_identifier": "acc", "token": "tok", "database": "db"});
for (engine, authenticator) in [
(DbtEngine::DbtCore1x, "oauth"),
(DbtEngine::DbtCore2x, "jwt"),
(DbtEngine::Fusion, "jwt"),
] {
let p = snowflake(engine, &r).unwrap();
assert!(
p.yaml
.contains(&format!(" authenticator: \"{authenticator}\"\n")),
"{engine:?}: {}",
p.yaml
);
assert!(p.yaml.contains(" token: \"tok\"\n"));
}
let err = snowflake(
DbtEngine::DbtCore1x,
&json!({"account_identifier": "acc", "token": "tok"}),
)
.unwrap_err()
.to_string();
assert!(err.contains("add `database`"), "{err}");
}
// Run identity has to survive a credential rotating, which an OAuth token does
// every few minutes, and still change when the connection moves.
#[test]
fn identity_masks_credentials_but_not_the_connection() {
let rendered = |account: &str, token: &str| {
snowflake(
DbtEngine::DbtCore1x,
&json!({"account_identifier": account, "token": token, "database": "db"}),
)
.unwrap()
.identity
};
assert_eq!(rendered("acc", "t1"), rendered("acc", "t2"));
assert_ne!(rendered("acc", "t1"), rendered("other", "t1"));
// A `dbt_profile` block, whose keys are the adapter's own, nested ones too.
let block = |adapter: KnownAdapter, v: Value| {
render_dbt_profile(
&adapter.into(),
v.as_object().unwrap(),
"wm",
"prod",
None,
None,
std::path::Path::new("/tmp/p"),
)
.unwrap()
.identity
};
let bq = |project: &str, key: &str, token_uri: &str| {
block(
KnownAdapter::Bigquery,
json!({"type": "bigquery", "project": project, "dataset": "d",
"keyfile_json": {"client_email": "e", "private_key": key,
"token_uri": token_uri}}),
)
};
assert_eq!(bq("p", "k1", "t"), bq("p", "k2", "t"));
assert_ne!(bq("p", "k1", "t"), bq("q", "k1", "t"));
assert_ne!(bq("p", "k1", "t"), bq("p", "k1", "elsewhere"));
// Only scalars are masked: a `secrets:` entry still names its endpoint.
// A rotated access key changes its id with its secret.
let duck = |endpoint: &str, secret: &str| {
block(
KnownAdapter::Duckdb,
json!({"type": "duckdb", "path": "x.duckdb",
"secrets": [{"type": "s3", "endpoint": endpoint,
"key_id": format!("id-{secret}"), "secret": secret}]}),
)
};
assert_eq!(duck("s3.a", "k1"), duck("s3.a", "k2"));
assert_ne!(duck("s3.a", "k1"), duck("s3.b", "k1"));
let pg = |pass: &str| {
block(
KnownAdapter::Postgres,
json!({"type": "postgres", "host": "h", "user": "u", "pass": pass}),
)
};
assert_eq!(pg("p1"), pg("p2"));
}
// dbt rejects a BigQuery target with no dataset and a service-account JSON
@@ -1233,6 +1398,7 @@ mod tests {
let r = json!({"project_id": "p", "client_email": "e", "private_key": "k"});
let err = render_profile(
&KnownAdapter::Bigquery.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"prod",
@@ -1245,6 +1411,7 @@ mod tests {
assert!(err.contains("profile.schema"), "{err}");
let p = render_profile(
&KnownAdapter::Bigquery.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"prod",
@@ -1284,6 +1451,7 @@ mod tests {
let r = json!({"host": "h", "dbname": "sales", "user": "u"});
let p = render_profile(
&KnownAdapter::Mysql.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"dev",
@@ -1304,6 +1472,7 @@ mod tests {
fn a_profile_name_or_target_cannot_open_a_sibling_key() {
let rendered = render_profile(
&KnownAdapter::Postgres.into(),
DbtEngine::DbtCore1x,
&serde_json::json!({"host": "h", "user": "u", "password": "p", "dbname": "d"}),
"prod # hidden",
"dev\n evil: yes",
@@ -1339,6 +1508,7 @@ mod tests {
"password": "p\"\nhost: evil.example.com\n#"});
let p = render_profile(
&KnownAdapter::Postgres.into(),
DbtEngine::DbtCore1x,
&r,
"wm",
"dev",
@@ -4438,6 +4438,8 @@ async fn push_next_flow_job(
.as_deref()
.filter(|t| !t.is_empty() && *t != flow_job.tag.as_str())
{
// A step with its own on-behalf-of carries a cached dispatch address, up to one
// notify poll stale; accepted, see `get_email_from_permissioned_as`.
let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?;
check_tag_available_for_workspace_internal(
db,
+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.810.0";
export const VERSION = "v1.811.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.810.0";
export const VERSION = "1.811.1";
+35 -11
View File
@@ -1,11 +1,12 @@
import { expect, test } from "bun:test";
import { deployItem } from "../windmill-utils-internal/src/deploy.ts";
// `deployItem` spreads the source item into the request body, and a script's/flow's
// on_behalf_of names a username that only exists in the source
// workspace. Sending it to the target pairs one workspace's principal with the other's
// email, which the backend rejects. Deleting the spread is an easy regression, so pin
// that the key never reaches the wire.
// `deployItem` spreads the source item into the request body, and the principal it carries
// (`on_behalf_of`, at the top level for a script or flow and inside the policy for an app)
// names a username that only exists in the source workspace. Sending it to the target pairs
// one workspace's principal with the other's email, which the backend rejects. Deleting the
// spread is an easy regression, so pin that the principal never reaches the wire while the
// caller's chosen address does.
function recordingProvider(captured: [string, any][], flowExists: boolean) {
const source = {
on_behalf_of_email: "alice@corp",
@@ -32,6 +33,19 @@ function recordingProvider(captured: [string, any][], flowExists: boolean) {
}),
createScript: async (p: any) =>
void captured.push(["createScript", p.requestBody]),
existsApp: async () => false,
getAppByPath: async () => ({
path: "f/x/a",
summary: "",
value: {},
raw_app: false,
policy: {
execution_mode: "publisher",
on_behalf_of: "u/alice",
on_behalf_of_email: "alice@corp",
},
}),
createApp: async (p: any) => void captured.push(["createApp", p.requestBody]),
} as any;
}
@@ -65,19 +79,29 @@ test("deployItem: never sends the source workspace's on_behalf_of", async () =>
"dst",
"alice@corp",
);
await deployItem(
recordingProvider(captured, false),
"app" as any,
"f/x/a",
"src",
"dst",
"alice@corp",
);
expect(captured.map(([fn]) => fn)).toEqual([
"createFlow",
"updateFlow",
"createScript",
"createApp",
]);
for (const [, body] of captured) {
// The email is still overridden with the caller's choice...
expect(body.on_behalf_of_email).toBe("alice@corp");
for (const [name, body] of captured) {
expect(body.preserve_on_behalf_of).toBe(true);
// Both surfaces spell it `on_behalf_of`; only its nesting differs — an app carries the
// identity inside its policy, the others at the top level.
const identity = name === "createApp" ? body.policy : body;
// The email is still overridden with the caller's choice...
expect(identity.on_behalf_of_email).toBe("alice@corp");
// ...while the principal is dropped, so the backend derives the target's own.
expect(
"on_behalf_of" in JSON.parse(JSON.stringify(body)),
).toBe(false);
expect("on_behalf_of" in JSON.parse(JSON.stringify(identity))).toBe(false);
}
});
+14 -1
View File
@@ -506,10 +506,23 @@ export async function deployItem(
},
});
} else if (kind === "app" || kind === "raw_app") {
const app = await provider.getAppByPath({
const rawApp = await provider.getAppByPath({
workspace: workspaceFrom,
path,
});
// See the flow branch: a source-workspace principal is never valid here, and the
// policy carries the app's in `on_behalf_of`. Clearing it lets the backend derive
// the target's own from the address. A group travels as its synthetic
// `group-*@windmill.dev` address, which an admin-created account holding it would
// win: known and accepted, see `users::permissioned_as_from_email` in the backend.
const app = {
...rawApp,
policy: {
...rawApp.policy,
on_behalf_of: undefined,
on_behalf_of_email: onBehalfOf,
},
};
if (alreadyExists) {
if (app.raw_app) {
const secret = await provider.getPublicSecretOfLatestVersionOfApp({
+47
View File
@@ -0,0 +1,47 @@
# Removing `policy.on_behalf_of_email`
An app's identity is `policy.on_behalf_of`; the address beside it is a function of that
principal. `on_behalf_of_email` is no longer required — a policy carrying only a principal
executes, deriving the address — but it is still written on every save, and that is the only
thing holding it in place.
## The gate
`get_on_behalf_of` gained its derive-when-absent fallback in **1.810**. Every replica before that
*requires* the key and errors outright without it, so it would 400 every anonymous, publisher and
guest app saved by a newer one. A rolling deploy runs both versions at once, which is why the
write stays until no replica older than 1.810 can be live — in practice, once
`MIN_KEEP_ALIVE_VERSION` (`windmill-common/src/min_version.rs`) has passed it.
There is no `MIN_VERSION_*` constant for this and it does not need one: those exist to gate
behavior at runtime or to trip the build when a constraint expires, and nothing here does either.
The key is written unconditionally, so no replica ever meets its absence until someone follows
the steps below.
## Step 1 — stop writing the key
- `stored_on_behalf_of_email` in `windmill-api/src/apps.rs`, and the `create_app` /
`update_app_internal` call sites that store what it returns.
- The CLI and frontend workspace-deploy paths (`cli/windmill-utils-internal/src/deploy.ts`,
`frontend/src/lib/utils_workspace_deploy.ts`). These send the address *instead of* a principal
for a cross-workspace deploy, which is the one case where it is the only identity available —
so this is "stop sending it once the target resolves a principal itself", not a deletion.
Policies written before this keep their key and keep being read from it; they agree with their
principal, so nothing has to strip them.
## Step 2 — drop the field
Remove `on_behalf_of_email` from `Policy` and the fallback in `get_on_behalf_of`, which then
always derives. Optionally strip the key from stored policies.
This can ship with step 1. It is written separately because step 1 alone is revertible without
touching stored data or the response schema, and because the gate above is what makes either
step safe — nothing about step 2 needs its own waiting period.
## Why the address is not derived on read
Read paths return the stored address verbatim rather than recomputing it. Deriving on read means
resolving a principal that, for a draft, is caller-controlled — which turns the read into an
oracle for addresses the caller cannot otherwise see, and leaves a principal that resolves to
nobody with no address at all. Both were live defects while the read paths did derive.
+30 -3
View File
@@ -293,6 +293,32 @@ dbt hands the driver — it is written beside `profiles.yml` and pointed at by
`sslrootcert`, as it is for a translated postgres resource. `profile.schema` and
`threads` from the descriptor override their block keys rather than joining them.
**A `snowflake_oauth` warehouse behaves like a key-pair `snowflake` one**,
although its credential is an access token that lasts ten minutes:
- The token goes under the `authenticator` each engine reads as an access
token: `oauth` on dbt-core 1.x, `jwt` on the Rust engines. Those read `oauth`
as the refresh-token flow and refuse a profile without client credentials,
while dbt-snowflake has `jwt` only from 1.9 and the 1.x engine can resolve 1.8.
- Every dbt process that logs in re-resolves the warehouse first
(`PreparedProject::refresh_profile`), and resolving an expired OAuth token
refreshes it. So the build, the `after_all` tests, a node retry and the
column-lineage pass each start with a live token, unless resolving fails: the
process then keeps the profile it already has, token included.
- Run identity masks credentials (`RenderedProfile::identity`), so a token
refreshed between a failure and its retry still matches.
- The OAuth connect flow asks for `database`, `warehouse`, `role` and `schema`
(`resource_fields` in `oauth_connect.json`). No token response carries them,
and dbt needs a database.
One gap stays: a login after the token its dbt process started with has expired
fails. That is a thread whose first connection opens late in a long process, or
a process's first login when the token it was handed had only seconds left.
Refreshing tokens ahead of expiry would narrow it, but a token's lifetime is not
stored, so no margin fits every provider. Closing it would mean handing dbt the
refresh token and the instance's client secret, which any model can read on
dbt-core 1.x.
Three things follow, and they are the reason for the rule rather than
consequences to work around.
@@ -1278,9 +1304,10 @@ relations that are no longer there. Keyed on all four, it reads as an
environment nothing has published yet, which is what it is.
What the key deliberately does NOT carry is the resolved connection. That is the
`profile_digest` a retry is held to, and it moves when a password is rotated,
which moves no relation; a warehouse pointing somewhere else entirely is
decision 11's accepted limitation, spelled the same way here as everywhere else.
`profile_digest` a retry is held to. It masks credentials, but it still moves
with changes that move no relation, like another Snowflake warehouse or role; a
warehouse pointing somewhere else entirely is decision 11's accepted limitation,
spelled the same way here as everywhere else.
Today one script has one environment, because a descriptor fixes both the
warehouse and the target and a run cannot override either. The key is what makes
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.810.0",
"version": "1.811.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.810.0",
"version": "1.811.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.810.0",
"version": "1.811.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",
@@ -50,6 +50,7 @@
} from './pickerPopularity'
import Label from './Label.svelte'
import ResourcePathHint from './ResourcePathHint.svelte'
import SchemaForm from './SchemaForm.svelte'
interface Props {
step?: number
@@ -230,6 +231,28 @@
| undefined
)
/** Fields of the resource type the provider's registry entry asks for once the token is
* in (`resource_fields`): what no token response carries, like Snowflake's database. A
* list rather than "every other field" because most OAuth types also hold the fields of
* another way in: ServiceNow's basic-auth password, Bitbucket's app password. */
let resourceFields = $derived((registryEntry()?.resource_fields as string[] | undefined) ?? [])
/** Their slice of the resource type's schema, so they render with the type's own
* descriptions; plain text inputs while the type is not synced from the hub. */
let resourceFieldsSchema = $derived.by(() => {
const props: Record<string, SchemaProperty> =
(resourceTypeInfo?.schema as any)?.properties ?? {}
return {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
order: resourceFields,
properties: Object.fromEntries(
resourceFields.map((f) => [f, props[f] ?? { type: 'string', description: '' }])
),
required: []
}
})
/** Instance entry declares client credentials but not authorization_code
* (custom provider configured with only a token URL) */
let authCodeUnavailable = $state(false)
@@ -646,9 +669,11 @@
export async function next() {
if (step == 1) {
linkedSecrets = []
// Both branches: the OAuth one fills `resourceFields` into the same map, and fields
// typed into another type's form before Back would otherwise ride along.
args = {}
if (manual) {
getResourceTypeInfo()
args = {}
} else {
getResourceTypeInfo()
// Awaited: the popup is built from `scopes`, so advancing before this
@@ -887,10 +912,19 @@
)
}
const resourceValue = args
// A copy: the form is still mounted and bound to `args` across the awaits below, and
// puts back the default of any field removed from it.
const resourceValue = $state.snapshot(args)
let savedVariableCount = 0
if (!manual) {
// A field left blank is absent, not an empty string a consumer reads as a value:
// the Snowflake executor sends any `database` it finds, empty or not.
for (const f of resourceFields) {
if (resourceValue[f] === '' || resourceValue[f] == undefined) {
delete resourceValue[f]
}
}
// OAuth flow: single secret variable for the token
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
savedVariableCount++
@@ -1562,6 +1596,17 @@
<Toggle bind:checked={wsSpecific} />
</Label>
{/if}
<!-- Not for express or `fillPath`, which save as soon as the token arrives: the fields
are then filled by editing the resource. -->
{#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0}
<Label
label="Connection details"
tooltip="Saved on the resource with the token, and editable later"
class="mt-6"
>
<SchemaForm onlyMaskPassword noDelete schema={resourceFieldsSchema} bind:args />
</Label>
{/if}
{#if apiTokenApps[resourceType] || !manual}
<ul class="mt-6">
<li class="text-xs text-primary font-normal">
+13 -9
View File
@@ -27,18 +27,16 @@
let interval
onMount(() => {
// compact schedules itself below; it needs no fixed rate.
if (compact) return
// compact and noSeconds schedule themselves below; they need no fixed rate.
if (compact || noSeconds) return
// Update every minute for noSeconds mode, every second otherwise.
const intervalMs = noSeconds ? 60000 : 1000
interval = setInterval(() => {
computeDate()
if (!isRecent) {
clearInterval(interval)
interval = undefined
}
}, intervalMs)
}, 1000)
// Add explicit cleanup
return () => {
@@ -49,21 +47,27 @@
// Waking on the boundary of the unit on screen, rather than at a fixed rate: `2h` only
// changes on the hour, and a row that reads `5d` must not hold a 1s timer to find that
// out. Re-armed when `date` changes, so an item edited to now leaves its day-long wait.
// noSeconds rides the same schedule: its relative forms keep counting hours and days,
// so a fixed-rate timer that stops once the date is an hour old would freeze them.
$effect(() => {
if (!compact) return
if (!compact && !noSeconds) return
const at = date
// An absent or unparsable date has no boundary to wait for: the delay below would
// be NaN, which setTimeout runs immediately, and the tick would re-arm itself in
// a tight loop.
if (Number.isNaN(new Date(at).getTime())) return
let handle: ReturnType<typeof setTimeout> | undefined
const tick = () => {
computeDate()
handle = setTimeout(tick, compactDelayMs(at))
handle = setTimeout(tick, nextUnitBoundaryMs(at))
}
handle = setTimeout(tick, compactDelayMs(at))
handle = setTimeout(tick, nextUnitBoundaryMs(at))
return () => {
handle && clearTimeout(handle)
}
})
function compactDelayMs(dateString: string): number {
function nextUnitBoundaryMs(dateString: string): number {
const secs = secondsAgo(new Date(dateString))
const left =
secs < 60
@@ -9,7 +9,14 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { ScriptService, type Script } from '$lib/gen'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import {
disableHubStore,
hubBaseUrlStore,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { scriptToHubUrl } from '$lib/hub'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
@@ -32,6 +39,7 @@
FolderOpen,
ChevronUpSquare,
GitFork,
Globe2,
List,
Pen,
Shield,
@@ -47,7 +55,12 @@
import Popover from '$lib/components/Popover.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
import {
claimTab,
editInForkAllowed,
editInForkLabel,
onEditInForkClick
} from '$lib/utils/editInFork'
import EditInForkButton from './EditInForkButton.svelte'
import { isCloudHosted } from '$lib/cloud'
@@ -403,6 +416,32 @@
copyToClipboard(script.path)
}
},
{
displayName: 'Publish to Hub',
icon: Globe2,
action: async () => {
// The row only carries metadata, so the code has to be fetched first; the tab is
// claimed before that, since Safari won't open one after an await.
const tab = claimTab()
try {
const fullScript = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: script.path
})
const url = scriptToHubUrl(fullScript, $hubBaseUrlStore).toString()
if (tab) {
tab.show(url)
} else if (!window.open(url)) {
sendUserToast('Allow popups to publish this script to the Hub', true)
}
} catch (e: any) {
tab?.discard()
sendUserToast(`Could not load ${script.path}: ${e?.body ?? e?.message ?? e}`, true)
}
},
// Operators can't write scripts, so they have nothing to publish.
hide: $disableHubStore || $userStore?.operator
},
{
displayName: script.archived ? 'Unarchive' : 'Archive',
icon: Archive,
@@ -22,6 +22,7 @@
import type { GitSyncRepository } from './GitSyncContext.svelte'
import GitSyncModeDisplay from './GitSyncModeDisplay.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import EEOnly from '$lib/components/EEOnly.svelte'
import { GitSyncService, ResourceService, VariableService } from '$lib/gen'
@@ -57,6 +58,10 @@
const gitSyncTestJob = $derived(idx !== null ? gitSyncContext.gitSyncTestJobs?.[idx] : null)
let confirmingDelete = $state(false)
function pullStatusDate(status: { at: number }): string {
return new Date(status.at * 1000).toISOString()
}
// Enable/disable automatic repo → workspace pulls, managing the optional
// auto_pull object without binding into a possibly-undefined value.
function setAutoPullEnabled(enabled: boolean) {
@@ -919,10 +924,18 @@
{#if repo.auto_pull.last_pull_status.success}
Last synced{repo.auto_pull.last_pull_status.synced_sha
? ` to ${repo.auto_pull.last_pull_status.synced_sha.slice(0, 7)}`
: ''}.
: ''}
<TimeAgo
date={pullStatusDate(repo.auto_pull.last_pull_status)}
noSeconds
/>.
{:else}
<span class="text-red-600 dark:text-red-400">
Last sync failed{repo.auto_pull.last_pull_status.error
Last sync failed
<TimeAgo
date={pullStatusDate(repo.auto_pull.last_pull_status)}
noSeconds
/>{repo.auto_pull.last_pull_status.error
? `: ${repo.auto_pull.last_pull_status.error}`
: ''}.
</span>
@@ -1000,13 +1013,21 @@
{#if repo.auto_pull.last_pull_status.success}
Last synced{repo.auto_pull.last_pull_status.synced_sha
? ` to ${repo.auto_pull.last_pull_status.synced_sha.slice(0, 7)}`
: ''}.
: ''}
<TimeAgo
date={pullStatusDate(repo.auto_pull.last_pull_status)}
noSeconds
/>.
{viaWebhook
? ' Syncing instantly via webhook.'
: ' Checking the tracked branch about every minute.'}
{:else}
<span class="text-red-600 dark:text-red-400">
Last sync failed{repo.auto_pull.last_pull_status.error
Last sync failed
<TimeAgo
date={pullStatusDate(repo.auto_pull.last_pull_status)}
noSeconds
/>{repo.auto_pull.last_pull_status.error
? `: ${repo.auto_pull.last_pull_status.error}`
: ''}.
</span>
+28 -1
View File
@@ -1,5 +1,6 @@
import { AppService, FlowService } from './gen'
import { AppService, FlowService, type Script } from './gen'
import hubPathsData from './hubPaths.json'
import { encodeState } from './utils'
import {
replacePlaceholderForSignatureScriptTemplate,
SIGNATURE_TEMPLATE_FLOW_HUB_ID,
@@ -17,6 +18,32 @@ export const HubFlow = {
SIGNATURE_TEMPLATE: SIGNATURE_TEMPLATE_FLOW_HUB_ID
} as const
/**
* The Hub's script submission form, prefilled with this script. The Hub decodes the
* hash, so the code never reaches its server until the user submits. Flows and apps
* reach the Hub inside a project, published from a folder, so only scripts have this.
*/
export function scriptToHubUrl(
script: Pick<
Script,
'content' | 'summary' | 'description' | 'kind' | 'language' | 'schema' | 'lock'
>,
hubBaseUrl: string
): URL {
const { content, summary, kind, language, schema } = script
const url = new URL(hubBaseUrl + '/scripts/add')
url.hash = encodeState({
content,
summary,
description: script.description ?? '',
kind,
language,
schema,
lock: script.lock ?? ''
})
return url
}
export function replaceScriptPlaceholderWithItsValues(id: string, content: string) {
switch (id) {
case HubScript.SIGNATURE_TEMPLATE:
+41 -27
View File
@@ -186,12 +186,20 @@ export type DeployConflict = { hit: boolean }
* nowhere else since usernames are per-workspace. The key is therefore always overwritten:
* with the picked user's principal for a custom choice, and cleared otherwise so the
* backend derives the target's own from the email it is given. The shared `deployItem`
* clears it too, but this app consumes the published package, so the clear has to exist
* does the same, but this app consumes the published package, so the rewrite has to exist
* on both sides until that version ships.
*
* An app carries both halves inside its `policy` instead, so it needs both stamped here:
* the published package leaves the policy untouched, and a source principal beside a
* target address is rejected as a pair naming two different accounts.
*
* A group kept as the target identity travels as its synthetic `group-*@windmill.dev`
* address, which an admin-created account holding it would win on the backend: known and
* accepted, see `users::permissioned_as_from_email`.
*/
function makeProvider(
onBehalfOfPrincipal?: string,
appIdentity?: AppIdentity,
onBehalfOf?: string,
/**
* Refuse the writes the shared `deployItem` reaches for only when the item already exists in
* the target, turning its silent switch to an update into a failure the caller can act on.
@@ -205,6 +213,17 @@ function makeProvider(
...requestBody,
on_behalf_of: onBehalfOfPrincipal
})
const withPolicyIdentity = <T extends Record<string, any>>(app: T): T =>
app.policy
? {
...app,
policy: {
...app.policy,
on_behalf_of: onBehalfOfPrincipal,
on_behalf_of_email: onBehalfOf
}
}
: app
const refuseUpdate = (): never => {
if (conflict) conflict.hit = true
throw new Error('item already exists in the target workspace')
@@ -231,26 +250,25 @@ function makeProvider(
? refuseUpdate()
: ScriptService.createScript({ ...p, requestBody: withPermissionedAs(p.requestBody) }),
archiveScriptByPath: (p) => ScriptService.archiveScriptByPath(p),
// An app's identity lives in its policy, and the shared deploy forwards the source policy
// untouched — it only turns `onBehalfOf` into `preserve_on_behalf_of: true`. Rewriting the
// policy on the way out is therefore the only way a chosen identity reaches the target; the
// backend honours it (`should_preserve` requires `policy.on_behalf_of.is_some()`).
getAppByPath: async (p) => {
const app = await AppService.getAppByPath(p)
if (!appIdentity) return app
return {
...app,
policy: {
...app.policy,
on_behalf_of: appIdentity.permissionedAs,
on_behalf_of_email: appIdentity.email
}
}
},
createApp: (p) => AppService.createApp(p),
updateApp: (p) => (conflict ? refuseUpdate() : AppService.updateApp(p)),
createAppRaw: (p) => AppService.createAppRaw(p),
updateAppRaw: (p) => (conflict ? refuseUpdate() : AppService.updateAppRaw(p)),
getAppByPath: (p) => AppService.getAppByPath(p),
createApp: (p) =>
AppService.createApp({ ...p, requestBody: withPolicyIdentity(p.requestBody) }),
updateApp: (p) =>
conflict
? refuseUpdate()
: AppService.updateApp({ ...p, requestBody: withPolicyIdentity(p.requestBody) }),
createAppRaw: (p) =>
AppService.createAppRaw({
...p,
formData: { ...p.formData, app: withPolicyIdentity(p.formData.app) }
}),
updateAppRaw: (p) =>
conflict
? refuseUpdate()
: AppService.updateAppRaw({
...p,
formData: { ...p.formData, app: withPolicyIdentity(p.formData.app) }
}),
getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p),
getRawAppData: (p) => AppService.getRawAppData(p),
deleteApp: (p) => AppService.deleteApp(p),
@@ -401,13 +419,9 @@ export async function deployItem(
}
}
const appIdentity =
(kind === 'app' || kind === 'raw_app') && onBehalfOf && onBehalfOfPrincipal
? { email: onBehalfOf, permissionedAs: onBehalfOfPrincipal }
: undefined
const conflict: DeployConflict | undefined = createOnly ? { hit: false } : undefined
const result = await sharedDeployItem(
makeProvider(onBehalfOfPrincipal, appIdentity, conflict),
makeProvider(onBehalfOfPrincipal, onBehalfOf, conflict),
kind as DeployKind,
path,
workspaceFrom,
@@ -343,7 +343,9 @@
if (account) {
OauthService.disconnectAccount({ workspace: $workspaceStore!, id: account })
}
await ResourceService.deleteResource({ workspace: $workspaceStore!, path })
// The response names the linked variables that went with it, which nothing else on the
// page would show.
sendUserToast(await ResourceService.deleteResource({ workspace: $workspaceStore!, path }))
reload()
}
@@ -762,8 +764,8 @@
>
{#if deleteIsLinked}
<Alert type="warning" title="Linked variable">
This resource is linked with a variable of the same path. The linked variable will also be
deleted.
This resource is linked with a variable of the same path. That variable is deleted with it,
unless another resource still references it.
</Alert>
{/if}
<Alert type="info" title="Bypass confirmation">
@@ -22,7 +22,14 @@
} from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import {
disableHubStore,
enterpriseLicense,
hubBaseUrlStore,
userStore,
userWorkspaces,
workspaceStore
} from '$lib/stores'
import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable'
import AIFormAssistant from '$lib/components/copilot/AIFormAssistant.svelte'
@@ -59,6 +66,7 @@
Eye,
FolderOpen,
GitFork,
Globe2,
History,
Loader2,
Pen,
@@ -71,6 +79,7 @@
ChevronDown,
ChevronRight
} from 'lucide-svelte'
import { scriptToHubUrl } from '$lib/hub'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import Popover from '$lib/components/Popover.svelte'
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
@@ -619,6 +628,17 @@
})
}
if (!$disableHubStore) {
menuItems.push({
label: 'Publish to Hub',
Icon: Globe2,
onclick: () => {
if (!script) return
window.open(scriptToHubUrl(script, $hubBaseUrlStore).toString(), '_blank', 'noopener')
}
})
}
if (showEditButtons) {
if (script.archived) {
menuItems.push({
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.810.0"
wmill = ">=1.811.1"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.810.0
version: 1.811.1
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.810.0'
ModuleVersion = '1.811.1'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.810.0"
version = "1.811.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.810.0",
"version": "1.811.1",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.810.0",
"version": "1.811.1",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.810.0
1.811.1
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.810.0",
"version": "1.811.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-yaml-validator",
"version": "1.810.0",
"version": "1.811.1",
"license": "Apache 2.0",
"dependencies": {
"@stoplight/yaml": "^4.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-yaml-validator",
"version": "1.810.0",
"version": "1.811.1",
"description": "YAML validator for Windmill flow, schedule, and trigger files",
"main": "dist/index.js",
"types": "dist/index.d.ts",