mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Merge remote-tracking branch 'origin/main' into ai-agents-dynamic-tools-memory-input
# Conflicts: # backend/ee-repo-ref.txt
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.810.0"
|
||||
".": "1.811.1"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
Generated
+96
-95
@@ -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
@@ -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 @@
|
||||
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();
|
||||
@@ -241,6 +241,7 @@
|
||||
}
|
||||
},
|
||||
"snowflake_oauth": {
|
||||
"resource_fields": ["database", "warehouse", "role", "schema"],
|
||||
"connect_config_template": {
|
||||
"display_name": "Snowflake",
|
||||
"label": "Snowflake Account Identifier",
|
||||
|
||||
+24
-24
@@ -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>"]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: >
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
&[
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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">
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.810.0"
|
||||
wmill = ">=1.811.1"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -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,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,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,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
@@ -1 +1 @@
|
||||
1.810.0
|
||||
1.811.1
|
||||
|
||||
+2
-2
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user